Compare commits
11 Commits
3.2.0+132
...
313ebc64cc
Author | SHA1 | Date | |
---|---|---|---|
|
313ebc64cc | ||
|
1ed8b1d0c1 | ||
4af816d931 | |||
1c058a4323 | |||
461ed1fcda | |||
5363afa558
|
|||
f0d2737da8
|
|||
1f2f80aa3e
|
|||
240a872e65
|
|||
c1ec6f0849 | |||
ab42686d4d |
@@ -181,7 +181,7 @@ class IslandApp extends HookConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
if (!kIsWeb && Platform.isLinux) {
|
if (!kIsWeb && (Platform.isLinux || Platform.isWindows)) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -1,7 +1,9 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'dart:developer' as developer;
|
import 'dart:developer' as developer;
|
||||||
|
import 'dart:ffi';
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
import 'dart:isolate';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:island/pods/network.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:shelf_web_socket/shelf_web_socket.dart';
|
||||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||||
import 'package:path/path.dart' as path;
|
import 'package:path/path.dart' as path;
|
||||||
|
import 'package:win32/win32.dart';
|
||||||
|
import 'package:ffi/ffi.dart';
|
||||||
|
|
||||||
const String kRpcLogPrefix = 'arRPC.websocket';
|
const String kRpcLogPrefix = 'arRPC.websocket';
|
||||||
const String kRpcIpcLogPrefix = 'arRPC.ipc';
|
const String kRpcIpcLogPrefix = 'arRPC.ipc';
|
||||||
@@ -43,12 +47,14 @@ class IpcErrorCodes {
|
|||||||
static const int invalidEncoding = 4005;
|
static const int invalidEncoding = 4005;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Reference https://github.com/OpenAsar/arrpc/blob/main/src/transports/ipc.js
|
||||||
class ActivityRpcServer {
|
class ActivityRpcServer {
|
||||||
static const List<int> portRange = [6463, 6472]; // Ports 6463–6472
|
static const List<int> portRange = [6463, 6472]; // Ports 6463–6472
|
||||||
Map<String, Function>
|
Map<String, Function> handlers; // {connection: (socket), message: (socket, data), close: (socket)}
|
||||||
handlers; // {connection: (socket), message: (socket, data), close: (socket)}
|
|
||||||
HttpServer? _httpServer;
|
HttpServer? _httpServer;
|
||||||
ServerSocket? _ipcServer;
|
ServerSocket? _ipcServer;
|
||||||
|
int? _pipeHandle; // For Windows named pipe
|
||||||
|
Timer? _ipcTimer; // Store timer for cancellation
|
||||||
final List<WebSocketChannel> _wsSockets = [];
|
final List<WebSocketChannel> _wsSockets = [];
|
||||||
final List<_IpcSocketWrapper> _ipcSockets = [];
|
final List<_IpcSocketWrapper> _ipcSockets = [];
|
||||||
|
|
||||||
@@ -79,6 +85,8 @@ class ActivityRpcServer {
|
|||||||
|
|
||||||
// Find available IPC socket path
|
// Find available IPC socket path
|
||||||
Future<String> _findAvailableIpcPath() async {
|
Future<String> _findAvailableIpcPath() async {
|
||||||
|
if (Platform.isWindows) return r'\\.\pipe\discord-ipc';
|
||||||
|
|
||||||
// Build list of directories to try, with macOS-specific handling
|
// Build list of directories to try, with macOS-specific handling
|
||||||
final baseDirs = <String>[];
|
final baseDirs = <String>[];
|
||||||
|
|
||||||
@@ -98,11 +106,11 @@ class ActivityRpcServer {
|
|||||||
|
|
||||||
// Add other standard directories
|
// Add other standard directories
|
||||||
final otherDirs = [
|
final otherDirs = [
|
||||||
Platform.environment['XDG_RUNTIME_DIR'], // User runtime directory
|
Platform.environment['XDG_RUNTIME_DIR'],
|
||||||
Platform.environment['TMPDIR'], // App container temp (fallback)
|
Platform.environment['TMPDIR'],
|
||||||
Platform.environment['TMP'],
|
Platform.environment['TMP'],
|
||||||
Platform.environment['TEMP'],
|
Platform.environment['TEMP'],
|
||||||
'/tmp', // System temp directory - most compatible
|
'/tmp',
|
||||||
];
|
];
|
||||||
|
|
||||||
baseDirs.addAll(
|
baseDirs.addAll(
|
||||||
@@ -118,7 +126,6 @@ class ActivityRpcServer {
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
socket.close();
|
socket.close();
|
||||||
// Clean up the test socket
|
|
||||||
try {
|
try {
|
||||||
await File(socketPath).delete();
|
await File(socketPath).delete();
|
||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
@@ -128,9 +135,7 @@ class ActivityRpcServer {
|
|||||||
);
|
);
|
||||||
return socketPath;
|
return socketPath;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// Path not available, try next
|
|
||||||
if (i == 0) {
|
if (i == 0) {
|
||||||
// Log only for the first attempt per directory
|
|
||||||
developer.log(
|
developer.log(
|
||||||
'IPC path $socketPath not available: $e',
|
'IPC path $socketPath not available: $e',
|
||||||
name: kRpcIpcLogPrefix,
|
name: kRpcIpcLogPrefix,
|
||||||
@@ -145,22 +150,20 @@ class ActivityRpcServer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the WebSocket server
|
// Start the server
|
||||||
Future<void> start() async {
|
Future<void> start() async {
|
||||||
int port = portRange[0];
|
int port = portRange[0];
|
||||||
bool wsSuccess = false;
|
bool wsSuccess = false;
|
||||||
|
|
||||||
// Start WebSocket server
|
// Start WebSocket server
|
||||||
while (port <= portRange[1]) {
|
while (port <= portRange[1]) {
|
||||||
developer.log('trying port $port', name: kRpcLogPrefix);
|
developer.log('Trying port $port', name: kRpcLogPrefix);
|
||||||
try {
|
try {
|
||||||
// Start HTTP server
|
|
||||||
_httpServer = await HttpServer.bind(InternetAddress.loopbackIPv4, port);
|
_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 {
|
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') {
|
if (request.headers['upgrade']?.toLowerCase() == 'websocket') {
|
||||||
final handler = webSocketHandler((WebSocketChannel channel, _) {
|
final handler = webSocketHandler((WebSocketChannel channel, _) {
|
||||||
_wsSockets.add(channel);
|
_wsSockets.add(channel);
|
||||||
@@ -169,7 +172,7 @@ class ActivityRpcServer {
|
|||||||
return handler(request);
|
return handler(request);
|
||||||
}
|
}
|
||||||
developer.log(
|
developer.log(
|
||||||
'new request disposed due to not websocket',
|
'New request disposed due to not websocket',
|
||||||
name: kRpcLogPrefix,
|
name: kRpcLogPrefix,
|
||||||
);
|
);
|
||||||
return Response.notFound('Not a WebSocket request');
|
return Response.notFound('Not a WebSocket request');
|
||||||
@@ -178,12 +181,12 @@ class ActivityRpcServer {
|
|||||||
break;
|
break;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
if (e is SocketException && e.osError?.errorCode == 98) {
|
if (e is SocketException && e.osError?.errorCode == 98) {
|
||||||
// EADDRINUSE
|
|
||||||
developer.log('$port in use!', name: kRpcLogPrefix);
|
developer.log('$port in use!', name: kRpcLogPrefix);
|
||||||
} else {
|
} else {
|
||||||
developer.log('http error: $e', name: kRpcLogPrefix);
|
developer.log('HTTP error: $e', name: kRpcLogPrefix);
|
||||||
}
|
}
|
||||||
port++;
|
port++;
|
||||||
|
await Future.delayed(Duration(milliseconds: 100)); // Add delay
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -193,54 +196,185 @@ class ActivityRpcServer {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start IPC server (skip on macOS due to sandboxing)
|
// Start IPC server
|
||||||
final shouldStartIpc = !Platform.isMacOS;
|
final shouldStartIpc = !Platform.isMacOS && !kIsWeb;
|
||||||
if (shouldStartIpc) {
|
if (shouldStartIpc) {
|
||||||
try {
|
if (Platform.isWindows) {
|
||||||
final ipcPath = await _findAvailableIpcPath();
|
await _startWindowsIpcServer();
|
||||||
_ipcServer = await ServerSocket.bind(
|
} else {
|
||||||
InternetAddress(ipcPath, type: InternetAddressType.unix),
|
try {
|
||||||
0,
|
final ipcPath = await _findAvailableIpcPath();
|
||||||
);
|
_ipcServer = await ServerSocket.bind(
|
||||||
developer.log('IPC listening at $ipcPath', name: kRpcIpcLogPrefix);
|
InternetAddress(ipcPath, type: InternetAddressType.unix),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
developer.log('IPC listening at $ipcPath', name: kRpcIpcLogPrefix);
|
||||||
|
|
||||||
_ipcServer!.listen((Socket socket) {
|
_ipcServer!.listen((Socket socket) {
|
||||||
_onIpcConnection(socket);
|
_onIpcConnection(socket);
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
developer.log('IPC server error: $e', name: kRpcIpcLogPrefix);
|
developer.log('IPC server error: $e', name: kRpcIpcLogPrefix);
|
||||||
// Continue without IPC if it fails
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
developer.log(
|
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,
|
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
|
// Stop the server
|
||||||
Future<void> stop() async {
|
Future<void> stop() async {
|
||||||
// Stop WebSocket server
|
// Stop WebSocket server
|
||||||
for (var socket in _wsSockets) {
|
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();
|
_wsSockets.clear();
|
||||||
await _httpServer?.close();
|
await _httpServer?.close(force: true);
|
||||||
|
|
||||||
// Stop IPC server
|
// Stop IPC server
|
||||||
for (var socket in _ipcSockets) {
|
for (var socket in _ipcSockets) {
|
||||||
socket.close();
|
try {
|
||||||
|
socket.close();
|
||||||
|
} catch (e) {
|
||||||
|
developer.log('Error closing IPC socket: $e', name: kRpcIpcLogPrefix);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_ipcSockets.clear();
|
_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();
|
await _ipcServer?.close();
|
||||||
|
|
||||||
developer.log('servers stopped', name: kRpcLogPrefix);
|
developer.log('Servers stopped', name: kRpcLogPrefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle new WebSocket connection
|
// Handle new WebSocket connection
|
||||||
void _onWsConnection(WebSocketChannel socket, Request request) {
|
void _onWsConnection(WebSocketChannel socket, Request request) {
|
||||||
// Parse query parameters
|
|
||||||
final uri = request.url;
|
final uri = request.url;
|
||||||
final params = uri.queryParameters;
|
final params = uri.queryParameters;
|
||||||
final ver = int.tryParse(params['v'] ?? '1') ?? 1;
|
final ver = int.tryParse(params['v'] ?? '1') ?? 1;
|
||||||
@@ -249,43 +383,38 @@ class ActivityRpcServer {
|
|||||||
final origin = request.headers['origin'] ?? '';
|
final origin = request.headers['origin'] ?? '';
|
||||||
|
|
||||||
developer.log(
|
developer.log(
|
||||||
'new WS connection! origin: $origin, params: $params',
|
'New WS connection! origin: $origin, params: $params',
|
||||||
name: kRpcLogPrefix,
|
name: kRpcLogPrefix,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Validate origin
|
|
||||||
if (origin.isNotEmpty &&
|
if (origin.isNotEmpty &&
|
||||||
![
|
![
|
||||||
'https://discord.com',
|
'https://discord.com',
|
||||||
'https://ptb.discord.com',
|
'https://ptb.discord.com',
|
||||||
'https://canary.discord.com',
|
'https://canary.discord.com',
|
||||||
].contains(origin)) {
|
].contains(origin)) {
|
||||||
developer.log('disallowed origin: $origin', name: kRpcLogPrefix);
|
developer.log('Disallowed origin: $origin', name: kRpcLogPrefix);
|
||||||
socket.sink.close();
|
socket.sink.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate encoding
|
|
||||||
if (encoding != 'json') {
|
if (encoding != 'json') {
|
||||||
developer.log(
|
developer.log(
|
||||||
'unsupported encoding requested: $encoding',
|
'Unsupported encoding requested: $encoding',
|
||||||
name: kRpcLogPrefix,
|
name: kRpcLogPrefix,
|
||||||
);
|
);
|
||||||
socket.sink.close();
|
socket.sink.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate version
|
|
||||||
if (ver != 1) {
|
if (ver != 1) {
|
||||||
developer.log('unsupported version requested: $ver', name: kRpcLogPrefix);
|
developer.log('Unsupported version requested: $ver', name: kRpcLogPrefix);
|
||||||
socket.sink.close();
|
socket.sink.close();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Store client info on socket
|
|
||||||
final socketWithMeta = _WsSocketWrapper(socket, clientId, encoding);
|
final socketWithMeta = _WsSocketWrapper(socket, clientId, encoding);
|
||||||
|
|
||||||
// Set up event listeners
|
|
||||||
socket.stream.listen(
|
socket.stream.listen(
|
||||||
(data) => _onWsMessage(socketWithMeta, data),
|
(data) => _onWsMessage(socketWithMeta, data),
|
||||||
onError: (e) {
|
onError: (e) {
|
||||||
@@ -298,18 +427,16 @@ class ActivityRpcServer {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
// Notify handler of new connection
|
|
||||||
handlers['connection']?.call(socketWithMeta);
|
handlers['connection']?.call(socketWithMeta);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle new IPC connection
|
// Handle new IPC connection
|
||||||
void _onIpcConnection(Socket socket) {
|
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);
|
_ipcSockets.add(socketWrapper);
|
||||||
|
|
||||||
// Set up event listeners
|
|
||||||
socket.listen(
|
socket.listen(
|
||||||
(data) => _onIpcData(socketWrapper, data),
|
(data) => _onIpcData(socketWrapper, data),
|
||||||
onError: (e) {
|
onError: (e) {
|
||||||
@@ -325,9 +452,17 @@ class ActivityRpcServer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Handle incoming WebSocket message
|
// 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 {
|
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);
|
developer.log('WS message: $jsonData', name: kRpcLogPrefix);
|
||||||
handlers['message']?.call(socket, jsonData);
|
handlers['message']?.call(socket, jsonData);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -359,7 +494,6 @@ class ActivityRpcServer {
|
|||||||
|
|
||||||
case IpcTypes.pong:
|
case IpcTypes.pong:
|
||||||
developer.log('IPC pong received', name: kRpcIpcLogPrefix);
|
developer.log('IPC pong received', name: kRpcIpcLogPrefix);
|
||||||
// Handle pong if needed
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case IpcTypes.handshake:
|
case IpcTypes.handshake:
|
||||||
@@ -394,7 +528,6 @@ class ActivityRpcServer {
|
|||||||
final ver = int.tryParse(params['v']?.toString() ?? '1') ?? 1;
|
final ver = int.tryParse(params['v']?.toString() ?? '1') ?? 1;
|
||||||
final clientId = params['client_id']?.toString() ?? '';
|
final clientId = params['client_id']?.toString() ?? '';
|
||||||
|
|
||||||
// Validate version
|
|
||||||
if (ver != 1) {
|
if (ver != 1) {
|
||||||
developer.log(
|
developer.log(
|
||||||
'IPC unsupported version requested: $ver',
|
'IPC unsupported version requested: $ver',
|
||||||
@@ -404,7 +537,6 @@ class ActivityRpcServer {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate client ID
|
|
||||||
if (clientId.isEmpty) {
|
if (clientId.isEmpty) {
|
||||||
developer.log('IPC client ID required', name: kRpcIpcLogPrefix);
|
developer.log('IPC client ID required', name: kRpcIpcLogPrefix);
|
||||||
socket.closeWithCode(IpcErrorCodes.invalidClientId);
|
socket.closeWithCode(IpcErrorCodes.invalidClientId);
|
||||||
@@ -413,7 +545,6 @@ class ActivityRpcServer {
|
|||||||
|
|
||||||
socket.clientId = clientId;
|
socket.clientId = clientId;
|
||||||
|
|
||||||
// Notify handler of new connection
|
|
||||||
handlers['connection']?.call(socket);
|
handlers['connection']?.call(socket);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -434,12 +565,14 @@ class _WsSocketWrapper {
|
|||||||
|
|
||||||
// IPC wrapper
|
// IPC wrapper
|
||||||
class _IpcSocketWrapper {
|
class _IpcSocketWrapper {
|
||||||
final Socket socket;
|
final Socket? socket;
|
||||||
|
final int? pipeHandle;
|
||||||
String clientId = '';
|
String clientId = '';
|
||||||
bool handshook = false;
|
bool handshook = false;
|
||||||
final List<int> _buffer = [];
|
final List<int> _buffer = [];
|
||||||
|
|
||||||
_IpcSocketWrapper(this.socket);
|
_IpcSocketWrapper(this.pipeHandle) : socket = null;
|
||||||
|
_IpcSocketWrapper.fromSocket(this.socket) : pipeHandle = null;
|
||||||
|
|
||||||
void addData(List<int> data) {
|
void addData(List<int> data) {
|
||||||
_buffer.addAll(data);
|
_buffer.addAll(data);
|
||||||
@@ -448,23 +581,82 @@ class _IpcSocketWrapper {
|
|||||||
void send(Map<String, dynamic> msg) {
|
void send(Map<String, dynamic> msg) {
|
||||||
developer.log('IPC sending: $msg', name: kRpcIpcLogPrefix);
|
developer.log('IPC sending: $msg', name: kRpcIpcLogPrefix);
|
||||||
final packet = ActivityRpcServer.encodeIpcPacket(IpcTypes.frame, msg);
|
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) {
|
void sendPong(dynamic data) {
|
||||||
final packet = ActivityRpcServer.encodeIpcPacket(IpcTypes.pong, 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() {
|
void close() {
|
||||||
socket.close();
|
if (Platform.isWindows && pipeHandle != null) {
|
||||||
|
CloseHandle(pipeHandle!);
|
||||||
|
} else {
|
||||||
|
socket?.close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void closeWithCode(int code, [String message = '']) {
|
void closeWithCode(int code, [String message = '']) {
|
||||||
final closeData = {'code': code, 'message': message};
|
final closeData = {'code': code, 'message': message};
|
||||||
final packet = ActivityRpcServer.encodeIpcPacket(IpcTypes.close, closeData);
|
final packet = ActivityRpcServer.encodeIpcPacket(IpcTypes.close, closeData);
|
||||||
socket.add(packet);
|
if (Platform.isWindows && pipeHandle != null) {
|
||||||
socket.close();
|
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() {
|
List<_IpcPacket> readPackets() {
|
||||||
@@ -519,10 +711,9 @@ class ServerStateNotifier extends StateNotifier<ServerState> {
|
|||||||
final ActivityRpcServer server;
|
final ActivityRpcServer server;
|
||||||
|
|
||||||
ServerStateNotifier(this.server)
|
ServerStateNotifier(this.server)
|
||||||
: super(ServerState(status: 'Server not started'));
|
: super(ServerState(status: 'Server not started'));
|
||||||
|
|
||||||
Future<void> start() async {
|
Future<void> start() async {
|
||||||
// Only start server on desktop platforms
|
|
||||||
if (!Platform.isAndroid && !Platform.isIOS && !kIsWeb) {
|
if (!Platform.isAndroid && !Platform.isIOS && !kIsWeb) {
|
||||||
try {
|
try {
|
||||||
await server.start();
|
await server.start();
|
||||||
@@ -547,77 +738,74 @@ class ServerStateNotifier extends StateNotifier<ServerState> {
|
|||||||
// Providers
|
// Providers
|
||||||
final rpcServerStateProvider =
|
final rpcServerStateProvider =
|
||||||
StateNotifierProvider<ServerStateNotifier, ServerState>((ref) {
|
StateNotifierProvider<ServerStateNotifier, ServerState>((ref) {
|
||||||
final server = ActivityRpcServer({});
|
final server = ActivityRpcServer({});
|
||||||
final notifier = ServerStateNotifier(server);
|
final notifier = ServerStateNotifier(server);
|
||||||
server.updateHandlers({
|
server.updateHandlers({
|
||||||
'connection': (socket) {
|
'connection': (socket) {
|
||||||
final clientId =
|
final clientId =
|
||||||
socket is _WsSocketWrapper
|
socket is _WsSocketWrapper
|
||||||
? socket.clientId
|
? socket.clientId
|
||||||
: (socket as _IpcSocketWrapper).clientId;
|
: (socket as _IpcSocketWrapper).clientId;
|
||||||
notifier.updateStatus('Client connected (ID: $clientId)');
|
notifier.updateStatus('Client connected (ID: $clientId)');
|
||||||
// Send READY event
|
socket.send({
|
||||||
socket.send({
|
'cmd': 'DISPATCH',
|
||||||
'cmd': 'DISPATCH',
|
'data': {
|
||||||
'data': {
|
'v': 1,
|
||||||
'v': 1,
|
'config': {
|
||||||
'config': {
|
'cdn_host': 'fake.cdn',
|
||||||
'cdn_host': 'fake.cdn',
|
'api_endpoint': '//fake.api',
|
||||||
'api_endpoint': '//fake.api',
|
'environment': 'dev',
|
||||||
'environment': 'dev',
|
},
|
||||||
},
|
'user': {
|
||||||
'user': {
|
'id': 'fake_user_id',
|
||||||
'id': 'fake_user_id',
|
'username': 'FakeUser',
|
||||||
'username': 'FakeUser',
|
'discriminator': '0001',
|
||||||
'discriminator': '0001',
|
'avatar': null,
|
||||||
'avatar': null,
|
'bot': false,
|
||||||
'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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
|
'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 rpcServerProvider = Provider<ActivityRpcServer>((ref) {
|
||||||
final notifier = ref.watch(rpcServerStateProvider.notifier);
|
final notifier = ref.watch(rpcServerStateProvider.notifier);
|
||||||
@@ -648,4 +836,4 @@ Future<void> unsetRemoteActivityStatus(Ref ref, String appId) async {
|
|||||||
'/id/accounts/me/statuses',
|
'/id/accounts/me/statuses',
|
||||||
queryParameters: {'app': appId},
|
queryParameters: {'app': appId},
|
||||||
);
|
);
|
||||||
}
|
}
|
@@ -2,7 +2,6 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:gap/gap.dart';
|
import 'package:gap/gap.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
@@ -48,11 +48,12 @@ class TrayService {
|
|||||||
void handleAction(MenuItem item) {
|
void handleAction(MenuItem item) {
|
||||||
switch (item.key) {
|
switch (item.key) {
|
||||||
case 'show_window':
|
case 'show_window':
|
||||||
if (appWindow.isVisible) {
|
() async {
|
||||||
appWindow.restore();
|
appWindow.show();
|
||||||
} else {
|
appWindow.restore();
|
||||||
appWindow.show();
|
await Future.delayed(const Duration(milliseconds: 32));
|
||||||
}
|
appWindow.show();
|
||||||
|
}();
|
||||||
break;
|
break;
|
||||||
case 'exit_app':
|
case 'exit_app':
|
||||||
appWindow.close();
|
appWindow.close();
|
||||||
|
@@ -1,230 +1,47 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
import 'dart:developer';
|
|
||||||
import 'dart:io';
|
import 'dart:io';
|
||||||
|
|
||||||
import 'package:dio/dio.dart';
|
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/material.dart';
|
||||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
|
||||||
import 'package:flutter_riverpod/flutter_riverpod.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 =
|
// Conditional imports based on platform
|
||||||
FlutterLocalNotificationsPlugin();
|
import 'notify.windows.dart' as windows_notify;
|
||||||
|
import 'notify.universal.dart' as universal_notify;
|
||||||
AppLifecycleState _appLifecycleState = AppLifecycleState.resumed;
|
|
||||||
|
|
||||||
void _onAppLifecycleChanged(AppLifecycleState state) {
|
|
||||||
_appLifecycleState = state;
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Platform-specific delegation
|
||||||
Future<void> initializeLocalNotifications() async {
|
Future<void> initializeLocalNotifications() async {
|
||||||
const AndroidInitializationSettings initializationSettingsAndroid =
|
if (Platform.isWindows) {
|
||||||
AndroidInitializationSettings('@mipmap/ic_launcher');
|
return windows_notify.initializeLocalNotifications();
|
||||||
|
} else {
|
||||||
const DarwinInitializationSettings initializationSettingsIOS =
|
return universal_notify.initializeLocalNotifications();
|
||||||
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(
|
StreamSubscription setupNotificationListener(
|
||||||
BuildContext context,
|
BuildContext context,
|
||||||
WidgetRef ref,
|
WidgetRef ref,
|
||||||
) {
|
) {
|
||||||
final ws = ref.watch(websocketProvider);
|
if (Platform.isWindows) {
|
||||||
return ws.dataStream.listen((pkt) async {
|
return windows_notify.setupNotificationListener(context, ref);
|
||||||
if (pkt.type == "notifications.new") {
|
} else {
|
||||||
final notification = SnNotification.fromJson(pkt.data!);
|
return universal_notify.setupNotificationListener(context, ref);
|
||||||
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}',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> subscribePushNotification(
|
Future<void> subscribePushNotification(
|
||||||
Dio apiClient, {
|
Dio apiClient, {
|
||||||
bool detailedErrors = false,
|
bool detailedErrors = false,
|
||||||
}) async {
|
}) async {
|
||||||
if (!kIsWeb && Platform.isLinux) {
|
if (Platform.isWindows) {
|
||||||
return;
|
return windows_notify.subscribePushNotification(
|
||||||
}
|
|
||||||
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,
|
apiClient,
|
||||||
deviceToken,
|
detailedErrors: detailedErrors,
|
||||||
!kIsWeb && (Platform.isIOS || Platform.isMacOS) ? 0 : 1,
|
);
|
||||||
|
} 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
|
source: hosted
|
||||||
version: "1.3.3"
|
version: "1.3.3"
|
||||||
ffi:
|
ffi:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: ffi
|
name: ffi
|
||||||
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418"
|
||||||
@@ -1281,10 +1281,10 @@ packages:
|
|||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: image_picker_android
|
name: image_picker_android
|
||||||
sha256: "28f3987ca0ec702d346eae1d90eda59603a2101b52f1e234ded62cff1d5cfa6e"
|
sha256: a45bef33deb24839a51fb85a4d9e504ead2b1ad1c4779d02d09bf6a8857cdd52
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.8.13+1"
|
version: "0.8.13+2"
|
||||||
image_picker_for_web:
|
image_picker_for_web:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -1449,10 +1449,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: local_auth_android
|
name: local_auth_android
|
||||||
sha256: "48924f4a8b3cc45994ad5993e2e232d3b00788a305c1bf1c7db32cef281ce9a3"
|
sha256: "1ee0e63fb8b5c6fa286796b5fb1570d256857c2f4a262127e728b36b80a570cf"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.52"
|
version: "1.0.53"
|
||||||
local_auth_darwin:
|
local_auth_darwin:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -2576,10 +2576,10 @@ packages:
|
|||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
name: url_launcher_android
|
name: url_launcher_android
|
||||||
sha256: "69ee86740f2847b9a4ba6cffa74ed12ce500bbe2b07f3dc1e643439da60637b7"
|
sha256: "81777b08c498a292d93ff2feead633174c386291e35612f8da438d6e92c4447e"
|
||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "6.3.18"
|
version: "6.3.20"
|
||||||
url_launcher_ios:
|
url_launcher_ios:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -2765,7 +2765,7 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "1.3.0"
|
version: "1.3.0"
|
||||||
win32:
|
win32:
|
||||||
dependency: transitive
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
name: win32
|
name: win32
|
||||||
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
|
sha256: "66814138c3562338d05613a6e368ed8cfb237ad6d64a9e9334be3f309acfca03"
|
||||||
@@ -2780,6 +2780,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.1.0"
|
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:
|
xdg_directories:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -2806,4 +2814,4 @@ packages:
|
|||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.9.0 <4.0.0"
|
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
|
file_picker: ^10.3.2
|
||||||
riverpod_annotation: ^2.6.1
|
riverpod_annotation: ^2.6.1
|
||||||
image_picker_platform_interface: ^2.11.0
|
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
|
super_context_menu: ^0.9.1
|
||||||
modal_bottom_sheet: ^3.0.0
|
modal_bottom_sheet: ^3.0.0
|
||||||
firebase_messaging: ^16.0.1
|
firebase_messaging: ^16.0.1
|
||||||
@@ -147,6 +147,9 @@ dependencies:
|
|||||||
slide_countdown: ^2.0.2
|
slide_countdown: ^2.0.2
|
||||||
shelf: ^1.4.2
|
shelf: ^1.4.2
|
||||||
shelf_web_socket: ^3.0.0
|
shelf_web_socket: ^3.0.0
|
||||||
|
windows_notification: ^1.3.0
|
||||||
|
win32: ^5.14.0
|
||||||
|
ffi: ^2.1.4
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
; ==================================================
|
; ==================================================
|
||||||
#define AppVersion "3.2.0"
|
#define AppVersion "3.2.0"
|
||||||
#define BuildNumber "124"
|
#define BuildNumber "132"
|
||||||
; ==================================================
|
; ==================================================
|
||||||
|
|
||||||
#define FullVersion AppVersion + "." + BuildNumber
|
#define FullVersion AppVersion + "." + BuildNumber
|
||||||
|
@@ -31,6 +31,7 @@
|
|||||||
#include <tray_manager/tray_manager_plugin.h>
|
#include <tray_manager/tray_manager_plugin.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
#include <volume_controller/volume_controller_plugin_c_api.h>
|
#include <volume_controller/volume_controller_plugin_c_api.h>
|
||||||
|
#include <windows_notification/windows_notification_plugin_c_api.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
BitsdojoWindowPluginRegisterWithRegistrar(
|
BitsdojoWindowPluginRegisterWithRegistrar(
|
||||||
@@ -83,4 +84,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
|
|||||||
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
registry->GetRegistrarForPlugin("UrlLauncherWindows"));
|
||||||
VolumeControllerPluginCApiRegisterWithRegistrar(
|
VolumeControllerPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("VolumeControllerPluginCApi"));
|
registry->GetRegistrarForPlugin("VolumeControllerPluginCApi"));
|
||||||
|
WindowsNotificationPluginCApiRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("WindowsNotificationPluginCApi"));
|
||||||
}
|
}
|
||||||
|
@@ -28,6 +28,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
|
|||||||
tray_manager
|
tray_manager
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
volume_controller
|
volume_controller
|
||||||
|
windows_notification
|
||||||
)
|
)
|
||||||
|
|
||||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||||
|
Reference in New Issue
Block a user