"""浏览器 localStorage 协议的共享边界。""" import asyncio import json from dataclasses import dataclass from typing import Any from core.workspace import ( FAVORITES_STORAGE_KEY, HISTORY_STORAGE_KEY, LEGACY_STAGED_STORAGE_KEY, WORKSPACE_STORAGE_KEY, WorkspaceDataError, dump_workspace, empty_favorites, empty_history, merge_favorites, merge_history, merge_workspaces, migrate_legacy_workspace, normalize_favorites, normalize_history, normalize_workspace, ) from webui.constants import CONFIG_LS_KEY from webui.constants import ( LOCAL_STORAGE_MAX_READ_CHARS, LOCAL_STORAGE_READ_CHUNK_CHARS, LOCAL_STORAGE_RESTORE_CACHE, ) def storage_keys() -> dict[str, str]: return { 'workspace': WORKSPACE_STORAGE_KEY, 'legacy': LEGACY_STAGED_STORAGE_KEY, 'config': CONFIG_LS_KEY, 'history': HISTORY_STORAGE_KEY, 'favorites': FAVORITES_STORAGE_KEY, } def write_is_ready(name: str, states: dict[str, str], applying: set[str]) -> bool: """只有已安全恢复且未处于应用阶段的数据域允许写回浏览器。""" return states.get(name) == 'ready' and name not in applying async def backup_key(client, source_key: str, backup_key: str) -> bool: """在覆盖前仅创建一次浏览器内备份;已有备份绝不覆盖。""" source_key_js = json.dumps(source_key, ensure_ascii=False) backup_key_js = json.dumps(backup_key, ensure_ascii=False) try: status = await client.run_javascript( f'''(() => {{ const source = localStorage.getItem({source_key_js}); if (source === null) return 'missing'; if (localStorage.getItem({backup_key_js}) !== null) return 'exists'; try {{ localStorage.setItem({backup_key_js}, source); return 'created'; }} catch (error) {{ return `error:${{error && error.name ? error.name : 'unknown'}}`; }} }})()''', timeout=5.0, ) except Exception: return False return status in {'created', 'exists'} def storage_listener_script() -> str: keys = storage_keys() return f''' if (!window.__danbooruWorkspaceStorageListenerV1) {{ window.__danbooruWorkspaceStorageListenerV1 = true; window.addEventListener('storage', (event) => {{ const watchedKeys = new Set([{keys['workspace']!r}, {keys['history']!r}, {keys['favorites']!r}]); if (watchedKeys.has(event.key) && event.newValue !== event.oldValue) {{ if (window.confirm('工作区数据已在另一个标签页更新。是否重新加载当前页面以同步最新内容?')) window.location.reload(); }} }}); }} ''' async def read_prepared_value(client, connected, name: str, key: str, length) -> str | None: """按 UTF-16 安全边界分块读取准备好的浏览器缓存值。""" if length is None: return None if isinstance(length, bool) or not isinstance(length, (int, float)): raise RuntimeError(f'localStorage key {key!r} returned an invalid length') length = int(length) if length < 0 or length > LOCAL_STORAGE_MAX_READ_CHARS: raise WorkspaceDataError(f'localStorage key {key!r} exceeds the read limit') if length == 0: return '' name_js, key_js = json.dumps(name, ensure_ascii=False), json.dumps(key, ensure_ascii=False) cache_key_js = json.dumps(LOCAL_STORAGE_RESTORE_CACHE) chunks: list[str] = [] offset = 0 while offset < length: if not connected(): raise RuntimeError('client disconnected during localStorage restore') result = await client.run_javascript( f'''(() => {{ const cache = window[{cache_key_js}]; const value = cache && Object.prototype.hasOwnProperty.call(cache, {name_js}) ? cache[{name_js}] : localStorage.getItem({key_js}); if (value === null) return null; let end = Math.min(value.length, {offset + LOCAL_STORAGE_READ_CHUNK_CHARS}); if (end < value.length) {{ const last = value.charCodeAt(end - 1); if (last >= 0xD800 && last <= 0xDBFF) end += 1; }} return {{chunk: value.slice({offset}, end), next_offset: end}}; }})()''', timeout=5.0, ) if not isinstance(result, dict): raise RuntimeError(f'localStorage key {key!r} disappeared during restore') chunk, next_offset = result.get('chunk'), result.get('next_offset') if not isinstance(chunk, str) or not isinstance(next_offset, (int, float)): raise RuntimeError(f'localStorage key {key!r} returned an invalid chunk') next_offset = int(next_offset) if next_offset <= offset or next_offset > length: raise RuntimeError(f'localStorage key {key!r} returned an invalid offset') chunks.append(chunk) offset = next_offset return ''.join(chunks) def clear_restore_cache(client) -> None: try: client.run_javascript(f'delete window[{json.dumps(LOCAL_STORAGE_RESTORE_CACHE)}];') except RuntimeError: pass async def prepare_restore_snapshot(client, connected, keys: dict[str, str]) -> dict: """快照请求域;仅在浏览器内存中预压缩旧 history,绝不直接覆盖存储。""" if not connected(): raise RuntimeError('client is disconnected') keys_js = json.dumps(keys, ensure_ascii=False) cache_key_js = json.dumps(LOCAL_STORAGE_RESTORE_CACHE) result = await client.run_javascript( f'''(() => {{ const keys = {keys_js}, values = {{}}, manifest = {{}}; for (const [name, key] of Object.entries(keys)) {{ let value = localStorage.getItem(key), prepared = false; const originalLength = value === null ? null : value.length; if (name === 'history' && value) try {{ const data = JSON.parse(value); if (data && typeof data === 'object' && (data.schema_version === 1 || data.schema_version === 2) && Array.isArray(data.items)) {{ let changed = data.schema_version !== 2; const items = data.items.map((item) => {{ if (!item || typeof item !== 'object' || !item.workspace || typeof item.workspace !== 'object' || typeof item.query !== 'string' || !item.query.trim() || !item.settings || typeof item.settings !== 'object' || Array.isArray(item.settings)) return item; const query = item.query.trim().slice(0, 4000); const searchedAt = typeof item.searched_at === 'string' && item.searched_at ? item.searched_at : new Date().toISOString(); const oldQueries = item.workspace.queries; if (!Array.isArray(oldQueries) || oldQueries.length !== 1 || !oldQueries[0] || oldQueries[0].query !== query) changed = true; const workspace = {{...item.workspace, queries: [{{query, searched_at: searchedAt, settings: item.settings}}], updated_at: searchedAt}}; return {{...item, workspace_id: workspace.workspace_id, workspace}}; }}); if (changed) {{ value = JSON.stringify({{...data, schema_version: 2, items}}); prepared = true; }} }} }} catch (_) {{ /* Python validates and backs up corruption. */ }} values[name] = value; manifest[name] = {{length: value === null ? null : value.length, original_length: originalLength, prepared}}; }} window[{cache_key_js}] = values; return manifest; }})()''', timeout=5.0, ) if not isinstance(result, dict): raise RuntimeError('localStorage manifest is invalid') return result async def restore_staged_storage( controller: Any, names: tuple[str, ...], config_version: int, ) -> tuple[dict[str, str], set[str], list[str]]: """执行一次恢复尝试;控制器只提供页面状态和应用回调。""" unresolved = [ name for name in names if controller._storage_states.get(name) != 'ready' ] if not unresolved: return {}, set(), [] failures: dict[str, str] = {} persist: set[str] = set() warnings: list[str] = [] keys = controller._local_storage_keys() try: manifest = await controller._prepare_local_storage_restore(unresolved) except Exception as exc: message = str(exc) or type(exc).__name__ return {name: message for name in unresolved}, persist, warnings try: for name in unresolved: meta = manifest.get(name) if not isinstance(meta, dict): failures[name] = 'missing manifest entry' continue try: raw = await controller._read_local_storage_value( name, keys[name], meta.get('length') ) except Exception as exc: failures[name] = str(exc) or type(exc).__name__ continue controller._storage_raw_values[name] = raw finally: controller._clear_local_storage_restore_cache() if 'legacy' in unresolved and 'legacy' not in failures: controller._storage_states['legacy'] = 'ready' if 'config' in unresolved and 'config' not in failures: raw_config = controller._storage_raw_values.get('config') config_dirty = 'config' in controller._storage_session_dirty try: cfg = json.loads(raw_config) if raw_config else {} if not isinstance(cfg, dict): raise WorkspaceDataError('config must be a JSON object') except Exception as exc: config_key = keys['config'] if raw_config and not await controller._backup_local_storage_key( config_key, f'{config_key}_corrupt_backup' ): failures['config'] = f'corrupt config backup failed: {exc}' else: warnings.append('config_corrupt') persist.add('config') else: if cfg and cfg.get('version') != config_version: warnings.append('config_schema_migrated') persist.add('config') if not config_dirty: controller._storage_applying.add('config') try: controller._apply_config_state(cfg) finally: controller._storage_applying.discard('config') else: persist.add('config') if 'config' not in failures: controller._storage_states['config'] = 'ready' if 'workspace' in unresolved and 'workspace' not in failures: raw_workspace = controller._storage_raw_values.get('workspace') if not raw_workspace and any( controller._storage_states.get(name) != 'ready' for name in ('legacy', 'config') ): failures['workspace'] = 'legacy workspace inputs are not available' else: workspace_warnings: list[str] = [] try: if raw_workspace: workspace, workspace_warnings = normalize_workspace(raw_workspace) else: workspace, workspace_warnings = migrate_legacy_workspace( controller._storage_raw_values.get('legacy'), controller._storage_raw_values.get('config'), ) persist.add('workspace') except WorkspaceDataError as exc: backed_up = await controller._backup_local_storage_key( keys['workspace'], f"{keys['workspace']}_corrupt_backup" ) if raw_workspace and not backed_up: failures['workspace'] = f'corrupt workspace backup failed: {exc}' else: workspace, migration_warnings = migrate_legacy_workspace( controller._storage_raw_values.get('legacy'), controller._storage_raw_values.get('config'), ) workspace_warnings = ['workspace_corrupt'] + migration_warnings persist.add('workspace') if 'workspace' not in failures: if 'workspace' in controller._storage_session_dirty: workspace = merge_workspaces( controller.workspace_state, workspace, origin='local_restore', source='浏览器本地恢复', ) persist.add('workspace') controller._storage_applying.add('workspace') try: controller._apply_workspace_state( workspace, persist=False, refresh_recommendations=False ) finally: controller._storage_applying.discard('workspace') warnings.extend(workspace_warnings) if workspace_warnings: persist.add('workspace') controller._storage_states['workspace'] = 'ready' if raw_workspace: controller._storage_states['legacy'] = 'ready' failures.pop('legacy', None) if 'history' in unresolved and 'history' not in failures: raw_history = controller._storage_raw_values.get('history') history_prepared = bool( isinstance(manifest.get('history'), dict) and manifest['history'].get('prepared') ) try: history, history_warnings = normalize_history(raw_history) except WorkspaceDataError as exc: backed_up = await controller._backup_local_storage_key( keys['history'], f"{keys['history']}_corrupt_backup" ) if raw_history and not backed_up: failures['history'] = f'corrupt history backup failed: {exc}' else: history, history_warnings = empty_history(), ['history_corrupt'] persist.add('history') if 'history' not in failures: if history_prepared: if not await controller._backup_history_before_compaction(): failures['history'] = 'legacy history backup failed' else: history_warnings.extend([ 'history_schema_migrated', 'history_workspace_queries_compacted', ]) persist.add('history') if 'history' not in failures: if 'history' in controller._storage_session_dirty: history = merge_history(controller.search_history, history) persist.add('history') controller.search_history = history warnings.extend(history_warnings) if history_warnings: persist.add('history') controller._storage_states['history'] = 'ready' if 'favorites' in unresolved and 'favorites' not in failures: raw_favorites = controller._storage_raw_values.get('favorites') try: favorites, favorite_warnings = normalize_favorites(raw_favorites) except WorkspaceDataError as exc: backed_up = await controller._backup_local_storage_key( keys['favorites'], f"{keys['favorites']}_corrupt_backup" ) if raw_favorites and not backed_up: failures['favorites'] = f'corrupt favorites backup failed: {exc}' else: favorites, favorite_warnings = empty_favorites(), ['favorites_corrupt'] persist.add('favorites') if 'favorites' not in failures: if 'favorites' in controller._storage_session_dirty: favorites = merge_favorites(controller.favorites, favorites) persist.add('favorites') controller.favorites = favorites warnings.extend(favorite_warnings) if favorite_warnings: persist.add('favorites') controller._storage_states['favorites'] = 'ready' for name in failures: controller._storage_states[name] = 'failed' controller._update_undo_buttons() controller._update_workspace_counts() return failures, persist, warnings @dataclass(frozen=True) class RestoreRetryResult: """一次恢复任务的无界面结果,交由页面层决定日志和提示。""" completed: bool client_stopped: bool failures: dict[str, str] warnings: list[str] async def restore_with_retries( controller: Any, retry_delays: tuple[float, ...], ) -> RestoreRetryResult: """在连接可用时执行有限次恢复,不触碰 NiceGUI 的通知上下文。""" last_failures: dict[str, str] = {} all_warnings: list[str] = [] for delay in retry_delays: if delay: await asyncio.sleep(delay) if not controller._client_alive(): return RestoreRetryResult(False, True, last_failures, all_warnings) if not controller._client_connected(): last_failures = {'connection': 'client is disconnected'} continue controller._storage_restoring = True try: failures, persist, warnings = await controller._restore_staged_tags() finally: controller._storage_restoring = False persist_restored_storage(controller, persist) all_warnings.extend(warnings) last_failures = failures if not failures: return RestoreRetryResult(True, False, {}, all_warnings) return RestoreRetryResult(False, False, last_failures, all_warnings) def persist_restored_storage(controller: Any, names: set[str]) -> None: """按既定顺序写回已恢复的数据域,具体写入仍由页面控制器完成。""" for name, saver_name in ( ('config', '_save_config'), ('workspace', '_save_staged_tags'), ('history', '_save_history'), ('favorites', '_save_favorites'), ): if name in names: getattr(controller, saver_name)() def flush_storage_session_changes(controller: Any) -> None: """只刷新已安全恢复的数据域,失败或未恢复域绝不覆盖浏览器旧值。""" ready_dirty = { name for name in controller._storage_session_dirty if controller._storage_states.get(name) == 'ready' } persist_restored_storage(controller, ready_dirty) def start_storage_restore_task(controller: Any, worker) -> Any: """仅在仍有待恢复域或待写回数据时创建一个恢复任务。""" task = controller._storage_restore_task if task is not None and not task.done(): return task needs_work = any( state != 'ready' for state in controller._storage_states.values() ) or bool(controller._storage_session_dirty) if not needs_work: return None controller._storage_restore_task = asyncio.create_task(worker()) return controller._storage_restore_task def pause_storage_restore(controller: Any) -> None: """取消当前恢复任务;重连后的重启决定仍由页面生命周期处理。""" task = controller._storage_restore_task if task is not None and not task.done(): task.cancel() def finish_storage_restore_task( controller: Any, current_task: Any, was_cancelled: bool, ) -> bool: """清理当前任务,并告知页面层是否应在已连接时重新启动恢复。""" controller._storage_restoring = False if controller._storage_restore_task is not current_task: return False controller._storage_restore_task = None return was_cancelled and controller._client_connected() def schedule_workspace_persist(controller: Any, debounce_seconds: float) -> None: """去抖写入工作区;恢复未就绪或写入失败时保留原有浏览器数据。""" if not controller._storage_write_allowed('workspace'): return if controller._workspace_save_task and not controller._workspace_save_task.done(): controller._workspace_save_task.cancel() async def persist() -> None: try: await asyncio.sleep(debounce_seconds) except asyncio.CancelledError: return try: data = dump_workspace(controller.workspace_state) except WorkspaceDataError as exc: print(f'[UI] 工作区保存前校验失败: {exc}', flush=True) return if not controller._storage_write_allowed('workspace'): return try: controller.client.run_javascript( f"localStorage.setItem({json.dumps(WORKSPACE_STORAGE_KEY)}, {json.dumps(data)});" ) except RuntimeError: controller._storage_session_dirty.add('workspace') return controller._storage_session_dirty.discard('workspace') controller._workspace_save_task = asyncio.ensure_future(persist())