feat: add school authentication entry with guarded import
This commit is contained in:
@@ -0,0 +1,254 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:webview_flutter/webview_flutter.dart';
|
||||
|
||||
import '../../data/json_schedule_import.dart';
|
||||
import 'school_navigation_policy.dart';
|
||||
import 'school_page_adapter.dart';
|
||||
|
||||
enum SchoolConnectionState {
|
||||
signedOut,
|
||||
authenticating,
|
||||
connected,
|
||||
expired,
|
||||
unknown,
|
||||
}
|
||||
|
||||
class SchoolSession extends ChangeNotifier {
|
||||
SchoolSession() {
|
||||
web = WebViewController()
|
||||
..setJavaScriptMode(JavaScriptMode.unrestricted)
|
||||
..setNavigationDelegate(
|
||||
NavigationDelegate(
|
||||
onNavigationRequest: (request) {
|
||||
final uri = Uri.tryParse(request.url);
|
||||
if (uri == null ||
|
||||
!SchoolNavigationPolicy.canNavigate(
|
||||
uri,
|
||||
isMainFrame: request.isMainFrame,
|
||||
)) {
|
||||
message = '学校跳转到了其他地址,已停止自动交接登录信息。';
|
||||
notifyListeners();
|
||||
return NavigationDecision.prevent;
|
||||
}
|
||||
return NavigationDecision.navigate;
|
||||
},
|
||||
onPageStarted: (_) {
|
||||
if (!hasOpened) {
|
||||
return;
|
||||
}
|
||||
_loading = true;
|
||||
formReady = false;
|
||||
notifyListeners();
|
||||
},
|
||||
onPageFinished: _loaded,
|
||||
onWebResourceError: (error) {
|
||||
if (error.isForMainFrame == true) {
|
||||
_loading = false;
|
||||
message = '学校页面暂时未能加载,请检查网络或使用统一认证入口。';
|
||||
notifyListeners();
|
||||
}
|
||||
},
|
||||
onHttpError: (error) async {
|
||||
final current = Uri.tryParse(await web.currentUrl() ?? '');
|
||||
if (!hasOpened ||
|
||||
error.request == null ||
|
||||
error.request!.uri != current) {
|
||||
return;
|
||||
}
|
||||
_loading = false;
|
||||
message = '学校页面请求未完成,可稍后重试。';
|
||||
notifyListeners();
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
late final WebViewController web;
|
||||
final _reader = SchoolReader(SchoolPageAdapter());
|
||||
SchoolConnectionState connectionState = SchoolConnectionState.signedOut;
|
||||
bool _loading = false,
|
||||
_submitting = false,
|
||||
hasOpened = false,
|
||||
formReady = false;
|
||||
bool get busy => _loading || _submitting || _reader.busy;
|
||||
ImportDraft? pendingDraft;
|
||||
String message = '请通过学校统一身份认证登录。';
|
||||
String get statusLabel => switch (connectionState) {
|
||||
SchoolConnectionState.signedOut => '未登录',
|
||||
SchoolConnectionState.authenticating => '请在学校页面完成认证',
|
||||
SchoolConnectionState.connected => '学校认证已确认',
|
||||
SchoolConnectionState.expired => '学校登录已过期',
|
||||
SchoolConnectionState.unknown => '学校页面已打开 · 课表识别待联调',
|
||||
};
|
||||
static final authUrl = Uri.parse(
|
||||
'https://idas.uestc.edu.cn/authserver/login',
|
||||
);
|
||||
static final timetableUrl = Uri.parse(
|
||||
'https://yjsjy.uestc.edu.cn/pyxx/pygl/xskbcx/index/1',
|
||||
);
|
||||
Future<void> open() => openTimetable();
|
||||
Future<void> openTimetable() async {
|
||||
hasOpened = true;
|
||||
_loading = true;
|
||||
pendingDraft = null;
|
||||
message = '正在打开研究生课表页面…';
|
||||
notifyListeners();
|
||||
try {
|
||||
await web.loadRequest(timetableUrl);
|
||||
} catch (_) {
|
||||
_loading = false;
|
||||
message = '学校课表页面无法打开,请检查校园网络。';
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openAuthentication() async {
|
||||
hasOpened = true;
|
||||
_loading = true;
|
||||
message = '正在准备学校认证…';
|
||||
notifyListeners();
|
||||
try {
|
||||
await web.loadRequest(authUrl);
|
||||
} catch (_) {
|
||||
_loading = false;
|
||||
message = '学校认证页面暂时不可用。';
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
static const _readyScript = """(() => {
|
||||
if (location.origin !== 'https://idas.uestc.edu.cn' || location.pathname !== '/authserver/login') return false;
|
||||
const f = document.querySelector('.login-main #pwdFromId');
|
||||
if (!f || f.method.toLowerCase() !== 'post') return false;
|
||||
const action = new URL(f.action, location.href);
|
||||
return action.origin === location.origin && action.pathname === '/authserver/login'
|
||||
&& !!f.querySelector('#username') && !!f.querySelector('#password')
|
||||
&& !!f.querySelector('#saltPassword') && !!f.querySelector('#pwdEncryptSalt')
|
||||
&& !!f.querySelector('#login_submit') && typeof window.startLogin === 'function'
|
||||
&& typeof window.encryptPassword === 'function' && typeof window.checkUserCaptcha === 'function';
|
||||
})()""";
|
||||
bool _truth(Object value) => value == true || value.toString() == 'true';
|
||||
Future<void> _loaded(String url) async {
|
||||
if (!hasOpened) {
|
||||
return;
|
||||
}
|
||||
_loading = false;
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri != null && SchoolNavigationPolicy.canInjectCredentials(uri)) {
|
||||
connectionState = connectionState == SchoolConnectionState.connected
|
||||
? SchoolConnectionState.expired
|
||||
: SchoolConnectionState.authenticating;
|
||||
try {
|
||||
formReady = _truth(
|
||||
await web.runJavaScriptReturningResult(_readyScript),
|
||||
);
|
||||
} catch (_) {
|
||||
formReady = false;
|
||||
}
|
||||
message = formReady ? '可以使用河畔界面登录;验证码会在学校页面完成。' : '请使用学校原页面完成认证。';
|
||||
} else if (uri != null && SchoolNavigationPolicy.isTimetable(uri)) {
|
||||
connectionState = SchoolConnectionState.unknown;
|
||||
message = '已打开课表页面,正在核对课程结构。';
|
||||
try {
|
||||
pendingDraft = await readTimetable();
|
||||
connectionState = SchoolConnectionState.connected;
|
||||
} catch (error) {
|
||||
message = error is UnsupportedSchoolLayout
|
||||
? error.toString()
|
||||
: '课表读取未完成,已有课表保持不变。';
|
||||
}
|
||||
} else {
|
||||
connectionState = SchoolConnectionState.unknown;
|
||||
message = '可点击右上角日历打开学校课表。';
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
Future<void> submitCredentials(String username, String password) async {
|
||||
if (busy) return;
|
||||
_submitting = true;
|
||||
message = '正在交接学校认证…';
|
||||
notifyListeners();
|
||||
try {
|
||||
final url = Uri.tryParse(await web.currentUrl() ?? '');
|
||||
if (url == null ||
|
||||
!SchoolNavigationPolicy.canInjectCredentials(url) ||
|
||||
!_truth(await web.runJavaScriptReturningResult(_readyScript))) {
|
||||
throw StateError('学校表单尚未准备好,请使用学校页面登录');
|
||||
}
|
||||
final result = await web.runJavaScriptReturningResult('''(() => {
|
||||
if (!($_readyScript)) return false;
|
||||
const f = document.querySelector('.login-main #pwdFromId');
|
||||
const u = f.querySelector('#username'), p = f.querySelector('#password');
|
||||
u.value = ${jsonEncode(username)}; p.value = ${jsonEncode(password)};
|
||||
p.removeAttribute('readonly');
|
||||
u.dispatchEvent(new Event('input', {bubbles:true}));
|
||||
p.dispatchEvent(new Event('input', {bubbles:true}));
|
||||
window.checkUserCaptcha(); return true;
|
||||
})()''');
|
||||
if (!_truth(result)) throw StateError('学校表单已变化,请使用学校页面登录');
|
||||
var settled = false;
|
||||
for (var i = 0; i < 20; i++) {
|
||||
if (_truth(
|
||||
await web.runJavaScriptReturningResult(
|
||||
'!!window.jQuery && window.jQuery.active === 0',
|
||||
),
|
||||
)) {
|
||||
settled = true;
|
||||
break;
|
||||
}
|
||||
await Future<void>.delayed(const Duration(milliseconds: 200));
|
||||
}
|
||||
final current = Uri.tryParse(await web.currentUrl() ?? '');
|
||||
if (!settled ||
|
||||
current == null ||
|
||||
!SchoolNavigationPolicy.canInjectCredentials(current)) {
|
||||
message = '请在学校页面继续完成验证。';
|
||||
return;
|
||||
}
|
||||
await web.runJavaScript('''(() => {
|
||||
if (!($_readyScript)) return;
|
||||
window.startLogin(document.querySelector('.login-main #pwdFromId #login_submit'));
|
||||
})()''');
|
||||
message = '已交给学校处理;如有验证码,请在学校页面完成。';
|
||||
} catch (_) {
|
||||
message = '登录交接未完成,请使用学校页面继续。';
|
||||
} finally {
|
||||
_submitting = false;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
|
||||
Future<ImportDraft> readTimetable() async {
|
||||
final uri = Uri.tryParse(await web.currentUrl() ?? '');
|
||||
if (uri == null) throw UnsupportedSchoolLayout();
|
||||
return _reader.read(uri, () async {
|
||||
final raw = await web.runJavaScriptReturningResult(
|
||||
'JSON.stringify(document.documentElement.outerHTML)',
|
||||
);
|
||||
final value = raw is String ? raw : raw.toString();
|
||||
try {
|
||||
final decoded = jsonDecode(value);
|
||||
return decoded is String ? decoded : value;
|
||||
} catch (_) {
|
||||
return value;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> logout() async {
|
||||
hasOpened = false;
|
||||
await WebViewCookieManager().clearCookies();
|
||||
await web.clearCache();
|
||||
await web.clearLocalStorage();
|
||||
await web.loadHtmlString('<html></html>');
|
||||
_loading = false;
|
||||
hasOpened = false;
|
||||
pendingDraft = null;
|
||||
formReady = false;
|
||||
connectionState = SchoolConnectionState.signedOut;
|
||||
message = '已退出学校登录。';
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user