Compare commits

..

18 Commits

Author SHA1 Message Date
be414891ec 🐛 Fix pipe path 2025-09-13 17:59:33 +08:00
787876ab6a 🗑️ Removed unused method 2025-09-13 15:57:45 +08:00
8578cde620 🐛 Fix expired at must non null 2025-09-13 15:46:07 +08:00
14d55d45a8 🐛 Fix ipc server on web 2025-09-13 15:31:05 +08:00
724391584e 🐛 Fix and optimize alert 2025-09-13 15:31:05 +08:00
3a5e45808a 🐛 Fix firebase analytics not available on windows 2025-09-13 15:29:38 +08:00
488055955c ♻️ Splitting up ipc servers in unix and windows 2025-09-13 12:59:54 +08:00
LittleSheep
313ebc64cc 🔀 Merge pull request #177 from Texas0295/v3
[FIX] tray: ensure Show Window works reliably on Linux/Wayland
2025-09-12 21:07:56 +08:00
Texas0295
1ed8b1d0c1 [FIX] tray: ensure Show Window works reliably on Linux/Wayland
Avoid relying on appWindow.isVisible, which is not trustworthy under

bitsdojo on Linux/Wayland. Instead, always run show → restore → show

sequence to guarantee window is re-mapped and raised.


Signed-off-by: Texas0295<kimura@texas0295.top>
2025-09-12 13:26:15 +08:00
4af816d931 🐛 Fix windows rpc 2025-09-11 01:15:52 +08:00
1c058a4323 ♻️ Better windows support 2025-09-11 01:06:58 +08:00
461ed1fcda ♻️ FFI windows rpc ipc implmentation 2025-09-11 00:56:26 +08:00
5363afa558 🐛 Trying to fix windows rpc ipc 2025-09-11 00:33:44 +08:00
f0d2737da8 Windows RPC IPC 2025-09-11 00:23:14 +08:00
1f2f80aa3e 🐛 Trying to fix windows notification issue 2025-09-10 23:40:19 +08:00
240a872e65 Rollback windows gha changes 2025-09-10 23:12:57 +08:00
c1ec6f0849 Merge branch 'v3' of https://github.com/Solsynth/Solian into v3 2025-09-10 22:48:54 +08:00
ab42686d4d 🔨 Trying to fix windows build issue 2025-09-10 22:48:50 +08:00
24 changed files with 1178 additions and 512 deletions

View File

