Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions lib/main.dart
Original file line number Diff line number Diff line change
Expand Up @@ -91,9 +91,12 @@ Future<void> _realMain(SharedPreferences prefs) async {
// Cloud-sync push hook: after any binding mutation, best-effort push the new
// state (throttled). Wired via post-construction setter to avoid a circular
// dependency between ThirdPartyAuthService and SyncService.
thirdPartyAuthService.onBindingsChanged = () {
return syncService.pushIfDue();
thirdPartyAuthService.onBindingsChanged = ({force = false}) {
return force ? syncService.forcePush() : syncService.pushIfDue();
};
// Cloud-sync tombstone hook: record a deletion so the next LWW merge does
// not resurrect an older remote copy of the unbound platform.
thirdPartyAuthService.onUnbind = syncService.recordTombstone;

// Cloud-sync pull hook: after a fresh SSO login, pull cloud bindings onto
// this device (or surface the master-password restore prompt).
Expand Down Expand Up @@ -166,7 +169,7 @@ Future<void> _realMain(SharedPreferences prefs) async {
);
}

if (thirdPartyAuthService.hasEgateBinding) {
if (thirdPartyAuthService.hasCpdailyBinding) {
await scheduleService.fetchAll();
}
if (authService.isLoggedIn ||
Expand Down
82 changes: 67 additions & 15 deletions lib/models/third_party_account.dart
Original file line number Diff line number Diff line change
@@ -1,23 +1,37 @@
enum ThirdPartyPlatform {
gradescope,
hydro,
egate;
cpdaily;

/// Stable id used for storage keys and cloud-sync payloads. Note: the
/// backend bind route for cpdaily is still 'egate' (unrenamed); see
/// [apiPath].
String get id => name;
String get label => switch (this) {
ThirdPartyPlatform.gradescope => 'Gradescope',
ThirdPartyPlatform.hydro => 'Hydro',
ThirdPartyPlatform.egate => 'eGate / IDS',
ThirdPartyPlatform.cpdaily => 'CpDaily / IDS',
};

/// Backend bind/renew route name. cpdaily maps to 'egate' (the backend
/// route was not renamed); all others map to their own [id].
String get apiPath => switch (this) {
ThirdPartyPlatform.cpdaily => 'egate',
_ => id,
};

/// Parse a platform id, accepting the legacy 'egate' alias for cpdaily.
static ThirdPartyPlatform? fromId(String id) {
for (final p in ThirdPartyPlatform.values) {
if (p.id == id) return p;
}
return null;
return switch (id) {
'gradescope' => ThirdPartyPlatform.gradescope,
'hydro' => ThirdPartyPlatform.hydro,
'cpdaily' || 'egate' => ThirdPartyPlatform.cpdaily,
_ => null,
};
}
}


class ThirdPartyAccount {
final ThirdPartyPlatform platform;
final String account;
Expand All @@ -36,6 +50,20 @@ class ThirdPartyAccount {
final bool autoRenew;
final String? password;

/// Wall-clock timestamp of the most recent local mutation of this account
/// (bind / rebind / renew / raw update). Used by the cloud-sync merge to do
/// per-account last-writer-wins with [deviceId] as a deterministic
/// tie-breaker. Defaults to [boundAt] for accounts created before this
/// field existed (back-compat: treated as "ancient" so any newer write
/// wins).
final DateTime updatedAt;

/// Stable id of the device that produced the current [updatedAt] bump. Used
/// as the LWW tie-breaker so two devices with skewed clocks still converge
/// deterministically. Empty for legacy accounts; newer devices always win
/// against an empty deviceId.
final String deviceId;

const ThirdPartyAccount({
required this.platform,
required this.account,
Expand All @@ -50,24 +78,36 @@ class ThirdPartyAccount {
required this.boundAt,
this.autoRenew = false,
this.password,
});
DateTime? updatedAt,
String? deviceId,
}) : updatedAt = updatedAt ?? boundAt,
deviceId = deviceId ?? '';

DateTime? get expireAt => expire == null
? null
: DateTime.fromMillisecondsSinceEpoch(expire! * 1000);

bool get isExpired {
final at = expireAt;
return at != null && at.isBefore(DateTime.now());
}
bool get isExpired =>
expireAt != null && DateTime.now().isAfter(expireAt!);

String get displayName {
if (name != null && name!.isNotEmpty) return name!;
if (email != null && email!.isNotEmpty) return email!;
if (sid != null && sid!.isNotEmpty) return sid!;
return account;
}

/// Comparison key for LWW merge: newer [updatedAt] wins; on a tie the
/// lexicographically-larger [deviceId] wins (deterministic, total order).
/// An empty deviceId is treated as oldest of all so legacy data loses to
/// any real device write.
int compareVersionTo(ThirdPartyAccount other) {
final c = updatedAt.compareTo(other.updatedAt);
if (c != 0) return c;
// Empty deviceId always loses.
if (deviceId.isEmpty && other.deviceId.isNotEmpty) return -1;
if (deviceId.isNotEmpty && other.deviceId.isEmpty) return 1;
return deviceId.compareTo(other.deviceId);
}

Map<String, dynamic> toJson() => {
'platform': platform.id,
'account': account,
Expand All @@ -82,9 +122,13 @@ class ThirdPartyAccount {
'boundAt': boundAt.toIso8601String(),
'autoRenew': autoRenew,
if (password != null) 'password': password,
'updatedAt': updatedAt.toIso8601String(),
'deviceId': deviceId,
};

factory ThirdPartyAccount.fromJson(Map<String, dynamic> json) {
final boundAt =
DateTime.tryParse(json['boundAt'] as String? ?? '') ?? DateTime.now();
return ThirdPartyAccount(
platform: ThirdPartyPlatform.fromId(json['platform'] as String? ?? '') ??
ThirdPartyPlatform.gradescope,
Expand All @@ -98,10 +142,14 @@ class ThirdPartyAccount {
hydroOrigin: json['hydroOrigin'] as String?,
hydroDomains:
(json['hydroDomains'] as List?)?.map((e) => e as String).toList(),
boundAt:
DateTime.tryParse(json['boundAt'] as String? ?? '') ?? DateTime.now(),
boundAt: boundAt,
autoRenew: json['autoRenew'] as bool? ?? false,
password: json['password'] as String?,
// Back-compat: pre-v2 blobs had no updatedAt/deviceId. Fall back to
// boundAt / empty so they merge as "older than any real write".
updatedAt:
DateTime.tryParse(json['updatedAt'] as String? ?? '') ?? boundAt,
deviceId: json['deviceId'] as String? ?? '',
);
}

Expand All @@ -116,6 +164,8 @@ class ThirdPartyAccount {
List<String>? hydroDomains,
bool? autoRenew,
String? password,
DateTime? updatedAt,
String? deviceId,
}) {
return ThirdPartyAccount(
platform: platform,
Expand All @@ -131,6 +181,8 @@ class ThirdPartyAccount {
boundAt: boundAt,
autoRenew: autoRenew ?? this.autoRenew,
password: password ?? this.password,
updatedAt: updatedAt ?? this.updatedAt,
deviceId: deviceId ?? this.deviceId,
);
}
}
23 changes: 12 additions & 11 deletions lib/pages/home_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -394,17 +394,18 @@ class _HomePageState extends State<HomePage> with TickerProviderStateMixin {
if (cookieType == null) return cookies;

final sp = ServiceProvider.of(context);
// ecourse and student-leave both authenticate against the eGate/IDS
// SSO, so they share the eGate binding's CpDaily cookies (which always
// include CASTGC via [ThirdPartyAuthService.egateCookies]). Without an
// eGate binding there is nothing to inject and the webview opens
// unauthenticated — the UI should steer the user to bind eGate first.
final String rawCookies = sp.thirdPartyAuthService.egateCookies();
if (rawCookies.isEmpty) return cookies;

final domain = 'ids.shanghaitech.edu.cn';

for (final part in rawCookies.split(';')) {
// ecourse / student-leave / eams webviews all authenticate against the
// CpDaily session, whose cookies are exposed by the cpdaily session node
// (the CASTGC-bearing cookie set that webviews consume directly). Read
// it through the unified [CookieProvider] view so the source is abstracted.
final cp = sp.thirdPartyAuthService.cpdailyNode.cookieProvider;
if (cp == null || cp.isEmpty) return cookies;

final domain = cp.domain.isNotEmpty
? cp.domain
: 'ids.shanghaitech.edu.cn';

for (final part in cp.cookies.split(';')) {
final idx = part.indexOf('=');
if (idx > 0) {
final key = part.substring(0, idx).trim();
Expand Down
14 changes: 7 additions & 7 deletions lib/pages/oa_gym_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ class _OaGymPageState extends State<OaGymPage>
body: ListenableBuilder(
listenable: Listenable.merge([auth, tpAuth]),
builder: (context, _) {
final ready = auth.isLoggedIn && tpAuth.hasEgateBinding;
final ready = auth.isLoggedIn && tpAuth.hasCpdailyBinding;
return ready
? Column(
children: [
Expand Down Expand Up @@ -973,15 +973,15 @@ class _ProfileTabState extends State<_ProfileTab> {
Widget build(BuildContext context) {
final sp = ServiceProvider.of(context);
final tpAuth = sp.thirdPartyAuthService;
final egate = tpAuth.egateBinding;
final cpdaily = tpAuth.cpdailyBinding;

// Identity card prefers eGate binding (real name + student id) over the
// Identity card prefers cpdaily binding (real name + student id) over the
// primary SSO account, whose userName/userId are Casdoor UUIDs with no
// meaning in the OA booking context.
final displayName = egate?.name?.isNotEmpty == true
? egate!.name!
: (egate?.account.isNotEmpty == true ? egate!.account : 'TechPie 用户');
final studentId = egate?.sid ?? '';
final displayName = cpdaily?.name?.isNotEmpty == true
? cpdaily!.name!
: (cpdaily?.account.isNotEmpty == true ? cpdaily!.account : 'TechPie 用户');
final studentId = cpdaily?.sid ?? '';
final avatarText = displayName.characters.firstOrNull ?? 'U';

return ListView(
Expand Down
2 changes: 1 addition & 1 deletion lib/pages/schedule_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -729,7 +729,7 @@ class _SchedulePageState extends State<SchedulePage> {
? 0
: adaptiveTopBarHeight() + MediaQuery.viewPaddingOf(context).top,
),
child: !auth.isLoggedIn || !tpAuth.hasEgateBinding
child: !auth.isLoggedIn || !tpAuth.hasCpdailyBinding
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
Expand Down
14 changes: 7 additions & 7 deletions lib/pages/settings_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ class _SettingsPageState extends State<SettingsPage> {
].join(' · '),
),
),
_EgateBindingTile(tpAuth: tpAuth),
_CpdailyBindingTile(tpAuth: tpAuth),
ListTile(
leading: const Icon(Icons.account_tree_outlined),
title: const Text('Linked accounts'),
Expand Down Expand Up @@ -558,26 +558,26 @@ class _AdaptiveSwitchTile extends StatelessWidget {
}
}

class _EgateBindingTile extends StatelessWidget {
class _CpdailyBindingTile extends StatelessWidget {
final ThirdPartyAuthService tpAuth;

const _EgateBindingTile({required this.tpAuth});
const _CpdailyBindingTile({required this.tpAuth});

@override
Widget build(BuildContext context) {
final egate = tpAuth.account(ThirdPartyPlatform.egate);
final bound = egate != null;
final cpdaily = tpAuth.account(ThirdPartyPlatform.cpdaily);
final bound = cpdaily != null;
final theme = Theme.of(context);

return ListTile(
leading: Icon(
bound ? Icons.vpn_key : Icons.vpn_key_outlined,
color: bound ? theme.colorScheme.primary : null,
),
title: const Text('eGate / IDS'),
title: const Text('CpDaily / IDS'),
subtitle: Text(
bound
? '已绑定 · ${egate.name ?? egate.sid ?? egate.account}'
? '已绑定 · ${cpdaily.name ?? cpdaily.sid ?? cpdaily.account}'
: '未绑定 · 需要绑定以启用课表和考试功能',
),
trailing: bound
Expand Down
2 changes: 1 addition & 1 deletion lib/pages/third_party_accounts_page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ class _ThirdPartyTile extends StatelessWidget {
IconData get _icon => switch (platform) {
ThirdPartyPlatform.gradescope => Icons.grading_outlined,
ThirdPartyPlatform.hydro => Icons.terminal_outlined,
ThirdPartyPlatform.egate => Icons.vpn_key_outlined,
ThirdPartyPlatform.cpdaily => Icons.vpn_key_outlined,
};

@override
Expand Down
Loading
Loading