Compare commits

..

24 Commits

Author SHA1 Message Date
LittleSheep
a243cda1df 🔀 Merge pull request #178 from Texas0295/v3
[FIX] chat: fix message pagination logic
2025-09-17 22:17:47 +08:00
Texas0295
7b238f32fd [FIX] chat: fix message pagination logic
`loadInitial()` fetched 100 messages while `_page_size` was 20,
so `_has_more` turned false too early and history stopped around
10h back. `loadMore()` also used `_currentPage * _page_size`
for offset, causing duplicates.

Use `_page_size` for initial load and rely on current message
count as offset. This removes `_currentPage` entirely and lets
older messages load correctly.

Signed-off-by: Texas0295 <kimura@texas0295.top>
2025-09-17 19:36:51 +08:00
313af28d7f 👽 Update service usage 2025-09-17 00:48:01 +08:00
c64e1e208c Desktop device name, close #7 2025-09-14 19:38:01 +08:00
c9b07a9a2a 🐛 Fix errors 2025-09-14 17:38:11 +08:00
55c0e355f1 ♻️ Refactor ICP server to make it available across platform 2025-09-13 20:32:39 +08:00
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
31 changed files with 1075 additions and 618 deletions

View File

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

View File

@@ -14,11 +14,11 @@ sealed class AppToken with _$AppToken {
@freezed @freezed
sealed class GeoIpLocation with _$GeoIpLocation { sealed class GeoIpLocation with _$GeoIpLocation {
const factory GeoIpLocation({ const factory GeoIpLocation({
required double latitude, required double? latitude,
required double longitude, required double? longitude,
required String countryCode, required String? countryCode,
required String country, required String? country,
required String city, required String? city,
}) = _GeoIpLocation; }) = _GeoIpLocation;
factory GeoIpLocation.fromJson(Map<String, dynamic> json) => factory GeoIpLocation.fromJson(Map<String, dynamic> json) =>
@@ -29,7 +29,7 @@ sealed class GeoIpLocation with _$GeoIpLocation {
sealed class SnAuthChallenge with _$SnAuthChallenge { sealed class SnAuthChallenge with _$SnAuthChallenge {
const factory SnAuthChallenge({ const factory SnAuthChallenge({
required String id, required String id,
required DateTime expiredAt, required DateTime? expiredAt,
required int stepRemain, required int stepRemain,
required int stepTotal, required int stepTotal,
required int failedAttempts, required int failedAttempts,
@@ -57,7 +57,7 @@ sealed class SnAuthSession with _$SnAuthSession {
required String id, required String id,
required String? label, required String? label,
required DateTime lastGrantedAt, required DateTime lastGrantedAt,
required DateTime expiredAt, required DateTime? expiredAt,
required String accountId, required String accountId,
required String challengeId, required String challengeId,
required SnAuthChallenge challenge, required SnAuthChallenge challenge,

View File

@@ -272,7 +272,7 @@ as String,
/// @nodoc /// @nodoc
mixin _$GeoIpLocation { mixin _$GeoIpLocation {
double get latitude; double get longitude; String get countryCode; String get country; String get city; double? get latitude; double? get longitude; String? get countryCode; String? get country; String? get city;
/// Create a copy of GeoIpLocation /// Create a copy of GeoIpLocation
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -305,7 +305,7 @@ abstract mixin class $GeoIpLocationCopyWith<$Res> {
factory $GeoIpLocationCopyWith(GeoIpLocation value, $Res Function(GeoIpLocation) _then) = _$GeoIpLocationCopyWithImpl; factory $GeoIpLocationCopyWith(GeoIpLocation value, $Res Function(GeoIpLocation) _then) = _$GeoIpLocationCopyWithImpl;
@useResult @useResult
$Res call({ $Res call({
double latitude, double longitude, String countryCode, String country, String city double? latitude, double? longitude, String? countryCode, String? country, String? city
}); });
@@ -322,14 +322,14 @@ class _$GeoIpLocationCopyWithImpl<$Res>
/// Create a copy of GeoIpLocation /// Create a copy of GeoIpLocation
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@pragma('vm:prefer-inline') @override $Res call({Object? latitude = null,Object? longitude = null,Object? countryCode = null,Object? country = null,Object? city = null,}) { @pragma('vm:prefer-inline') @override $Res call({Object? latitude = freezed,Object? longitude = freezed,Object? countryCode = freezed,Object? country = freezed,Object? city = freezed,}) {
return _then(_self.copyWith( return _then(_self.copyWith(
latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable latitude: freezed == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable
as double,longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable as double?,longitude: freezed == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable
as double,countryCode: null == countryCode ? _self.countryCode : countryCode // ignore: cast_nullable_to_non_nullable as double?,countryCode: freezed == countryCode ? _self.countryCode : countryCode // ignore: cast_nullable_to_non_nullable
as String,country: null == country ? _self.country : country // ignore: cast_nullable_to_non_nullable as String?,country: freezed == country ? _self.country : country // ignore: cast_nullable_to_non_nullable
as String,city: null == city ? _self.city : city // ignore: cast_nullable_to_non_nullable as String?,city: freezed == city ? _self.city : city // ignore: cast_nullable_to_non_nullable
as String, as String?,
)); ));
} }
@@ -411,7 +411,7 @@ return $default(_that);case _:
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( double latitude, double longitude, String countryCode, String country, String city)? $default,{required TResult orElse(),}) {final _that = this; @optionalTypeArgs TResult maybeWhen<TResult extends Object?>(TResult Function( double? latitude, double? longitude, String? countryCode, String? country, String? city)? $default,{required TResult orElse(),}) {final _that = this;
switch (_that) { switch (_that) {
case _GeoIpLocation() when $default != null: case _GeoIpLocation() when $default != null:
return $default(_that.latitude,_that.longitude,_that.countryCode,_that.country,_that.city);case _: return $default(_that.latitude,_that.longitude,_that.countryCode,_that.country,_that.city);case _:
@@ -432,7 +432,7 @@ return $default(_that.latitude,_that.longitude,_that.countryCode,_that.country,_
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( double latitude, double longitude, String countryCode, String country, String city) $default,) {final _that = this; @optionalTypeArgs TResult when<TResult extends Object?>(TResult Function( double? latitude, double? longitude, String? countryCode, String? country, String? city) $default,) {final _that = this;
switch (_that) { switch (_that) {
case _GeoIpLocation(): case _GeoIpLocation():
return $default(_that.latitude,_that.longitude,_that.countryCode,_that.country,_that.city);} return $default(_that.latitude,_that.longitude,_that.countryCode,_that.country,_that.city);}
@@ -449,7 +449,7 @@ return $default(_that.latitude,_that.longitude,_that.countryCode,_that.country,_
/// } /// }
/// ``` /// ```
@optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( double latitude, double longitude, String countryCode, String country, String city)? $default,) {final _that = this; @optionalTypeArgs TResult? whenOrNull<TResult extends Object?>(TResult? Function( double? latitude, double? longitude, String? countryCode, String? country, String? city)? $default,) {final _that = this;
switch (_that) { switch (_that) {
case _GeoIpLocation() when $default != null: case _GeoIpLocation() when $default != null:
return $default(_that.latitude,_that.longitude,_that.countryCode,_that.country,_that.city);case _: return $default(_that.latitude,_that.longitude,_that.countryCode,_that.country,_that.city);case _:
@@ -467,11 +467,11 @@ class _GeoIpLocation implements GeoIpLocation {
const _GeoIpLocation({required this.latitude, required this.longitude, required this.countryCode, required this.country, required this.city}); const _GeoIpLocation({required this.latitude, required this.longitude, required this.countryCode, required this.country, required this.city});
factory _GeoIpLocation.fromJson(Map<String, dynamic> json) => _$GeoIpLocationFromJson(json); factory _GeoIpLocation.fromJson(Map<String, dynamic> json) => _$GeoIpLocationFromJson(json);
@override final double latitude; @override final double? latitude;
@override final double longitude; @override final double? longitude;
@override final String countryCode; @override final String? countryCode;
@override final String country; @override final String? country;
@override final String city; @override final String? city;
/// Create a copy of GeoIpLocation /// Create a copy of GeoIpLocation
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@@ -506,7 +506,7 @@ abstract mixin class _$GeoIpLocationCopyWith<$Res> implements $GeoIpLocationCopy
factory _$GeoIpLocationCopyWith(_GeoIpLocation value, $Res Function(_GeoIpLocation) _then) = __$GeoIpLocationCopyWithImpl; factory _$GeoIpLocationCopyWith(_GeoIpLocation value, $Res Function(_GeoIpLocation) _then) = __$GeoIpLocationCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $Res call({
double latitude, double longitude, String countryCode, String country, String city double? latitude, double? longitude, String? countryCode, String? country, String? city
}); });
@@ -523,14 +523,14 @@ class __$GeoIpLocationCopyWithImpl<$Res>
/// Create a copy of GeoIpLocation /// Create a copy of GeoIpLocation
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@override @pragma('vm:prefer-inline') $Res call({Object? latitude = null,Object? longitude = null,Object? countryCode = null,Object? country = null,Object? city = null,}) { @override @pragma('vm:prefer-inline') $Res call({Object? latitude = freezed,Object? longitude = freezed,Object? countryCode = freezed,Object? country = freezed,Object? city = freezed,}) {
return _then(_GeoIpLocation( return _then(_GeoIpLocation(
latitude: null == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable latitude: freezed == latitude ? _self.latitude : latitude // ignore: cast_nullable_to_non_nullable
as double,longitude: null == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable as double?,longitude: freezed == longitude ? _self.longitude : longitude // ignore: cast_nullable_to_non_nullable
as double,countryCode: null == countryCode ? _self.countryCode : countryCode // ignore: cast_nullable_to_non_nullable as double?,countryCode: freezed == countryCode ? _self.countryCode : countryCode // ignore: cast_nullable_to_non_nullable
as String,country: null == country ? _self.country : country // ignore: cast_nullable_to_non_nullable as String?,country: freezed == country ? _self.country : country // ignore: cast_nullable_to_non_nullable
as String,city: null == city ? _self.city : city // ignore: cast_nullable_to_non_nullable as String?,city: freezed == city ? _self.city : city // ignore: cast_nullable_to_non_nullable
as String, as String?,
)); ));
} }
@@ -541,7 +541,7 @@ as String,
/// @nodoc /// @nodoc
mixin _$SnAuthChallenge { 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 /// Create a copy of SnAuthChallenge
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -574,7 +574,7 @@ abstract mixin class $SnAuthChallengeCopyWith<$Res> {
factory $SnAuthChallengeCopyWith(SnAuthChallenge value, $Res Function(SnAuthChallenge) _then) = _$SnAuthChallengeCopyWithImpl; factory $SnAuthChallengeCopyWith(SnAuthChallenge value, $Res Function(SnAuthChallenge) _then) = _$SnAuthChallengeCopyWithImpl;
@useResult @useResult
$Res call({ $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 /// Create a copy of SnAuthChallenge
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable 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 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 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,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,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 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) { switch (_that) {
case _SnAuthChallenge() when $default != null: 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 _: 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) { switch (_that) {
case _SnAuthChallenge(): 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);} 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) { switch (_that) {
case _SnAuthChallenge() when $default != null: 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 _: 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); factory _SnAuthChallenge.fromJson(Map<String, dynamic> json) => _$SnAuthChallengeFromJson(json);
@override final String id; @override final String id;
@override final DateTime expiredAt; @override final DateTime? expiredAt;
@override final int stepRemain; @override final int stepRemain;
@override final int stepTotal; @override final int stepTotal;
@override final int failedAttempts; @override final int failedAttempts;
@@ -829,7 +829,7 @@ abstract mixin class _$SnAuthChallengeCopyWith<$Res> implements $SnAuthChallenge
factory _$SnAuthChallengeCopyWith(_SnAuthChallenge value, $Res Function(_SnAuthChallenge) _then) = __$SnAuthChallengeCopyWithImpl; factory _$SnAuthChallengeCopyWith(_SnAuthChallenge value, $Res Function(_SnAuthChallenge) _then) = __$SnAuthChallengeCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $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 /// Create a copy of SnAuthChallenge
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_SnAuthChallenge(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable 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 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 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,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,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 as int,type: null == type ? _self.type : type // ignore: cast_nullable_to_non_nullable
@@ -888,7 +888,7 @@ $GeoIpLocationCopyWith<$Res>? get location {
/// @nodoc /// @nodoc
mixin _$SnAuthSession { 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 /// Create a copy of SnAuthSession
/// with the given fields replaced by the non-null parameter values. /// with the given fields replaced by the non-null parameter values.
@JsonKey(includeFromJson: false, includeToJson: false) @JsonKey(includeFromJson: false, includeToJson: false)
@@ -921,7 +921,7 @@ abstract mixin class $SnAuthSessionCopyWith<$Res> {
factory $SnAuthSessionCopyWith(SnAuthSession value, $Res Function(SnAuthSession) _then) = _$SnAuthSessionCopyWithImpl; factory $SnAuthSessionCopyWith(SnAuthSession value, $Res Function(SnAuthSession) _then) = _$SnAuthSessionCopyWithImpl;
@useResult @useResult
$Res call({ $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 /// Create a copy of SnAuthSession
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_self.copyWith(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable 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,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 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,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 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,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 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 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) { switch (_that) {
case _SnAuthSession() when $default != null: 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 _: 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) { switch (_that) {
case _SnAuthSession(): case _SnAuthSession():
return $default(_that.id,_that.label,_that.lastGrantedAt,_that.expiredAt,_that.accountId,_that.challengeId,_that.challenge,_that.createdAt,_that.updatedAt,_that.deletedAt);} 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) { switch (_that) {
case _SnAuthSession() when $default != null: 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 _: 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 id;
@override final String? label; @override final String? label;
@override final DateTime lastGrantedAt; @override final DateTime lastGrantedAt;
@override final DateTime expiredAt; @override final DateTime? expiredAt;
@override final String accountId; @override final String accountId;
@override final String challengeId; @override final String challengeId;
@override final SnAuthChallenge challenge; @override final SnAuthChallenge challenge;
@@ -1141,7 +1141,7 @@ abstract mixin class _$SnAuthSessionCopyWith<$Res> implements $SnAuthSessionCopy
factory _$SnAuthSessionCopyWith(_SnAuthSession value, $Res Function(_SnAuthSession) _then) = __$SnAuthSessionCopyWithImpl; factory _$SnAuthSessionCopyWith(_SnAuthSession value, $Res Function(_SnAuthSession) _then) = __$SnAuthSessionCopyWithImpl;
@override @useResult @override @useResult
$Res call({ $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 /// Create a copy of SnAuthSession
/// with the given fields replaced by the non-null parameter values. /// 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( return _then(_SnAuthSession(
id: null == id ? _self.id : id // ignore: cast_nullable_to_non_nullable 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,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 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,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 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,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 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 as SnAuthChallenge,createdAt: null == createdAt ? _self.createdAt : createdAt // ignore: cast_nullable_to_non_nullable

View File

@@ -15,11 +15,11 @@ Map<String, dynamic> _$AppTokenToJson(_AppToken instance) => <String, dynamic>{
_GeoIpLocation _$GeoIpLocationFromJson(Map<String, dynamic> json) => _GeoIpLocation _$GeoIpLocationFromJson(Map<String, dynamic> json) =>
_GeoIpLocation( _GeoIpLocation(
latitude: (json['latitude'] as num).toDouble(), latitude: (json['latitude'] as num?)?.toDouble(),
longitude: (json['longitude'] as num).toDouble(), longitude: (json['longitude'] as num?)?.toDouble(),
countryCode: json['country_code'] as String, countryCode: json['country_code'] as String?,
country: json['country'] as String, country: json['country'] as String?,
city: json['city'] as String, city: json['city'] as String?,
); );
Map<String, dynamic> _$GeoIpLocationToJson(_GeoIpLocation instance) => Map<String, dynamic> _$GeoIpLocationToJson(_GeoIpLocation instance) =>
@@ -34,7 +34,10 @@ Map<String, dynamic> _$GeoIpLocationToJson(_GeoIpLocation instance) =>
_SnAuthChallenge _$SnAuthChallengeFromJson(Map<String, dynamic> json) => _SnAuthChallenge _$SnAuthChallengeFromJson(Map<String, dynamic> json) =>
_SnAuthChallenge( _SnAuthChallenge(
id: json['id'] as String, 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(), stepRemain: (json['step_remain'] as num).toInt(),
stepTotal: (json['step_total'] as num).toInt(), stepTotal: (json['step_total'] as num).toInt(),
failedAttempts: (json['failed_attempts'] 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) => Map<String, dynamic> _$SnAuthChallengeToJson(_SnAuthChallenge instance) =>
<String, dynamic>{ <String, dynamic>{
'id': instance.id, 'id': instance.id,
'expired_at': instance.expiredAt.toIso8601String(), 'expired_at': instance.expiredAt?.toIso8601String(),
'step_remain': instance.stepRemain, 'step_remain': instance.stepRemain,
'step_total': instance.stepTotal, 'step_total': instance.stepTotal,
'failed_attempts': instance.failedAttempts, 'failed_attempts': instance.failedAttempts,
@@ -89,7 +92,10 @@ _SnAuthSession _$SnAuthSessionFromJson(Map<String, dynamic> json) =>
id: json['id'] as String, id: json['id'] as String,
label: json['label'] as String?, label: json['label'] as String?,
lastGrantedAt: DateTime.parse(json['last_granted_at'] 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, accountId: json['account_id'] as String,
challengeId: json['challenge_id'] as String, challengeId: json['challenge_id'] as String,
challenge: SnAuthChallenge.fromJson( challenge: SnAuthChallenge.fromJson(
@@ -108,7 +114,7 @@ Map<String, dynamic> _$SnAuthSessionToJson(_SnAuthSession instance) =>
'id': instance.id, 'id': instance.id,
'label': instance.label, 'label': instance.label,
'last_granted_at': instance.lastGrantedAt.toIso8601String(), 'last_granted_at': instance.lastGrantedAt.toIso8601String(),
'expired_at': instance.expiredAt.toIso8601String(), 'expired_at': instance.expiredAt?.toIso8601String(),
'account_id': instance.accountId, 'account_id': instance.accountId,
'challenge_id': instance.challengeId, 'challenge_id': instance.challengeId,
'challenge': instance.challenge.toJson(), 'challenge': instance.challenge.toJson(),

View File

@@ -9,7 +9,9 @@ import 'package:shelf/shelf.dart';
import 'package:shelf/shelf_io.dart' as shelf_io; 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;
// Conditional imports for IPC server - use web stubs on web platform
import 'ipc_server.dart' if (dart.library.html) 'ipc_server.web.dart';
const String kRpcLogPrefix = 'arRPC.websocket'; const String kRpcLogPrefix = 'arRPC.websocket';
const String kRpcIpcLogPrefix = 'arRPC.ipc'; const String kRpcIpcLogPrefix = 'arRPC.ipc';
@@ -43,14 +45,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 64636472 static const List<int> portRange = [6463, 6472]; // Ports 64636472
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; IpcServer? _ipcServer;
final List<WebSocketChannel> _wsSockets = []; final List<WebSocketChannel> _wsSockets = [];
final List<_IpcSocketWrapper> _ipcSockets = [];
ActivityRpcServer(this.handlers); ActivityRpcServer(this.handlers);
@@ -58,109 +60,20 @@ class ActivityRpcServer {
handlers = newHandlers; handlers = newHandlers;
} }
// Encode IPC packet // Start the server
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
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 +82,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 +91,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,27 +106,24 @@ 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 { try {
final ipcPath = await _findAvailableIpcPath(); _ipcServer = MultiPlatformIpcServer();
_ipcServer = await ServerSocket.bind(
InternetAddress(ipcPath, type: InternetAddressType.unix),
0,
);
developer.log('IPC listening at $ipcPath', name: kRpcIpcLogPrefix);
_ipcServer!.listen((Socket socket) { // Set up IPC handlers
_onIpcConnection(socket); _ipcServer!.handlePacket = (socket, packet, _) {
}); _handleIpcPacket(socket, packet);
};
await _ipcServer!.start();
} 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,
); );
} }
@@ -223,24 +133,23 @@ class ActivityRpcServer {
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) { await _ipcServer?.stop();
socket.close();
}
_ipcSockets.clear();
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 +158,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,36 +202,27 @@ class ActivityRpcServer {
}, },
); );
// Notify handler of new connection
handlers['connection']?.call(socketWithMeta); 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 // 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) {
@@ -335,22 +230,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 // Handle IPC packet
void _handleIpcPacket(_IpcSocketWrapper socket, _IpcPacket packet) { void _handleIpcPacket(IpcSocketWrapper socket, IpcPacket packet) {
switch (packet.type) { switch (packet.type) {
case IpcTypes.ping: case IpcTypes.ping:
developer.log('IPC ping received', name: kRpcIpcLogPrefix); developer.log('IPC ping received', name: kRpcIpcLogPrefix);
@@ -359,7 +240,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:
@@ -388,13 +268,12 @@ class ActivityRpcServer {
} }
// Handle IPC handshake // 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); developer.log('IPC handshake: $params', name: kRpcIpcLogPrefix);
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 +283,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 +291,6 @@ class ActivityRpcServer {
socket.clientId = clientId; socket.clientId = clientId;
// Notify handler of new connection
handlers['connection']?.call(socket); handlers['connection']?.call(socket);
} }
} }
@@ -432,74 +309,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 // State management for server status and activities
class ServerState { class ServerState {
final String status; final String status;
@@ -522,7 +331,6 @@ class ServerStateNotifier extends StateNotifier<ServerState> {
: 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();
@@ -554,9 +362,8 @@ final rpcServerStateProvider =
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': {
@@ -575,7 +382,7 @@ final rpcServerStateProvider =
}, },
}, },
'evt': 'READY', 'evt': 'READY',
'nonce': '12345', // Should be dynamic 'nonce': '12345',
}); });
}, },
'message': (socket, dynamic data) async { 'message': (socket, dynamic data) async {
@@ -583,7 +390,6 @@ final rpcServerStateProvider =
notifier.addActivity( notifier.addActivity(
'Activity: ${data['args']['activity']['details'] ?? 'Unknown'}', 'Activity: ${data['args']['activity']['details'] ?? 'Unknown'}',
); );
// Call setRemoteActivityStatus
final label = data['args']['activity']['details'] ?? 'Unknown'; final label = data['args']['activity']['details'] ?? 'Unknown';
final appId = socket.clientId; final appId = socket.clientId;
try { try {
@@ -594,7 +400,6 @@ final rpcServerStateProvider =
name: kRpcLogPrefix, name: kRpcLogPrefix,
); );
} }
// Echo back success
socket.send({ socket.send({
'cmd': 'SET_ACTIVITY', 'cmd': 'SET_ACTIVITY',
'data': data['args']['activity'], 'data': data['args']['activity'],

View File

@@ -0,0 +1,297 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer' as developer;
import 'dart:io';
import 'dart:typed_data';
import 'package:dart_ipc/dart_ipc.dart';
import 'package:path/path.dart' as path;
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;
}
}
// Multiplatform IPC Server implementation using dart_ipc
class MultiPlatformIpcServer extends IpcServer {
StreamSubscription? _serverSubscription;
@override
Future<void> start() async {
try {
final ipcPath = Platform.isWindows
? r'\\.\pipe\discord-ipc-0'
: await _findAvailableUnixIpcPath();
final serverSocket = await bind(ipcPath);
developer.log(
'IPC listening at $ipcPath',
name: kRpcIpcLogPrefix,
);
_serverSubscription = serverSocket.listen((socket) {
final socketWrapper = MultiPlatformIpcSocketWrapper(socket);
addSocket(socketWrapper);
developer.log(
'New IPC connection!',
name: kRpcIpcLogPrefix,
);
_handleIpcData(socketWrapper);
});
} catch (e) {
throw Exception('Failed to start IPC server: $e');
}
}
@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();
_serverSubscription?.cancel();
}
// Handle incoming IPC data
void _handleIpcData(MultiPlatformIpcSocketWrapper socket) {
final startTime = DateTime.now();
socket.socket.listen((data) {
final readStart = DateTime.now();
socket.addData(data);
final readDuration = DateTime.now().difference(readStart).inMicroseconds;
developer.log(
'Read data took $readDuration microseconds',
name: kRpcIpcLogPrefix,
);
final packets = socket.readPackets();
for (final packet in packets) {
handlePacket?.call(socket, packet, {});
}
}, onDone: () {
developer.log('IPC connection closed', name: kRpcIpcLogPrefix);
socket.close();
}, onError: (e) {
developer.log('IPC data error: $e', name: kRpcIpcLogPrefix);
socket.closeWithCode(IpcCloseCodes.closeUnsupported, e.toString());
});
final totalDuration = DateTime.now().difference(startTime).inMicroseconds;
developer.log(
'_handleIpcData took $totalDuration microseconds',
name: kRpcIpcLogPrefix,
);
}
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 for Unix-like systems
Future<String> _findAvailableUnixIpcPath() 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 bind(socketPath);
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',
);
}
}
// Multiplatform IPC Socket Wrapper
class MultiPlatformIpcSocketWrapper extends IpcSocketWrapper {
final dynamic socket;
MultiPlatformIpcSocketWrapper(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,61 @@
// 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 MultiPlatformIpcServer extends IpcServer {}
class MultiPlatformIpcSocketWrapper extends IpcSocketWrapper {}

View File

@@ -30,7 +30,7 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
final user = SnAccount.fromJson(response.data); final user = SnAccount.fromJson(response.data);
state = AsyncValue.data(user); state = AsyncValue.data(user);
if (kIsWeb || !Platform.isLinux) { if (kIsWeb || !(Platform.isLinux || Platform.isWindows)) {
FirebaseAnalytics.instance.setUserId(id: user.id); FirebaseAnalytics.instance.setUserId(id: user.id);
} }
} catch (error, stackTrace) { } catch (error, stackTrace) {
@@ -44,7 +44,7 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
: 'failedToLoadUserInfoNetwork') : 'failedToLoadUserInfoNetwork')
.tr() .tr()
.trim(), .trim(),
'${error.response!.statusCode}\n${error.response?.headers}', '${error.response?.statusCode ?? 'Network Error'}\n${error.response?.headers}',
jsonEncode(error.response?.data), jsonEncode(error.response?.data),
].join('\n\n'), ].join('\n\n'),
iconStyle: IconStyle.error, iconStyle: IconStyle.error,
@@ -87,7 +87,7 @@ class UserInfoNotifier extends StateNotifier<AsyncValue<SnAccount?>> {
final prefs = _ref.read(sharedPreferencesProvider); final prefs = _ref.read(sharedPreferencesProvider);
await prefs.remove(kTokenPairStoreKey); await prefs.remove(kTokenPairStoreKey);
_ref.invalidate(tokenProvider); _ref.invalidate(tokenProvider);
if (kIsWeb || !Platform.isLinux) { if (kIsWeb || !(Platform.isLinux || Platform.isWindows)) {
FirebaseAnalytics.instance.setUserId(id: null); FirebaseAnalytics.instance.setUserId(id: null);
} }
} }

View File

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

View File

@@ -6,7 +6,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.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';
import 'package:island/services/udid.native.dart'; import 'package:island/services/udid.dart' as udid;
import 'package:island/widgets/alert.dart'; import 'package:island/widgets/alert.dart';
import 'package:island/widgets/app_scaffold.dart'; import 'package:island/widgets/app_scaffold.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
@@ -68,7 +68,7 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
try { try {
final deviceInfoPlugin = DeviceInfoPlugin(); final deviceInfoPlugin = DeviceInfoPlugin();
_deviceInfo = await deviceInfoPlugin.deviceInfo; _deviceInfo = await deviceInfoPlugin.deviceInfo;
_deviceUdid = await getUdid(); _deviceUdid = await udid.getUdid();
if (mounted) { if (mounted) {
setState(() {}); setState(() {});
} }
@@ -174,12 +174,20 @@ class _AboutScreenState extends ConsumerState<AboutScreen> {
context, context,
title: 'Device Information', title: 'Device Information',
children: [ children: [
_buildInfoItem( FutureBuilder<String>(
context, future: udid.getDeviceName(),
icon: Symbols.label, builder: (context, snapshot) {
label: 'aboutDeviceName'.tr(), final value =
value: snapshot.hasData
_deviceInfo?.data['name'] ?? 'unknown'.tr(), ? snapshot.data!
: 'unknown'.tr();
return _buildInfoItem(
context,
icon: Symbols.label,
label: 'aboutDeviceName'.tr(),
value: value,
);
},
), ),
_buildInfoItem( _buildInfoItem(
context, context,

View File

@@ -1,9 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'dart:io';
import 'dart:math' as math; import 'dart:math' as math;
import 'package:animations/animations.dart'; import 'package:animations/animations.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
@@ -42,22 +39,6 @@ final Map<int, (String, String, IconData)> kFactorTypes = {
4: ('authFactorPin', 'authFactorPinDescription', Symbols.nest_secure_alarm), 4: ('authFactorPin', 'authFactorPinDescription', Symbols.nest_secure_alarm),
}; };
Future<String?> getDeviceName() async {
if (kIsWeb) return null;
String? name;
if (Platform.isIOS) {
final deviceInfo = await DeviceInfoPlugin().iosInfo;
name = deviceInfo.name;
} else if (Platform.isAndroid) {
final deviceInfo = await DeviceInfoPlugin().androidInfo;
name = deviceInfo.name;
} else if (Platform.isWindows) {
final deviceInfo = await DeviceInfoPlugin().windowsInfo;
name = deviceInfo.computerName;
}
return name;
}
class LoginScreen extends HookConsumerWidget { class LoginScreen extends HookConsumerWidget {
const LoginScreen({super.key}); const LoginScreen({super.key});

View File

@@ -289,7 +289,6 @@ class MessagesNotifier extends _$MessagesNotifier {
bool? _withAttachments; bool? _withAttachments;
late final String _roomId; late final String _roomId;
int _currentPage = 0;
static const int _pageSize = 20; static const int _pageSize = 20;
bool _hasMore = true; bool _hasMore = true;
bool _isSyncing = false; bool _isSyncing = false;
@@ -565,47 +564,46 @@ class MessagesNotifier extends _$MessagesNotifier {
} }
} }
Future<void> loadInitial() async { Future<void> loadInitial() async {
developer.log('Loading initial messages', name: 'MessagesNotifier'); developer.log('Loading initial messages', name: 'MessagesNotifier');
if (_searchQuery == null || _searchQuery!.isEmpty) { if (_searchQuery == null || _searchQuery!.isEmpty) {
syncMessages(); syncMessages();
}
final messages = await _getCachedMessages(offset: 0, take: 100);
_currentPage = 0;
_hasMore = messages.length == _pageSize;
state = AsyncValue.data(messages);
} }
Future<void> loadMore() async { final messages = await _getCachedMessages(offset: 0, take: _pageSize);
if (!_hasMore || state is AsyncLoading) return;
developer.log('Loading more messages', name: 'MessagesNotifier');
try { _hasMore = messages.length == _pageSize;
final currentMessages = state.value ?? [];
_currentPage++;
final newMessages = await listMessages(
offset: _currentPage * _pageSize,
take: _pageSize,
);
if (newMessages.isEmpty || newMessages.length < _pageSize) { state = AsyncValue.data(messages);
_hasMore = false; }
}
state = AsyncValue.data( Future<void> loadMore() async {
_sortMessages([...currentMessages, ...newMessages]), if (!_hasMore || state is AsyncLoading) return;
); developer.log('Loading more messages', name: 'MessagesNotifier');
} catch (err, stackTrace) {
developer.log( try {
'Error loading more messages', final currentMessages = state.value ?? [];
name: 'MessagesNotifier', final offset = currentMessages.length;
error: err,
stackTrace: stackTrace, final newMessages = await listMessages(offset: offset, take: _pageSize);
);
showErrorAlert(err); if (newMessages.isEmpty || newMessages.length < _pageSize) {
_currentPage--; _hasMore = false;
} }
state = AsyncValue.data(
_sortMessages([...currentMessages, ...newMessages]),
);
} catch (err, stackTrace) {
developer.log(
'Error loading more messages',
name: 'MessagesNotifier',
error: err,
stackTrace: stackTrace,
);
showErrorAlert(err);
} }
}
Future<void> sendMessage( Future<void> sendMessage(
String content, String content,

View File

@@ -39,7 +39,7 @@ class NotificationUnreadCountNotifier
try { try {
final client = ref.read(apiClientProvider); final client = ref.read(apiClientProvider);
final response = await client.get('/pusher/notifications/count'); final response = await client.get('/ring/notifications/count');
return (response.data as num).toInt(); return (response.data as num).toInt();
} catch (_) { } catch (_) {
return 0; return 0;
@@ -89,7 +89,7 @@ class NotificationListNotifier extends _$NotificationListNotifier
final queryParams = {'offset': offset, 'take': _pageSize}; final queryParams = {'offset': offset, 'take': _pageSize};
final response = await client.get( final response = await client.get(
'/pusher/notifications', '/ring/notifications',
queryParameters: queryParams, queryParameters: queryParams,
); );
final total = int.parse(response.headers.value('X-Total') ?? '0'); final total = int.parse(response.headers.value('X-Total') ?? '0');
@@ -121,7 +121,7 @@ class NotificationScreen extends HookConsumerWidget {
Future<void> markAllRead() async { Future<void> markAllRead() async {
showLoadingModal(context); showLoadingModal(context);
final apiClient = ref.watch(apiClientProvider); final apiClient = ref.watch(apiClientProvider);
await apiClient.post('/pusher/notifications/all/read'); await apiClient.post('/ring/notifications/all/read');
if (!context.mounted) return; if (!context.mounted) return;
hideLoadingModal(context); hideLoadingModal(context);
ref.invalidate(notificationListNotifierProvider); ref.invalidate(notificationListNotifierProvider);

View File

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

View File

@@ -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();

View File

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

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(
"/ring/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(
"/ring/notifications/subscription",
data: {"provider": provider, "device_token": token},
);
}

View File

@@ -1 +1,3 @@
export 'udid.native.dart' if (dart.library.html) 'udid.web.dart'; export 'udid.native.dart'
if (dart.library.html) 'udid.web.dart'
if (dart.library.io) 'udid.native.dart';

View File

@@ -1,3 +1,6 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_udid/flutter_udid.dart'; import 'package:flutter_udid/flutter_udid.dart';
String? _cachedUdid; String? _cachedUdid;
@@ -9,3 +12,18 @@ Future<String> getUdid() async {
_cachedUdid = await FlutterUdid.consistentUdid; _cachedUdid = await FlutterUdid.consistentUdid;
return _cachedUdid!; return _cachedUdid!;
} }
Future<String> getDeviceName() async {
DeviceInfoPlugin deviceInfo = DeviceInfoPlugin();
if (Platform.isAndroid) {
AndroidDeviceInfo androidInfo = await deviceInfo.androidInfo;
return androidInfo.device;
} else if (Platform.isIOS) {
IosDeviceInfo iosInfo = await deviceInfo.iosInfo;
return iosInfo.name;
} else if (Platform.isLinux || Platform.isMacOS || Platform.isWindows) {
return Platform.localHostname;
} else {
return 'unknown'.tr();
}
}

View File

@@ -9,3 +9,18 @@ Future<String> getUdid() async {
final hash = sha256.convert(bytes); final hash = sha256.convert(bytes);
return hash.toString(); return hash.toString();
} }
Future<String> getDeviceName() async {
final userAgent = window.navigator.userAgent;
if (userAgent.contains('Chrome') && !userAgent.contains('Edg')) {
return 'Chrome';
} else if (userAgent.contains('Firefox')) {
return 'Firefox';
} else if (userAgent.contains('Safari') && !userAgent.contains('Chrome')) {
return 'Safari';
} else if (userAgent.contains('Edg')) {
return 'Edge';
} else {
return 'Browser';
}
}

View File

@@ -3,7 +3,7 @@ import 'package:bitsdojo_window/bitsdojo_window.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.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/pods/websocket.dart';
import 'package:island/screens/tray_manager.dart'; import 'package:island/screens/tray_manager.dart';
import 'package:island/services/notify.dart'; import 'package:island/services/notify.dart';

View File

@@ -228,7 +228,7 @@ class CheckInWidget extends HookConsumerWidget {
), ),
Column( Column(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
spacing: 3, crossAxisAlignment: CrossAxisAlignment.end,
children: [ children: [
AnimatedSwitcher( AnimatedSwitcher(
duration: const Duration(milliseconds: 300), duration: const Duration(milliseconds: 300),
@@ -244,7 +244,7 @@ class CheckInWidget extends HookConsumerWidget {
loading: () => Text('checkInNone').tr().fontSize(15).bold(), loading: () => Text('checkInNone').tr().fontSize(15).bold(),
error: (err, stack) => Text('error').tr().fontSize(15).bold(), error: (err, stack) => Text('error').tr().fontSize(15).bold(),
), ),
), ).padding(right: 4),
IconButton.outlined( IconButton.outlined(
iconSize: 16, iconSize: 16,
visualDensity: const VisualDensity( visualDensity: const VisualDensity(

View File

@@ -9,6 +9,11 @@ String _parseRemoteError(DioException err) {
String? message; String? message;
if (err.response?.data is String) { if (err.response?.data is String) {
message = err.response?.data; 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) { } else if (err.response?.data?['errors'] != null) {
final errors = err.response?.data['errors'] as Map<String, dynamic>; final errors = err.response?.data['errors'] as Map<String, dynamic>;
message = errors.values message = errors.values

View File

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

View File

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

View File

@@ -401,6 +401,14 @@ packages:
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "1.0.0+7.7.0" version: "1.0.0+7.7.0"
dart_ipc:
dependency: "direct main"
description:
name: dart_ipc
sha256: "6cad558cda5304017c1f581df4c96fd4f8e4ee212aae7bfa4357716236faa9ba"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
dart_style: dart_style:
dependency: transitive dependency: transitive
description: description:
@@ -546,7 +554,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 +1289,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 +1457,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 +2584,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 +2773,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 +2788,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 +2822,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"

View File

@@ -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,10 @@ 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
dart_ipc: ^1.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View File

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

View File

@@ -8,6 +8,7 @@
#include <bitsdojo_window_windows/bitsdojo_window_plugin.h> #include <bitsdojo_window_windows/bitsdojo_window_plugin.h>
#include <connectivity_plus/connectivity_plus_windows_plugin.h> #include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <dart_ipc/dart_ipc_plugin_c_api.h>
#include <file_saver/file_saver_plugin.h> #include <file_saver/file_saver_plugin.h>
#include <file_selector_windows/file_selector_windows.h> #include <file_selector_windows/file_selector_windows.h>
#include <firebase_core/firebase_core_plugin_c_api.h> #include <firebase_core/firebase_core_plugin_c_api.h>
@@ -31,12 +32,15 @@
#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(
registry->GetRegistrarForPlugin("BitsdojoWindowPlugin")); registry->GetRegistrarForPlugin("BitsdojoWindowPlugin"));
ConnectivityPlusWindowsPluginRegisterWithRegistrar( ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin")); registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
DartIpcPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("DartIpcPluginCApi"));
FileSaverPluginRegisterWithRegistrar( FileSaverPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FileSaverPlugin")); registry->GetRegistrarForPlugin("FileSaverPlugin"));
FileSelectorWindowsRegisterWithRegistrar( FileSelectorWindowsRegisterWithRegistrar(
@@ -83,4 +87,6 @@ void RegisterPlugins(flutter::PluginRegistry* registry) {
registry->GetRegistrarForPlugin("UrlLauncherWindows")); registry->GetRegistrarForPlugin("UrlLauncherWindows"));
VolumeControllerPluginCApiRegisterWithRegistrar( VolumeControllerPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("VolumeControllerPluginCApi")); registry->GetRegistrarForPlugin("VolumeControllerPluginCApi"));
WindowsNotificationPluginCApiRegisterWithRegistrar(
registry->GetRegistrarForPlugin("WindowsNotificationPluginCApi"));
} }

View File

@@ -5,6 +5,7 @@
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
bitsdojo_window_windows bitsdojo_window_windows
connectivity_plus connectivity_plus
dart_ipc
file_saver file_saver
file_selector_windows file_selector_windows
firebase_core firebase_core
@@ -28,6 +29,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