@@ -181,7 +181,7 @@ class IslandApp extends HookConsumerWidget {
}
useEffect(() {
if (!kIsWeb && Platform.isLinux) {
if (!kIsWeb && (Platform.isLinux || Platform.isWindows)) {
return null;
}

View File

@@ -29,7 +29,7 @@ sealed class GeoIpLocation with _$GeoIpLocation {
sealed class SnAuthChallenge with _$SnAuthChallenge {
const factory SnAuthChallenge({
required String id,
required DateTime expiredAt,
required DateTime? expiredAt,
required int stepRemain,
required int stepTotal,
required int failedAttempts,
@@ -57,7 +57,7 @@ sealed class SnAuthSession with _$SnAuthSession {
required String id,
required String? label,
required DateTime lastGrantedAt,
required DateTime expiredAt,
required DateTime? expiredAt,
required String accountId,
required String challengeId,
required SnAuthChallenge challenge,

View File

@@ -541,7 +541,7 @@ as String,
/// @nodoc
mixin _$SnAuthChallenge {
String get id; DateTime get expiredAt; int get stepRemain; int get stepTotal; int get failedAttempts; int get type; List<String> get blacklistFactors; List<dynamic> get audiences; List<dynamic> get scopes; String get ipAddress; String get userAgent; String? get nonce; GeoIpLocation? get location; String get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
String get id; DateTime? get expiredAt; int get stepRemain; int get stepTotal; int get failedAttempts; int get type; List<String> get blacklistFactors; List<dynamic> get audiences; List<dynamic> get scopes; String get ipAddress; String get userAgent; String? get nonce; GeoIpLocation? get location; String get accountId; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
/// Create a copy of SnAuthChallenge
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -574,7 +574,7 @@ abstract mixin class $SnAuthChallengeCopyWith<$Res> {
factory $SnAuthChallengeCopyWith(SnAuthChallenge value, $Res Function(SnAuthChallenge) _then) = _$SnAuthChallengeCopyWithImpl;
@useResult
$Res call({
String id, DateTime expiredAt, int stepRemain, int stepTotal, int failedAttempts, int type, List<String> blacklistFactors, List<dynamic> audiences, List<dynamic> scopes, String ipAddress, String userAgent, String? nonce, GeoIpLocation? location, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
String id, DateTime? expiredAt, int stepRemain, int stepTotal, int failedAttempts, int type, List<String> blacklistFactors, List<dynamic> audiences, List<dynamic> scopes, String ipAddress, String userAgent, String? nonce, GeoIpLocation? location, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
@@ -591,11 +591,11 @@ class _$SnAuthChallengeCopyWithImpl<$Res>
/// Create a copy of SnAuthChallenge
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? expiredAt = null,Object? stepRemain = null,Object? stepTotal = null,Object? failedAttempts = null,Object? type = null,Object? blacklistFactors = null,Object? audiences = null,Object? scopes = null,Object? ipAddress = null,Object? userAgent = null,Object? nonce = freezed,Object? location = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? expiredAt = freezed,Object? stepRemain = null,Object? stepTotal = null,Object? failedAttempts = null,Object? type = null,Object? blacklistFactors = null,Object? audiences = null,Object? scopes = null,Object? ipAddress = null,Object? userAgent = null,Object? nonce = freezed,Object? location = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime,stepRemain: null == stepRemain ? _self.stepRemain : stepRemain // ignore: cast_nullable_to_non_nullable
as String,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime?,stepRemain: null == stepRemain ? _self.stepRemain : stepRemain // ignore: cast_nullable_to_non_nullable
as int,stepTotal: null == stepTotal ? _self.stepTotal : stepTotal // ignore: cast_nullable_to_non_nullable
as int,failedAttempts: null == failedAttempts ? _self.failedAttempts : failedAttempts // ignore: cast_nullable_to_non_nullable
as int,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
@@ -704,7 +704,7 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, DateTime expiredAt, int stepRemain, int stepTotal, int failedAttempts, int type, List<String> blacklistFactors, List<dynamic> audiences, List<dynamic> scopes, String ipAddress, String userAgent, String? nonce, GeoIpLocation? location, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, DateTime? expiredAt, int stepRemain, int stepTotal, int failedAttempts, int type, List<String> blacklistFactors, List<dynamic> audiences, List<dynamic> scopes, String ipAddress, String userAgent, String? nonce, GeoIpLocation? location, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnAuthChallenge() when $default != null:
return $default(_that.id,_that.expiredAt,_that.stepRemain,_that.stepTotal,_that.failedAttempts,_that.type,_that.blacklistFactors,_that.audiences,_that.scopes,_that.ipAddress,_that.userAgent,_that.nonce,_that.location,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
@@ -725,7 +725,7 @@ return $default(_that.id,_that.expiredAt,_that.stepRemain,_that.stepTotal,_that.
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, DateTime expiredAt, int stepRemain, int stepTotal, int failedAttempts, int type, List<String> blacklistFactors, List<dynamic> audiences, List<dynamic> scopes, String ipAddress, String userAgent, String? nonce, GeoIpLocation? location, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, DateTime? expiredAt, int stepRemain, int stepTotal, int failedAttempts, int type, List<String> blacklistFactors, List<dynamic> audiences, List<dynamic> scopes, String ipAddress, String userAgent, String? nonce, GeoIpLocation? location, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
switch (_that) {
case _SnAuthChallenge():
return $default(_that.id,_that.expiredAt,_that.stepRemain,_that.stepTotal,_that.failedAttempts,_that.type,_that.blacklistFactors,_that.audiences,_that.scopes,_that.ipAddress,_that.userAgent,_that.nonce,_that.location,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);}
@@ -742,7 +742,7 @@ return $default(_that.id,_that.expiredAt,_that.stepRemain,_that.stepTotal,_that.
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, DateTime expiredAt, int stepRemain, int stepTotal, int failedAttempts, int type, List<String> blacklistFactors, List<dynamic> audiences, List<dynamic> scopes, String ipAddress, String userAgent, String? nonce, GeoIpLocation? location, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, DateTime? expiredAt, int stepRemain, int stepTotal, int failedAttempts, int type, List<String> blacklistFactors, List<dynamic> audiences, List<dynamic> scopes, String ipAddress, String userAgent, String? nonce, GeoIpLocation? location, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
switch (_that) {
case _SnAuthChallenge() when $default != null:
return $default(_that.id,_that.expiredAt,_that.stepRemain,_that.stepTotal,_that.failedAttempts,_that.type,_that.blacklistFactors,_that.audiences,_that.scopes,_that.ipAddress,_that.userAgent,_that.nonce,_that.location,_that.accountId,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
@@ -761,7 +761,7 @@ class _SnAuthChallenge implements SnAuthChallenge {
factory _SnAuthChallenge.fromJson(Map<String, dynamic> json) => _$SnAuthChallengeFromJson(json);
@override final String id;
@override final DateTime expiredAt;
@override final DateTime? expiredAt;
@override final int stepRemain;
@override final int stepTotal;
@override final int failedAttempts;
@@ -829,7 +829,7 @@ abstract mixin class _$SnAuthChallengeCopyWith<$Res> implements $SnAuthChallenge
factory _$SnAuthChallengeCopyWith(_SnAuthChallenge value, $Res Function(_SnAuthChallenge) _then) = __$SnAuthChallengeCopyWithImpl;
@override @useResult
$Res call({
String id, DateTime expiredAt, int stepRemain, int stepTotal, int failedAttempts, int type, List<String> blacklistFactors, List<dynamic> audiences, List<dynamic> scopes, String ipAddress, String userAgent, String? nonce, GeoIpLocation? location, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
String id, DateTime? expiredAt, int stepRemain, int stepTotal, int failedAttempts, int type, List<String> blacklistFactors, List<dynamic> audiences, List<dynamic> scopes, String ipAddress, String userAgent, String? nonce, GeoIpLocation? location, String accountId, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
@@ -846,11 +846,11 @@ class __$SnAuthChallengeCopyWithImpl<$Res>
/// Create a copy of SnAuthChallenge
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? expiredAt = null,Object? stepRemain = null,Object? stepTotal = null,Object? failedAttempts = null,Object? type = null,Object? blacklistFactors = null,Object? audiences = null,Object? scopes = null,Object? ipAddress = null,Object? userAgent = null,Object? nonce = freezed,Object? location = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? expiredAt = freezed,Object? stepRemain = null,Object? stepTotal = null,Object? failedAttempts = null,Object? type = null,Object? blacklistFactors = null,Object? audiences = null,Object? scopes = null,Object? ipAddress = null,Object? userAgent = null,Object? nonce = freezed,Object? location = freezed,Object? accountId = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_SnAuthChallenge(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime,stepRemain: null == stepRemain ? _self.stepRemain : stepRemain // ignore: cast_nullable_to_non_nullable
as String,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime?,stepRemain: null == stepRemain ? _self.stepRemain : stepRemain // ignore: cast_nullable_to_non_nullable
as int,stepTotal: null == stepTotal ? _self.stepTotal : stepTotal // ignore: cast_nullable_to_non_nullable
as int,failedAttempts: null == failedAttempts ? _self.failedAttempts : failedAttempts // ignore: cast_nullable_to_non_nullable
as int,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
@@ -888,7 +888,7 @@ $GeoIpLocationCopyWith<$Res>? get location {
/// @nodoc
mixin _$SnAuthSession {
String get id; String? get label; DateTime get lastGrantedAt; DateTime get expiredAt; String get accountId; String get challengeId; SnAuthChallenge get challenge; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
String get id; String? get label; DateTime get lastGrantedAt; DateTime? get expiredAt; String get accountId; String get challengeId; SnAuthChallenge get challenge; DateTime get createdAt; DateTime get updatedAt; DateTime? get deletedAt;
/// Create a copy of SnAuthSession
/// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false)
@@ -921,7 +921,7 @@ abstract mixin class $SnAuthSessionCopyWith<$Res> {
factory $SnAuthSessionCopyWith(SnAuthSession value, $Res Function(SnAuthSession) _then) = _$SnAuthSessionCopyWithImpl;
@useResult
$Res call({
String id, String? label, DateTime lastGrantedAt, DateTime expiredAt, String accountId, String challengeId, SnAuthChallenge challenge, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
String id, String? label, DateTime lastGrantedAt, DateTime? expiredAt, String accountId, String challengeId, SnAuthChallenge challenge, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
@@ -938,13 +938,13 @@ class _$SnAuthSessionCopyWithImpl<$Res>
/// Create a copy of SnAuthSession
/// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? label = freezed,Object? lastGrantedAt = null,Object? expiredAt = null,Object? accountId = null,Object? challengeId = null,Object? challenge = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
@pragma('vm:prefer-inline') @override $Res call({Object? id = null,Object? label = freezed,Object? lastGrantedAt = null,Object? expiredAt = freezed,Object? accountId = null,Object? challengeId = null,Object? challenge = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,label: freezed == label ? _self.label : label // ignore: cast_nullable_to_non_nullable
as String?,lastGrantedAt: null == lastGrantedAt ? _self.lastGrantedAt : lastGrantedAt // ignore: cast_nullable_to_non_nullable
as DateTime,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as DateTime,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String,challengeId: null == challengeId ? _self.challengeId : challengeId // ignore: cast_nullable_to_non_nullable
as String,challenge: null == challenge ? _self.challenge : challenge // ignore: cast_nullable_to_non_nullable
as SnAuthChallenge,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable
@@ -1041,7 +1041,7 @@ return $default(_that);case _:
/// }
/// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String? label, DateTime lastGrantedAt, DateTime expiredAt, String accountId, String challengeId, SnAuthChallenge challenge, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( String id, String? label, DateTime lastGrantedAt, DateTime? expiredAt, String accountId, String challengeId, SnAuthChallenge challenge, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) {
case _SnAuthSession() when $default != null:
return $default(_that.id,_that.label,_that.lastGrantedAt,_that.expiredAt,_that.accountId,_that.challengeId,_that.challenge,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
@@ -1062,7 +1062,7 @@ return $default(_that.id,_that.label,_that.lastGrantedAt,_that.expiredAt,_that.a
/// }
/// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String? label, DateTime lastGrantedAt, DateTime expiredAt, String accountId, String challengeId, SnAuthChallenge challenge, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( String id, String? label, DateTime lastGrantedAt, DateTime? expiredAt, String accountId, String challengeId, SnAuthChallenge challenge, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt) $default,) {final _that = this;
switch (_that) {
case _SnAuthSession():
return $default(_that.id,_that.label,_that.lastGrantedAt,_that.expiredAt,_that.accountId,_that.challengeId,_that.challenge,_that.createdAt,_that.updatedAt,_that.deletedAt);}
@@ -1079,7 +1079,7 @@ return $default(_that.id,_that.label,_that.lastGrantedAt,_that.expiredAt,_that.a
/// }
/// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String? label, DateTime lastGrantedAt, DateTime expiredAt, String accountId, String challengeId, SnAuthChallenge challenge, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( String id, String? label, DateTime lastGrantedAt, DateTime? expiredAt, String accountId, String challengeId, SnAuthChallenge challenge, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt)? $default,) {final _that = this;
switch (_that) {
case _SnAuthSession() when $default != null:
return $default(_that.id,_that.label,_that.lastGrantedAt,_that.expiredAt,_that.accountId,_that.challengeId,_that.challenge,_that.createdAt,_that.updatedAt,_that.deletedAt);case _:
@@ -1100,7 +1100,7 @@ class _SnAuthSession implements SnAuthSession {
@override final String id;
@override final String? label;
@override final DateTime lastGrantedAt;
@override final DateTime expiredAt;
@override final DateTime? expiredAt;
@override final String accountId;
@override final String challengeId;
@override final SnAuthChallenge challenge;
@@ -1141,7 +1141,7 @@ abstract mixin class _$SnAuthSessionCopyWith<$Res> implements $SnAuthSessionCopy
factory _$SnAuthSessionCopyWith(_SnAuthSession value, $Res Function(_SnAuthSession) _then) = __$SnAuthSessionCopyWithImpl;
@override @useResult
$Res call({
String id, String? label, DateTime lastGrantedAt, DateTime expiredAt, String accountId, String challengeId, SnAuthChallenge challenge, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
String id, String? label, DateTime lastGrantedAt, DateTime? expiredAt, String accountId, String challengeId, SnAuthChallenge challenge, DateTime createdAt, DateTime updatedAt, DateTime? deletedAt
});
@@ -1158,13 +1158,13 @@ class __$SnAuthSessionCopyWithImpl<$Res>
/// Create a copy of SnAuthSession
/// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? label = freezed,Object? lastGrantedAt = null,Object? expiredAt = null,Object? accountId = null,Object? challengeId = null,Object? challenge = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
@override @pragma('vm:prefer-inline') $Res call({Object? id = null,Object? label = freezed,Object? lastGrantedAt = null,Object? expiredAt = freezed,Object? accountId = null,Object? challengeId = null,Object? challenge = null,Object? createdAt = null,Object? updatedAt = null,Object? deletedAt = freezed,}) {
return _then(_SnAuthSession(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable
as String,label: freezed == label ? _self.label : label // ignore: cast_nullable_to_non_nullable
as String?,lastGrantedAt: null == lastGrantedAt ? _self.lastGrantedAt : lastGrantedAt // ignore: cast_nullable_to_non_nullable
as DateTime,expiredAt: null == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as DateTime,expiredAt: freezed == expiredAt ? _self.expiredAt : expiredAt // ignore: cast_nullable_to_non_nullable
as DateTime?,accountId: null == accountId ? _self.accountId : accountId // ignore: cast_nullable_to_non_nullable
as String,challengeId: null == challengeId ? _self.challengeId : challengeId // ignore: cast_nullable_to_non_nullable
as String,challenge: null == challenge ? _self.challenge : challenge // ignore: cast_nullable_to_non_nullable
as SnAuthChallenge,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable

View File

@@ -34,7 +34,10 @@ Map<String, dynamic> _$GeoIpLocationToJson(_GeoIpLocation instance) =>
_SnAuthChallenge _$SnAuthChallengeFromJson(Map<String, dynamic> json) =>
_SnAuthChallenge(
id: json['id'] as String,
expiredAt: DateTime.parse(json['expired_at'] as String),
expiredAt:
json['expired_at'] == null
? null
: DateTime.parse(json['expired_at'] as String),
stepRemain: (json['step_remain'] as num).toInt(),
stepTotal: (json['step_total'] as num).toInt(),
failedAttempts: (json['failed_attempts'] as num).toInt(),
@@ -66,7 +69,7 @@ _SnAuthChallenge _$SnAuthChallengeFromJson(Map<String, dynamic> json) =>
Map<String, dynamic> _$SnAuthChallengeToJson(_SnAuthChallenge instance) =>
<String, dynamic>{
'id': instance.id,
'expired_at': instance.expiredAt.toIso8601String(),
'expired_at': instance.expiredAt?.toIso8601String(),
'step_remain': instance.stepRemain,
'step_total': instance.stepTotal,
'failed_attempts': instance.failedAttempts,
@@ -89,7 +92,10 @@ _SnAuthSession _$SnAuthSessionFromJson(Map<String, dynamic> json) =>
id: json['id'] as String,
label: json['label'] as String?,
lastGrantedAt: DateTime.parse(json['last_granted_at'] as String),
expiredAt: DateTime.parse(json['expired_at'] as String),
expiredAt:
json['expired_at'] == null
? null
: DateTime.parse(json['expired_at'] as String),
accountId: json['account_id'] as String,
challengeId: json['challenge_id'] as String,
challenge: SnAuthChallenge.fromJson(
@@ -108,7 +114,7 @@ Map<String, dynamic> _$SnAuthSessionToJson(_SnAuthSession instance) =>
'id': instance.id,
'label': instance.label,
'last_granted_at': instance.lastGrantedAt.toIso8601String(),
'expired_at': instance.expiredAt.toIso8601String(),
'expired_at': instance.expiredAt?.toIso8601String(),
'account_id': instance.accountId,
'challenge_id': instance.challengeId,
'challenge': instance.challenge.toJson(),

View File

@@ -9,7 +9,11 @@ import 'package:shelf/shelf.dart';
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;
// Conditional imports for IPC server - use web stubs on web platform
import 'ipc_server.dart' if (dart.library.html) 'ipc_server.web.dart';
import 'ipc_server.windows.dart' if (dart.library.html) 'ipc_server.web.dart';
import 'ipc_server.unix.dart' if (dart.library.html) 'ipc_server.web.dart';
const String kRpcLogPrefix = 'arRPC.websocket';
const String kRpcIpcLogPrefix = 'arRPC.ipc';
@@ -43,14 +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 64636472
Map<String, Function>
handlers; // {connection: (socket), message: (socket, data), close: (socket)}
HttpServer? _httpServer;
ServerSocket? _ipcServer;
IpcServer? _ipcServer;
final List<WebSocketChannel> _wsSockets = [];
final List<_IpcSocketWrapper> _ipcSockets = [];
ActivityRpcServer(this.handlers);
@@ -58,109 +62,20 @@ class ActivityRpcServer {
handlers = newHandlers;
}
// Encode IPC packet
static Uint8List encodeIpcPacket(int type, Map<String, dynamic> data) {
final jsonData = jsonEncode(data);
final dataBytes = utf8.encode(jsonData);
final dataSize = dataBytes.length;
final buffer = ByteData(8 + dataSize);
buffer.setInt32(0, type, Endian.little);
buffer.setInt32(4, dataSize, Endian.little);
buffer.buffer.asUint8List().setRange(8, 8 + dataSize, dataBytes);
return buffer.buffer.asUint8List();
}
Future<String> _getMacOsSystemTmpDir() async {
final result = await Process.run('getconf', ['DARWIN_USER_TEMP_DIR']);
return (result.stdout as String).trim();
}
// Find available IPC socket path
Future<String> _findAvailableIpcPath() async {
// Build list of directories to try, with macOS-specific handling
final baseDirs = <String>[];
if (Platform.isMacOS) {
try {
final macTempDir = await _getMacOsSystemTmpDir();
if (macTempDir.isNotEmpty) {
baseDirs.add(macTempDir);
}
} catch (e) {
developer.log(
'Failed to get macOS system temp dir: $e',
name: kRpcIpcLogPrefix,
);
}
}
// Add other standard directories
final otherDirs = [
Platform.environment['XDG_RUNTIME_DIR'], // User runtime directory
Platform.environment['TMPDIR'], // App container temp (fallback)
Platform.environment['TMP'],
Platform.environment['TEMP'],
'/tmp', // System temp directory - most compatible
];
baseDirs.addAll(
otherDirs.where((dir) => dir != null && dir.isNotEmpty).cast<String>(),
);
for (final baseDir in baseDirs) {
for (int i = 0; i < 10; i++) {
final socketPath = path.join(baseDir, '$kIpcBasePath-$i');
try {
final socket = await ServerSocket.bind(
InternetAddress(socketPath, type: InternetAddressType.unix),
0,
);
socket.close();
// Clean up the test socket
try {
await File(socketPath).delete();
} catch (_) {}
developer.log(
'IPC socket will be created at: $socketPath',
name: kRpcIpcLogPrefix,
);
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,
);
}
continue;
}
}
}
throw Exception(
'No available IPC socket paths found in any temp directory',
);
}
// 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 +84,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 +93,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,27 +108,28 @@ 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) {
_ipcServer = WindowsIpcServer();
} else {
_ipcServer = UnixIpcServer();
}
_ipcServer!.listen((Socket socket) {
_onIpcConnection(socket);
});
// Set up IPC handlers
_ipcServer!.handlePacket = (socket, packet, _) {
_handleIpcPacket(socket, packet);
};
await _ipcServer!.start();
} catch (e) {
developer.log('IPC server error: $e', name: kRpcIpcLogPrefix);
// Continue without IPC if it fails
}
} 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,
);
}
@@ -223,24 +139,23 @@ class ActivityRpcServer {
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();
}
_ipcSockets.clear();
await _ipcServer?.close();
await _ipcServer?.stop();
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 +164,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,36 +208,27 @@ 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);
final socketWrapper = _IpcSocketWrapper(socket);
_ipcSockets.add(socketWrapper);
// Set up event listeners
socket.listen(
(data) => _onIpcData(socketWrapper, data),
onError: (e) {
developer.log('IPC socket error: $e', name: kRpcIpcLogPrefix);
socket.close();
},
onDone: () {
developer.log('IPC socket closed', name: kRpcIpcLogPrefix);
handlers['close']?.call(socketWrapper);
_ipcSockets.remove(socketWrapper);
},
);
}
// 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) {
@@ -335,22 +236,8 @@ class ActivityRpcServer {
}
}
// Handle incoming IPC data
void _onIpcData(_IpcSocketWrapper socket, List<int> data) {
try {
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());
}
}
// Handle IPC packet
void _handleIpcPacket(_IpcSocketWrapper socket, _IpcPacket packet) {
void _handleIpcPacket(IpcSocketWrapper socket, IpcPacket packet) {
switch (packet.type) {
case IpcTypes.ping:
developer.log('IPC ping received', name: kRpcIpcLogPrefix);
@@ -359,7 +246,6 @@ class ActivityRpcServer {
case IpcTypes.pong:
developer.log('IPC pong received', name: kRpcIpcLogPrefix);
// Handle pong if needed
break;
case IpcTypes.handshake:
@@ -388,13 +274,12 @@ class ActivityRpcServer {
}
// Handle IPC handshake
void _onIpcHandshake(_IpcSocketWrapper socket, Map<String, dynamic> params) {
void _onIpcHandshake(IpcSocketWrapper socket, Map<String, dynamic> params) {
developer.log('IPC handshake: $params', name: kRpcIpcLogPrefix);
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 +289,6 @@ class ActivityRpcServer {
return;
}
// Validate client ID
if (clientId.isEmpty) {
developer.log('IPC client ID required', name: kRpcIpcLogPrefix);
socket.closeWithCode(IpcErrorCodes.invalidClientId);
@@ -413,7 +297,6 @@ class ActivityRpcServer {
socket.clientId = clientId;
// Notify handler of new connection
handlers['connection']?.call(socket);
}
}
@@ -432,74 +315,6 @@ class _WsSocketWrapper {
}
}
// IPC wrapper
class _IpcSocketWrapper {
final Socket socket;
String clientId = '';
bool handshook = false;
final List<int> _buffer = [];
_IpcSocketWrapper(this.socket);
void addData(List<int> data) {
_buffer.addAll(data);
}
void send(Map<String, dynamic> msg) {
developer.log('IPC sending: $msg', name: kRpcIpcLogPrefix);
final packet = ActivityRpcServer.encodeIpcPacket(IpcTypes.frame, msg);
socket.add(packet);
}
void sendPong(dynamic data) {
final packet = ActivityRpcServer.encodeIpcPacket(IpcTypes.pong, data ?? {});
socket.add(packet);
}
void close() {
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();
}
List<_IpcPacket> readPackets() {
final packets = <_IpcPacket>[];
while (_buffer.length >= 8) {
final buffer = Uint8List.fromList(_buffer);
final byteData = ByteData.view(buffer.buffer);
final type = byteData.getInt32(0, Endian.little);
final dataSize = byteData.getInt32(4, Endian.little);
if (_buffer.length < 8 + dataSize) break;
final dataBytes = _buffer.sublist(8, 8 + dataSize);
final jsonStr = utf8.decode(dataBytes);
final jsonData = jsonDecode(jsonStr);
packets.add(_IpcPacket(type, jsonData));
_buffer.removeRange(0, 8 + dataSize);
}
return packets;
}
}
// IPC Packet structure
class _IpcPacket {
final int type;
final Map<String, dynamic> data;
_IpcPacket(this.type, this.data);
}
// State management for server status and activities
class ServerState {
final String status;
@@ -522,7 +337,6 @@ class ServerStateNotifier extends StateNotifier<ServerState> {
: 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();
@@ -554,9 +368,8 @@ final rpcServerStateProvider =
final clientId =
socket is _WsSocketWrapper
? socket.clientId
: (socket as _IpcSocketWrapper).clientId;
: (socket as IpcSocketWrapper).clientId;
notifier.updateStatus('Client connected (ID: $clientId)');
// Send READY event
socket.send({
'cmd': 'DISPATCH',
'data': {
@@ -575,7 +388,7 @@ final rpcServerStateProvider =
},
},
'evt': 'READY',
'nonce': '12345', // Should be dynamic
'nonce': '12345',
});
},
'message': (socket, dynamic data) async {
@@ -583,7 +396,6 @@ final rpcServerStateProvider =
notifier.addActivity(
'Activity: ${data['args']['activity']['details'] ?? 'Unknown'}',
);
// Call setRemoteActivityStatus
final label = data['args']['activity']['details'] ?? 'Unknown';
final appId = socket.clientId;
try {
@@ -594,7 +406,6 @@ final rpcServerStateProvider =
name: kRpcLogPrefix,
);
}
// Echo back success
socket.send({
'cmd': 'SET_ACTIVITY',
'data': data['args']['activity'],

View File

@@ -0,0 +1,118 @@
import 'dart:async';
import 'dart:convert';
import 'dart:typed_data';
const String kRpcIpcLogPrefix = 'arRPC.ipc';
// IPC Packet Types
class IpcTypes {
static const int handshake = 0;
static const int frame = 1;
static const int close = 2;
static const int ping = 3;
static const int pong = 4;
}
// IPC Close Codes
class IpcCloseCodes {
static const int closeNormal = 1000;
static const int closeUnsupported = 1003;
static const int closeAbnormal = 1006;
}
// IPC Error Codes
class IpcErrorCodes {
static const int invalidClientId = 4000;
static const int invalidOrigin = 4001;
static const int rateLimited = 4002;
static const int tokenRevoked = 4003;
static const int invalidVersion = 4004;
static const int invalidEncoding = 4005;
}
// IPC Packet structure
class IpcPacket {
final int type;
final Map<String, dynamic> data;
IpcPacket(this.type, this.data);
}
// Abstract base class for IPC server
abstract class IpcServer {
final List<IpcSocketWrapper> _sockets = [];
// Encode IPC packet
static Uint8List encodeIpcPacket(int type, Map<String, dynamic> data) {
final jsonData = jsonEncode(data);
final dataBytes = utf8.encode(jsonData);
final dataSize = dataBytes.length;
final buffer = ByteData(8 + dataSize);
buffer.setInt32(0, type, Endian.little);
buffer.setInt32(4, dataSize, Endian.little);
buffer.buffer.asUint8List().setRange(8, 8 + dataSize, dataBytes);
return buffer.buffer.asUint8List();
}
Future<void> start();
Future<void> stop();
void addSocket(IpcSocketWrapper socket) {
_sockets.add(socket);
}
void removeSocket(IpcSocketWrapper socket) {
_sockets.remove(socket);
}
List<IpcSocketWrapper> get sockets => _sockets;
void Function(
IpcSocketWrapper socket,
IpcPacket packet,
Map<String, Function> handlers,
)?
handlePacket;
}
// Abstract base class for IPC socket wrapper
abstract class IpcSocketWrapper {
String clientId = '';
bool handshook = false;
final List<int> _buffer = [];
void addData(List<int> data) {
_buffer.addAll(data);
}
void send(Map<String, dynamic> msg);
void sendPong(dynamic data);
void close();
void closeWithCode(int code, [String message = '']);
List<IpcPacket> readPackets() {
final packets = <IpcPacket>[];
while (_buffer.length >= 8) {
final buffer = Uint8List.fromList(_buffer);
final byteData = ByteData.view(buffer.buffer);
final type = byteData.getInt32(0, Endian.little);
final dataSize = byteData.getInt32(4, Endian.little);
if (_buffer.length < 8 + dataSize) break;
final dataBytes = _buffer.sublist(8, 8 + dataSize);
final jsonStr = utf8.decode(dataBytes);
final jsonData = jsonDecode(jsonStr);
packets.add(IpcPacket(type, jsonData));
_buffer.removeRange(0, 8 + dataSize);
}
return packets;
}
}

View File

@@ -0,0 +1,172 @@
import 'dart:async';
import 'dart:developer' as developer;
import 'dart:io';
import 'package:path/path.dart' as path;
import 'ipc_server.dart';
class UnixIpcServer extends IpcServer {
ServerSocket? _ipcServer;
@override
Future<void> start() async {
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);
});
}
@override
Future<void> stop() async {
for (var socket in sockets) {
try {
socket.close();
} catch (e) {
developer.log('Error closing IPC socket: $e', name: kRpcIpcLogPrefix);
}
}
sockets.clear();
await _ipcServer?.close();
}
// Handle new IPC connection
void _onIpcConnection(Socket socket) {
developer.log('New IPC connection!', name: kRpcIpcLogPrefix);
final socketWrapper = UnixIpcSocketWrapper(socket);
addSocket(socketWrapper);
socket.listen(
(data) => _onIpcData(socketWrapper, data),
onError: (e) {
developer.log('IPC socket error: $e', name: kRpcIpcLogPrefix);
socket.close();
},
onDone: () {
developer.log('IPC socket closed', name: kRpcIpcLogPrefix);
removeSocket(socketWrapper);
},
);
}
// Handle incoming IPC data
void _onIpcData(UnixIpcSocketWrapper socket, List<int> data) {
try {
socket.addData(data);
final packets = socket.readPackets();
for (final packet in packets) {
handlePacket?.call(socket, packet, {});
}
} catch (e) {
developer.log('IPC data error: $e', name: kRpcIpcLogPrefix);
socket.closeWithCode(IpcCloseCodes.closeUnsupported, e.toString());
}
}
Future<String> _getMacOsSystemTmpDir() async {
final result = await Process.run('getconf', ['DARWIN_USER_TEMP_DIR']);
return (result.stdout as String).trim();
}
// Find available IPC socket path
Future<String> _findAvailableIpcPath() async {
// Build list of directories to try, with macOS-specific handling
final baseDirs = <String>[];
if (Platform.isMacOS) {
try {
final macTempDir = await _getMacOsSystemTmpDir();
if (macTempDir.isNotEmpty) {
baseDirs.add(macTempDir);
}
} catch (e) {
developer.log(
'Failed to get macOS system temp dir: $e',
name: kRpcIpcLogPrefix,
);
}
}
// Add other standard directories
final otherDirs = [
Platform.environment['XDG_RUNTIME_DIR'],
Platform.environment['TMPDIR'],
Platform.environment['TMP'],
Platform.environment['TEMP'],
'/tmp',
];
baseDirs.addAll(
otherDirs.where((dir) => dir != null && dir.isNotEmpty).cast<String>(),
);
for (final baseDir in baseDirs) {
for (int i = 0; i < 10; i++) {
final socketPath = path.join(baseDir, 'discord-ipc-$i');
try {
final socket = await ServerSocket.bind(
InternetAddress(socketPath, type: InternetAddressType.unix),
0,
);
socket.close();
try {
await File(socketPath).delete();
} catch (_) {}
developer.log(
'IPC socket will be created at: $socketPath',
name: kRpcIpcLogPrefix,
);
return socketPath;
} catch (e) {
if (i == 0) {
developer.log(
'IPC path $socketPath not available: $e',
name: kRpcIpcLogPrefix,
);
}
continue;
}
}
}
throw Exception(
'No available IPC socket paths found in any temp directory',
);
}
}
class UnixIpcSocketWrapper extends IpcSocketWrapper {
final Socket socket;
UnixIpcSocketWrapper(this.socket);
@override
void send(Map<String, dynamic> msg) {
developer.log('IPC sending: $msg', name: kRpcIpcLogPrefix);
final packet = IpcServer.encodeIpcPacket(IpcTypes.frame, msg);
socket.add(packet);
}
@override
void sendPong(dynamic data) {
final packet = IpcServer.encodeIpcPacket(IpcTypes.pong, data ?? {});
socket.add(packet);
}
@override
void close() {
socket.close();
}
@override
void closeWithCode(int code, [String message = '']) {
final closeData = {'code': code, 'message': message};
final packet = IpcServer.encodeIpcPacket(IpcTypes.close, closeData);
socket.add(packet);
socket.close();
}
}

View File

@@ -0,0 +1,63 @@
// Stub implementation for web platform
// This file provides empty implementations to avoid import errors on web
// IPC Packet Types
class IpcTypes {
static const int handshake = 0;
static const int frame = 1;
static const int close = 2;
static const int ping = 3;
static const int pong = 4;
}
// IPC Close Codes
class IpcCloseCodes {
static const int closeNormal = 1000;
static const int closeUnsupported = 1003;
static const int closeAbnormal = 1006;
}
// IPC Error Codes
class IpcErrorCodes {
static const int invalidClientId = 4000;
static const int invalidOrigin = 4001;
static const int rateLimited = 4002;
static const int tokenRevoked = 4003;
static const int invalidVersion = 4004;
static const int invalidEncoding = 4005;
}
// IPC Packet structure
class IpcPacket {
final int type;
final Map<String, dynamic> data;
IpcPacket(this.type, this.data);
}
class IpcServer {
Future<void> start() async {}
Future<void> stop() async {}
void Function(dynamic, dynamic, dynamic)? handlePacket;
void addSocket(dynamic socket) {}
void removeSocket(dynamic socket) {}
List<dynamic> get sockets => [];
}
class IpcSocketWrapper {
String clientId = '';
bool handshook = false;
void addData(List<int> data) {}
void send(Map<String, dynamic> msg) {}
void sendPong(dynamic data) {}
void close() {}
void closeWithCode(int code, [String message = '']) {}
List<dynamic> readPackets() => [];
}
class WindowsIpcServer extends IpcServer {}
class UnixIpcServer extends IpcServer {}
class WindowsIpcSocketWrapper extends IpcSocketWrapper {}

View File

@@ -0,0 +1,243 @@
import 'dart:async';
import 'dart:developer' as developer;
import 'dart:ffi';
import 'dart:io';
import 'dart:isolate';
import 'package:ffi/ffi.dart';
import 'package:win32/win32.dart';
import 'ipc_server.dart';
class WindowsIpcServer extends IpcServer {
int? _pipeHandle;
Timer? _ipcTimer;
@override
Future<void> start() async {
final pipeName = r'\\.\pipe\discord-ipc-0'.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(
r'IPC named pipe created at \\.\pipe\discord-ipc-0',
name: kRpcIpcLogPrefix,
);
// Start listening for connections in a separate isolate
_listenWindowsIpc();
} finally {
free(pipeName);
}
}
@override
Future<void> stop() async {
for (var socket in sockets) {
try {
socket.close();
} catch (e) {
developer.log('Error closing IPC socket: $e', name: kRpcIpcLogPrefix);
}
}
sockets.clear();
if (_pipeHandle != null) {
try {
CloseHandle(_pipeHandle!);
} catch (e) {
developer.log('Error closing named pipe: $e', name: kRpcIpcLogPrefix);
}
_pipeHandle = null;
}
_ipcTimer?.cancel();
}
// Listen for Windows IPC connections in an isolate
void _listenWindowsIpc() async {
final receivePort = ReceivePort();
await Isolate.spawn(_windowsIpcIsolate, receivePort.sendPort);
receivePort.listen((message) {
developer.log(message.toString(), name: kRpcIpcLogPrefix);
if (message is int) {
final socketWrapper = WindowsIpcSocketWrapper(message);
addSocket(socketWrapper);
developer.log(
'New IPC connection on named pipe',
name: kRpcIpcLogPrefix,
);
_handleWindowsIpcData(socketWrapper);
start(); // Create new pipe for next connection
}
});
}
static void _windowsIpcIsolate(SendPort sendPort) {
while (true) {
final pipeHandle = CreateNamedPipe(
r'\\.\pipe\discord-ipc-0'.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: 500));
}
}
// Handle Windows IPC data
void _handleWindowsIpcData(WindowsIpcSocketWrapper socket) async {
final startTime = DateTime.now();
final buffer = malloc.allocate<BYTE>(4096);
final bytesRead = malloc.allocate<DWORD>(4);
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(0);
socket.addData(data);
final packets = socket.readPackets();
for (final packet in packets) {
handlePacket?.call(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,
);
}
}
}
class WindowsIpcSocketWrapper extends IpcSocketWrapper {
final int? pipeHandle;
WindowsIpcSocketWrapper(this.pipeHandle);
@override
void send(Map<String, dynamic> msg) {
developer.log('IPC sending: $msg', name: kRpcIpcLogPrefix);
final packet = IpcServer.encodeIpcPacket(IpcTypes.frame, msg);
final buffer = malloc.allocate<BYTE>(packet.length);
buffer.asTypedList(packet.length).setAll(0, packet);
final bytesWritten = malloc.allocate<DWORD>(4); // DWORD is 4 bytes
try {
WriteFile(
pipeHandle!,
buffer.cast(),
packet.length,
bytesWritten,
nullptr,
);
} finally {
malloc.free(buffer);
malloc.free(bytesWritten);
}
}
@override
void sendPong(dynamic data) {
final packet = IpcServer.encodeIpcPacket(IpcTypes.pong, data ?? {});
final buffer = malloc.allocate<BYTE>(packet.length);
buffer.asTypedList(packet.length).setAll(0, packet);
final bytesWritten = malloc.allocate<DWORD>(4); // DWORD is 4 bytes
try {
WriteFile(
pipeHandle!,
buffer.cast(),
packet.length,
bytesWritten,
nullptr,
);
} finally {
malloc.free(buffer);
malloc.free(bytesWritten);
}
}
@override
void close() {
if (pipeHandle != null) {
CloseHandle(pipeHandle!);
}
}
@override
void closeWithCode(int code, [String message = '']) {
final closeData = {'code': code, 'message': message};
final packet = IpcServer.encodeIpcPacket(IpcTypes.close, closeData);
final buffer = malloc.allocate<BYTE>(packet.length);
buffer.asTypedList(packet.length).setAll(0, packet);
final bytesWritten = malloc.allocate<DWORD>(4); // DWORD is 4 bytes
try {
WriteFile(
pipeHandle!,
buffer.cast(),
packet.length,
bytesWritten,
nullptr,
);
} finally {
malloc.free(buffer);
malloc.free(bytesWritten);
}
CloseHandle(pipeHandle!);
}
}

View File

@@ -89,8 +89,7 @@ bool get _supportsAnalytics =>
kIsWeb ||
Platform.isAndroid ||
Platform.isIOS ||
Platform.isMacOS ||
Platform.isWindows;
Platform.isMacOS;
// Provider for the router
final routerProvider = Provider<GoRouter>((ref) {

View File

@@ -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';

View File

@@ -48,11 +48,12 @@ class TrayService {
void handleAction(MenuItem item) {
switch (item.key) {
case 'show_window':
if (appWindow.isVisible) {
appWindow.restore();
} else {
appWindow.show();
}
() async {
appWindow.show();
appWindow.restore();
await Future.delayed(const Duration(milliseconds: 32));
appWindow.show();
}();
break;
case 'exit_app':
appWindow.close();

View File

@@ -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},
);
}

View 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},
);
}

View 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},
);
}

View File

@@ -3,7 +3,7 @@ import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:island/pods/activity_rpc.dart';
import 'package:island/pods/activity/activity_rpc.dart';
import 'package:island/pods/websocket.dart';
import 'package:island/screens/tray_manager.dart';
import 'package:island/services/notify.dart';

View File

@@ -9,6 +9,11 @@ String _parseRemoteError(DioException err) {
String? message;
if (err.response?.data is String) {
message = err.response?.data;
} else if (err.response?.data?['message'] != null) {
message = <String?>[
err.response?.data?['message']?.toString(),
err.response?.data?['detail']?.toString(),
].where((e) => e != null).cast<String>().map((e) => e.trim()).join('\n');
} else if (err.response?.data?['errors'] != null) {
final errors = err.response?.data['errors'] as Map<String, dynamic>;
message = errors.values

View File

@@ -8,17 +8,26 @@ import 'package:easy_localization/easy_localization.dart';
String _parseRemoteError(DioException err) {
log('${err.requestOptions.method} ${err.requestOptions.uri} ${err.message}');
if (err.response?.data is String) return err.response?.data;
if (err.response?.data?['errors'] != null) {
String? message;
if (err.response?.data is String) {
message = err.response?.data;
} else if (err.response?.data?['message'] != null) {
message = <String?>[
err.response?.data?['message']?.toString(),
err.response?.data?['detail']?.toString(),
].where((e) => e != null).cast<String>().map((e) => e.trim()).join('\n');
} else if (err.response?.data?['errors'] != null) {
final errors = err.response?.data['errors'] as Map<String, dynamic>;
return errors.values
message = errors.values
.map(
(ele) =>
(ele as List<dynamic>).map((ele) => ele.toString()).join('\n'),
)
.join('\n');
}
return err.message ?? err.toString();
if (message == null || message.isEmpty) message = err.response?.statusMessage;
message ??= err.message;
return message ?? err.toString();
}
void showErrorAlert(dynamic err) async {

View File

@@ -7,7 +7,7 @@ part of 'post_award_history_sheet.dart';
// **************************************************************************
String _$postAwardListNotifierHash() =>
r'492ae59a5dbbfb5c98f863f036023193b6e08668';
r'834d08f90ef352a2dfb0192455c75b1620e859c2';
/// Copied from Dart SDK
class _SystemHash {

View File

@@ -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"

View File

@@ -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:

View File

@@ -1,6 +1,6 @@
; ==================================================
#define AppVersion "3.2.0"
#define BuildNumber "124"
#define BuildNumber "132"
; ==================================================
#define FullVersion AppVersion + "." + BuildNumber

View File

@@ -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"));
}

View File

@@ -28,6 +28,7 @@ list(APPEND FLUTTER_PLUGIN_LIST
tray_manager
url_launcher_windows
volume_controller
windows_notification
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST