# Copyright Mirage authors & contributors # SPDX-License-Identifier: LGPL-3.0-or-later """User data and configuration files definitions.""" import asyncio import json import os import platform from dataclasses import dataclass, field from pathlib import Path from typing import TYPE_CHECKING, Any, ClassVar, Dict, Optional import aiofiles import pyotherside from .theme_parser import convert_to_qml from .utils import atomic_write, dict_update_recursive if TYPE_CHECKING: from .backend import Backend JsonData = Dict[str, Any] WRITE_LOCK = asyncio.Lock() @dataclass class DataFile: """Base class representing a user data file.""" is_config: ClassVar[bool] = False create_missing: ClassVar[bool] = True backend: "Backend" = field(repr=False) filename: str = field() _to_write: Optional[str] = field(init=False, default=None) def __post_init__(self) -> None: asyncio.ensure_future(self._write_loop()) @property def path(self) -> Path: """Full path of the file, even if it doesn't exist yet.""" if self.is_config: return Path( os.environ.get("MIRAGE_CONFIG_DIR") or self.backend.appdirs.user_config_dir, ) / self.filename return Path( os.environ.get("MIRAGE_DATA_DIR") or self.backend.appdirs.user_data_dir, ) / self.filename async def default_data(self): """Default content if the file doesn't exist.""" return "" async def read(self): """Return future, existing or default content for the file on disk.""" if self._to_write is not None: return self._to_write try: return self.path.read_text() except FileNotFoundError: default = await self.default_data() if self.create_missing: await self.write(default) return default async def write(self, data) -> None: """Request for the file to be written/updated with data.""" self._to_write = data async def _write_loop(self) -> None: """Write/update file on disk with a 1 second cooldown.""" self.path.parent.mkdir(parents=True, exist_ok=True) while True: await asyncio.sleep(1) if self._to_write is None: continue if not self.create_missing and not self.path.exists(): continue async with atomic_write(self.path) as (new, done): await new.write(self._to_write) done() self._to_write = None @dataclass class JSONDataFile(DataFile): """Represent a user data file in the JSON format.""" _data: Optional[Dict[str, Any]] = field(init=False, default=None) def __getitem__(self, key: str) -> Any: if self._data is None: raise RuntimeError(f"{self}: read() hasn't been called yet") return self._data[key] async def default_data(self) -> JsonData: return {} async def read(self) -> JsonData: """Return future, existing or default content for the file on disk. If the file has missing keys, the missing data will be merged and written to disk before returning. If `create_missing` is `True` and the file doesn't exist, it will be created. """ if self._to_write is not None: return json.loads(self._to_write) try: data = json.loads(self.path.read_text()) except FileNotFoundError: if not self.create_missing: data = await self.default_data() self._data = data return data data = {} except json.JSONDecodeError: data = {} all_data = await self.default_data() dict_update_recursive(all_data, data) if data != all_data: await self.write(all_data) self._data = all_data return all_data async def write(self, data: JsonData) -> None: js = json.dumps(data, indent=4, ensure_ascii=False, sort_keys=True) await super().write(js) @dataclass class Accounts(JSONDataFile): """Config file for saved matrix accounts: user ID, access tokens, etc.""" is_config = True filename: str = "accounts.json" async def any_saved(self) -> bool: """Return whether there are any accounts saved on disk.""" return bool(await self.read()) async def add(self, user_id: str) -> None: """Add an account to the config and write it on disk. The account's details such as its access token are retrieved from the corresponding `MatrixClient` in `backend.clients`. """ client = self.backend.clients[user_id] saved = await self.read() account = self.backend.models["accounts"][user_id] await self.write({ **saved, client.user_id: { "homeserver": client.homeserver, "token": client.access_token, "device_id": client.device_id, "enabled": True, "presence": account.presence.value, "status_msg": account.status_msg, "order": account.order, }, }) async def update( self, user_id: str, enabled: Optional[str] = None, presence: Optional[str] = None, order: Optional[int] = None, status_msg: Optional[str] = None, ) -> None: """Update an account if found in the config file and write to disk.""" saved = await self.read() if user_id not in saved: return if enabled is not None: saved[user_id]["enabled"] = enabled if presence is not None: saved[user_id]["presence"] = presence if order is not None: saved[user_id]["order"] = order if status_msg is not None: saved[user_id]["status_msg"] = status_msg await self.write({**saved}) async def delete(self, user_id: str) -> None: """Delete an account from the config and write it on disk.""" await self.write({ uid: info for uid, info in (await self.read()).items() if uid != user_id }) @dataclass class UISettings(JSONDataFile): """Config file for QML interface settings and keybindings.""" is_config = True filename: str = "settings.json" async def default_data(self) -> JsonData: def ctrl_or_osx_ctrl() -> str: # Meta in Qt corresponds to Ctrl on OSX return "Meta" if platform.system() == "Darwin" else "Ctrl" def alt_or_cmd() -> str: # Ctrl in Qt corresponds to Cmd on OSX return "Ctrl" if platform.system() == "Darwin" else "Alt" return { "alertOnMentionForMsec": -1, "alertOnMessageForMsec": 0, "alwaysCenterRoomHeader": False, # "autoHideScrollBarsAfterMsec": 2000, "beUnavailableAfterSecondsIdle": 60 * 10, "centerRoomListOnClick": False, "compactMode": False, "clearRoomFilterOnEnter": True, "clearRoomFilterOnEscape": True, "clearMemberFilterOnEscape": True, "closeMinimizesToTray": False, "collapseSidePanesUnderWindowWidth": 450, "enableKineticScrolling": True, "hideProfileChangeEvents": True, "hideMembershipEvents": False, "hideUnknownEvents": True, "kineticScrollingMaxSpeed": 2500, "kineticScrollingDeceleration": 1500, "lexicalRoomSorting": False, "markRoomReadMsecDelay": 200, "maxMessageCharactersPerLine": 65, "ownMessagesOnLeftAboveWidth": 895, "theme": "Midnight.qpl", "writeAliases": {}, "zoom": 1.0, "media": { "autoLoad": True, "autoPlay": False, "autoPlayGIF": True, "autoHideOSDAfterMsec": 3000, "defaultVolume": 100, "openExternallyOnClick": False, "startMuted": False, }, "keys": { "startPythonDebugger": ["Alt+Shift+D"], "toggleDebugConsole": ["Alt+Shift+C", "F1"], "reloadConfig": ["Alt+Shift+R"], "zoomIn": ["Ctrl++"], "zoomOut": ["Ctrl+-"], "zoomReset": ["Ctrl+="], "toggleCompactMode": ["Ctrl+Alt+C"], "toggleHideRoomPane": ["Ctrl+Alt+R"], "scrollUp": ["Alt+Up", "Alt+K"], "scrollDown": ["Alt+Down", "Alt+J"], "scrollPageUp": ["Alt+Ctrl+Up", "Alt+Ctrl+K", "PgUp"], "scrollPageDown": ["Alt+Ctrl+Down", "Alt+Ctrl+J", "PgDown"], "scrollToTop": ["Alt+Ctrl+Shift+Up", "Alt+Ctrl+Shift+K", "Home"], "scrollToBottom": ["Alt+Ctrl+Shift+Down", "Alt+Ctrl+Shift+J", "End"], "previousTab": ["Alt+Shift+Left", "Alt+Shift+H"], "nextTab": ["Alt+Shift+Right", "Alt+Shift+L"], "addNewAccount": ["Alt+Shift+A"], "accountSettings": ["Alt+A"], "addNewChat": ["Alt+C"], "toggleFocusMainPane": ["Alt+F"], "clearRoomFilter": ["Alt+Shift+F"], "toggleCollapseAccount": ["Alt+O"], "openPresenceMenu": ["Alt+P"], "togglePresenceUnavailable": ["Alt+Ctrl+U", "Alt+Ctrl+A"], "togglePresenceInvisible": ["Alt+Ctrl+I"], "togglePresenceOffline": ["Alt+Ctrl+O"], "goToLastPage": ["Ctrl+Tab"], "goToPreviousAccount": ["Alt+Shift+N"], "goToNextAccount": ["Alt+N"], "goToPreviousRoom": ["Alt+Shift+Up", "Alt+Shift+K"], "goToNextRoom": ["Alt+Shift+Down", "Alt+Shift+J"], "goToPreviousUnreadRoom": ["Alt+Shift+U"], "goToNextUnreadRoom": ["Alt+U"], "goToPreviousMentionedRoom": ["Alt+Shift+M"], "goToNextMentionedRoom": ["Alt+M"], "focusAccountAtIndex": { "01": f"{ctrl_or_osx_ctrl()}+1", "02": f"{ctrl_or_osx_ctrl()}+2", "03": f"{ctrl_or_osx_ctrl()}+3", "04": f"{ctrl_or_osx_ctrl()}+4", "05": f"{ctrl_or_osx_ctrl()}+5", "06": f"{ctrl_or_osx_ctrl()}+6", "07": f"{ctrl_or_osx_ctrl()}+7", "08": f"{ctrl_or_osx_ctrl()}+8", "09": f"{ctrl_or_osx_ctrl()}+9", "10": f"{ctrl_or_osx_ctrl()}+0", }, # On OSX, alt+numbers if used for symbols, use cmd instead "focusRoomAtIndex": { "01": f"{alt_or_cmd()}+1", "02": f"{alt_or_cmd()}+2", "03": f"{alt_or_cmd()}+3", "04": f"{alt_or_cmd()}+4", "05": f"{alt_or_cmd()}+5", "06": f"{alt_or_cmd()}+6", "07": f"{alt_or_cmd()}+7", "08": f"{alt_or_cmd()}+8", "09": f"{alt_or_cmd()}+9", "10": f"{alt_or_cmd()}+0", }, "unfocusOrDeselectAllMessages": ["Ctrl+D"], "focusPreviousMessage": ["Ctrl+Up", "Ctrl+K"], "focusNextMessage": ["Ctrl+Down", "Ctrl+J"], "toggleSelectMessage": ["Ctrl+Space"], "selectMessagesUntilHere": ["Ctrl+Shift+Space"], "removeFocusedOrSelectedMessages": ["Ctrl+R", "Alt+Del"], "replyToFocusedOrLastMessage": ["Ctrl+Q"], # Q → Quote "debugFocusedMessage": ["Ctrl+Shift+D"], "openMessagesLinksOrFiles": ["Ctrl+O"], "openMessagesLinksOrFilesExternally": ["Ctrl+Shift+O"], "copyFilesLocalPath": ["Ctrl+Shift+C"], "clearRoomMessages": ["Ctrl+L"], "sendFile": ["Alt+S"], "sendFileFromPathInClipboard": ["Alt+Shift+S"], "inviteToRoom": ["Alt+I"], "leaveRoom": ["Alt+Escape"], "forgetRoom": ["Alt+Shift+Escape"], "toggleFocusRoomPane": ["Alt+R"], "refreshDevices": ["Alt+R", "F5"], "signOutCheckedOrAllDevices": ["Alt+S", "Delete"], "imageViewer": { "panLeft": ["H", "Left", "Alt+H", "Alt+Left"], "panDown": ["J", "Down", "Alt+J", "Alt+Down"], "panUp": ["K", "Up", "Alt+K", "Alt+Up"], "panRight": ["L", "Right", "Alt+L", "Alt+Right"], "zoomOut": ["Z", "-", "Ctrl+-"], "zoomIn": ["Shift+Z", "+", "Ctrl++"], "zoomReset": ["Alt+Z", "=", "Ctrl+="], "rotateLeft": ["R"], "rotateRight": ["Shift+R"], "rotateReset": ["Alt+R"], "previousSpeed": ["S"], "nextSpeed": ["Shift+S"], "resetSpeed": ["Alt+S"], "pause": ["Space"], "expand": ["E"], "fullScreen": ["F", "F11", "Alt+Return", "Alt+Enter"], "close": ["X", "Q"], }, }, } @dataclass class UIState(JSONDataFile): """File to save and restore the state of the QML interface.""" filename: str = "state.json" async def default_data(self) -> JsonData: return { "collapseAccounts": {}, "page": "Pages/Default.qml", "pageProperties": {}, } @dataclass class History(JSONDataFile): """File to save and restore lines typed by the user in QML components.""" filename: str = "history.json" async def default_data(self) -> JsonData: return {"console": []} @dataclass class Theme(DataFile): """A theme file defining the look of QML components.""" # Since it currently breaks at every update and the file format will be # changed later, don't copy the theme to user data dir if it doesn't exist. create_missing = False @property def path(self) -> Path: data_dir = Path( os.environ.get("MIRAGE_DATA_DIR") or self.backend.appdirs.user_data_dir, ) return data_dir / "themes" / self.filename async def default_data(self) -> str: path = f"src/themes/{self.filename}" try: byte_content = pyotherside.qrc_get_file_contents(path) except ValueError: # App was compiled without QRC async with aiofiles.open(path) as file: return await file.read() else: return byte_content.decode() async def read(self) -> str: return convert_to_qml(await super().read())