Harden web session persistence flow

This commit is contained in:
Haitao Pan 2026-03-22 10:55:22 +08:00
parent ce9049d54c
commit bfbe564cd6
4 changed files with 78 additions and 41 deletions

View File

@ -261,7 +261,6 @@ class AppController extends ChangeNotifier {
_aiGatewayApiKeyCache = await _store.loadAiGatewayApiKey();
_relayTokenCache = await _store.loadRelayToken();
_relayPasswordCache = await _store.loadRelayPassword();
_webSessionApiTokenCache = await _store.loadWebSessionApiToken();
_webSessionClientId = await _store.loadOrCreateWebSessionClientId();
final records = await _loadThreadRecords();
for (final record in records) {
@ -301,16 +300,6 @@ class AppController extends ChangeNotifier {
final normalizedRemoteBaseUrl = RemoteWebSessionRepository.normalizeBaseUrl(
trimmedRemoteBaseUrl,
);
_settings = _settings.copyWith(
webSessionPersistence: _settings.webSessionPersistence.copyWith(
mode: mode,
remoteBaseUrl:
normalizedRemoteBaseUrl?.toString() ?? trimmedRemoteBaseUrl,
),
);
_webSessionApiTokenCache = apiToken.trim();
await _store.saveWebSessionApiToken(_webSessionApiTokenCache);
await _persistSettings();
if (mode == WebSessionPersistenceMode.remote &&
trimmedRemoteBaseUrl.isNotEmpty &&
normalizedRemoteBaseUrl == null) {
@ -321,6 +310,15 @@ class AppController extends ChangeNotifier {
notifyListeners();
return;
}
_settings = _settings.copyWith(
webSessionPersistence: _settings.webSessionPersistence.copyWith(
mode: mode,
remoteBaseUrl:
normalizedRemoteBaseUrl?.toString() ?? trimmedRemoteBaseUrl,
),
);
_webSessionApiTokenCache = apiToken.trim();
await _persistSettings();
await _persistThreads();
notifyListeners();
}
@ -752,7 +750,7 @@ class AppController extends ChangeNotifier {
RemoteWebSessionRepository.normalizeBaseUrl(
snapshot.webSessionPersistence.remoteBaseUrl,
)?.toString() ??
snapshot.webSessionPersistence.remoteBaseUrl.trim();
'';
return snapshot.copyWith(
assistantExecutionTarget: target,
gateway: snapshot.gateway.copyWith(
@ -917,19 +915,11 @@ class AppController extends ChangeNotifier {
await _browserSessionRepository.saveThreadRecords(remoteRecords);
return remoteRecords;
}
if (browserRecords.isNotEmpty) {
await remoteRepository.saveThreadRecords(browserRecords);
_sessionPersistenceStatusMessage = appText(
'远端 Session API 为空,已使用当前浏览器缓存完成初始化。',
'The remote session API was empty, so the current browser cache was used to seed it.',
);
} else {
_sessionPersistenceStatusMessage = appText(
'远端 Session API 已启用,当前还没有可恢复的会话。',
'The remote session API is active and there are no saved conversations yet.',
);
}
return browserRecords;
_sessionPersistenceStatusMessage = appText(
'远端 Session API 已启用,但当前为空;浏览器缓存不会自动导入远端。',
'The remote session API is active but empty, and the browser cache will not be imported automatically.',
);
return const <AssistantThreadRecord>[];
} catch (error) {
_sessionPersistenceStatusMessage = _sessionPersistenceErrorLabel(error);
return browserRecords;

View File

@ -341,8 +341,11 @@ class _WebSettingsPageState extends State<WebSettingsPage> {
decoration: InputDecoration(
labelText: appText('Session API Token', 'Session API token'),
helperText: controller.storedWebSessionApiTokenMask == null
? null
: '${appText('已保存', 'Stored')}: ${controller.storedWebSessionApiTokenMask}',
? appText(
'只保留在当前浏览器会话内存中;刷新页面后需要重新输入。',
'Kept only in the current browser session memory; re-enter it after reload.',
)
: '${appText('当前会话', 'This session')}: ${controller.storedWebSessionApiTokenMask} · ${appText('刷新后需重新输入', 'Re-enter after reload')}',
),
),
],

View File

@ -13,7 +13,6 @@ class WebStore {
static const relayTokenKey = 'xworkmate.web.relay.token';
static const relayPasswordKey = 'xworkmate.web.relay.password';
static const relayDeviceIdentityKey = 'xworkmate.web.relay.device_identity';
static const sessionApiTokenKey = 'xworkmate.web.session.api_token';
static const sessionClientIdKey = 'xworkmate.web.session.client_id';
static const themeModeKey = 'xworkmate.web.theme_mode';
@ -93,16 +92,6 @@ class WebStore {
await _prefs!.setString(relayPasswordKey, value.trim());
}
Future<String> loadWebSessionApiToken() async {
await initialize();
return (_prefs!.getString(sessionApiTokenKey) ?? '').trim();
}
Future<void> saveWebSessionApiToken(String value) async {
await initialize();
await _prefs!.setString(sessionApiTokenKey, value.trim());
}
Future<String> loadOrCreateWebSessionClientId() async {
await initialize();
final existing = (_prefs!.getString(sessionClientIdKey) ?? '').trim();

View File

@ -74,7 +74,8 @@ void main() {
);
expect(reloaded.storedAiGatewayApiKeyMask, isNotNull);
expect(reloaded.storedRelayTokenMask, isNotNull);
expect(reloaded.storedWebSessionApiTokenMask, isNotNull);
expect(controller.storedWebSessionApiTokenMask, isNotNull);
expect(reloaded.storedWebSessionApiTokenMask, isNull);
expect(remoteRecords, isNotEmpty);
expect(reloaded.conversations, isNotEmpty);
@ -97,12 +98,66 @@ void main() {
expect(controller.usesRemoteSessionPersistence, isFalse);
expect(controller.sessionPersistenceStatusMessage, contains('HTTPS'));
expect(
controller.settings.webSessionPersistence.remoteBaseUrl,
'http://xworkmate.svc.plus/api/web-sessions',
controller.settings.webSessionPersistence.mode,
WebSessionPersistenceMode.browser,
);
expect(controller.settings.webSessionPersistence.remoteBaseUrl, isEmpty);
expect(controller.storedWebSessionApiTokenMask, isNull);
controller.dispose();
});
test(
'empty remote session api does not import stale browser cache',
() async {
SharedPreferences.setMockInitialValues(<String, Object>{});
final store = WebStore();
final remoteRecords = <AssistantThreadRecord>[];
await store.initialize();
await store.saveSettingsSnapshot(
SettingsSnapshot.defaults().copyWith(
webSessionPersistence: const WebSessionPersistenceConfig(
mode: WebSessionPersistenceMode.remote,
remoteBaseUrl: 'https://xworkmate.svc.plus/api/web-sessions',
),
),
);
await store.saveAssistantThreadRecords(<AssistantThreadRecord>[
AssistantThreadRecord(
sessionKey: 'direct:stale-browser-cache',
messages: const <GatewayChatMessage>[],
updatedAtMs: 1,
title: 'stale browser cache',
archived: false,
executionTarget: AssistantExecutionTarget.aiGatewayOnly,
messageViewMode: AssistantMessageViewMode.rendered,
),
]);
final controller = AppController(
store: store,
remoteSessionRepositoryBuilder: (config, clientId, accessToken) =>
_MemoryRemoteSessionRepository(remoteRecords),
);
await _waitForReady(controller);
expect(remoteRecords, isEmpty);
expect(
controller.sessionPersistenceStatusMessage,
anyOf(
contains('不会自动导入远端'),
contains('will not be imported automatically'),
),
);
expect(
controller.conversations.single.title,
isNot('stale browser cache'),
);
controller.dispose();
},
);
}
class _MemoryRemoteSessionRepository implements WebSessionRepository {