Compare commits
	
		
			9 Commits
		
	
	
		
			3.2.0+132
			...
			4af816d931
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 4af816d931 | |||
| 1c058a4323 | |||
| 461ed1fcda | |||
| 5363afa558 | |||
| f0d2737da8 | |||
| 1f2f80aa3e | |||
| 240a872e65 | |||
| c1ec6f0849 | |||
| ab42686d4d | 
| @@ -181,7 +181,7 @@ class IslandApp extends HookConsumerWidget { | ||||
|     } | ||||
|  | ||||
|     useEffect(() { | ||||
|       if (!kIsWeb && Platform.isLinux) { | ||||
|       if (!kIsWeb && (Platform.isLinux || Platform.isWindows)) { | ||||
|         return null; | ||||
|       } | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,9 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:convert'; | ||||
| import 'dart:developer' as developer; | ||||
| import 'dart:ffi'; | ||||
| import 'dart:io'; | ||||
| import 'dart:isolate'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:island/pods/network.dart'; | ||||
| @@ -10,6 +12,8 @@ import 'package:shelf/shelf_io.dart' as shelf_io; | ||||
| import 'package:shelf_web_socket/shelf_web_socket.dart'; | ||||
| import 'package:web_socket_channel/web_socket_channel.dart'; | ||||
| import 'package:path/path.dart' as path; | ||||
| import 'package:win32/win32.dart'; | ||||
| import 'package:ffi/ffi.dart'; | ||||
|  | ||||
| const String kRpcLogPrefix = 'arRPC.websocket'; | ||||
| const String kRpcIpcLogPrefix = 'arRPC.ipc'; | ||||
| @@ -43,12 +47,14 @@ class IpcErrorCodes { | ||||
|   static const int invalidEncoding = 4005; | ||||
| } | ||||
|  | ||||
| // Reference https://github.com/OpenAsar/arrpc/blob/main/src/transports/ipc.js | ||||
| class ActivityRpcServer { | ||||
|   static const List<int> portRange = [6463, 6472]; // Ports 6463–6472 | ||||
|   Map<String, Function> | ||||
|   handlers; // {connection: (socket), message: (socket, data), close: (socket)} | ||||
|   Map<String, Function> handlers; // {connection: (socket), message: (socket, data), close: (socket)} | ||||
|   HttpServer? _httpServer; | ||||
|   ServerSocket? _ipcServer; | ||||
|   int? _pipeHandle; // For Windows named pipe | ||||
|   Timer? _ipcTimer; // Store timer for cancellation | ||||
|   final List<WebSocketChannel> _wsSockets = []; | ||||
|   final List<_IpcSocketWrapper> _ipcSockets = []; | ||||
|  | ||||
| @@ -79,6 +85,8 @@ class ActivityRpcServer { | ||||
|  | ||||
|   // Find available IPC socket path | ||||
|   Future<String> _findAvailableIpcPath() async { | ||||
|     if (Platform.isWindows) return r'\\.\pipe\discord-ipc'; | ||||
|  | ||||
|     // Build list of directories to try, with macOS-specific handling | ||||
|     final baseDirs = <String>[]; | ||||
|  | ||||
| @@ -98,11 +106,11 @@ class ActivityRpcServer { | ||||
|  | ||||
|     // Add other standard directories | ||||
|     final otherDirs = [ | ||||
|       Platform.environment['XDG_RUNTIME_DIR'], // User runtime directory | ||||
|       Platform.environment['TMPDIR'], // App container temp (fallback) | ||||
|       Platform.environment['XDG_RUNTIME_DIR'], | ||||
|       Platform.environment['TMPDIR'], | ||||
|       Platform.environment['TMP'], | ||||
|       Platform.environment['TEMP'], | ||||
|       '/tmp', // System temp directory - most compatible | ||||
|       '/tmp', | ||||
|     ]; | ||||
|  | ||||
|     baseDirs.addAll( | ||||
| @@ -118,7 +126,6 @@ class ActivityRpcServer { | ||||
|             0, | ||||
|           ); | ||||
|           socket.close(); | ||||
|           // Clean up the test socket | ||||
|           try { | ||||
|             await File(socketPath).delete(); | ||||
|           } catch (_) {} | ||||
| @@ -128,9 +135,7 @@ class ActivityRpcServer { | ||||
|           ); | ||||
|           return socketPath; | ||||
|         } catch (e) { | ||||
|           // Path not available, try next | ||||
|           if (i == 0) { | ||||
|             // Log only for the first attempt per directory | ||||
|             developer.log( | ||||
|               'IPC path $socketPath not available: $e', | ||||
|               name: kRpcIpcLogPrefix, | ||||
| @@ -145,22 +150,20 @@ class ActivityRpcServer { | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   // Start the WebSocket server | ||||
|   // Start the server | ||||
|   Future<void> start() async { | ||||
|     int port = portRange[0]; | ||||
|     bool wsSuccess = false; | ||||
|  | ||||
|     // Start WebSocket server | ||||
|     while (port <= portRange[1]) { | ||||
|       developer.log('trying port $port', name: kRpcLogPrefix); | ||||
|       developer.log('Trying port $port', name: kRpcLogPrefix); | ||||
|       try { | ||||
|         // Start HTTP server | ||||
|         _httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, port); | ||||
|         developer.log('listening on $port', name: kRpcLogPrefix); | ||||
|         developer.log('Listening on $port', name: kRpcLogPrefix); | ||||
|  | ||||
|         // Handle WebSocket upgrades | ||||
|         shelf_io.serveRequests(_httpServer!, (Request request) async { | ||||
|           developer.log('new request', name: kRpcLogPrefix); | ||||
|           developer.log('New request', name: kRpcLogPrefix); | ||||
|           if (request.headers['upgrade']?.toLowerCase() == 'websocket') { | ||||
|             final handler = webSocketHandler((WebSocketChannel channel, _) { | ||||
|               _wsSockets.add(channel); | ||||
| @@ -169,7 +172,7 @@ class ActivityRpcServer { | ||||
|             return handler(request); | ||||
|           } | ||||
|           developer.log( | ||||
|             'new request disposed due to not websocket', | ||||
|             'New request disposed due to not websocket', | ||||
|             name: kRpcLogPrefix, | ||||
|           ); | ||||
|           return Response.notFound('Not a WebSocket request'); | ||||
| @@ -178,12 +181,12 @@ class ActivityRpcServer { | ||||
|         break; | ||||
|       } catch (e) { | ||||
|         if (e is SocketException && e.osError?.errorCode == 98) { | ||||
|           // EADDRINUSE | ||||
|           developer.log('$port in use!', name: kRpcLogPrefix); | ||||
|         } else { | ||||
|           developer.log('http error: $e', name: kRpcLogPrefix); | ||||
|           developer.log('HTTP error: $e', name: kRpcLogPrefix); | ||||
|         } | ||||
|         port++; | ||||
|         await Future.delayed(Duration(milliseconds: 100)); // Add delay | ||||
|       } | ||||
|     } | ||||
|  | ||||
| @@ -193,54 +196,185 @@ class ActivityRpcServer { | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     // Start IPC server (skip on macOS due to sandboxing) | ||||
|     final shouldStartIpc = !Platform.isMacOS; | ||||
|     // Start IPC server | ||||
|     final shouldStartIpc = !Platform.isMacOS && !kIsWeb; | ||||
|     if (shouldStartIpc) { | ||||
|       try { | ||||
|         final ipcPath = await _findAvailableIpcPath(); | ||||
|         _ipcServer = await ServerSocket.bind( | ||||
|           InternetAddress(ipcPath, type: InternetAddressType.unix), | ||||
|           0, | ||||
|         ); | ||||
|         developer.log('IPC listening at $ipcPath', name: kRpcIpcLogPrefix); | ||||
|       if (Platform.isWindows) { | ||||
|         await _startWindowsIpcServer(); | ||||
|       } else { | ||||
|         try { | ||||
|           final ipcPath = await _findAvailableIpcPath(); | ||||
|           _ipcServer = await ServerSocket.bind( | ||||
|             InternetAddress(ipcPath, type: InternetAddressType.unix), | ||||
|             0, | ||||
|           ); | ||||
|           developer.log('IPC listening at $ipcPath', name: kRpcIpcLogPrefix); | ||||
|  | ||||
|         _ipcServer!.listen((Socket socket) { | ||||
|           _onIpcConnection(socket); | ||||
|         }); | ||||
|       } catch (e) { | ||||
|         developer.log('IPC server error: $e', name: kRpcIpcLogPrefix); | ||||
|         // Continue without IPC if it fails | ||||
|           _ipcServer!.listen((Socket socket) { | ||||
|             _onIpcConnection(socket); | ||||
|           }); | ||||
|         } catch (e) { | ||||
|           developer.log('IPC server error: $e', name: kRpcIpcLogPrefix); | ||||
|         } | ||||
|       } | ||||
|     } else { | ||||
|       developer.log( | ||||
|         'IPC server disabled on macOS in production mode due to sandboxing', | ||||
|         'IPC server disabled on macOS or web in production mode', | ||||
|         name: kRpcIpcLogPrefix, | ||||
|       ); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Start Windows-specific IPC server using Winsock2 named pipe | ||||
|   Future<void> _startWindowsIpcServer() async { | ||||
|     final pipeName = r'\\.\pipe\discord-ipc'.toNativeUtf16(); | ||||
|     try { | ||||
|       _pipeHandle = CreateNamedPipe( | ||||
|         pipeName, | ||||
|         PIPE_ACCESS_DUPLEX, | ||||
|         PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, | ||||
|         PIPE_UNLIMITED_INSTANCES, | ||||
|         4096, // Output buffer size | ||||
|         4096, // Input buffer size | ||||
|         0, // Default timeout | ||||
|         nullptr, // Security attributes | ||||
|       ); | ||||
|  | ||||
|       if (_pipeHandle == INVALID_HANDLE_VALUE) { | ||||
|         final error = GetLastError(); | ||||
|         throw Exception('Failed to create named pipe: error code $error'); | ||||
|       } | ||||
|  | ||||
|       developer.log('IPC named pipe created at \\\\.\\pipe\\discord-ipc', name: kRpcIpcLogPrefix); | ||||
|  | ||||
|       // Start listening for connections in a separate isolate | ||||
|       _listenWindowsIpc(); | ||||
|     } finally { | ||||
|       free(pipeName); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Listen for Windows IPC connections in an isolate | ||||
|   void _listenWindowsIpc() async { | ||||
|     final receivePort = ReceivePort(); | ||||
|     await Isolate.spawn(_windowsIpcIsolate, receivePort.sendPort); | ||||
|  | ||||
|     receivePort.listen((message) { | ||||
|       if (message is int) { | ||||
|         final socketWrapper = _IpcSocketWrapper(message); | ||||
|         _ipcSockets.add(socketWrapper); | ||||
|         developer.log('New IPC connection on named pipe', name: kRpcIpcLogPrefix); | ||||
|         _handleWindowsIpcData(socketWrapper); | ||||
|         _startWindowsIpcServer(); // Create new pipe for next connection | ||||
|       } | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   static void _windowsIpcIsolate(SendPort sendPort) { | ||||
|     while (true) { | ||||
|       final pipeHandle = CreateNamedPipe( | ||||
|         r'\\.\pipe\discord-ipc'.toNativeUtf16(), | ||||
|         PIPE_ACCESS_DUPLEX, | ||||
|         PIPE_TYPE_MESSAGE | PIPE_READMODE_MESSAGE | PIPE_WAIT, | ||||
|         PIPE_UNLIMITED_INSTANCES, | ||||
|         4096, | ||||
|         4096, | ||||
|         0, | ||||
|         nullptr, | ||||
|       ); | ||||
|       if (pipeHandle == INVALID_HANDLE_VALUE) { | ||||
|         developer.log('Failed to create named pipe: ${GetLastError()}', name: kRpcIpcLogPrefix); | ||||
|         break; | ||||
|       } | ||||
|       final connected = ConnectNamedPipe(pipeHandle, nullptr); | ||||
|       if (connected != 0 || GetLastError() == ERROR_PIPE_CONNECTED) { | ||||
|         sendPort.send(pipeHandle); | ||||
|       } | ||||
|       // Avoid tight loop | ||||
|       sleep(Duration(milliseconds: 100)); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Handle Windows IPC data | ||||
|   void _handleWindowsIpcData(_IpcSocketWrapper socket) async { | ||||
|     final startTime = DateTime.now(); | ||||
|     final buffer = malloc.allocate<Uint8>(4096); | ||||
|     final bytesRead = malloc.allocate<Uint32>(sizeOf<Uint32>()); | ||||
|     try { | ||||
|       while (socket.pipeHandle != null) { | ||||
|         final readStart = DateTime.now(); | ||||
|         final success = ReadFile( | ||||
|           socket.pipeHandle!, | ||||
|           buffer.cast(), | ||||
|           4096, | ||||
|           bytesRead, | ||||
|           nullptr, | ||||
|         ); | ||||
|         final readDuration = DateTime.now().difference(readStart).inMicroseconds; | ||||
|         developer.log('ReadFile took $readDuration microseconds', name: kRpcIpcLogPrefix); | ||||
|  | ||||
|         if (success == FALSE && GetLastError() != ERROR_MORE_DATA) { | ||||
|           developer.log('IPC read error: ${GetLastError()}', name: kRpcIpcLogPrefix); | ||||
|           socket.close(); | ||||
|           break; | ||||
|         } | ||||
|  | ||||
|         final data = buffer.asTypedList(bytesRead.value); | ||||
|         socket.addData(data); | ||||
|         final packets = socket.readPackets(); | ||||
|         for (final packet in packets) { | ||||
|           _handleIpcPacket(socket, packet); | ||||
|         } | ||||
|       } | ||||
|     } catch (e) { | ||||
|       developer.log('IPC data error: $e', name: kRpcIpcLogPrefix); | ||||
|       socket.closeWithCode(IpcCloseCodes.closeUnsupported, e.toString()); | ||||
|     } finally { | ||||
|       malloc.free(buffer); | ||||
|       malloc.free(bytesRead); | ||||
|       final totalDuration = DateTime.now().difference(startTime).inMicroseconds; | ||||
|       developer.log('handleWindowsIpcData took $totalDuration microseconds', name: kRpcIpcLogPrefix); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   // Stop the server | ||||
|   Future<void> stop() async { | ||||
|     // Stop WebSocket server | ||||
|     for (var socket in _wsSockets) { | ||||
|       await socket.sink.close(); | ||||
|       try { | ||||
|         await socket.sink.close(); | ||||
|       } catch (e) { | ||||
|         developer.log('Error closing WebSocket: $e', name: kRpcLogPrefix); | ||||
|       } | ||||
|     } | ||||
|     _wsSockets.clear(); | ||||
|     await _httpServer?.close(); | ||||
|     await _httpServer?.close(force: true); | ||||
|  | ||||
|     // Stop IPC server | ||||
|     for (var socket in _ipcSockets) { | ||||
|       socket.close(); | ||||
|       try { | ||||
|         socket.close(); | ||||
|       } catch (e) { | ||||
|         developer.log('Error closing IPC socket: $e', name: kRpcIpcLogPrefix); | ||||
|       } | ||||
|     } | ||||
|     _ipcSockets.clear(); | ||||
|     if (Platform.isWindows && _pipeHandle != null) { | ||||
|       try { | ||||
|         CloseHandle(_pipeHandle!); | ||||
|       } catch (e) { | ||||
|         developer.log('Error closing named pipe: $e', name: kRpcIpcLogPrefix); | ||||
|       } | ||||
|       _pipeHandle = null; | ||||
|     } | ||||
|     _ipcTimer?.cancel(); | ||||
|     await _ipcServer?.close(); | ||||
|  | ||||
|     developer.log('servers stopped', name: kRpcLogPrefix); | ||||
|     developer.log('Servers stopped', name: kRpcLogPrefix); | ||||
|   } | ||||
|  | ||||
|   // Handle new WebSocket connection | ||||
|   void _onWsConnection(WebSocketChannel socket, Request request) { | ||||
|     // Parse query parameters | ||||
|     final uri = request.url; | ||||
|     final params = uri.queryParameters; | ||||
|     final ver = int.tryParse(params['v'] ?? '1') ?? 1; | ||||
| @@ -249,43 +383,38 @@ class ActivityRpcServer { | ||||
|     final origin = request.headers['origin'] ?? ''; | ||||
|  | ||||
|     developer.log( | ||||
|       'new WS connection! origin: $origin, params: $params', | ||||
|       'New WS connection! origin: $origin, params: $params', | ||||
|       name: kRpcLogPrefix, | ||||
|     ); | ||||
|  | ||||
|     // Validate origin | ||||
|     if (origin.isNotEmpty && | ||||
|         ![ | ||||
|           'https://discord.com', | ||||
|           'https://ptb.discord.com', | ||||
|           'https://canary.discord.com', | ||||
|         ].contains(origin)) { | ||||
|       developer.log('disallowed origin: $origin', name: kRpcLogPrefix); | ||||
|       developer.log('Disallowed origin: $origin', name: kRpcLogPrefix); | ||||
|       socket.sink.close(); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Validate encoding | ||||
|     if (encoding != 'json') { | ||||
|       developer.log( | ||||
|         'unsupported encoding requested: $encoding', | ||||
|         'Unsupported encoding requested: $encoding', | ||||
|         name: kRpcLogPrefix, | ||||
|       ); | ||||
|       socket.sink.close(); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Validate version | ||||
|     if (ver != 1) { | ||||
|       developer.log('unsupported version requested: $ver', name: kRpcLogPrefix); | ||||
|       developer.log('Unsupported version requested: $ver', name: kRpcLogPrefix); | ||||
|       socket.sink.close(); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Store client info on socket | ||||
|     final socketWithMeta = _WsSocketWrapper(socket, clientId, encoding); | ||||
|  | ||||
|     // Set up event listeners | ||||
|     socket.stream.listen( | ||||
|       (data) => _onWsMessage(socketWithMeta, data), | ||||
|       onError: (e) { | ||||
| @@ -298,18 +427,16 @@ class ActivityRpcServer { | ||||
|       }, | ||||
|     ); | ||||
|  | ||||
|     // Notify handler of new connection | ||||
|     handlers['connection']?.call(socketWithMeta); | ||||
|   } | ||||
|  | ||||
|   // Handle new IPC connection | ||||
|   void _onIpcConnection(Socket socket) { | ||||
|     developer.log('new IPC connection!', name: kRpcIpcLogPrefix); | ||||
|     developer.log('New IPC connection!', name: kRpcIpcLogPrefix); | ||||
|  | ||||
|     final socketWrapper = _IpcSocketWrapper(socket); | ||||
|     final socketWrapper = _IpcSocketWrapper.fromSocket(socket); | ||||
|     _ipcSockets.add(socketWrapper); | ||||
|  | ||||
|     // Set up event listeners | ||||
|     socket.listen( | ||||
|       (data) => _onIpcData(socketWrapper, data), | ||||
|       onError: (e) { | ||||
| @@ -325,9 +452,17 @@ class ActivityRpcServer { | ||||
|   } | ||||
|  | ||||
|   // Handle incoming WebSocket message | ||||
|   void _onWsMessage(_WsSocketWrapper socket, dynamic data) { | ||||
|   Future<void> _onWsMessage(_WsSocketWrapper socket, dynamic data) async { | ||||
|     if (data is! String) { | ||||
|       developer.log('Invalid WebSocket message: not a string', name: kRpcLogPrefix); | ||||
|       return; | ||||
|     } | ||||
|     try { | ||||
|       final jsonData = jsonDecode(data as String); | ||||
|       final jsonData = await compute(jsonDecode, data); | ||||
|       if (jsonData is! Map<String, dynamic>) { | ||||
|         developer.log('Invalid WebSocket message: not a JSON object', name: kRpcLogPrefix); | ||||
|         return; | ||||
|       } | ||||
|       developer.log('WS message: $jsonData', name: kRpcLogPrefix); | ||||
|       handlers['message']?.call(socket, jsonData); | ||||
|     } catch (e) { | ||||
| @@ -359,7 +494,6 @@ class ActivityRpcServer { | ||||
|  | ||||
|       case IpcTypes.pong: | ||||
|         developer.log('IPC pong received', name: kRpcIpcLogPrefix); | ||||
|         // Handle pong if needed | ||||
|         break; | ||||
|  | ||||
|       case IpcTypes.handshake: | ||||
| @@ -394,7 +528,6 @@ class ActivityRpcServer { | ||||
|     final ver = int.tryParse(params['v']?.toString() ?? '1') ?? 1; | ||||
|     final clientId = params['client_id']?.toString() ?? ''; | ||||
|  | ||||
|     // Validate version | ||||
|     if (ver != 1) { | ||||
|       developer.log( | ||||
|         'IPC unsupported version requested: $ver', | ||||
| @@ -404,7 +537,6 @@ class ActivityRpcServer { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // Validate client ID | ||||
|     if (clientId.isEmpty) { | ||||
|       developer.log('IPC client ID required', name: kRpcIpcLogPrefix); | ||||
|       socket.closeWithCode(IpcErrorCodes.invalidClientId); | ||||
| @@ -413,7 +545,6 @@ class ActivityRpcServer { | ||||
|  | ||||
|     socket.clientId = clientId; | ||||
|  | ||||
|     // Notify handler of new connection | ||||
|     handlers['connection']?.call(socket); | ||||
|   } | ||||
| } | ||||
| @@ -434,12 +565,14 @@ class _WsSocketWrapper { | ||||
|  | ||||
| // IPC wrapper | ||||
| class _IpcSocketWrapper { | ||||
|   final Socket socket; | ||||
|   final Socket? socket; | ||||
|   final int? pipeHandle; | ||||
|   String clientId = ''; | ||||
|   bool handshook = false; | ||||
|   final List<int> _buffer = []; | ||||
|  | ||||
|   _IpcSocketWrapper(this.socket); | ||||
|   _IpcSocketWrapper(this.pipeHandle) : socket = null; | ||||
|   _IpcSocketWrapper.fromSocket(this.socket) : pipeHandle = null; | ||||
|  | ||||
|   void addData(List<int> data) { | ||||
|     _buffer.addAll(data); | ||||
| @@ -448,23 +581,82 @@ class _IpcSocketWrapper { | ||||
|   void send(Map<String, dynamic> msg) { | ||||
|     developer.log('IPC sending: $msg', name: kRpcIpcLogPrefix); | ||||
|     final packet = ActivityRpcServer.encodeIpcPacket(IpcTypes.frame, msg); | ||||
|     socket.add(packet); | ||||
|     if (Platform.isWindows && pipeHandle != null) { | ||||
|       final buffer = malloc.allocate<Uint8>(packet.length); | ||||
|       buffer.asTypedList(packet.length).setAll(0, packet); | ||||
|       final bytesWritten = malloc.allocate<Uint32>(sizeOf<Uint32>()); | ||||
|       try { | ||||
|         WriteFile( | ||||
|           pipeHandle!, | ||||
|           buffer.cast(), | ||||
|           packet.length, | ||||
|           bytesWritten, | ||||
|           nullptr, | ||||
|         ); | ||||
|       } finally { | ||||
|         malloc.free(buffer); | ||||
|         malloc.free(bytesWritten); | ||||
|       } | ||||
|     } else { | ||||
|       socket?.add(packet); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void sendPong(dynamic data) { | ||||
|     final packet = ActivityRpcServer.encodeIpcPacket(IpcTypes.pong, data ?? {}); | ||||
|     socket.add(packet); | ||||
|     if (Platform.isWindows && pipeHandle != null) { | ||||
|       final buffer = malloc.allocate<Uint8>(packet.length); | ||||
|       buffer.asTypedList(packet.length).setAll(0, packet); | ||||
|       final bytesWritten = malloc.allocate<Uint32>(sizeOf<Uint32>()); | ||||
|       try { | ||||
|         WriteFile( | ||||
|           pipeHandle!, | ||||
|           buffer.cast(), | ||||
|           packet.length, | ||||
|           bytesWritten, | ||||
|           nullptr, | ||||
|         ); | ||||
|       } finally { | ||||
|         malloc.free(buffer); | ||||
|         malloc.free(bytesWritten); | ||||
|       } | ||||
|     } else { | ||||
|       socket?.add(packet); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void close() { | ||||
|     socket.close(); | ||||
|     if (Platform.isWindows && pipeHandle != null) { | ||||
|       CloseHandle(pipeHandle!); | ||||
|     } else { | ||||
|       socket?.close(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void closeWithCode(int code, [String message = '']) { | ||||
|     final closeData = {'code': code, 'message': message}; | ||||
|     final packet = ActivityRpcServer.encodeIpcPacket(IpcTypes.close, closeData); | ||||
|     socket.add(packet); | ||||
|     socket.close(); | ||||
|     if (Platform.isWindows && pipeHandle != null) { | ||||
|       final buffer = malloc.allocate<Uint8>(packet.length); | ||||
|       buffer.asTypedList(packet.length).setAll(0, packet); | ||||
|       final bytesWritten = malloc.allocate<Uint32>(sizeOf<Uint32>()); | ||||
|       try { | ||||
|         WriteFile( | ||||
|           pipeHandle!, | ||||
|           buffer.cast(), | ||||
|           packet.length, | ||||
|           bytesWritten, | ||||
|           nullptr, | ||||
|         ); | ||||
|       } finally { | ||||
|         malloc.free(buffer); | ||||
|         malloc.free(bytesWritten); | ||||
|       } | ||||
|       CloseHandle(pipeHandle!); | ||||
|     } else { | ||||
|       socket?.add(packet); | ||||
|       socket?.close(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   List<_IpcPacket> readPackets() { | ||||
| @@ -519,10 +711,9 @@ class ServerStateNotifier extends StateNotifier<ServerState> { | ||||
|   final ActivityRpcServer server; | ||||
|  | ||||
|   ServerStateNotifier(this.server) | ||||
|     : super(ServerState(status: 'Server not started')); | ||||
|       : super(ServerState(status: 'Server not started')); | ||||
|  | ||||
|   Future<void> start() async { | ||||
|     // Only start server on desktop platforms | ||||
|     if (!Platform.isAndroid && !Platform.isIOS && !kIsWeb) { | ||||
|       try { | ||||
|         await server.start(); | ||||
| @@ -547,77 +738,74 @@ class ServerStateNotifier extends StateNotifier<ServerState> { | ||||
| // Providers | ||||
| final rpcServerStateProvider = | ||||
|     StateNotifierProvider<ServerStateNotifier, ServerState>((ref) { | ||||
|       final server = ActivityRpcServer({}); | ||||
|       final notifier = ServerStateNotifier(server); | ||||
|       server.updateHandlers({ | ||||
|         'connection': (socket) { | ||||
|           final clientId = | ||||
|               socket is _WsSocketWrapper | ||||
|                   ? socket.clientId | ||||
|                   : (socket as _IpcSocketWrapper).clientId; | ||||
|           notifier.updateStatus('Client connected (ID: $clientId)'); | ||||
|           // Send READY event | ||||
|           socket.send({ | ||||
|             'cmd': 'DISPATCH', | ||||
|             'data': { | ||||
|               'v': 1, | ||||
|               'config': { | ||||
|                 'cdn_host': 'fake.cdn', | ||||
|                 'api_endpoint': '//fake.api', | ||||
|                 'environment': 'dev', | ||||
|               }, | ||||
|               'user': { | ||||
|                 'id': 'fake_user_id', | ||||
|                 'username': 'FakeUser', | ||||
|                 'discriminator': '0001', | ||||
|                 'avatar': null, | ||||
|                 'bot': false, | ||||
|               }, | ||||
|             }, | ||||
|             'evt': 'READY', | ||||
|             'nonce': '12345', // Should be dynamic | ||||
|           }); | ||||
|         }, | ||||
|         'message': (socket, dynamic data) async { | ||||
|           if (data['cmd'] == 'SET_ACTIVITY') { | ||||
|             notifier.addActivity( | ||||
|               'Activity: ${data['args']['activity']['details'] ?? 'Unknown'}', | ||||
|             ); | ||||
|             // Call setRemoteActivityStatus | ||||
|             final label = data['args']['activity']['details'] ?? 'Unknown'; | ||||
|             final appId = socket.clientId; | ||||
|             try { | ||||
|               await setRemoteActivityStatus(ref, label, appId); | ||||
|             } catch (e) { | ||||
|               developer.log( | ||||
|                 'Failed to set remote activity status: $e', | ||||
|                 name: kRpcLogPrefix, | ||||
|               ); | ||||
|             } | ||||
|             // Echo back success | ||||
|             socket.send({ | ||||
|               'cmd': 'SET_ACTIVITY', | ||||
|               'data': data['args']['activity'], | ||||
|               'evt': null, | ||||
|               'nonce': data['nonce'], | ||||
|             }); | ||||
|           } | ||||
|         }, | ||||
|         'close': (socket) async { | ||||
|           notifier.updateStatus('Client disconnected'); | ||||
|           final appId = socket.clientId; | ||||
|           try { | ||||
|             await unsetRemoteActivityStatus(ref, appId); | ||||
|           } catch (e) { | ||||
|             developer.log( | ||||
|               'Failed to unset remote activity status: $e', | ||||
|               name: kRpcLogPrefix, | ||||
|             ); | ||||
|           } | ||||
|   final server = ActivityRpcServer({}); | ||||
|   final notifier = ServerStateNotifier(server); | ||||
|   server.updateHandlers({ | ||||
|     'connection': (socket) { | ||||
|       final clientId = | ||||
|           socket is _WsSocketWrapper | ||||
|               ? socket.clientId | ||||
|               : (socket as _IpcSocketWrapper).clientId; | ||||
|       notifier.updateStatus('Client connected (ID: $clientId)'); | ||||
|       socket.send({ | ||||
|         'cmd': 'DISPATCH', | ||||
|         'data': { | ||||
|           'v': 1, | ||||
|           'config': { | ||||
|             'cdn_host': 'fake.cdn', | ||||
|             'api_endpoint': '//fake.api', | ||||
|             'environment': 'dev', | ||||
|           }, | ||||
|           'user': { | ||||
|             'id': 'fake_user_id', | ||||
|             'username': 'FakeUser', | ||||
|             'discriminator': '0001', | ||||
|             'avatar': null, | ||||
|             'bot': false, | ||||
|           }, | ||||
|         }, | ||||
|         'evt': 'READY', | ||||
|         'nonce': '12345', | ||||
|       }); | ||||
|       return notifier; | ||||
|     }); | ||||
|     }, | ||||
|     'message': (socket, dynamic data) async { | ||||
|       if (data['cmd'] == 'SET_ACTIVITY') { | ||||
|         notifier.addActivity( | ||||
|           'Activity: ${data['args']['activity']['details'] ?? 'Unknown'}', | ||||
|         ); | ||||
|         final label = data['args']['activity']['details'] ?? 'Unknown'; | ||||
|         final appId = socket.clientId; | ||||
|         try { | ||||
|           await setRemoteActivityStatus(ref, label, appId); | ||||
|         } catch (e) { | ||||
|           developer.log( | ||||
|             'Failed to set remote activity status: $e', | ||||
|             name: kRpcLogPrefix, | ||||
|           ); | ||||
|         } | ||||
|         socket.send({ | ||||
|           'cmd': 'SET_ACTIVITY', | ||||
|           'data': data['args']['activity'], | ||||
|           'evt': null, | ||||
|           'nonce': data['nonce'], | ||||
|         }); | ||||
|       } | ||||
|     }, | ||||
|     'close': (socket) async { | ||||
|       notifier.updateStatus('Client disconnected'); | ||||
|       final appId = socket.clientId; | ||||
|       try { | ||||
|         await unsetRemoteActivityStatus(ref, appId); | ||||
|       } catch (e) { | ||||
|         developer.log( | ||||
|           'Failed to unset remote activity status: $e', | ||||
|           name: kRpcLogPrefix, | ||||
|         ); | ||||
|       } | ||||
|     }, | ||||
|   }); | ||||
|   return notifier; | ||||
| }); | ||||
|  | ||||
| final rpcServerProvider = Provider<ActivityRpcServer>((ref) { | ||||
|   final notifier = ref.watch(rpcServerStateProvider.notifier); | ||||
| @@ -648,4 +836,4 @@ Future<void> unsetRemoteActivityStatus(Ref ref, String appId) async { | ||||
|     '/id/accounts/me/statuses', | ||||
|     queryParameters: {'app': appId}, | ||||
|   ); | ||||
| } | ||||
| } | ||||
| @@ -2,7 +2,6 @@ import 'dart:async'; | ||||
|  | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:gap/gap.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
|   | ||||
| @@ -1,230 +1,47 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:developer'; | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:firebase_messaging/firebase_messaging.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_local_notifications/flutter_local_notifications.dart'; | ||||
| import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:island/main.dart'; | ||||
| import 'package:island/route.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/websocket.dart'; | ||||
| import 'package:island/widgets/app_notification.dart'; | ||||
| import 'package:top_snackbar_flutter/top_snack_bar.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = | ||||
|     FlutterLocalNotificationsPlugin(); | ||||
|  | ||||
| AppLifecycleState _appLifecycleState = AppLifecycleState.resumed; | ||||
|  | ||||
| void _onAppLifecycleChanged(AppLifecycleState state) { | ||||
|   _appLifecycleState = state; | ||||
| } | ||||
| // Conditional imports based on platform | ||||
| import 'notify.windows.dart' as windows_notify; | ||||
| import 'notify.universal.dart' as universal_notify; | ||||
|  | ||||
| // Platform-specific delegation | ||||
| Future<void> initializeLocalNotifications() async { | ||||
|   const AndroidInitializationSettings initializationSettingsAndroid = | ||||
|       AndroidInitializationSettings('@mipmap/ic_launcher'); | ||||
|  | ||||
|   const DarwinInitializationSettings initializationSettingsIOS = | ||||
|       DarwinInitializationSettings(); | ||||
|  | ||||
|   const DarwinInitializationSettings initializationSettingsMacOS = | ||||
|       DarwinInitializationSettings(); | ||||
|  | ||||
|   const LinuxInitializationSettings initializationSettingsLinux = | ||||
|       LinuxInitializationSettings(defaultActionName: 'Open notification'); | ||||
|  | ||||
|   const WindowsInitializationSettings initializationSettingsWindows = | ||||
|       WindowsInitializationSettings( | ||||
|         appName: 'Island', | ||||
|         appUserModelId: 'dev.solsynth.solian', | ||||
|         guid: 'dev.solsynth.solian', | ||||
|       ); | ||||
|  | ||||
|   const InitializationSettings initializationSettings = InitializationSettings( | ||||
|     android: initializationSettingsAndroid, | ||||
|     iOS: initializationSettingsIOS, | ||||
|     macOS: initializationSettingsMacOS, | ||||
|     linux: initializationSettingsLinux, | ||||
|     windows: initializationSettingsWindows, | ||||
|   ); | ||||
|  | ||||
|   await flutterLocalNotificationsPlugin.initialize( | ||||
|     initializationSettings, | ||||
|     onDidReceiveNotificationResponse: (NotificationResponse response) async { | ||||
|       final payload = response.payload; | ||||
|       if (payload != null) { | ||||
|         if (payload.startsWith('/')) { | ||||
|           // In-app routes | ||||
|           rootNavigatorKey.currentContext?.push(payload); | ||||
|         } else { | ||||
|           // External URLs | ||||
|           launchUrlString(payload); | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|   ); | ||||
|  | ||||
|   WidgetsBinding.instance.addObserver( | ||||
|     LifecycleEventHandler(onAppLifecycleChanged: _onAppLifecycleChanged), | ||||
|   ); | ||||
| } | ||||
|  | ||||
| class LifecycleEventHandler extends WidgetsBindingObserver { | ||||
|   final void Function(AppLifecycleState) onAppLifecycleChanged; | ||||
|  | ||||
|   LifecycleEventHandler({required this.onAppLifecycleChanged}); | ||||
|  | ||||
|   @override | ||||
|   void didChangeAppLifecycleState(AppLifecycleState state) { | ||||
|     onAppLifecycleChanged(state); | ||||
|   if (Platform.isWindows) { | ||||
|     return windows_notify.initializeLocalNotifications(); | ||||
|   } else { | ||||
|     return universal_notify.initializeLocalNotifications(); | ||||
|   } | ||||
| } | ||||
|  | ||||
| StreamSubscription<WebSocketPacket> setupNotificationListener( | ||||
| StreamSubscription setupNotificationListener( | ||||
|   BuildContext context, | ||||
|   WidgetRef ref, | ||||
| ) { | ||||
|   final ws = ref.watch(websocketProvider); | ||||
|   return ws.dataStream.listen((pkt) async { | ||||
|     if (pkt.type == "notifications.new") { | ||||
|       final notification = SnNotification.fromJson(pkt.data!); | ||||
|       if (_appLifecycleState == AppLifecycleState.resumed) { | ||||
|         // App is focused, show in-app notification | ||||
|         log( | ||||
|           '[Notification] Showing in-app notification: ${notification.title}', | ||||
|         ); | ||||
|         showTopSnackBar( | ||||
|           globalOverlay.currentState!, | ||||
|           Center( | ||||
|             child: ConstrainedBox( | ||||
|               constraints: const BoxConstraints(maxWidth: 480), | ||||
|               child: NotificationCard(notification: notification), | ||||
|             ), | ||||
|           ), | ||||
|           onTap: () { | ||||
|             if (notification.meta['action_uri'] != null) { | ||||
|               var uri = notification.meta['action_uri'] as String; | ||||
|               if (uri.startsWith('/')) { | ||||
|                 // In-app routes | ||||
|                 rootNavigatorKey.currentContext?.push( | ||||
|                   notification.meta['action_uri'], | ||||
|                 ); | ||||
|               } else { | ||||
|                 // External URLs | ||||
|                 launchUrlString(uri); | ||||
|               } | ||||
|             } | ||||
|           }, | ||||
|           onDismissed: () {}, | ||||
|           dismissType: DismissType.onSwipe, | ||||
|           displayDuration: const Duration(seconds: 5), | ||||
|           snackBarPosition: SnackBarPosition.top, | ||||
|           padding: EdgeInsets.only( | ||||
|             left: 16, | ||||
|             right: 16, | ||||
|             top: | ||||
|                 (!kIsWeb && | ||||
|                         (Platform.isMacOS || | ||||
|                             Platform.isWindows || | ||||
|                             Platform.isLinux)) | ||||
|                     ? 28 | ||||
|                     // ignore: use_build_context_synchronously | ||||
|                     : MediaQuery.of(context).padding.top + 16, | ||||
|             bottom: 16, | ||||
|           ), | ||||
|         ); | ||||
|       } else { | ||||
|         // App is in background, show system notification (only on supported platforms) | ||||
|         if (!kIsWeb && !Platform.isIOS) { | ||||
|           log( | ||||
|             '[Notification] Showing system notification: ${notification.title}', | ||||
|           ); | ||||
|           const AndroidNotificationDetails androidNotificationDetails = | ||||
|               AndroidNotificationDetails( | ||||
|                 'channel_id', | ||||
|                 'channel_name', | ||||
|                 channelDescription: 'channel_description', | ||||
|                 importance: Importance.max, | ||||
|                 priority: Priority.high, | ||||
|                 ticker: 'ticker', | ||||
|               ); | ||||
|           const NotificationDetails notificationDetails = NotificationDetails( | ||||
|             android: androidNotificationDetails, | ||||
|           ); | ||||
|           await flutterLocalNotificationsPlugin.show( | ||||
|             0, | ||||
|             notification.title, | ||||
|             notification.content, | ||||
|             notificationDetails, | ||||
|             payload: notification.meta['action_uri'] as String?, | ||||
|           ); | ||||
|         } else { | ||||
|           log( | ||||
|             '[Notification] Skipping system notification for unsupported platform: ${notification.title}', | ||||
|           ); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }); | ||||
|   if (Platform.isWindows) { | ||||
|     return windows_notify.setupNotificationListener(context, ref); | ||||
|   } else { | ||||
|     return universal_notify.setupNotificationListener(context, ref); | ||||
|   } | ||||
| } | ||||
|  | ||||
| Future<void> subscribePushNotification( | ||||
|   Dio apiClient, { | ||||
|   bool detailedErrors = false, | ||||
| }) async { | ||||
|   if (!kIsWeb && Platform.isLinux) { | ||||
|     return; | ||||
|   } | ||||
|   await FirebaseMessaging.instance.requestPermission( | ||||
|     alert: true, | ||||
|     badge: true, | ||||
|     sound: true, | ||||
|   ); | ||||
|  | ||||
|   String? deviceToken; | ||||
|   if (kIsWeb) { | ||||
|     deviceToken = await FirebaseMessaging.instance.getToken( | ||||
|       vapidKey: | ||||
|           "BFN2mkqyeI6oi4d2PAV4pfNyG3Jy0FBEblmmPrjmP0r5lHOPrxrcqLIWhM21R_cicF-j4Xhtr1kyDyDgJYRPLgU", | ||||
|     ); | ||||
|   } else if (Platform.isAndroid) { | ||||
|     deviceToken = await FirebaseMessaging.instance.getToken(); | ||||
|   } else if (Platform.isIOS) { | ||||
|     deviceToken = await FirebaseMessaging.instance.getAPNSToken(); | ||||
|   } | ||||
|  | ||||
|   FirebaseMessaging.instance.onTokenRefresh | ||||
|       .listen((fcmToken) { | ||||
|         _putTokenToRemote(apiClient, fcmToken, 1); | ||||
|       }) | ||||
|       .onError((err) { | ||||
|         log("Failed to get firebase cloud messaging push token: $err"); | ||||
|       }); | ||||
|  | ||||
|   if (deviceToken != null) { | ||||
|     _putTokenToRemote( | ||||
|   if (Platform.isWindows) { | ||||
|     return windows_notify.subscribePushNotification( | ||||
|       apiClient, | ||||
|       deviceToken, | ||||
|       !kIsWeb && (Platform.isIOS || Platform.isMacOS) ? 0 : 1, | ||||
|       detailedErrors: detailedErrors, | ||||
|     ); | ||||
|   } else { | ||||
|     return universal_notify.subscribePushNotification( | ||||
|       apiClient, | ||||
|       detailedErrors: detailedErrors, | ||||
|     ); | ||||
|   } else if (detailedErrors) { | ||||
|     throw Exception("Failed to get device token for push notifications."); | ||||
|   } | ||||
| } | ||||
|  | ||||
| Future<void> _putTokenToRemote( | ||||
|   Dio apiClient, | ||||
|   String token, | ||||
|   int provider, | ||||
| ) async { | ||||
|   await apiClient.put( | ||||
|     "/pusher/notifications/subscription", | ||||
|     data: {"provider": provider, "device_token": token}, | ||||
|   ); | ||||
| } | ||||
|   | ||||
							
								
								
									
										232
									
								
								lib/services/notify.universal.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										232
									
								
								lib/services/notify.universal.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,232 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:developer'; | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:firebase_messaging/firebase_messaging.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_local_notifications/flutter_local_notifications.dart'; | ||||
| import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:island/main.dart'; | ||||
| import 'package:island/route.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/websocket.dart'; | ||||
| import 'package:island/widgets/app_notification.dart'; | ||||
| import 'package:top_snackbar_flutter/top_snack_bar.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
|  | ||||
| final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin = | ||||
|     FlutterLocalNotificationsPlugin(); | ||||
|  | ||||
| AppLifecycleState _appLifecycleState = AppLifecycleState.resumed; | ||||
|  | ||||
| void _onAppLifecycleChanged(AppLifecycleState state) { | ||||
|   _appLifecycleState = state; | ||||
| } | ||||
|  | ||||
| Future<void> initializeLocalNotifications() async { | ||||
|   const AndroidInitializationSettings initializationSettingsAndroid = | ||||
|       AndroidInitializationSettings('@mipmap/ic_launcher'); | ||||
|  | ||||
|   const DarwinInitializationSettings initializationSettingsIOS = | ||||
|       DarwinInitializationSettings(); | ||||
|  | ||||
|   const DarwinInitializationSettings initializationSettingsMacOS = | ||||
|       DarwinInitializationSettings(); | ||||
|  | ||||
|   const LinuxInitializationSettings initializationSettingsLinux = | ||||
|       LinuxInitializationSettings(defaultActionName: 'Open notification'); | ||||
|  | ||||
|   const WindowsInitializationSettings initializationSettingsWindows = | ||||
|       WindowsInitializationSettings( | ||||
|         appName: 'Island', | ||||
|         appUserModelId: 'dev.solsynth.solian', | ||||
|         guid: 'dev.solsynth.solian', | ||||
|       ); | ||||
|  | ||||
|   const InitializationSettings initializationSettings = InitializationSettings( | ||||
|     android: initializationSettingsAndroid, | ||||
|     iOS: initializationSettingsIOS, | ||||
|     macOS: initializationSettingsMacOS, | ||||
|     linux: initializationSettingsLinux, | ||||
|     windows: initializationSettingsWindows, | ||||
|   ); | ||||
|  | ||||
|   await flutterLocalNotificationsPlugin.initialize( | ||||
|     initializationSettings, | ||||
|     onDidReceiveNotificationResponse: (NotificationResponse response) async { | ||||
|       final payload = response.payload; | ||||
|       if (payload != null) { | ||||
|         if (payload.startsWith('/')) { | ||||
|           // In-app routes | ||||
|           rootNavigatorKey.currentContext?.push(payload); | ||||
|         } else { | ||||
|           // External URLs | ||||
|           launchUrlString(payload); | ||||
|         } | ||||
|       } | ||||
|     }, | ||||
|   ); | ||||
|  | ||||
|   WidgetsBinding.instance.addObserver( | ||||
|     LifecycleEventHandler(onAppLifecycleChanged: _onAppLifecycleChanged), | ||||
|   ); | ||||
| } | ||||
|  | ||||
| class LifecycleEventHandler extends WidgetsBindingObserver { | ||||
|   final void Function(AppLifecycleState) onAppLifecycleChanged; | ||||
|  | ||||
|   LifecycleEventHandler({required this.onAppLifecycleChanged}); | ||||
|  | ||||
|   @override | ||||
|   void didChangeAppLifecycleState(AppLifecycleState state) { | ||||
|     onAppLifecycleChanged(state); | ||||
|   } | ||||
| } | ||||
|  | ||||
| StreamSubscription<WebSocketPacket> setupNotificationListener( | ||||
|   BuildContext context, | ||||
|   WidgetRef ref, | ||||
| ) { | ||||
|   final ws = ref.watch(websocketProvider); | ||||
|   return ws.dataStream.listen((pkt) async { | ||||
|     if (pkt.type == "notifications.new") { | ||||
|       final notification = SnNotification.fromJson(pkt.data!); | ||||
|       if (_appLifecycleState == AppLifecycleState.resumed) { | ||||
|         // App is focused, show in-app notification | ||||
|         log( | ||||
|           '[Notification] Showing in-app notification: ${notification.title}', | ||||
|         ); | ||||
|         showTopSnackBar( | ||||
|           globalOverlay.currentState!, | ||||
|           Center( | ||||
|             child: ConstrainedBox( | ||||
|               constraints: const BoxConstraints(maxWidth: 480), | ||||
|               child: NotificationCard(notification: notification), | ||||
|             ), | ||||
|           ), | ||||
|           onTap: () { | ||||
|             if (notification.meta['action_uri'] != null) { | ||||
|               var uri = notification.meta['action_uri'] as String; | ||||
|               if (uri.startsWith('/')) { | ||||
|                 // In-app routes | ||||
|                 rootNavigatorKey.currentContext?.push( | ||||
|                   notification.meta['action_uri'], | ||||
|                 ); | ||||
|               } else { | ||||
|                 // External URLs | ||||
|                 launchUrlString(uri); | ||||
|               } | ||||
|             } | ||||
|           }, | ||||
|           onDismissed: () {}, | ||||
|           dismissType: DismissType.onSwipe, | ||||
|           displayDuration: const Duration(seconds: 5), | ||||
|           snackBarPosition: SnackBarPosition.top, | ||||
|           padding: EdgeInsets.only( | ||||
|             left: 16, | ||||
|             right: 16, | ||||
|             top: | ||||
|                 (!kIsWeb && | ||||
|                         (Platform.isMacOS || | ||||
|                             Platform.isWindows || | ||||
|                             Platform.isLinux)) | ||||
|                     ? 28 | ||||
|                     // ignore: use_build_context_synchronously | ||||
|                     : MediaQuery.of(context).padding.top + 16, | ||||
|             bottom: 16, | ||||
|           ), | ||||
|         ); | ||||
|       } else { | ||||
|         // App is in background, show system notification (only on supported platforms) | ||||
|         if (!kIsWeb && !Platform.isIOS) { | ||||
|           log( | ||||
|             '[Notification] Showing system notification: ${notification.title}', | ||||
|           ); | ||||
|  | ||||
|           // Use flutter_local_notifications for universal platforms | ||||
|           const AndroidNotificationDetails androidNotificationDetails = | ||||
|               AndroidNotificationDetails( | ||||
|                 'channel_id', | ||||
|                 'channel_name', | ||||
|                 channelDescription: 'channel_description', | ||||
|                 importance: Importance.max, | ||||
|                 priority: Priority.high, | ||||
|                 ticker: 'ticker', | ||||
|               ); | ||||
|           const NotificationDetails notificationDetails = NotificationDetails( | ||||
|             android: androidNotificationDetails, | ||||
|           ); | ||||
|           await flutterLocalNotificationsPlugin.show( | ||||
|             0, | ||||
|             notification.title, | ||||
|             notification.content, | ||||
|             notificationDetails, | ||||
|             payload: notification.meta['action_uri'] as String?, | ||||
|           ); | ||||
|         } else { | ||||
|           log( | ||||
|             '[Notification] Skipping system notification for unsupported platform: ${notification.title}', | ||||
|           ); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }); | ||||
| } | ||||
|  | ||||
| Future<void> subscribePushNotification( | ||||
|   Dio apiClient, { | ||||
|   bool detailedErrors = false, | ||||
| }) async { | ||||
|   if (!kIsWeb && Platform.isLinux) { | ||||
|     return; | ||||
|   } | ||||
|   await FirebaseMessaging.instance.requestPermission( | ||||
|     alert: true, | ||||
|     badge: true, | ||||
|     sound: true, | ||||
|   ); | ||||
|  | ||||
|   String? deviceToken; | ||||
|   if (kIsWeb) { | ||||
|     deviceToken = await FirebaseMessaging.instance.getToken( | ||||
|       vapidKey: | ||||
|           "BFN2mkqyeI6oi4d2PAV4pfNyG3Jy0FBEblmmPrjmP0r5lHOPrxrcqLIWhM21R_cicF-j4Xhtr1kyDyDgJYRPLgU", | ||||
|     ); | ||||
|   } else if (Platform.isAndroid) { | ||||
|     deviceToken = await FirebaseMessaging.instance.getToken(); | ||||
|   } else if (Platform.isIOS) { | ||||
|     deviceToken = await FirebaseMessaging.instance.getAPNSToken(); | ||||
|   } | ||||
|  | ||||
|   FirebaseMessaging.instance.onTokenRefresh | ||||
|       .listen((fcmToken) { | ||||
|         _putTokenToRemote(apiClient, fcmToken, 1); | ||||
|       }) | ||||
|       .onError((err) { | ||||
|         log("Failed to get firebase cloud messaging push token: $err"); | ||||
|       }); | ||||
|  | ||||
|   if (deviceToken != null) { | ||||
|     _putTokenToRemote( | ||||
|       apiClient, | ||||
|       deviceToken, | ||||
|       !kIsWeb && (Platform.isIOS || Platform.isMacOS) ? 0 : 1, | ||||
|     ); | ||||
|   } else if (detailedErrors) { | ||||
|     throw Exception("Failed to get device token for push notifications."); | ||||
|   } | ||||
| } | ||||
|  | ||||
| Future<void> _putTokenToRemote( | ||||
|   Dio apiClient, | ||||
|   String token, | ||||
|   int provider, | ||||
| ) async { | ||||
|   await apiClient.put( | ||||
|     "/pusher/notifications/subscription", | ||||
|     data: {"provider": provider, "device_token": token}, | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										176
									
								
								lib/services/notify.windows.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										176
									
								
								lib/services/notify.windows.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,176 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:developer'; | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:firebase_messaging/firebase_messaging.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_riverpod/flutter_riverpod.dart'; | ||||
| import 'package:go_router/go_router.dart'; | ||||
| import 'package:island/main.dart'; | ||||
| import 'package:island/route.dart'; | ||||
| import 'package:island/models/account.dart'; | ||||
| import 'package:island/pods/websocket.dart'; | ||||
| import 'package:island/widgets/app_notification.dart'; | ||||
| import 'package:top_snackbar_flutter/top_snack_bar.dart'; | ||||
| import 'package:url_launcher/url_launcher_string.dart'; | ||||
| import 'package:windows_notification/windows_notification.dart' | ||||
|     as windows_notification; | ||||
| import 'package:windows_notification/notification_message.dart'; | ||||
|  | ||||
| import 'package:dio/dio.dart'; | ||||
|  | ||||
| // Windows notification instance | ||||
| windows_notification.WindowsNotification? windowsNotification; | ||||
|  | ||||
| AppLifecycleState _appLifecycleState = AppLifecycleState.resumed; | ||||
|  | ||||
| void _onAppLifecycleChanged(AppLifecycleState state) { | ||||
|   _appLifecycleState = state; | ||||
| } | ||||
|  | ||||
| Future<void> initializeLocalNotifications() async { | ||||
|   // Initialize Windows notification for Windows platform | ||||
|   windowsNotification = windows_notification.WindowsNotification( | ||||
|     applicationId: 'dev.solsynth.solian', | ||||
|   ); | ||||
|  | ||||
|   WidgetsBinding.instance.addObserver( | ||||
|     LifecycleEventHandler(onAppLifecycleChanged: _onAppLifecycleChanged), | ||||
|   ); | ||||
| } | ||||
|  | ||||
| class LifecycleEventHandler extends WidgetsBindingObserver { | ||||
|   final void Function(AppLifecycleState) onAppLifecycleChanged; | ||||
|  | ||||
|   LifecycleEventHandler({required this.onAppLifecycleChanged}); | ||||
|  | ||||
|   @override | ||||
|   void didChangeAppLifecycleState(AppLifecycleState state) { | ||||
|     onAppLifecycleChanged(state); | ||||
|   } | ||||
| } | ||||
|  | ||||
| StreamSubscription<WebSocketPacket> setupNotificationListener( | ||||
|   BuildContext context, | ||||
|   WidgetRef ref, | ||||
| ) { | ||||
|   final ws = ref.watch(websocketProvider); | ||||
|   return ws.dataStream.listen((pkt) async { | ||||
|     if (pkt.type == "notifications.new") { | ||||
|       final notification = SnNotification.fromJson(pkt.data!); | ||||
|       if (_appLifecycleState == AppLifecycleState.resumed) { | ||||
|         // App is focused, show in-app notification | ||||
|         log( | ||||
|           '[Notification] Showing in-app notification: ${notification.title}', | ||||
|         ); | ||||
|         showTopSnackBar( | ||||
|           globalOverlay.currentState!, | ||||
|           Center( | ||||
|             child: ConstrainedBox( | ||||
|               constraints: const BoxConstraints(maxWidth: 480), | ||||
|               child: NotificationCard(notification: notification), | ||||
|             ), | ||||
|           ), | ||||
|           onTap: () { | ||||
|             if (notification.meta['action_uri'] != null) { | ||||
|               var uri = notification.meta['action_uri'] as String; | ||||
|               if (uri.startsWith('/')) { | ||||
|                 // In-app routes | ||||
|                 rootNavigatorKey.currentContext?.push( | ||||
|                   notification.meta['action_uri'], | ||||
|                 ); | ||||
|               } else { | ||||
|                 // External URLs | ||||
|                 launchUrlString(uri); | ||||
|               } | ||||
|             } | ||||
|           }, | ||||
|           onDismissed: () {}, | ||||
|           dismissType: DismissType.onSwipe, | ||||
|           displayDuration: const Duration(seconds: 5), | ||||
|           snackBarPosition: SnackBarPosition.top, | ||||
|           padding: EdgeInsets.only( | ||||
|             left: 16, | ||||
|             right: 16, | ||||
|             top: 28, // Windows specific padding | ||||
|             bottom: 16, | ||||
|           ), | ||||
|         ); | ||||
|       } else { | ||||
|         // App is in background, show Windows system notification | ||||
|         log( | ||||
|           '[Notification] Showing Windows system notification: ${notification.title}', | ||||
|         ); | ||||
|  | ||||
|         if (windowsNotification != null) { | ||||
|           // Use Windows notification for Windows platform | ||||
|           final notificationMessage = NotificationMessage.fromPluginTemplate( | ||||
|             DateTime.now().millisecondsSinceEpoch.toString(), // unique id | ||||
|             notification.title, | ||||
|             notification.content, | ||||
|             launch: notification.meta['action_uri'] as String?, | ||||
|           ); | ||||
|           await windowsNotification!.showNotificationPluginTemplate( | ||||
|             notificationMessage, | ||||
|           ); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   }); | ||||
| } | ||||
|  | ||||
| Future<void> subscribePushNotification( | ||||
|   Dio apiClient, { | ||||
|   bool detailedErrors = false, | ||||
| }) async { | ||||
|   if (!kIsWeb && Platform.isLinux) { | ||||
|     return; | ||||
|   } | ||||
|   await FirebaseMessaging.instance.requestPermission( | ||||
|     alert: true, | ||||
|     badge: true, | ||||
|     sound: true, | ||||
|   ); | ||||
|  | ||||
|   String? deviceToken; | ||||
|   if (kIsWeb) { | ||||
|     deviceToken = await FirebaseMessaging.instance.getToken( | ||||
|       vapidKey: | ||||
|           "BFN2mkqyeI6oi4d2PAV4pfNyG3Jy0FBEblmmPrjmP0r5lHOPrxrcqLIWhM21R_cicF-j4Xhtr1kyDyDgJYRPLgU", | ||||
|     ); | ||||
|   } else if (Platform.isAndroid) { | ||||
|     deviceToken = await FirebaseMessaging.instance.getToken(); | ||||
|   } else if (Platform.isIOS) { | ||||
|     deviceToken = await FirebaseMessaging.instance.getAPNSToken(); | ||||
|   } | ||||
|  | ||||
|   FirebaseMessaging.instance.onTokenRefresh | ||||
|       .listen((fcmToken) { | ||||
|         _putTokenToRemote(apiClient, fcmToken, 1); | ||||
|       }) | ||||
|       .onError((err) { | ||||
|         log("Failed to get firebase cloud messaging push token: $err"); | ||||
|       }); | ||||
|  | ||||
|   if (deviceToken != null) { | ||||
|     _putTokenToRemote( | ||||
|       apiClient, | ||||
|       deviceToken, | ||||
|       !kIsWeb && (Platform.isIOS || Platform.isMacOS) ? 0 : 1, | ||||
|     ); | ||||
|   } else if (detailedErrors) { | ||||
|     throw Exception("Failed to get device token for push notifications."); | ||||
|   } | ||||
| } | ||||
|  | ||||
| Future<void> _putTokenToRemote( | ||||
|   Dio apiClient, | ||||
|   String token, | ||||
|   int provider, | ||||
| ) async { | ||||
|   await apiClient.put( | ||||
|     "/pusher/notifications/subscription", | ||||
|     data: {"provider": provider, "device_token": token}, | ||||
|   ); | ||||
| } | ||||
							
								
								
									
										26
									
								
								pubspec.lock
									
									
									
									
									
								
							
							
						
						
									
										26
									
								
								pubspec.lock
									
									
									
									
									
								
							| @@ -546,7 +546,7 @@ packages: | ||||
|     source: hosted | ||||
|     version: "1.3.3" | ||||
|   ffi: | ||||
|     dependency: transitive | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: ffi | ||||
|       sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" | ||||
| @@ -1281,10 +1281,10 @@ packages: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: image_picker_android | ||||
|       sha256: "28f3987ca0ec702d346eae1d90eda59603a2101b52f1e234ded62cff1d5cfa6e" | ||||
|       sha256: a45bef33deb24839a51fb85a4d9e504ead2b1ad1c4779d02d09bf6a8857cdd52 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "0.8.13+1" | ||||
|     version: "0.8.13+2" | ||||
|   image_picker_for_web: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -1449,10 +1449,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: local_auth_android | ||||
|       sha256: "48924f4a8b3cc45994ad5993e2e232d3b00788a305c1bf1c7db32cef281ce9a3" | ||||
|       sha256: "1ee0e63fb8b5c6fa286796b5fb1570d256857c2f4a262127e728b36b80a570cf" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.0.52" | ||||
|     version: "1.0.53" | ||||
|   local_auth_darwin: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -2576,10 +2576,10 @@ packages: | ||||
|     dependency: transitive | ||||
|     description: | ||||
|       name: url_launcher_android | ||||
|       sha256: "69ee86740f2847b9a4ba6cffa74ed12ce500bbe2b07f3dc1e643439da60637b7" | ||||
|       sha256: "81777b08c498a292d93ff2feead633174c386291e35612f8da438d6e92c4447e" | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "6.3.18" | ||||
|     version: "6.3.20" | ||||
|   url_launcher_ios: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -2765,7 +2765,7 @@ packages: | ||||
|     source: hosted | ||||
|     version: "1.3.0" | ||||
|   win32: | ||||
|     dependency: transitive | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: win32 | ||||
|       sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03" | ||||
| @@ -2780,6 +2780,14 @@ packages: | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "2.1.0" | ||||
|   windows_notification: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: windows_notification | ||||
|       sha256: be3e650874615f315402c9b9f3656e29af156709c4b5cc272cb4ca0ab7ba94a8 | ||||
|       url: "https://pub.dev" | ||||
|     source: hosted | ||||
|     version: "1.3.0" | ||||
|   xdg_directories: | ||||
|     dependency: transitive | ||||
|     description: | ||||
| @@ -2806,4 +2814,4 @@ packages: | ||||
|     version: "3.1.3" | ||||
| sdks: | ||||
|   dart: ">=3.9.0 <4.0.0" | ||||
|   flutter: ">=3.32.0" | ||||
|   flutter: ">=3.35.0" | ||||
|   | ||||
| @@ -76,7 +76,7 @@ dependencies: | ||||
|   file_picker: ^10.3.2 | ||||
|   riverpod_annotation: ^2.6.1 | ||||
|   image_picker_platform_interface: ^2.11.0 | ||||
|   image_picker_android: ^0.8.13+1 | ||||
|   image_picker_android: ^0.8.13+2 | ||||
|   super_context_menu: ^0.9.1 | ||||
|   modal_bottom_sheet: ^3.0.0 | ||||
|   firebase_messaging: ^16.0.1 | ||||
| @@ -147,6 +147,9 @@ dependencies: | ||||
|   slide_countdown: ^2.0.2 | ||||
|   shelf: ^1.4.2 | ||||
|   shelf_web_socket: ^3.0.0 | ||||
|   windows_notification: ^1.3.0 | ||||
|   win32: ^5.14.0 | ||||
|   ffi: ^2.1.4 | ||||
|  | ||||
| dev_dependencies: | ||||
|   flutter_test: | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| ; ================================================== | ||||
| #define AppVersion "3.2.0" | ||||
| #define BuildNumber "124" | ||||
| #define BuildNumber "132" | ||||
| ; ================================================== | ||||
|  | ||||
| #define FullVersion AppVersion + "." + BuildNumber | ||||
|   | ||||
| @@ -31,6 +31,7 @@ | ||||
| #include <tray_manager/tray_manager_plugin.h> | ||||
| #include <url_launcher_windows/url_launcher_windows.h> | ||||
| #include <volume_controller/volume_controller_plugin_c_api.h> | ||||
| #include <windows_notification/windows_notification_plugin_c_api.h> | ||||
|  | ||||
| void RegisterPlugins(flutter::PluginRegistry* registry) { | ||||
|   BitsdojoWindowPluginRegisterWithRegistrar( | ||||
| @@ -83,4 +84,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) { | ||||
|       registry->GetRegistrarForPlugin("UrlLauncherWindows")); | ||||
|   VolumeControllerPluginCApiRegisterWithRegistrar( | ||||
|       registry->GetRegistrarForPlugin("VolumeControllerPluginCApi")); | ||||
|   WindowsNotificationPluginCApiRegisterWithRegistrar( | ||||
|       registry->GetRegistrarForPlugin("WindowsNotificationPluginCApi")); | ||||
| } | ||||
|   | ||||
| @@ -28,6 +28,7 @@ list(APPEND FLUTTER_PLUGIN_LIST | ||||
|   tray_manager | ||||
|   url_launcher_windows | ||||
|   volume_controller | ||||
|   windows_notification | ||||
| ) | ||||
|  | ||||
| list(APPEND FLUTTER_FFI_PLUGIN_LIST | ||||
|   | ||||
		Reference in New Issue
	
	Block a user