481 lines
15 KiB
Python
481 lines
15 KiB
Python
# 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,
|
|
"compactMode": False,
|
|
"clearRoomFilterOnEnter": True,
|
|
"clearRoomFilterOnEscape": True,
|
|
"clearMemberFilterOnEscape": True,
|
|
"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())
|