diff --git a/CHANGELOG.md b/CHANGELOG.md index 96948c8..eaeaac2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,52 @@ takes its description from its section, so a version cannot be released without У каждой версии здесь свой раздел, на английском и на русском. Описание релиза на GitHub берётся из этого раздела, поэтому без него версия не выйдет. +## 0.5.0 — 2026-09-28 + +### English + +**Added** +- **Controller layouts shared between games.** Steam offers a layout only in the game it was + made for. Now the game page lists the game's layouts in Steam, and "Save to DeckDrop" makes + one available to every game on the Deck: controller settings → Templates → "DeckDrop: name". +- **Apply a saved layout from the phone**: "Layout from DeckDrop" → "Apply" on the game page + (with Steam control). DeckDrop checks that Steam took it; the game's previous layout stays in + Steam. The page also shows the game's current layout. +- **Layout for new games** in Settings: applied when DeckDrop adds a game to Steam; "—" by default. +- **Settings → Controller layouts**: rename, delete, download as `.vdf`, or upload a `.vdf`, + for example one from another Deck. +- Saving, renaming and deleting change only DeckDrop's own layout files: never a game's layouts, + Valve's templates or DeckDrop's settings. If a Steam update clears the templates, DeckDrop + puts its layouts back when it starts. + +**Fixed** +- Settings → Performance → self-check no longer counts as a download of `deckdrop.py` on GitHub: + it checks that the releases page answers instead of knocking on the file. The tests do not touch + GitHub at all, so the download counter shows real downloads again. + +### Русский + +**Добавлено** +- **Раскладки контроллера, общие для всех игр.** Steam предлагает раскладку только в той игре, + для которой её сделали. Теперь на странице игры видны её раскладки в Steam, а «Сохранить + в DeckDrop» делает раскладку доступной в любой игре на деке: настройки контроллера → + Шаблоны → «DeckDrop: название». +- **Применить сохранённую раскладку с телефона**: на странице игры «Раскладка из DeckDrop» → + «Применить» (при управлении Steam). DeckDrop проверяет, что Steam её поставил; прежняя раскладка + игры остаётся в Steam. Там же видно, какая раскладка у игры сейчас. +- **Раскладка для новых игр** в настройках: ставится, когда DeckDrop добавляет игру в Steam; + по умолчанию «—». +- **Настройки → Раскладки контроллера**: переименовать, удалить, скачать `.vdf` или загрузить + `.vdf`, например с другого дека. +- Сохранение, переименование и удаление меняют только собственные файлы раскладок DeckDrop: + ни раскладки игр, ни шаблоны Valve, ни настройки DeckDrop не трогаются. Если обновление Steam + очистит шаблоны, DeckDrop вернёт свои раскладки при запуске. + +**Исправлено** +- Самопроверка (Настройки → Производительность) больше не засчитывается на GitHub как скачивание + `deckdrop.py`: она проверяет, что отвечает страница релизов, а не сам файл. Тесты к GitHub + вообще не обращаются, так что счётчик скачиваний снова показывает настоящие скачивания. + ## 0.4.2 — 2026-09-28 ### English diff --git a/README.en.md b/README.en.md index 7cf2458..c77cd00 100644 --- a/README.en.md +++ b/README.en.md @@ -33,6 +33,8 @@ same Wi-Fi, and from the couch you can: - **drop a patch** straight into the game folder, or unpack a patch archive over the game after seeing exactly what it will replace; - **back up saves** to a zip and restore them; +- **share controller layouts between games**: save a game's layout and apply it to any other, + or give every new game your layout; - **browse Steam screenshots and recordings** in a password-protected gallery and save them to your phone; - do all of this **in English or Russian**: the language follows Steam on the Deck. @@ -51,6 +53,10 @@ developer mode, and SteamOS updates do not break it. Gallery
Screenshot gallery Settings
Settings and language + +Controller layouts on the game page
Controller layouts: save and apply +Saved controller layouts in Settings
Saved layouts and the one for new games +

DeckDrop in a desktop browser

@@ -90,6 +96,7 @@ That's it. DeckDrop starts by itself every time the Deck boots and keeps working - [Game files and patches](#game-files-and-patches) - [Artwork](#artwork) - [Saves](#saves) +- [Controller layouts](#controller-layouts) - [Archives](#archives) - [Media](#media) - [Supported links](#supported-links) @@ -275,13 +282,13 @@ Steam keeps its settings in memory and rewrites its files on exit, so edits to ` `shortcuts.vdf` made while it runs are lost. DeckDrop does what Decky Loader does: it turns on Steam's local CEF debugging port (the `.cef-enable-remote-debugging` marker file in the Steam folder) and asks the running client to add a shortcut with the right name, set Proton, apply -artwork or remove the shortcut. +artwork, select a [controller layout](#controller-layouts) or remove the shortcut. - The port listens on the Deck only (localhost); it is not reachable from outside. - It becomes active after **one Deck reboot**. The status is shown at the bottom of the page and in Settings. -- Until then, games are added with `steamos-add-to-steam`, and the name and Proton are queued - and applied automatically once control is available. +- Until then, games are added with `steamos-add-to-steam`, and the name, Proton and the layout + for new games are queued and applied automatically once control is available. - Turning it off in Settings removes the marker; Steam closes the port on its next start. ## Passwords and PIN @@ -339,6 +346,8 @@ DeckDrop copies and moves nothing: the folder stays where it is and shows up in can be changed later with the pencil next to the title. - **Artwork**: all five Steam slots with previews, see [Artwork](#artwork). - **Saves**: what goes into the backup, "download backup" and "import zip". +- **Controller layouts**: the game's layouts in Steam, each can be saved to DeckDrop, see + [Controller layouts](#controller-layouts). - **Actions**: hide from the list, delete from the Deck with the PIN (a check box removes the Steam shortcut too). - **Details**: folder, size, name in Steam, AppID, Proton, artwork source. @@ -389,6 +398,40 @@ system clutter) and, for Linux builds, the Ren'Py and Unity folders in your home import DeckDrop backs up the current saves to `~/.cache/deckdrop`, and files from the archive cannot escape the target folders. +## Controller layouts + +Steam keeps a layout with the game it was made for: a layout saved in one game is not offered +in another. DeckDrop carries it over. + +1. On the game page, the "Controller layouts" card lists the layouts Steam has for this game: + its own one and the ones saved under a name. "Save to DeckDrop" asks for a name. +2. To use it in a game: "Layout from DeckDrop" → "Apply" on that game's page (needs + [Steam control](#steam-control)), or on the Deck: controller settings → Templates → + "DeckDrop: name". If it is not there, restart Steam. + +"Now in Steam" shows the game's current layout. Applying selects the saved layout the way Steam's +own picker does and checks that Steam reports it; the game's previous layout stays in Steam and +can be picked again there. + +**Layout for new games** (Settings → Controller layouts): one of the saved layouts, applied when +DeckDrop adds a game to Steam; "—" by default, Steam picks as usual. Without Steam control it is +applied once control is on, like a pending name or Proton. + +Settings → Controller layouts lists the saved layouts: rename, download as `.vdf`, delete, or +upload a `.vdf`, for example one downloaded from another Deck. Deleting removes the layout from +DeckDrop and from the templates; a game where it is already chosen keeps its layout, as Steam +copies a template into the game when you pick it. + +DeckDrop keeps its copies in `~/.config/deckdrop/layouts` and puts each one into Steam's +templates folder (`~/.local/share/Steam/controller_base/templates`) as +`controller_neptune_deckdrop_.vdf`. It never changes a game's own layouts or Valve's +templates, and applying a layout is asked of Steam, which records the choice itself. +A Steam update may clear that folder: DeckDrop puts its templates back when it +starts and whenever the list is opened. + +Only layouts of the Deck's own controls can be saved. A layout made for a game with its own +in-game actions (Steam Input API) only makes sense in that game. + ## Archives Everything in `_inbox` on every disk: size, date, unpacked or not. Unpack from here or delete @@ -462,7 +505,7 @@ Settings has a proxy used **by DeckDrop only**; the rest of the Deck goes online Open them with the tab or the ⚙ in the header; every switch saves immediately. They cover the interface language, the default Proton for new games, preferring Linux builds, Steam control, -automatic VNDB lookup and skipping 18+ images, the proxy and a connection test, the Mega +saved [controller layouts](#controller-layouts) and the layout for new games, automatic VNDB lookup and skipping 18+ images, the proxy and a connection test, the Mega checksum check, archive passwords, the update link, changing the PIN, and disk and address details. diff --git a/README.md b/README.md index 787546e..be2a371 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,13 @@ Paste a link or drop a file on your phone or PC, and the Deck downloads it (Mega included, decrypted on the Deck), unpacks it and adds the game to Steam with Proton and all five kinds of artwork. Your Deck's screenshots and recordings are just as easy to grab onto your phone or PC. -Plus patches, save backups and a built-in self-check with load measurements. +Plus controller layouts shared between games, patches, save backups and a built-in self-check. One Python file, nothing to install on stock SteamOS. English and Russian, following the Deck's Steam language. Вставляешь ссылку или кидаешь файл с телефона или ПК — дек скачивает его (Mega тоже, с расшифровкой прямо на деке), распаковывает и добавляет игру в Steam с Proton и всеми пятью видами обложек. А скриншоты и записи с дека так же просто забрать себе на телефон или ПК. -Плюс патчи, бэкапы сейвов и встроенная самопроверка с замером нагрузки. Один файл на Python, +Плюс раскладки контроллера, общие для всех игр, патчи, бэкапы сейвов и встроенная самопроверка. Один файл на Python, на стоковом SteamOS ничего доустанавливать не нужно. Русский и английский, по языку Steam на деке. diff --git a/README.ru.md b/README.ru.md index 3a4b8aa..072c43a 100644 --- a/README.ru.md +++ b/README.ru.md @@ -33,6 +33,8 @@ DeckDrop — маленькая веб-страница, которая живё - **положить патч** прямо в папку игры, а архив с патчем — распаковать поверх, заранее увидев, что заменится; - **забрать сейвы** в zip и вернуть их обратно; +- **переносить раскладки контроллера** между играми: сохранить раскладку одной игры и применить + её к любой другой или ставить свою раскладку всем новым играм; - **смотреть скриншоты и записи** Steam в галерее под паролем и скачивать их на телефон; - управлять всем этим **на русском или английском**: язык берётся из Steam на деке. @@ -50,6 +52,10 @@ DeckDrop — маленькая веб-страница, которая живё + + + +
Галерея
Галерея скриншотов
Настройки
Настройки и язык
Раскладки контроллера на странице игры
Раскладки контроллера: сохранить и применить
Сохранённые раскладки в настройках
Сохранённые раскладки и раскладка для новых игр

DeckDrop в браузере на ПК

@@ -89,6 +95,7 @@ DeckDrop — маленькая веб-страница, которая живё - [Файлы игры и патчи](#game-files-and-patches) - [Обложки](#artwork) - [Сейвы](#saves) +- [Раскладки контроллера](#controller-layouts) - [Архивы](#archives) - [Медиа](#media) - [Какие ссылки работают](#supported-links) @@ -274,12 +281,13 @@ Steam держит настройки в памяти и переписывае и `shortcuts.vdf` при запущенном клиенте пропадают. DeckDrop идёт тем же путём, что Decky Loader: включает у Steam локальный отладочный порт CEF (файл-маркер `.cef-enable-remote-debugging` в папке Steam) и через него просит клиент добавить ярлык -с нужным именем, выбрать Proton, поставить обложки, убрать ярлык. +с нужным именем, выбрать Proton, поставить обложки, выбрать +[раскладку контроллера](#controller-layouts), убрать ярлык. - Порт открывается только на самом деке (localhost), снаружи он недоступен. - Включается после **одной перезагрузки дека**. Состояние видно внизу страницы и в настройках. -- Пока управления нет, игры добавляются через `steamos-add-to-steam`, а имя и Proton встают - в очередь и применяются сами, как только управление появится. +- Пока управления нет, игры добавляются через `steamos-add-to-steam`, а имя, Proton и раскладка + для новых игр встают в очередь и применяются сами, как только управление появится. - Выключается в настройках: маркер удаляется, и Steam при следующем запуске закроет порт. ## Пароли и PIN @@ -333,6 +341,8 @@ DeckDrop ничего не копирует и не перемещает: пап полем для своего имени и выбором Proton. Потом имя меняется карандашом у заголовка. - **Обложки** — все пять слотов Steam с предпросмотром, см. [Обложки](#artwork). - **Сейвы** — что войдёт в бэкап, «скачать бэкап» и «импортировать zip». +- **Раскладки контроллера** — раскладки игры в Steam, каждую можно сохранить в DeckDrop, + см. [Раскладки контроллера](#controller-layouts). - **Действия** — скрыть из списка, удалить с дека по PIN (галочка убирает и ярлык из Steam). - **Сведения** — папка, размер, имя в Steam, AppID, Proton, источник обложек. @@ -379,6 +389,40 @@ hero-баннер 1920×620, логотип и иконка. Полный наб Перед импортом DeckDrop сам делает копию текущих сохранений в `~/.cache/deckdrop`, а файлы из архива не могут выйти за пределы целевых папок. +## Раскладки контроллера + +Steam хранит раскладку вместе с игрой, для которой её сделали: раскладку, сохранённую в одной +игре, в другой он не предлагает. DeckDrop переносит её. + +1. На странице игры в карточке «Раскладки контроллера» видны раскладки, которые есть у Steam + для этой игры: её собственная и сохранённые под своим названием. «Сохранить в DeckDrop» + спросит название. +2. Чтобы поставить её игре: на странице этой игры «Раскладка из DeckDrop» → «Применить» (нужно + [управление Steam](#steam-control)) или на деке: настройки контроллера → Шаблоны → + «DeckDrop: название». Если её там нет, перезапустите Steam. + +«Сейчас в Steam» показывает текущую раскладку игры. «Применить» выбирает сохранённую раскладку +так же, как это делает сам Steam, и проверяет, что Steam её подтвердил. Прежняя раскладка игры +остаётся в Steam, её можно выбрать там снова. + +**Раскладка для новых игр** (Настройки → Раскладки контроллера) — одна из сохранённых, ставится, +когда DeckDrop добавляет игру в Steam. По умолчанию «—»: Steam выбирает сам, как обычно. Без +управления Steam она применится, когда оно включится, как отложенные имя и Proton. + +В «Настройки → Раскладки контроллера» — список сохранённых: переименовать, скачать `.vdf`, +удалить или загрузить `.vdf`, например скачанный с другого дека. Удалённая раскладка пропадает +из DeckDrop и из шаблонов, а игра, где она уже выбрана, сохранит свою раскладку: при выборе +шаблона Steam копирует его в игру. + +Копии DeckDrop лежат в `~/.config/deckdrop/layouts`, а в папку шаблонов Steam +(`~/.local/share/Steam/controller_base/templates`) каждая кладётся как +`controller_neptune_deckdrop_.vdf`. Собственные раскладки игр и шаблоны Valve DeckDrop +не меняет, а выбор раскладки для игры записывает сам Steam. Обновление Steam может очистить эту папку: DeckDrop возвращает свои шаблоны при +запуске и каждый раз, когда открывают список. + +Сохранять можно только раскладки встроенного управления дека. Раскладка игры со своими +игровыми действиями (Steam Input API) имеет смысл только в этой игре. + ## Архивы Всё, что лежит в `_inbox` на всех дисках: размер, дата, распакован ли. Нераспакованный можно @@ -452,7 +496,8 @@ Mega шифрует файл прямо в браузере того, кто е Открываются вкладкой или шестерёнкой ⚙ в шапке; каждый переключатель сохраняется сразу. Там: язык интерфейса, Proton по умолчанию для новых игр, предпочтение Linux-сборок, -управление Steam, автопоиск VNDB и пропуск 18+, прокси и проверка связи, проверка контрольной +управление Steam, сохранённые [раскладки контроллера](#controller-layouts) и раскладка для новых +игр, автопоиск VNDB и пропуск 18+, прокси и проверка связи, проверка контрольной суммы Mega, пароли архивов, адрес обновления, смена PIN и сведения о дисках и адресах. ### Производительность diff --git a/docs/media/en/demo.gif b/docs/media/en/demo.gif index 68296e2..521be8a 100644 Binary files a/docs/media/en/demo.gif and b/docs/media/en/demo.gif differ diff --git a/docs/media/en/desktop.png b/docs/media/en/desktop.png index 2edeb8a..40a6ec8 100644 Binary files a/docs/media/en/desktop.png and b/docs/media/en/desktop.png differ diff --git a/docs/media/en/game.png b/docs/media/en/game.png index 2a1d6a2..fbaa634 100644 Binary files a/docs/media/en/game.png and b/docs/media/en/game.png differ diff --git a/docs/media/en/games.png b/docs/media/en/games.png index 110ca35..782fe5a 100644 Binary files a/docs/media/en/games.png and b/docs/media/en/games.png differ diff --git a/docs/media/en/layouts-settings.png b/docs/media/en/layouts-settings.png new file mode 100644 index 0000000..888a7cb Binary files /dev/null and b/docs/media/en/layouts-settings.png differ diff --git a/docs/media/en/layouts.png b/docs/media/en/layouts.png new file mode 100644 index 0000000..0f0caba Binary files /dev/null and b/docs/media/en/layouts.png differ diff --git a/docs/media/en/media.png b/docs/media/en/media.png index 1fd34b2..4d578fb 100644 Binary files a/docs/media/en/media.png and b/docs/media/en/media.png differ diff --git a/docs/media/en/settings.png b/docs/media/en/settings.png index 738eeea..b80dcb0 100644 Binary files a/docs/media/en/settings.png and b/docs/media/en/settings.png differ diff --git a/docs/media/ru/demo.gif b/docs/media/ru/demo.gif index 32d14a4..8ff1e5c 100644 Binary files a/docs/media/ru/demo.gif and b/docs/media/ru/demo.gif differ diff --git a/docs/media/ru/desktop.png b/docs/media/ru/desktop.png index 3006820..fcef597 100644 Binary files a/docs/media/ru/desktop.png and b/docs/media/ru/desktop.png differ diff --git a/docs/media/ru/game.png b/docs/media/ru/game.png index f833ea1..b8cffa8 100644 Binary files a/docs/media/ru/game.png and b/docs/media/ru/game.png differ diff --git a/docs/media/ru/games.png b/docs/media/ru/games.png index 7287062..282c529 100644 Binary files a/docs/media/ru/games.png and b/docs/media/ru/games.png differ diff --git a/docs/media/ru/layouts-settings.png b/docs/media/ru/layouts-settings.png new file mode 100644 index 0000000..7bc357a Binary files /dev/null and b/docs/media/ru/layouts-settings.png differ diff --git a/docs/media/ru/layouts.png b/docs/media/ru/layouts.png new file mode 100644 index 0000000..fcf9263 Binary files /dev/null and b/docs/media/ru/layouts.png differ diff --git a/docs/media/ru/media.png b/docs/media/ru/media.png index 6931e31..dd288dc 100644 Binary files a/docs/media/ru/media.png and b/docs/media/ru/media.png differ diff --git a/docs/media/ru/settings.png b/docs/media/ru/settings.png index 9dea010..2a4948b 100644 Binary files a/docs/media/ru/settings.png and b/docs/media/ru/settings.png differ diff --git a/src/deckdrop/__init__.py b/src/deckdrop/__init__.py index 00f600a..232196d 100644 --- a/src/deckdrop/__init__.py +++ b/src/deckdrop/__init__.py @@ -25,4 +25,4 @@ DECKDROP_DISKS (extra roots "label=path;..."), DECKDROP_CEF=0 (no Steam control), DECKDROP_CEF_PORT (8080), DECKDROP_EXTRACT=0 (don't unpack), DECKDROP_KEEP=0 """ -__version__ = "0.4.2" +__version__ = "0.5.0" diff --git a/src/deckdrop/app.py b/src/deckdrop/app.py index e3251b7..1786304 100644 --- a/src/deckdrop/app.py +++ b/src/deckdrop/app.py @@ -10,6 +10,7 @@ from .service import install, uninstall from .state import STATE, ensure_pin, load_state from .steam.cdp import CDP +from .steam.layouts import sync_templates from .steam.shortcuts import pending_loop from .storage import default_root from .web.server import Handler, local_urls @@ -25,6 +26,10 @@ def main(): log(f"new admin PIN: {STATE['admin_pin']} (change it on the Settings tab)") (default_root() / "_inbox").mkdir(parents=True, exist_ok=True) CDP.ensure_marker() + try: + sync_templates() # a Steam update may have wiped the layouts DeckDrop offers as templates + except Exception as e: # noqa: BLE001 + log(f"layout templates: {e}") threading.Thread(target=pending_loop, name=PENDING_THREAD, daemon=True).start() srv = ThreadingHTTPServer(("0.0.0.0", PORT), Handler) srv.daemon_threads = True diff --git a/src/deckdrop/diagnostics.py b/src/deckdrop/diagnostics.py index f0f284a..88f9242 100644 --- a/src/deckdrop/diagnostics.py +++ b/src/deckdrop/diagnostics.py @@ -289,10 +289,21 @@ def check_internet(): return item("ok" if ok else "warn", title, "; ".join(parts)) +RELEASE_ASSET = re.compile(r"^(https://github\.com/[^/]+/[^/]+)/releases/(?:latest/)?download/", re.I) + + +def update_probe(url): + """What to knock on to see that updates can be fetched. Not a release file itself: GitHub counts + even a HEAD on it as a download, so every self-check (and every test run) would add one. The + releases page answers from the same place without touching the counter.""" + m = RELEASE_ASSET.match(url) + return m.group(1) + "/releases/latest" if m else url + + def check_update(): title = tr("perf.c.update") url = STATE.get("update_url") or UPDATE_URL_DEFAULT - ms, err = reach(None, url) + ms, err = reach(None, update_probe(url)) if ms is None: return item("warn", title, tr("perf.c.update.fail", url=url, error=err)) return item("ok", title, tr("perf.c.update.ok", url=url, ms=ms)) diff --git a/src/deckdrop/games.py b/src/deckdrop/games.py index dab45d3..12e714b 100644 --- a/src/deckdrop/games.py +++ b/src/deckdrop/games.py @@ -3,6 +3,7 @@ import shutil import subprocess import threading +import time import urllib.parse from pathlib import Path @@ -17,6 +18,7 @@ from .state import STATE, STATE_LOCK, save_state, update_added from .steam.cdp import CDP from .steam.compat import compat_for, compat_label, compat_mapping +from .steam.layouts import apply_default, default_layout from .steam.library import shortcuts_index from .steam.session import session_env from .steam.shortcuts import queue_pending @@ -45,6 +47,8 @@ def add_to_steam(game_dir, exe, name=None, tool=None): CDP.set_compat(appid, tool) update_added(str(p), appid=appid, name=name, compat=tool, via="cdp", art=False) threading.Thread(target=carry(art_worker), args=(str(p), False, False), daemon=True).start() + if default_layout(): + threading.Thread(target=carry(_layout_for_new), args=(str(p), appid), daemon=True).start() return tr("addsteam.done", name=name) + ", " + (compat_label(tool) if tool else tr("addsteam.native")) # fallback: steamos-add-to-steam names the shortcut after the file; fix it up later via CEF env, running = session_env() @@ -66,10 +70,20 @@ def add_to_steam(game_dir, exe, name=None, tool=None): queue_pending(op="rename", exe=str(p), name=name) if tool: queue_pending(op="compat", exe=str(p), tool=tool) + if default_layout(): + queue_pending(op="layout", exe=str(p)) threading.Thread(target=carry(art_worker), args=(str(p), False, True), daemon=True).start() return tr("addsteam.queued", name=name) +def _layout_for_new(exe, appid): + """The default controller layout for a game just added; Steam may need a moment for a new shortcut.""" + for delay in (1, 4): + time.sleep(delay) + if apply_default(exe, appid): + return + + def game_dirs(): """(dir, imported) for every card on the Games tab: folders inside the roots, plus imported games.""" out = [] @@ -119,6 +133,7 @@ def list_games(): "art": bool(rec.get("art")), "art_error": rec.get("art_error"), "art_source": rec.get("art_source"), "vndb_title": rec.get("vndb_title"), "art_note": rec.get("art_note"), + "layout": rec.get("layout"), "layout_error": rec.get("layout_error"), "compat": compat_info[0], "compat_from": compat_info[1], "pending": full in pending_exes, "appid": appid}) diff --git a/src/deckdrop/i18n/en.json b/src/deckdrop/i18n/en.json index 2419903..69df3e4 100644 --- a/src/deckdrop/i18n/en.json +++ b/src/deckdrop/i18n/en.json @@ -215,6 +215,8 @@ "info.disk": "Disk: {disk}", "info.folder_html": "Folder: {name}", "info.from_steam_settings": "from Steam settings", + "info.layout": "Layout from DeckDrop: {name}", + "info.layout_error": "Layout not applied: {error}", "info.pending": "The name and Proton are applied once Steam control is on", "info.pending_n": "Queued until Steam control is on: {n}", "info.size": "Size: {size} · files: {n}", @@ -232,6 +234,52 @@ "jobs.title": "Tasks", "jobs.unpack": "unpack", "lang.name": "English", + "layouts.applied": "“{name}” is now this game's layout", + "layouts.apply": "Apply", + "layouts.apply_label": "Layout from DeckDrop", + "layouts.apply_needs_cdp": "Saved layouts can be applied from here once Steam control is on (Settings → Steam, then restart Steam). Until then pick one on the Deck: Templates → “DeckDrop: …”.", + "layouts.applying": "applying…", + "layouts.based_on": "based on “{name}”", + "layouts.current": "This game's own layout", + "layouts.delete": "Delete", + "layouts.delete_text": "It disappears from DeckDrop and from Steam's templates. Games where it is already chosen keep their own copy.", + "layouts.delete_title": "Delete the layout “{name}”?", + "layouts.deleted": "Deleted", + "layouts.download": "Download .vdf", + "layouts.empty": "No saved layouts yet: open a game and press “Save to DeckDrop”.", + "layouts.for_new": "Layout for new games", + "layouts.for_new_hint": "Applied when DeckDrop adds a game to Steam. “—”: Steam picks as usual.", + "layouts.for_new_none": "—", + "layouts.from_file": "from a file", + "layouts.from_game": "from {game}", + "layouts.hint": "Save a layout of this game to DeckDrop and pick it in any game on the Deck: controller settings → Templates → “DeckDrop: …”.", + "layouts.name": "Name", + "layouts.need_cdp": "Needs Steam control: turn it on in Settings → Steam and restart Steam", + "layouts.need_name": "Give the layout a name", + "layouts.no_controller": "Steam does not see the Deck's controls", + "layouts.no_templates": "Steam's templates folder was not found: layouts are kept in DeckDrop but Steam does not offer them.", + "layouts.none": "Steam has no layout for this game yet: open its controller settings on the Deck once.", + "layouts.not_a_layout": "This is not a Steam controller layout", + "layouts.not_applied": "Steam did not switch the layout", + "layouts.not_deck": "This layout is for another controller, not the Steam Deck", + "layouts.not_found": "No such saved layout", + "layouts.not_in_steam": "not in Steam yet", + "layouts.not_in_steam_game": "Add the game to Steam first", + "layouts.now_in_steam_html": "Now in Steam: {name}", + "layouts.rename": "Rename", + "layouts.renamed": "Renamed", + "layouts.save": "Save to DeckDrop", + "layouts.save_title": "Save the layout to DeckDrop", + "layouts.saved": "Saved. On the Deck: Templates → “DeckDrop: {name}”", + "layouts.saved_in_steam": "saved in Steam", + "layouts.saved_no_steam": "Saved in DeckDrop, but Steam's templates folder was not found, so Steam does not offer it yet", + "layouts.set_hint": "Layouts saved from game pages. Each one is offered to every game on the Deck: controller settings → Templates → “DeckDrop: name”. If one is missing there, restart Steam.", + "layouts.source_gone": "This layout is no longer there: refresh the page", + "layouts.template_desc": "Saved in DeckDrop from {game}", + "layouts.title": "Controller layouts", + "layouts.too_big": "The file is too big for a layout", + "layouts.upload": "Upload .vdf", + "layouts.upload_title": "Add a layout from a file", "media.all": "All", "media.clip_empty": "the clip has no video segments", "media.delete_clip": "Steam may show an empty recording until it restarts.", diff --git a/src/deckdrop/i18n/ru.json b/src/deckdrop/i18n/ru.json index 3dc57d7..2c7ac1b 100644 --- a/src/deckdrop/i18n/ru.json +++ b/src/deckdrop/i18n/ru.json @@ -215,6 +215,8 @@ "info.disk": "Диск: {disk}", "info.folder_html": "Папка: {name}", "info.from_steam_settings": "из настроек Steam", + "info.layout": "Раскладка из DeckDrop: {name}", + "info.layout_error": "Раскладка не применилась: {error}", "info.pending": "Имя и Proton применятся, когда включится управление Steam", "info.pending_n": "В очереди до включения управления Steam: {n}", "info.size": "Размер: {size} · файлов: {n}", @@ -232,6 +234,52 @@ "jobs.title": "Задания", "jobs.unpack": "распаковать", "lang.name": "Русский", + "layouts.applied": "Теперь у игры раскладка «{name}»", + "layouts.apply": "Применить", + "layouts.apply_label": "Раскладка из DeckDrop", + "layouts.apply_needs_cdp": "Применять сохранённые раскладки отсюда можно, когда включено управление Steam (Настройки → Steam, затем перезапуск Steam). Пока его нет, выберите раскладку на Deck: Шаблоны → «DeckDrop: …».", + "layouts.applying": "применяю…", + "layouts.based_on": "на основе «{name}»", + "layouts.current": "Своя раскладка игры", + "layouts.delete": "Удалить", + "layouts.delete_text": "Она пропадёт из DeckDrop и из шаблонов Steam. Игры, где она уже выбрана, сохранят свою копию.", + "layouts.delete_title": "Удалить раскладку «{name}»?", + "layouts.deleted": "Удалено", + "layouts.download": "Скачать .vdf", + "layouts.empty": "Сохранённых раскладок пока нет: откройте игру и нажмите «Сохранить в DeckDrop».", + "layouts.for_new": "Раскладка для новых игр", + "layouts.for_new_hint": "Ставится, когда DeckDrop добавляет игру в Steam. «—» — Steam выбирает сам, как обычно.", + "layouts.for_new_none": "—", + "layouts.from_file": "из файла", + "layouts.from_game": "из {game}", + "layouts.hint": "Сохраните раскладку этой игры в DeckDrop и выбирайте её в любой игре на Deck: настройки контроллера → Шаблоны → «DeckDrop: …».", + "layouts.name": "Название", + "layouts.need_cdp": "Нужно управление Steam: включите его в Настройки → Steam и перезапустите Steam", + "layouts.need_name": "Дайте раскладке название", + "layouts.no_controller": "Steam не видит управление дека", + "layouts.no_templates": "Папка шаблонов Steam не найдена: раскладки хранятся в DeckDrop, но Steam их не предлагает.", + "layouts.none": "У Steam ещё нет раскладки для этой игры: откройте её настройки контроллера на Deck хотя бы раз.", + "layouts.not_a_layout": "Это не раскладка контроллера Steam", + "layouts.not_applied": "Steam не переключил раскладку", + "layouts.not_deck": "Эта раскладка для другого контроллера, не для Steam Deck", + "layouts.not_found": "Такой сохранённой раскладки нет", + "layouts.not_in_steam": "пока не в Steam", + "layouts.not_in_steam_game": "Сначала добавьте игру в Steam", + "layouts.now_in_steam_html": "Сейчас в Steam: {name}", + "layouts.rename": "Переименовать", + "layouts.renamed": "Переименовано", + "layouts.save": "Сохранить в DeckDrop", + "layouts.save_title": "Сохранить раскладку в DeckDrop", + "layouts.saved": "Сохранено. На Deck: Шаблоны → «DeckDrop: {name}»", + "layouts.saved_in_steam": "сохранена в Steam", + "layouts.saved_no_steam": "Сохранено в DeckDrop, но папка шаблонов Steam не найдена, поэтому в Steam её пока нет", + "layouts.set_hint": "Раскладки, сохранённые со страниц игр. Каждую можно выбрать в любой игре на Deck: настройки контроллера → Шаблоны → «DeckDrop: название». Если её там нет, перезапустите Steam.", + "layouts.source_gone": "Этой раскладки уже нет: обновите страницу", + "layouts.template_desc": "Сохранена в DeckDrop из {game}", + "layouts.title": "Раскладки контроллера", + "layouts.too_big": "Файл слишком большой для раскладки", + "layouts.upload": "Загрузить .vdf", + "layouts.upload_title": "Добавить раскладку из файла", "media.all": "Все", "media.clip_empty": "в клипе нет фрагментов видео", "media.delete_clip": "Steam может показывать пустую запись до перезапуска.", diff --git a/src/deckdrop/state.py b/src/deckdrop/state.py index 7e361a0..b7a2fa9 100644 --- a/src/deckdrop/state.py +++ b/src/deckdrop/state.py @@ -31,10 +31,11 @@ "proxy_downloads": False, # also route game downloads through it "mega_verify": True, # check Mega's own checksum after a download "cef_enabled": CEF_ENABLED, + "default_layout": "", # id of the saved controller layout new games get; "" = Steam's own } SETTING_KEYS = ("default_compat", "prefer_linux", "vndb_auto", "vndb_nsfw", "archive_passwords", "default_disk", "cef_enabled", "update_url", "proxy", "proxy_downloads", - "mega_verify") + "mega_verify", "default_layout") PROTECTED_KEYS = ("proxy",) # may carry credentials: PIN required to read or change diff --git a/src/deckdrop/steam/layouts.py b/src/deckdrop/steam/layouts.py new file mode 100644 index 0000000..542135a --- /dev/null +++ b/src/deckdrop/steam/layouts.py @@ -0,0 +1,434 @@ +"""Controller layouts of the Deck saved in DeckDrop and offered to every game as Steam templates. + +Steam keeps a game's layouts in Steam Controller Configs//config//: the current one +(controller_neptune.vdf, or .vdf) and the ones saved under a name ("_0.vdf"). +It lists only that folder's files for the game, which is why a saved layout cannot be picked in +another game. A file put into controller_base/templates, though, is offered to every game. + +So DeckDrop copies a layout into its own folder (.vdf + .json) and mirrors each one into +the templates as controller_neptune_deckdrop_.vdf, titled "DeckDrop: ". Saving, renaming +and deleting touch only those files: never a game's layouts, Steam's own templates or state.json. + +Applying one to a game is asked of the running Steam (Steam control), the way its own layout +picker does it: select template:// for the game on the Deck's controller. Steam then +records the choice in its configset and keeps the game's previous layout file as it was. The +Deck's built-in controls are not controller 0: Steam gives them an index of its own (15 on a +Deck, measured), found in its ControllerStore. +""" + +import json +import os +import re +import secrets +import threading +import time +from pathlib import Path + +from ..config import STATE_FILE, STEAM_ROOT, log +from ..detect import game_exe_path +from .. import i18n +from ..i18n import tr +from ..state import STATE, update_added +from .cdp import CDP +from .library import shortcuts_index, text_vdf, vget + +LAYOUTS_DIR = Path(os.environ.get("DECKDROP_LAYOUTS", str(STATE_FILE.parent / "layouts"))) +CONFIGS = "steamapps/common/Steam Controller Configs" +TEMPLATE_PREFIX = "controller_neptune_deckdrop_" +DECK = "controller_neptune" +ID = re.compile(r"[0-9a-f]{8}") +SAVED = re.compile(r".+_\d+\.vdf") # "_.vdf": saved under a name in Steam +MAX_SIZE = 1 << 20 +NAME_MAX = 80 +_LOCK = threading.RLock() + + +# ---- reading layout files + +def read_layout(data): + """(text, controller_mappings dict) of a layout file's bytes; ValueError when it is not one.""" + text = data.decode("utf-8-sig", "replace") if isinstance(data, bytes) else data + m = vget(text_vdf(text), "controller_mappings") + if not isinstance(m, dict): + raise ValueError(tr("layouts.not_a_layout")) + return text, m + + +def layout_title(m): + """What Steam shows: the title, or the English one of a template ("#Title" is a lookup key).""" + title = str(m.get("title") or "") + if title.startswith("#"): + title = str(vget(m, "localization", "english", "title") or "") + return title.strip() + + +def set_value(text, key, value): + """Text with the first "key" "value" pair (the layout's own, above any block) set to value.""" + value = re.sub(r"[\x00-\x1f]", " ", value).replace("\\", "\\\\").replace('"', '\\"') + pat = re.compile(r'("' + re.escape(key) + r'"[ \t]+)"(?:\\.|[^"\\\n])*"') + if pat.search(text): + return pat.sub(lambda mm: mm.group(1) + '"' + value + '"', text, count=1) + # no such line: add it right after the opening brace of controller_mappings + return re.sub(r'("controller_mappings"\s*\{)', lambda mm: f'{mm.group(1)}\n\t"{key}"\t\t"{value}"', text, count=1) + + +def clean_name(name): + name = re.sub(r"\s+", " ", re.sub(r"[\x00-\x1f]", " ", str(name or ""))).strip() + if not name: + raise ValueError(tr("layouts.need_name")) + return name[:NAME_MAX] + + +# ---- where a game's layouts are + +def folder_key(name): + """Steam's folder for a non-Steam game: its name in lower case with only letters, digits, _, ! and spaces.""" + return re.sub(r"[^\w !]", "", str(name or "").lower()) + + +def config_dirs(account=None): + base = STEAM_ROOT / CONFIGS + if not base.is_dir(): + return [] + accounts = [base / account] if account else sorted(p for p in base.iterdir() if p.is_dir()) + return [a / "config" for a in accounts if (a / "config").is_dir()] + + +def shortcut_of(game_dir, exe): + """(exe path, appid or None, account or None) of the game's Steam shortcut.""" + p = game_exe_path(game_dir, exe) + sc = shortcuts_index().get(str(p)) or {} + rec = (STATE.get("added") or {}).get(str(p)) or {} + account = Path(sc["userdata"]).name if sc.get("userdata") else None + return p, sc.get("appid") or rec.get("appid"), account + + +def folder_of_url(url): + """The game folder of an autosave:///…/Steam Controller Configs//config// URL.""" + m = re.match(r"autosave://(/.*)$", str(url or "")) + if not m: + return None + f = Path(m.group(1)) + base = (STEAM_ROOT / CONFIGS).resolve() + try: + if f.parent.resolve().parent.parent.parent != base: + return None + except OSError: + return None + return f.parent.resolve() + + +def game_folders(game_dir, exe, current=None): + """Folders holding the layouts of the game's shortcut. Steam names the folder after the shortcut + when it first sees it, so a shortcut renamed later still uses the old name: look for all of them. + The running Steam, when asked (`current`), says exactly where its current layout is.""" + p = game_exe_path(game_dir, exe) + sc = shortcuts_index().get(str(p)) or {} + rec = (STATE.get("added") or {}).get(str(p)) or {} + names = [sc.get("name"), rec.get("name"), p.name, p.stem] + keys = [] + for k in [folder_key(n) for n in names if n] + [str(a) for a in (sc.get("appid"), rec.get("appid")) if a]: + if k and k not in keys and k not in (".", ".."): + keys.append(k) + account = Path(sc["userdata"]).name if sc.get("userdata") else None + out = [] + live = folder_of_url((current or {}).get("url")) + if live and live.is_dir(): + out.append(live) + for cfg in config_dirs(account) or config_dirs(): + out += [cfg / k for k in keys if (cfg / k).is_dir() and (cfg / k).resolve() != live] + return out + + +def game_sources(game_dir, exe, current=None): + """The Deck layouts Steam has for this game: [{src, title, kind, mtime}], current ones first.""" + out = [] + for folder in game_folders(game_dir, exe, current): + for f in sorted(folder.glob("*.vdf")): + try: + if f.stat().st_size > MAX_SIZE: + continue + _, m = read_layout(f.read_bytes()) + except (OSError, ValueError): + continue + if str(m.get("controller_type") or "") != DECK: + continue + kind = "saved" if SAVED.fullmatch(f.name) and f.name != DECK + ".vdf" else "current" + base = layout_title(m) if kind == "current" else "" + out.append({"src": f"{folder.parent.parent.name}/{folder.name}/{f.name}", "kind": kind, + "title": layout_title(m) if kind == "saved" else "", "base": base, + "mtime": int(f.stat().st_mtime)}) + out.sort(key=lambda s: (s["kind"] != "current", -s["mtime"])) + return out + + +def source_file(game_dir, exe, src): + """The file behind a source token, only if it is one of this game's sources.""" + _, appid, _ = shortcut_of(game_dir, exe) + if not any(s["src"] == src for s in game_sources(game_dir, exe, steam_current(appid))): + raise ValueError(tr("layouts.source_gone")) + account, folder, name = src.split("/") + return STEAM_ROOT / CONFIGS / account / "config" / folder / name + + +# ---- DeckDrop's own copies + +def _meta_path(lid): + return LAYOUTS_DIR / f"{lid}.json" + + +def _vdf_path(lid): + return LAYOUTS_DIR / f"{lid}.vdf" + + +def _check_id(lid): + lid = str(lid or "") + if not ID.fullmatch(lid) or not _meta_path(lid).is_file(): + raise ValueError(tr("layouts.not_found")) + return lid + + +def _write(path, data): + path.parent.mkdir(parents=True, exist_ok=True) + tmp = path.with_name(path.name + ".tmp") + tmp.write_bytes(data) + os.replace(tmp, path) + + +def list_layouts(): + out = [] + if LAYOUTS_DIR.is_dir(): + for f in LAYOUTS_DIR.glob("*.json"): + if not ID.fullmatch(f.stem) or not _vdf_path(f.stem).is_file(): + continue + try: + meta = json.loads(f.read_text("utf-8")) + except (OSError, ValueError): + continue + if isinstance(meta, dict): + out.append({"id": f.stem, "name": str(meta.get("name") or f.stem), "game": str(meta.get("game") or ""), + "created": int(meta.get("created") or 0), "template": _has_template(f.stem)}) + out.sort(key=lambda x: (x["name"].lower(), x["created"])) + return out + + +def _store(data, name, game, source=""): + _, m = read_layout(data) + if str(m.get("controller_type") or "") != DECK: + raise ValueError(tr("layouts.not_deck")) + name = clean_name(name) # checked before anything is written + with _LOCK: + lid = secrets.token_hex(4) + while _meta_path(lid).exists() or _vdf_path(lid).exists(): + lid = secrets.token_hex(4) + _write(_vdf_path(lid), data) + meta = {"name": name, "game": game, "source": source, "created": int(time.time())} + _write(_meta_path(lid), json.dumps(meta, ensure_ascii=False, indent=1).encode("utf-8")) + sync_templates() + return {"id": lid, "name": meta["name"], "template": _has_template(lid)} + + +def save_from_game(game_dir, exe, src, name): + f = source_file(game_dir, exe, src) + data = f.read_bytes() + if len(data) > MAX_SIZE: + raise ValueError(tr("layouts.too_big")) + game = (shortcuts_index().get(str(game_exe_path(game_dir, exe))) or {}).get("name") or Path(game_dir).name + return _store(data, name, game, src) + + +def save_upload(data, name): + if len(data) > MAX_SIZE: + raise ValueError(tr("layouts.too_big")) + return _store(data, name, "") + + +def rename_layout(lid, name): + with _LOCK: + lid = _check_id(lid) + meta = json.loads(_meta_path(lid).read_text("utf-8")) + meta["name"] = clean_name(name) + _write(_meta_path(lid), json.dumps(meta, ensure_ascii=False, indent=1).encode("utf-8")) + sync_templates() + return {"id": lid, "name": meta["name"]} + + +def delete_layout(lid): + with _LOCK: + lid = _check_id(lid) + for p in (_template_path(lid), _vdf_path(lid), _meta_path(lid)): + if p is None: + continue + try: + p.unlink() + except FileNotFoundError: + pass + return lid + + +def export_layout(lid): + """(file name, bytes) of a saved layout, titled with its DeckDrop name.""" + lid = _check_id(lid) + name = json.loads(_meta_path(lid).read_text("utf-8")).get("name") or lid + text, _ = read_layout(_vdf_path(lid).read_bytes()) + safe = re.sub(r'[\x00-\x1f<>:"/\\|?*]', "_", name).strip(" .") or lid + return safe + ".vdf", set_value(text, "title", name).encode("utf-8") + + +# ---- templates: what Steam offers every game + +def templates_dir(): + d = STEAM_ROOT / "controller_base" / "templates" + return d if d.is_dir() else None + + +def _template_path(lid): + d = templates_dir() + return d / f"{TEMPLATE_PREFIX}{lid}.vdf" if d else None + + +def _has_template(lid): + p = _template_path(lid) + return bool(p and p.is_file()) + + +def _template_text(lid, meta): + text, _ = read_layout(_vdf_path(lid).read_bytes()) + text = set_value(text, "title", "DeckDrop: " + meta["name"]) + game = meta.get("game") or "" + # in the language of the Deck's Steam, whoever's phone made the change + desc = tr("layouts.template_desc", lang=i18n.default(), game=game) if game else "DeckDrop" + return set_value(text, "description", desc) + + +def sync_templates(): + """Make the templates match the saved layouts: add missing ones (a Steam update may wipe them), + retitle renamed ones, drop ones whose layout is gone. Only DeckDrop's template files are touched.""" + d = templates_dir() + if d is None: + return 0 + changed = 0 + with _LOCK: + keep = set() + for item in list_layouts(): + lid = item["id"] + keep.add(lid) + try: + meta = json.loads(_meta_path(lid).read_text("utf-8")) + want = _template_text(lid, {"name": item["name"], "game": meta.get("game") or ""}).encode("utf-8") + p = _template_path(lid) + if not p.is_file() or p.read_bytes() != want: + _write(p, want) + changed += 1 + except (OSError, ValueError) as e: + log(f"layout template {lid}: {e}") + for p in d.glob(TEMPLATE_PREFIX + "*.vdf"): + lid = p.name[len(TEMPLATE_PREFIX):-4] + if ID.fullmatch(lid) and lid not in keep: + p.unlink() + changed += 1 + return changed + + +# ---- the running Steam: which layout a game has, and switching it + +# the Deck's own controls: the Steam Deck entry of Steam's controller list, else the last active one +DECK_CONTROLLER_JS = """(() => { + const store = window.ControllerStore || {}, list = store.m_controllerList || []; + const deck = list.find(c => c.eControllerType === 4) || list[0]; + const index = deck ? deck.nControllerIndex : store.m_nLastValidActiveControllerIndex; + return index === undefined ? null : index; +})()""" + +CURRENT_JS = """(async (appid) => { + const index = __DECK__; + if (index === null) return null; + const c = await SteamClient.Input.GetConfigForAppAndController(appid, index); + return c && c.bConfigurationEnabled ? {url: String(c.URL || ''), title: String(c.Title || ''), index} : null; +})(__APPID__)""" + +# select the layout the way Steam's own picker does (the last two arguments: not a preview, a user's +# choice; with fewer, Steam selects nothing), then read it back until Steam reports it +APPLY_JS = """(async (appid, url) => { + const input = SteamClient.Input, index = __DECK__; + if (index === null) return {ok: false, reason: 'no_controller'}; + const read = async () => { + const c = await input.GetConfigForAppAndController(appid, index); + return c ? {url: String(c.URL || ''), title: String(c.Title || '')} : {url: '', title: ''}; + }; + await input.SetSelectedConfigForApp(appid, index, url, false, 1); + for (let i = 0; i < 12; i++) { + const now = await read(); + if (now.url === url) return {ok: true, index, ...now}; + await new Promise(r => setTimeout(r, 250)); + } + return {ok: false, reason: 'not_switched', index, ...(await read())}; +})(__APPID__, __URL__)""" + + +def _js(template, appid, url=None): + js = template.replace("__DECK__", DECK_CONTROLLER_JS).replace("__APPID__", str(int(appid))) + return js.replace("__URL__", json.dumps(url)) if url is not None else js + + +def steam_current(appid): + """{url, title, index} of the game's layout in the running Steam, or None (no Steam control).""" + if not appid or not CDP.available(): + return None + try: + res = CDP.eval(_js(CURRENT_JS, appid), timeout=10) + except Exception as e: # noqa: BLE001 + log(f"layout of {appid}: {e}") + return None + return res if isinstance(res, dict) else None + + +def template_url(lid): + return f"template://{TEMPLATE_PREFIX}{lid}.vdf" + + +def apply_to_app(appid, lid): + """Switch the game to a saved layout through the running Steam; the layout's name when it took.""" + with _LOCK: + lid = _check_id(lid) + sync_templates() + if not _has_template(lid): + raise RuntimeError(tr("layouts.no_templates")) + name = json.loads(_meta_path(lid).read_text("utf-8")).get("name") or lid + if not CDP.available(): + raise RuntimeError(tr("layouts.need_cdp")) + res = CDP.eval(_js(APPLY_JS, appid, template_url(lid)), timeout=20) + if not isinstance(res, dict) or not res.get("ok"): + reason = (res or {}).get("reason") if isinstance(res, dict) else None + raise RuntimeError(tr("layouts.no_controller") if reason == "no_controller" else tr("layouts.not_applied")) + return name + + +def apply_layout(game_dir, exe, lid): + p, appid, _ = shortcut_of(game_dir, exe) + if not appid: + raise ValueError(tr("layouts.not_in_steam_game")) + name = apply_to_app(appid, lid) + update_added(str(p), layout=name, layout_error=None) + return tr("layouts.applied", name=name) + + +def default_layout(): + """The saved layout new games get (Settings), or None: none chosen, or it was deleted since.""" + lid = str(STATE.get("default_layout") or "") + return lid if ID.fullmatch(lid) and _meta_path(lid).is_file() and _vdf_path(lid).is_file() else None + + +def apply_default(exe, appid): + """Give a game DeckDrop just added to Steam the default layout; records how it went.""" + lid = default_layout() + if not lid: + return None + try: + name = apply_to_app(appid, lid) + except Exception as e: # noqa: BLE001 + log(f"default layout for {Path(exe).name}: {e}") + update_added(str(exe), layout_error=str(e)) + return None + update_added(str(exe), layout=name, layout_error=None) + return name diff --git a/src/deckdrop/steam/shortcuts.py b/src/deckdrop/steam/shortcuts.py index 372fbbb..abf4ca8 100644 --- a/src/deckdrop/steam/shortcuts.py +++ b/src/deckdrop/steam/shortcuts.py @@ -9,6 +9,7 @@ from ..state import STATE, STATE_LOCK, added_rec, save_state, update_added from ..steam.cdp import CDP from ..steam.compat import compat_label +from ..steam.layouts import apply_default from ..steam.library import shortcuts_index @@ -78,6 +79,8 @@ def drain_pending(): CDP.set_name(appid, op["name"]) elif op["op"] == "compat": CDP.set_compat(appid, op.get("tool") or "") + elif op["op"] == "layout": + apply_default(op["exe"], appid) # records how it went; a deleted layout is skipped done += 1 log(f"pending {op['op']} applied for {Path(op['exe']).name}") except Exception as e: # noqa: BLE001 diff --git a/src/deckdrop/web/app.js b/src/deckdrop/web/app.js index ff6fc1d..53822d2 100644 --- a/src/deckdrop/web/app.js +++ b/src/deckdrop/web/app.js @@ -35,7 +35,7 @@ async function api(path,body,method){const r=await fetch(path,{method:method||'P function showTab(name){tab=name;$('#pgGames').hidden=name!=='games';$('#pgArch').hidden=name!=='arch';$('#pgMedia').hidden=name!=='media';$('#pgSettings').hidden=name!=='settings'; [['tabGames','games'],['tabArch','arch'],['tabMedia','media'],['tabSettings','settings']].forEach(([id,k])=>$('#'+id).classList.toggle('on',name===k)); hdr.classList.remove('hide');window.scrollTo(0,0); - if(name==='media')mediaEnter();if(name==='arch')loadArchives();if(name==='settings')fillSettings();clearInterval(archTimer);if(name==='arch')archTimer=setInterval(loadArchives,5000);} + if(name==='media')mediaEnter();if(name==='arch')loadArchives();if(name==='settings'){fillSettings();loadLayouts();}clearInterval(archTimer);if(name==='arch')archTimer=setInterval(loadArchives,5000);} $('#tabGames').onclick=()=>showTab('games');$('#tabArch').onclick=()=>showTab('arch');$('#tabMedia').onclick=()=>showTab('media');$('#tabSettings').onclick=()=>showTab('settings');$('#settings').onclick=()=>showTab('settings'); // header slides away when scrolling down on a phone and comes back on scroll up const hdr=document.querySelector('header');let lastY=0;const mobile=matchMedia('(max-width:560px)'); @@ -131,20 +131,21 @@ function renderList(s){lastJobs=s.jobs||[]; if(gsig!==sigGames){sigGames=gsig;const nh=s.games.filter(g=>g.hidden).length;$('#toggleHidden').textContent=showHidden?t('games.hide_hidden'):t('games.show_hidden',{n:nh});$('#toggleHidden').hidden=!nh&&!showHidden; $('#games').innerHTML=s.games.filter(g=>showHidden||!g.hidden).map(listCard).join('')||`
${t('games.empty')}
`;}} function renderGame(s){const g=(s.games||[]).find(x=>x.path===view.path); - if(!g){$('#gpHead').innerHTML=`
${t('game.not_found')}
`;$('#gpExes').innerHTML='';$('#gpCoversCard').hidden=$('#gpSavesCard').hidden=$('#gpFilesCard').hidden=true;$('#gpActs').innerHTML='';$('#gpInfo').innerHTML='';return;} + if(!g){$('#gpHead').innerHTML=`
${t('game.not_found')}
`;$('#gpExes').innerHTML='';$('#gpCoversCard').hidden=$('#gpSavesCard').hidden=$('#gpFilesCard').hidden=$('#gpLayoutsCard').hidden=true;$('#gpActs').innerHTML='';$('#gpInfo').innerHTML='';return;} if(extrasFor!==g.path){extrasFor=g.path;loadGameExtras(g);} const sig=JSON.stringify([g,[...busy],s.compat_tools,gameInfo]);if(sig===sigGame||focusWithin('#gamePage')||Date.now()x.in_steam); $('#gpHead').innerHTML=`
${esc(g.title||g.name)}${inSteam?``:''}
${esc(g.path)}
${esc(g.disk)}${inSteam?`${t('game.in_steam')}`:`${t('game.not_added')}`}${g.imported?`${t('game.own')}`:''}${g.hidden?`${t('game.hidden')}`:''}
`; $('#gpExes').innerHTML=g.exes.map(x=>exeRow(g,x)).join('')||`
${t('game.no_exes')}
`; - $('#gpCoversCard').hidden=!inSteam;$('#gpSavesCard').hidden=!inSteam;$('#gpFilesCard').hidden=!g.exes.length; + $('#gpCoversCard').hidden=!inSteam;$('#gpSavesCard').hidden=!inSteam;$('#gpLayoutsCard').hidden=!inSteam;$('#gpFilesCard').hidden=!g.exes.length; $('#gpActs').innerHTML=(g.hidden?``:``)+(g.imported?``:'') +`` +(g.imported?`${t('game.own_hint')}`:''); const info=[t('info.folder_html',{name:esc(g.name)}),t('info.disk',{disk:esc(g.disk)})]; if(gameInfo&&gameInfo.path===g.path)info.push(t('info.size',{size:fmt(gameInfo.size),n:gameInfo.files})); if(ch&&ch.in_steam){info.push(t('info.steam_name',{name:esc(ch.name)}));if(ch.appid)info.push(`Steam AppID: ${ch.appid}`);if(!ch.linux)info.push(`Proton: ${esc(compatLabel(ch.compat))}${ch.compat_from==='steam'?' · '+t('info.from_steam_settings'):''}`); - if(ch.art_source)info.push(t('info.art',{source:ch.art_source==='vndb'?'VNDB · '+esc(ch.vndb_title||''):ch.art_source==='custom'?t('art.src.custom'):ch.art_source==='steam'?t('art.src.steam'):t('art.src.icon')}));if(ch.art_note)info.push(esc(ch.art_note));if(ch.art_error)info.push(`${esc(ch.art_error)}`);if(ch.pending)info.push(`${t('info.pending')}`);} + if(ch.art_source)info.push(t('info.art',{source:ch.art_source==='vndb'?'VNDB · '+esc(ch.vndb_title||''):ch.art_source==='custom'?t('art.src.custom'):ch.art_source==='steam'?t('art.src.steam'):t('art.src.icon')}));if(ch.art_note)info.push(esc(ch.art_note));if(ch.art_error)info.push(`${esc(ch.art_error)}`);if(ch.pending)info.push(`${t('info.pending')}`); + if(ch.layout)info.push(t('info.layout',{name:esc(ch.layout)}));if(ch.layout_error)info.push(`${t('info.layout_error',{error:esc(ch.layout_error)})}`);} $('#gpInfo').innerHTML=info.join('
');} function render(s){if(!s)return; $('#ver').textContent='v'+s.version; @@ -168,7 +169,7 @@ applyView(parseHash());refresh();setInterval(tick,1000); const LAB={portrait:[t('slot.portrait'),t('slot.portrait_size'),'2/3'],landscape:[t('slot.landscape'),'920×430','920/430'],hero:[t('slot.hero'),'1920×620','1920/620'],logo:[t('slot.logo'),t('slot.logo_size'),'16/5'],icon:[t('slot.icon'),t('slot.icon_size'),'1/1']}; function loadGameExtras(g){gameInfo=null;const ch=chosenExe(g);if(g.exes.length)setupFiles(g); fetch('/api/game/info?path='+encodeURIComponent(g.path)).then(r=>r.json()).then(j=>{if(!j.error){gameInfo={path:g.path,...j};sigGame='';render(lastState);}}).catch(()=>{}); - if(ch&&ch.in_steam){loadCovers(g,ch);loadSaves(g,ch);}else{$('#gpCovers').innerHTML='';$('#gpSaves').innerHTML='';}} + if(ch&&ch.in_steam){loadCovers(g,ch);loadSaves(g,ch);loadLayoutSources(g,ch);}else{$('#gpCovers').innerHTML='';$('#gpSaves').innerHTML='';$('#gpLayouts').innerHTML='';}} // ---- files inside the game: a patch next to the exe, or anywhere below the game folder let filesTimer=0; function filesQ(g){const exe=$('#gpFExe').value||((chosenExe(g)||{}).exe)||'';return `game=${encodeURIComponent(g.path)}&exe=${encodeURIComponent(exe)}&dir=${encodeURIComponent($('#gpFDir').value.trim())}`;} @@ -259,6 +260,35 @@ async function loadSaves(g,x){const q=`game=${encodeURIComponent(g.path)}&exe=${ try{const j=await(await fetch('/api/saves/info?'+q)).json();if(j.error){$('#gpSaves').innerHTML=`${esc(j.error)}`;$('#gpSvDlB').disabled=true;return;} $('#gpSaves').innerHTML=(j.sources.length?`${t('saves.what',{size:fmt(j.total)})}
    ${j.sources.map(s=>`
  • ${esc(s.path)} · ${t('common.files_n',{n:s.files})} · ${fmt(s.size)}
  • `).join('')}
`:t('saves.none'))+(j.prefix?t('saves.prefix_html',{path:esc(j.prefix)}):''); $('#gpSvDlB').disabled=!j.total;}catch(e){$('#gpSaves').textContent=t('status.error');}} +// ---- controller layouts: saved in DeckDrop, offered by Steam to every game as templates +function lyRow(name,sub,acts){return `
${esc(name)}
${sub.filter(Boolean).map(esc).join(' · ')}
${acts}
`;} +async function loadLayoutSources(g,x){const q=`game=${encodeURIComponent(g.path)}&exe=${encodeURIComponent(x.exe)}`;$('#gpLayouts').textContent=t('common.looking'); + try{const j=await(await fetch('/api/layouts/sources?'+q)).json();if(j.error){$('#gpLayouts').innerHTML=`${esc(j.error)}`;return;} + const ls=j.layouts||[]; + const apply=!ls.length?'':j.cdp?`
` + :`
${t('layouts.apply_needs_cdp')}
`; + $('#gpLayouts').innerHTML=(j.current&&j.current.title?`
${t('layouts.now_in_steam_html',{name:esc(j.current.title)})}
`:'')+j.sources.map((s,i)=>lyRow(s.kind==='current'?t('layouts.current'):s.title,[s.kind==='current'?(s.base&&t('layouts.based_on',{name:s.base})):t('layouts.saved_in_steam'),when(s.mtime)], + ``)).join('')+(j.sources.length?'':`
${t('layouts.none')}
`)+apply; + if($('#lyApply'))$('#lyApply').onclick=async()=>{const b=$('#lyApply');b.disabled=true;b.textContent=t('layouts.applying'); + const res=await api('/api/layouts/apply',{game:g.path,exe:x.exe,id:$('#lyPick').value});if(res.ok)toast(res.note,'ok');loadLayoutSources(g,x);}; + $('#gpLayouts').querySelectorAll('[data-lysave]').forEach(bt=>bt.onclick=async()=>{const s=j.sources[+bt.dataset.lysave]; + const r=await ask({title:t('layouts.save_title'),fields:[{label:t('layouts.name'),value:s.kind==='current'?(x.clean_name||g.title||g.name):s.title}],ok:t('btn.save')});if(!r)return; + const res=await api('/api/layouts/save',{game:g.path,exe:x.exe,src:s.src,name:r[0]});if(res.ok)toast(res.template?t('layouts.saved',{name:res.name}):t('layouts.saved_no_steam'),'ok');}); + }catch(e){$('#gpLayouts').textContent=t('status.error');}} +async function loadLayouts(){try{const j=await(await fetch('/api/layouts')).json();const ls=j.layouts||[];$('#lyWarn').hidden=j.templates!==false; + const def=((lastState||{}).settings||{}).default_layout||'';if(document.activeElement!==$('#sDefLayout'))$('#sDefLayout').innerHTML=``+ls.map(l=>``).join(''); + $('#lyList').innerHTML=ls.map(l=>lyRow(l.name,[l.game?t('layouts.from_game',{game:l.game}):t('layouts.from_file'),when(l.created),j.templates&&!l.template&&t('layouts.not_in_steam')], + ``)).join('')||`
${t('layouts.empty')}
`; + const byId=id=>ls.find(l=>l.id===id); + $('#lyList').querySelectorAll('[data-lyren]').forEach(bt=>bt.onclick=async()=>{const l=byId(bt.dataset.lyren);const r=await ask({title:t('layouts.rename'),fields:[{label:t('layouts.name'),value:l.name}],ok:t('btn.save')});if(!r)return; + const res=await api('/api/layouts/rename',{id:l.id,name:r[0]});if(res.ok){toast(t('layouts.renamed'),'ok');loadLayouts();}}); + $('#lyList').querySelectorAll('[data-lydel]').forEach(bt=>bt.onclick=async()=>{const l=byId(bt.dataset.lydel);const r=await ask({title:t('layouts.delete_title',{name:l.name}),text:t('layouts.delete_text'),ok:t('layouts.delete'),danger:true});if(!r)return; + const res=await api('/api/layouts/delete',{id:l.id});if(res.ok){toast(t('layouts.deleted'),'ok');loadLayouts();}}); + }catch(e){$('#lyList').innerHTML=`
${t('status.error')}
`;}} +$('#lyUp').onclick=()=>{const inp=document.createElement('input');inp.type='file';inp.accept='.vdf,text/plain';inp.onchange=async()=>{const f=inp.files[0];if(!f)return; + const r=await ask({title:t('layouts.upload_title'),fields:[{label:t('layouts.name'),value:f.name.replace(/\.vdf$/i,'')}],ok:t('btn.save')});if(!r)return; + const xh=new XMLHttpRequest();xh.open('PUT','/api/layouts/upload?name='+encodeURIComponent(r[0]));xh.onload=()=>{let j={};try{j=JSON.parse(xh.responseText);}catch(e){} + if(xh.status===200)toast(j.template?t('layouts.saved',{name:j.name}):t('layouts.saved_no_steam'),'ok');else toast(j.error||t('err.upload'),'err');loadLayouts();};xh.onerror=()=>toast(t('err.upload'),'err');xh.send(f);};inp.click();}; // ---- import one game that already lives elsewhere on the Deck function importNote(j){const a=(j.adopted||[])[0];if(!a)return t('import.added',{name:j.name,n:j.exes.length}); const bits=[];if(a.name)bits.push(t('import.bit_name',{name:a.name}));if(a.compat)bits.push(compatLabel(a.compat));if(a.covers&&a.covers.length)bits.push(t('import.bit_covers',{n:a.covers.length})); diff --git a/src/deckdrop/web/index.html b/src/deckdrop/web/index.html index 623b97a..b28eb2b 100644 --- a/src/deckdrop/web/index.html +++ b/src/deckdrop/web/index.html @@ -27,6 +27,7 @@
+

{{game.actions}}

{{game.details}}

@@ -57,6 +58,11 @@ +

{{layouts.title}}

+
{{layouts.set_hint}}
+
+
{{layouts.for_new_hint}}
+

{{set.covers.title}}

diff --git a/src/deckdrop/web/server.py b/src/deckdrop/web/server.py index 3f5b86a..fc01d64 100644 --- a/src/deckdrop/web/server.py +++ b/src/deckdrop/web/server.py @@ -43,6 +43,10 @@ ) from ..steam.cdp import CDP from ..steam.compat import compat_tools +from ..steam.layouts import ( + apply_layout, delete_layout, export_layout, game_sources, list_layouts, rename_layout, + save_from_game, save_upload, shortcut_of, steam_current, sync_templates, templates_dir, +) from ..steam.shortcuts import rename_shortcut, set_compat_for from ..storage import disks, root_for from ..update import self_update @@ -249,6 +253,19 @@ def do_GET(self): out.unlink(missing_ok=True) return self.send_json({"error": tr("saves.not_found")}, 404) self.send_file(out, download_name=out.name) + elif path == "/api/layouts": + sync_templates() + self.send_json({"layouts": list_layouts(), "templates": templates_dir() is not None}) + elif path == "/api/layouts/sources": + game, exe = self.q1(q, "game"), self.q1(q, "exe") + current = steam_current(shortcut_of(game, exe)[1]) + self.send_json({"sources": game_sources(game, exe, current), "current": current, + "layouts": list_layouts(), "cdp": CDP.available()}) + elif path == "/api/layouts/file": + name, data = export_layout(self.q1(q, "id")) + self._send(200, data, "text/plain; charset=utf-8", { + "Content-Disposition": "attachment; filename*=UTF-8''" + urllib.parse.quote(name), + "Cache-Control": "no-store"}) elif path == "/api/media/status": self.send_json({"set": bool(STATE.get("media_pw"))}) elif path == "/api/media/list": @@ -353,6 +370,14 @@ def do_POST(self): check_pin(body.get("pin")) removed, notes = delete_game(s("path"), bool(body.get("remove_shortcut"))) self.send_json({"ok": True, "removed": removed, "notes": notes}) + elif path == "/api/layouts/save": + self.send_json({"ok": True, **save_from_game(s("game"), s("exe"), s("src"), s("name"))}) + elif path == "/api/layouts/apply": + self.send_json({"ok": True, "note": apply_layout(s("game"), s("exe"), s("id"))}) + elif path == "/api/layouts/rename": + self.send_json({"ok": True, **rename_layout(s("id"), s("name"))}) + elif path == "/api/layouts/delete": + self.send_json({"ok": True, "removed": delete_layout(s("id"))}) elif path == "/api/archive/extract": self.send_json(archive_extract_job(s("path")).to_dict()) elif path == "/api/archive/delete": @@ -465,6 +490,13 @@ def do_PUT(self): self.send_json({"ok": True, **game_file_upload( self.q1(q, "game"), self.q1(q, "exe"), self.q1(q, "dir"), self.q1(q, "name"), self.q1(q, "replace") == "1", self.read_body_to)}) + elif path == "/api/layouts/upload": + n = int(self.headers.get("Content-Length") or 0) + if n > 1 << 20: + if n <= 16 << 20: + self.rfile.read(n) # a little too big: read it, so the phone gets the reason + raise ValueError(tr("layouts.too_big")) + self.send_json({"ok": True, **save_upload(self.rfile.read(n), self.q1(q, "name"))}) elif path == "/api/saves/import": CACHE_DIR.mkdir(parents=True, exist_ok=True) tmp = CACHE_DIR / f"import_{secrets.token_hex(4)}.zip" diff --git a/tests/fake_cdp.py b/tests/fake_cdp.py new file mode 100644 index 0000000..f858e09 --- /dev/null +++ b/tests/fake_cdp.py @@ -0,0 +1,142 @@ +"""A stand-in for Steam's CEF debugging port: enough of it for DeckDrop's Steam control in tests. + +It answers /json with a SharedJSContext target and serves its websocket. It cannot run JavaScript, +so it recognises what DeckDrop asks by the Steam calls in the expression and answers the way the +Deck did (tests/ui_check.py runs the same expressions in a real browser against a stand-in +SteamClient). Every call is recorded in `calls`. +""" +import base64 +import hashlib +import json +import re +import socket +import struct +import threading + +GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" + + +class FakeSteam: + def __init__(self, configs_dir=None): + self.configs_dir = configs_dir # Steam Controller Configs//config, for autosave URLs + self.mode = "ok" # ok | stuck (Steam does not switch) | no_controller + self.selected = {} # appid -> URL chosen through DeckDrop + self.current = {} # appid -> (URL, title) Steam reports before any choice + self.calls = [] + self.lock = threading.Lock() + self.sock = socket.socket() + self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) + self.sock.bind(("127.0.0.1", 0)) + self.sock.listen(16) + self.port = self.sock.getsockname()[1] + threading.Thread(target=self._accept, daemon=True).start() + + def close(self): + self.sock.close() + + # ---- what DeckDrop asks + def answer(self, expr): + if "SetSelectedConfigForApp" in expr: + m = re.search(r"\}\)\((\d+), (\"(?:\\.|[^\"\\])*\")\)\s*$", expr) + appid, url = int(m.group(1)), json.loads(m.group(2)) + with self.lock: + self.calls.append(("apply", appid, url)) + if self.mode == "no_controller": + return {"ok": False, "reason": "no_controller"} + if self.mode == "stuck": + return {"ok": False, "reason": "not_switched", "index": 15, "url": "", "title": ""} + self.selected[appid] = url + return {"ok": True, "index": 15, "url": url, "title": "DeckDrop: x"} + if "GetConfigForAppAndController" in expr: + appid = int(re.search(r"\}\)\((\d+)\)\s*$", expr).group(1)) + with self.lock: + self.calls.append(("current", appid)) + if appid in self.selected: + return {"url": self.selected[appid], "title": "DeckDrop", "index": 15} + url, title = self.current.get(appid, ("", "")) + return {"url": url, "title": title, "index": 15} if url else None + m = re.match(r"SteamClient\.Apps\.(\w+)\(", expr) + if m: + with self.lock: + self.calls.append(("apps", m.group(1))) + return 3000000077 if m.group(1) == "AddShortcut" else None + return None + + def calls_of(self, kind): + with self.lock: + return [c for c in self.calls if c[0] == kind] + + # ---- the protocol + def _accept(self): + while True: + try: + conn, _ = self.sock.accept() + except OSError: + return + threading.Thread(target=self._serve, args=(conn,), daemon=True).start() + + def _serve(self, conn): + try: + head = b"" + while b"\r\n\r\n" not in head: + chunk = conn.recv(4096) + if not chunk: + return + head += chunk + lines = head.split(b"\r\n\r\n", 1)[0].decode().split("\r\n") + path = lines[0].split()[1] + headers = {k.strip().lower(): v.strip() for k, v in (h.split(":", 1) for h in lines[1:] if ":" in h)} + if path == "/json": + body = json.dumps([{"title": "SharedJSContext", "type": "page", + "webSocketDebuggerUrl": f"ws://127.0.0.1:{self.port}/devtools/page/1"}]).encode() + conn.sendall(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: " + + str(len(body)).encode() + b"\r\nConnection: close\r\n\r\n" + body) + return + accept = base64.b64encode(hashlib.sha1((headers["sec-websocket-key"] + GUID).encode()).digest()) + conn.sendall(b"HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\n" + b"Sec-WebSocket-Accept: " + accept + b"\r\n\r\n") + buf = head.split(b"\r\n\r\n", 1)[1] + while True: + msg, buf = self._frame(conn, buf) + if msg is None: + return + req = json.loads(msg) + value = self.answer(req["params"]["expression"]) + result = {"type": "object", "value": value} if value is not None else {"type": "undefined"} + self._send(conn, json.dumps({"id": req["id"], "result": {"result": result}})) + except (OSError, ValueError, KeyError, AttributeError): + pass + finally: + conn.close() + + def _frame(self, conn, buf): + def need(n): + nonlocal buf + while len(buf) < n: + chunk = conn.recv(65536) + if not chunk: + raise OSError("closed") + buf += chunk + out, buf = buf[:n], buf[n:] + return out + try: + b1, b2 = need(2) + n = b2 & 0x7F + if n == 126: + n = struct.unpack(">H", need(2))[0] + elif n == 127: + n = struct.unpack(">Q", need(8))[0] + mask = need(4) if b2 & 0x80 else b"\0\0\0\0" + data = bytes(c ^ mask[i % 4] for i, c in enumerate(need(n))) + except OSError: + return None, buf + if b1 & 0x0F == 0x8: + return None, buf + return data.decode(), buf + + def _send(self, conn, text): + data = text.encode() + n = len(data) + head = bytes([0x81, n]) if n < 126 else bytes([0x81, 126]) + struct.pack(">H", n) if n < 65536 \ + else bytes([0x81, 127]) + struct.pack(">Q", n) + conn.sendall(head + data) diff --git a/tests/smoke.py b/tests/smoke.py index a92fa08..7fb0eea 100644 --- a/tests/smoke.py +++ b/tests/smoke.py @@ -46,7 +46,10 @@ def main(): with tempfile.TemporaryDirectory() as home: env = dict(os.environ, HOME=home, DECKDROP_PORT=str(port), DECKDROP_CEF="0", DECKDROP_STATE=str(Path(home) / "state.json"), DECKDROP_GAMES=str(Path(home) / "Games"), - DECKDROP_STEAM=str(Path(home) / "no-steam"), DECKDROP_PIN="1234") + DECKDROP_STEAM=str(Path(home) / "no-steam"), DECKDROP_PIN="1234", + # the self-check below knocks on the update link: not on the GitHub release, whose + # download counter would count every test run + DECKDROP_UPDATE_URL="http://127.0.0.1:9/deckdrop.py") proc = subprocess.Popen([sys.executable, str(SCRIPT)], env=env, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) try: diff --git a/tests/test_diagnostics.py b/tests/test_diagnostics.py index 8a3e804..beb2cfe 100644 --- a/tests/test_diagnostics.py +++ b/tests/test_diagnostics.py @@ -2,6 +2,7 @@ python -m unittest discover -s tests """ +import http.server import json import os import sys @@ -22,11 +23,52 @@ SECTIONS = 5 +class UpdateServer(http.server.ThreadingHTTPServer): + """Stands in for the update link: tests never knock on the GitHub release (that counts as a download).""" + + def __init__(self): + self.seen = [] + seen = self.seen + + class Handler(http.server.BaseHTTPRequestHandler): + def do_HEAD(self): + seen.append((self.command, self.path)) + self.send_response(200) + self.send_header("Content-Length", "0") + self.end_headers() + do_GET = do_HEAD + + def log_message(self, *args): + pass + super().__init__(("127.0.0.1", 0), Handler) + threading.Thread(target=self.serve_forever, daemon=True).start() + self.url = f"http://127.0.0.1:{self.server_address[1]}/deckdrop.py" + + +class UpdateProbeTest(unittest.TestCase): + def test_a_github_release_file_is_never_touched(self): + from deckdrop.diagnostics import update_probe + for url in ("https://github.com/Aniforka/deckdrop/releases/latest/download/deckdrop.py", + "https://github.com/Aniforka/deckdrop/releases/download/v0.4.2/deckdrop.py", + "https://GitHub.com/someone/fork/releases/latest/download/deckdrop.py"): + probe = update_probe(url) + self.assertNotIn("download", probe, url) + self.assertTrue(probe.lower().endswith("/releases/latest"), probe) + self.assertEqual(update_probe("https://github.com/Aniforka/deckdrop/releases/latest/download/deckdrop.py"), + "https://github.com/Aniforka/deckdrop/releases/latest") + + def test_other_links_are_checked_as_they_are(self): + from deckdrop.diagnostics import update_probe + for url in ("http://192.168.1.10:8000/deckdrop.py", "https://example.org/deckdrop.py"): + self.assertEqual(update_probe(url), url) + + class DiagnosticsTest(unittest.TestCase): """A running DeckDrop with a few planted problems.""" @classmethod def setUpClass(cls): + cls.updates = UpdateServer() cls.home = Path(tempfile.mkdtemp()) games = cls.home / "Games" (games / "Cool Game").mkdir(parents=True) @@ -42,13 +84,14 @@ def setUpClass(cls): env = {k: v for k, v in os.environ.items() if not k.startswith("DECKDROP_")} env.update(HOME=str(cls.home), USERPROFILE=str(cls.home), DECKDROP_CEF="0", DECKDROP_STATE=str(cls.home / "state.json"), DECKDROP_GAMES=str(games), - DECKDROP_STEAM=str(cls.home / "no-steam")) + DECKDROP_STEAM=str(cls.home / "no-steam"), DECKDROP_UPDATE_URL=cls.updates.url) cls.games = games cls.app = Running(ROOT / "tools" / "dev.py", env) @classmethod def tearDownClass(cls): cls.app.stop() + cls.updates.shutdown() def check(self): return self.app.post_ok("/api/perf/check", {}) @@ -78,6 +121,11 @@ def test_self_check_finds_planted_problems(self): self.assertIn("boom", pending["detail"]) self.assertEqual(self.by_title(res, "Steam")["status"], "warn") # no Steam folder here + def test_update_link_is_checked_without_downloading(self): + before = len(self.updates.seen) + self.assertEqual(self.by_title(self.check(), "Update")["status"], "ok") + self.assertEqual(self.updates.seen[before:], [("HEAD", "/deckdrop.py")]) + def test_self_check_healthy_parts(self): res = self.check() self.assertEqual(self.by_title(res, "Background tasks")["status"], "ok") diff --git a/tests/test_layouts.py b/tests/test_layouts.py new file mode 100644 index 0000000..0d5a825 --- /dev/null +++ b/tests/test_layouts.py @@ -0,0 +1,443 @@ +"""Controller layouts: saving, renaming and deleting change DeckDrop's layout files and nothing else. + +A live server runs on a fake home with a Steam folder laid out like a real Deck's (Steam Controller +Configs with a game's current and saved layouts, other games, configsets, Valve's templates). Every +test takes a snapshot of every file in the home before and after, and checks the exact difference: +a game's own layouts, other games, Steam's templates and state.json are never touched. + + python -m unittest discover -s tests +""" +import hashlib +import json +import os +import shutil +import sys +import tempfile +import time +import unittest +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +from fake_cdp import FakeSteam # noqa: E402 +from test_user_data import Running # noqa: E402 + +ROOT = Path(__file__).resolve().parent.parent +ACCOUNT = "360931465" +APPID = 2514257537 + + +def layout(title, controller="controller_neptune", extra=""): + return f'''"controller_mappings" +{{ +\t"version"\t\t"3" +\t"title"\t\t"{title}" +\t"description"\t\t"#SettingsController_AutosaveDescription" +\t"creator"\t\t"76561198000000000" +\t"controller_type"\t\t"{controller}" +\t"localization" +\t{{ +\t\t"english" +\t\t{{ +\t\t\t"title"\t\t"WASD" +\t\t\t"description"\t\t"A template" +\t\t}} +\t}} +\t"group" +\t{{ +\t\t"id"\t\t"0" +\t\t"mode"\t\t"four_buttons"{extra} +\t}} +}} +''' + + +def shortcut_entry(i, name, exe, appid): + def s(key, val): + return b"\x01" + key.encode() + b"\0" + val.encode() + b"\0" + return (b"\x00" + str(i).encode() + b"\0" + b"\x02appid\0" + appid.to_bytes(4, "little") + + s("appname", name) + s("exe", f'"{exe}"') + b"\x08") + + +def shortcuts_vdf(name, exe, appid): + """A binary shortcuts.vdf with one non-Steam game.""" + return b"\x00shortcuts\0" + shortcut_entry(0, name, exe, appid) + b"\x08\x08" + + +def snapshot(root): + return {str(p.relative_to(root)): hashlib.sha256(p.read_bytes()).hexdigest() + for p in sorted(root.rglob("*")) if p.is_file() and "__pycache__" not in p.parts} + + +def diff(before, after): + return {"added": sorted(set(after) - set(before)), "removed": sorted(set(before) - set(after)), + "changed": sorted(k for k in set(before) & set(after) if before[k] != after[k])} + + +class LayoutsTest(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.home = Path(tempfile.mkdtemp()) + h = cls.home + cls.game = h / "Games" / "Yosuga" + cls.game.mkdir(parents=True) + cls.exe = cls.game / "Yosuga.exe" + cls.exe.write_bytes(b"MZ" + b"\0" * 64) + cls.steam = h / "Steam" + cfg = cls.steam / "steamapps/common/Steam Controller Configs" / ACCOUNT / "config" + cls.cfg = cfg + # Steam named the folder after the shortcut's first name ("Yosuga.exe"), renamed since + (cfg / "yosugaexe").mkdir(parents=True) + (cfg / "yosugaexe" / "FYZZ53700874.vdf").write_text(layout("#Title"), "utf-8") + (cfg / "yosugaexe" / "vns_0.vdf").write_text("" + layout("VNs"), "utf-8") + (cfg / "yosugaexe" / "touch_0.vdf").write_text(layout("Touch", "controller_mobile_touch"), "utf-8") + (cfg / "yosugaexe" / "broken_0.vdf").write_text('"controller_mappings" { "title" "half', "utf-8") + (cfg / "fatestay night").mkdir() + (cfg / "fatestay night" / "vns 2.0_0.vdf").write_text(layout("VNs 2.0"), "utf-8") + (cfg / "configset_controller_neptune.vdf").write_text('"controller_config"\n{\n\t"yosugaexe"\n\t{\n' + '\t\t"autosave"\t\t"1"\n\t}\n}\n', "utf-8") + cls.templates = cls.steam / "controller_base" / "templates" + cls.templates.mkdir(parents=True) + (cls.templates / "controller_neptune_wasd.vdf").write_text(layout("#Title"), "utf-8") + (cls.templates / "controller_neptune_deckdrop_probe.vdf").write_text(layout("DeckDrop probe"), "utf-8") + ud = cls.steam / "userdata" / ACCOUNT / "config" + ud.mkdir(parents=True) + (ud / "shortcuts.vdf").write_bytes(shortcuts_vdf("Yosuga no Sora", cls.exe, APPID)) + env = {k: v for k, v in os.environ.items() if not k.startswith("DECKDROP_")} + env.update(HOME=str(h), DECKDROP_CEF="0", DECKDROP_PIN="1234", DECKDROP_STEAM=str(cls.steam), + DECKDROP_GAMES=str(h / "Games")) + cls.app = Running(ROOT / "tools" / "dev.py", env) + cls.layouts = h / ".config" / "deckdrop" / "layouts" + cls.state = h / ".config" / "deckdrop" / "state.json" + + @classmethod + def tearDownClass(cls): + cls.app.stop() + shutil.rmtree(cls.home, ignore_errors=True) + + def setUp(self): + # every test starts with no saved layouts; nothing else is reset between tests + if self.layouts.exists(): + for p in self.layouts.iterdir(): + p.unlink() + for p in self.templates.glob("controller_neptune_deckdrop_*.vdf"): + if p.name != "controller_neptune_deckdrop_probe.vdf": + p.unlink() + + # ---- helpers + def q(self): + return f"game={urllib.parse.quote(str(self.game))}&exe=Yosuga.exe" + + def sources(self): + status, res = self.app.get("/api/layouts/sources?" + self.q()) + self.assertEqual(status, 200, res) + return res["sources"] + + def save(self, src, name): + return self.app.call("/api/layouts/save", {"game": str(self.game), "exe": "Yosuga.exe", "src": src, "name": name}) + + def src(self, kind="current"): + return next(s["src"] for s in self.sources() if s["kind"] == kind) + + def listed(self): + status, res = self.app.get("/api/layouts") + self.assertEqual(status, 200, res) + return res + + def put(self, path, data): + req = urllib.request.Request(f"http://127.0.0.1:{self.app.port}{path}", data=data, method="PUT") + try: + with urllib.request.urlopen(req, timeout=30) as r: + return r.status, json.loads(r.read()) + except urllib.error.HTTPError as e: + return e.code, json.loads(e.read() or b"{}") + + def files_of(self, lid): + return [f".config/deckdrop/layouts/{lid}.json", f".config/deckdrop/layouts/{lid}.vdf", + f"Steam/controller_base/templates/controller_neptune_deckdrop_{lid}.vdf"] + + # ---- what Steam has for the game + def test_sources_of_a_renamed_shortcut(self): + got = {(s["kind"], s["title"], s["base"]) for s in self.sources()} + self.assertEqual(got, {("current", "", "WASD"), ("saved", "VNs", "")}) + self.assertTrue(all(s["src"].startswith(ACCOUNT + "/yosugaexe/") for s in self.sources())) + + # ---- save + def test_save_adds_only_its_own_files(self): + before = snapshot(self.home) + status, res = self.save(self.src(), "Для новелл") + self.assertEqual(status, 200, res) + self.assertTrue(res["template"]) + self.assertEqual(diff(before, snapshot(self.home)), + {"added": sorted(self.files_of(res["id"])), "removed": [], "changed": []}) + stored = (self.layouts / f"{res['id']}.vdf").read_bytes() + self.assertEqual(stored, (self.cfg / "yosugaexe" / "FYZZ53700874.vdf").read_bytes(), "an exact copy") + tpl = (self.templates / f"controller_neptune_deckdrop_{res['id']}.vdf").read_text("utf-8") + self.assertIn('"title"\t\t"DeckDrop: Для новелл"', tpl) + self.assertIn('"title"\t\t"WASD"', tpl, "only the layout's own title changes") + meta = json.loads((self.layouts / f"{res['id']}.json").read_text("utf-8")) + self.assertEqual((meta["name"], meta["game"]), ("Для новелл", "Yosuga no Sora")) + + def test_saved_layout_with_a_bom(self): + status, res = self.save(self.src("saved"), "VNs") + self.assertEqual(status, 200, res) + self.assertEqual([x["name"] for x in self.listed()["layouts"]], ["VNs"]) + + def test_same_name_twice_keeps_both(self): + _, a = self.save(self.src(), "Same") + first = snapshot(self.layouts) + _, b = self.save(self.src("saved"), "Same") + self.assertNotEqual(a["id"], b["id"]) + after = snapshot(self.layouts) + for name, digest in first.items(): + self.assertEqual(after[name], digest, "the first layout was rewritten") + self.assertEqual(sorted(x["id"] for x in self.listed()["layouts"]), sorted([a["id"], b["id"]])) + + def test_save_refuses_files_that_are_not_the_games_sources(self): + before = snapshot(self.home) + bad = [f"{ACCOUNT}/fatestay night/vns 2.0_0.vdf", # another game's layout + f"{ACCOUNT}/yosugaexe/touch_0.vdf", # not a Deck layout + f"{ACCOUNT}/yosugaexe/broken_0.vdf", + f"{ACCOUNT}/yosugaexe/../../../../../.config/deckdrop/state.json", + "../../state.json", "", "a/b"] + for src in bad: + status, _ = self.save(src, "x") + self.assertEqual(status, 400, src) + status, _ = self.save(self.src(), " ") + self.assertEqual(status, 400, "an empty name") + self.assertEqual(diff(before, snapshot(self.home)), {"added": [], "removed": [], "changed": []}) + + # ---- rename + def test_rename_changes_only_its_record_and_template(self): + _, a = self.save(self.src(), "Old") + _, b = self.save(self.src("saved"), "Other") + before = snapshot(self.home) + status, res = self.app.call("/api/layouts/rename", {"id": a["id"], "name": 'New "one"'}) + self.assertEqual(status, 200, res) + f = self.files_of(a["id"]) + self.assertEqual(diff(before, snapshot(self.home)), {"added": [], "removed": [], "changed": sorted([f[0], f[2]])}) + tpl = (self.templates / f"controller_neptune_deckdrop_{a['id']}.vdf").read_text("utf-8") + self.assertIn('"title"\t\t"DeckDrop: New \\"one\\""', tpl) + self.assertEqual({x["id"]: x["name"] for x in self.listed()["layouts"]}, {a["id"]: 'New "one"', b["id"]: "Other"}) + + # ---- delete + def test_delete_removes_only_its_files(self): + _, a = self.save(self.src(), "A") + _, b = self.save(self.src("saved"), "B") + before = snapshot(self.home) + status, res = self.app.call("/api/layouts/delete", {"id": a["id"]}) + self.assertEqual(status, 200, res) + self.assertEqual(diff(before, snapshot(self.home)), + {"added": [], "removed": sorted(self.files_of(a["id"])), "changed": []}) + self.assertEqual([x["id"] for x in self.listed()["layouts"]], [b["id"]]) + + def test_bad_ids_touch_nothing(self): + self.save(self.src(), "A") + before = snapshot(self.home) + for lid in ("../state", "state", "", "0000000g", "../../Steam/controller_base/templates/x", "deadbeef"): + for path, body in (("/api/layouts/delete", {"id": lid}), ("/api/layouts/rename", {"id": lid, "name": "x"})): + status, _ = self.app.call(path, body) + self.assertEqual(status, 400, (path, lid)) + status, _ = self.app.get("/api/layouts/file?id=../state") + self.assertEqual(status, 400) + self.assertEqual(diff(before, snapshot(self.home)), {"added": [], "removed": [], "changed": []}) + + def test_apply_needs_steam_control(self): + _, a = self.save(self.src(), "A") + before = snapshot(self.home) + status, res = self.app.call("/api/layouts/apply", {"game": str(self.game), "exe": "Yosuga.exe", "id": a["id"]}) + self.assertEqual(status, 400, res) + self.assertIn("Steam", res["error"]) + self.assertEqual(diff(before, snapshot(self.home)), {"added": [], "removed": [], "changed": []}) + + # ---- Steam's templates follow DeckDrop + def test_templates_come_back_and_strays_go(self): + _, a = self.save(self.src(), "A") + mine = self.templates / f"controller_neptune_deckdrop_{a['id']}.vdf" + want = mine.read_bytes() + mine.unlink() # a Steam update wiped it + stray = self.templates / "controller_neptune_deckdrop_0badc0de.vdf" # its layout was removed by hand + stray.write_text(layout("DeckDrop: gone"), "utf-8") + valve = snapshot(self.templates) + del valve[stray.name] + self.assertTrue(self.listed()["templates"]) + self.assertEqual(mine.read_bytes(), want) + self.assertFalse(stray.exists()) + now = snapshot(self.templates) + for name, digest in valve.items(): + self.assertEqual(now.get(name), digest, f"{name} is not DeckDrop's and must stay as it was") + + def test_a_broken_record_does_not_break_the_list(self): + _, a = self.save(self.src(), "A") + (self.layouts / "1234abcd.json").write_text("{not json", "utf-8") + (self.layouts / "1234abcd.vdf").write_text(layout("x"), "utf-8") + (self.layouts / "notes.txt").write_text("мои заметки", "utf-8") + self.assertEqual([x["id"] for x in self.listed()["layouts"]], [a["id"]]) + self.assertTrue((self.layouts / "notes.txt").exists()) + + # ---- files in and out + def test_download_and_upload(self): + _, a = self.save(self.src(), "Моя раскладка") + req = urllib.request.urlopen(f"http://127.0.0.1:{self.app.port}/api/layouts/file?id={a['id']}", timeout=30) + data = req.read() + self.assertIn("attachment", req.headers["Content-Disposition"]) + self.assertIn('"title"\t\t"Моя раскладка"', data.decode("utf-8")) + before = snapshot(self.home) + status, b = self.put("/api/layouts/upload?name=" + urllib.parse.quote("../из файла"), data) + self.assertEqual(status, 200, b) + self.assertEqual(diff(before, snapshot(self.home)), {"added": sorted(self.files_of(b["id"])), "removed": [], "changed": []}) + self.assertIn("../из файла", [x["name"] for x in self.listed()["layouts"]]) + before = snapshot(self.home) + for body in (b"not a layout", layout("Touch", "controller_mobile_touch").encode(), b"\0" * ((1 << 20) + 1)): + status, _ = self.put("/api/layouts/upload?name=x", body) + self.assertEqual(status, 400) + self.assertEqual(diff(before, snapshot(self.home)), {"added": [], "removed": [], "changed": []}) + + +class ApplyTest(unittest.TestCase): + """Applying a saved layout asks the running Steam; DeckDrop itself writes nothing into Steam's files.""" + + @classmethod + def setUpClass(cls): + cls.home = h = Path(tempfile.mkdtemp()) + cls.steam = h / "Steam" + cls.cfg = cls.steam / "steamapps/common/Steam Controller Configs" / ACCOUNT / "config" + games = {"Yosuga": ("Yosuga.exe", "Yosuga no Sora", APPID), "Mystery": ("run.exe", "Mystery Game", 2600000001)} + entries = b"" + for i, (folder, (exe, name, appid)) in enumerate(games.items()): + (h / "Games" / folder).mkdir(parents=True) + (h / "Games" / folder / exe).write_bytes(b"MZ" + b"\0" * 64) + entries += shortcut_entry(i, name, h / "Games" / folder / exe, appid) + (h / "Games" / "Newcomer").mkdir() + (h / "Games" / "Newcomer" / "new.exe").write_bytes(b"MZ" + b"\0" * 64) + (cls.cfg / "yosugaexe").mkdir(parents=True) + (cls.cfg / "yosugaexe" / "FYZZ53700874.vdf").write_text(layout("#Title"), "utf-8") + # a folder no name leads to: only Steam's own answer finds it + (cls.cfg / "odd name").mkdir() + (cls.cfg / "odd name" / "controller_neptune.vdf").write_text(layout("Odd"), "utf-8") + (cls.steam / "controller_base" / "templates").mkdir(parents=True) + (cls.steam / "userdata" / ACCOUNT / "config").mkdir(parents=True) + (cls.steam / "userdata" / ACCOUNT / "config" / "shortcuts.vdf").write_bytes(b"\x00shortcuts\0" + entries + b"\x08\x08") + cls.fake = FakeSteam() + cls.fake.current[2600000001] = ("autosave://" + str(cls.cfg / "odd name" / "controller_neptune.vdf"), "Odd") + env = {k: v for k, v in os.environ.items() if not k.startswith("DECKDROP_")} + env.update(HOME=str(h), DECKDROP_CEF="1", DECKDROP_CEF_PORT=str(cls.fake.port), DECKDROP_PIN="1234", + DECKDROP_STEAM=str(cls.steam), DECKDROP_GAMES=str(h / "Games")) + cls.app = Running(ROOT / "tools" / "dev.py", env) + cls.app.post_ok("/api/settings", {"vndb_auto": False}) + req = urllib.request.Request(f"http://127.0.0.1:{cls.app.port}/api/layouts/upload?name=VN", + data=layout("VN").encode(), method="PUT") + with urllib.request.urlopen(req, timeout=30) as r: + cls.lid = json.loads(r.read())["id"] + cls.url = f"template://controller_neptune_deckdrop_{cls.lid}.vdf" + + @classmethod + def tearDownClass(cls): + cls.app.stop() + cls.fake.close() + shutil.rmtree(cls.home, ignore_errors=True) + + def setUp(self): + self.fake.mode = "ok" + self.fake.calls.clear() + + def apply(self, folder, exe, lid=None): + return self.app.call("/api/layouts/apply", {"game": str(self.home / "Games" / folder), "exe": exe, + "id": self.lid if lid is None else lid}) + + def exe_of(self, folder): + _, st = self.app.get("/api/state") + return next(g for g in st["games"] if g["name"] == folder)["exes"][0] + + def test_apply_asks_steam_and_writes_nothing_into_steam(self): + before = snapshot(self.steam) + status, res = self.apply("Yosuga", "Yosuga.exe") + self.assertEqual(status, 200, res) + self.assertIn("VN", res["note"]) + self.assertEqual(self.fake.calls_of("apply"), [("apply", APPID, self.url)]) + self.assertEqual(snapshot(self.steam), before, "Steam records the choice itself") + self.assertEqual(self.exe_of("Yosuga")["layout"], "VN") + + def test_steam_that_does_not_switch_is_reported(self): + for mode, words in (("stuck", "did not switch"), ("no_controller", "controls")): + self.fake.mode = mode + status, res = self.apply("Yosuga", "Yosuga.exe") + self.assertEqual(status, 400, res) + self.assertIn(words, res["error"]) + + def test_apply_refuses_unknown_layouts_and_games(self): + for lid in ("deadbeef", "../x", ""): + status, _ = self.apply("Yosuga", "Yosuga.exe", lid) + self.assertEqual(status, 400, lid) + status, _ = self.apply("Newcomer", "new.exe") # not in Steam yet + self.assertEqual(status, 400) + self.assertEqual(self.fake.calls_of("apply"), []) + + def test_sources_follow_steams_own_answer(self): + q = f"game={urllib.parse.quote(str(self.home / 'Games' / 'Mystery'))}&exe=run.exe" + status, res = self.app.get("/api/layouts/sources?" + q) + self.assertEqual(status, 200, res) + self.assertEqual(res["current"]["title"], "Odd") + self.assertEqual([s["src"] for s in res["sources"]], [f"{ACCOUNT}/odd name/controller_neptune.vdf"]) + self.assertTrue(res["cdp"]) + + def test_steams_answer_outside_its_configs_is_ignored(self): + self.fake.current[2600000001] = ("autosave:///etc/passwd", "x") + try: + q = f"game={urllib.parse.quote(str(self.home / 'Games' / 'Mystery'))}&exe=run.exe" + _, res = self.app.get("/api/layouts/sources?" + q) + self.assertEqual(res["sources"], []) + finally: + self.fake.current[2600000001] = ("autosave://" + str(self.cfg / "odd name" / "controller_neptune.vdf"), "Odd") + + def test_default_layout_for_new_games(self): + self.app.post_ok("/api/settings", {"default_layout": self.lid}) + try: + self.app.post_ok("/api/add_to_steam", {"game": str(self.home / "Games" / "Newcomer"), "exe": "new.exe"}) + for _ in range(40): + if self.fake.calls_of("apply"): + break + time.sleep(0.25) + self.assertEqual(self.fake.calls_of("apply"), [("apply", 3000000077, self.url)]) + for _ in range(20): + if self.exe_of("Newcomer").get("layout"): + break + time.sleep(0.25) + self.assertEqual(self.exe_of("Newcomer")["layout"], "VN") + finally: + self.app.post_ok("/api/settings", {"default_layout": ""}) + + def test_no_default_layout_by_default(self): + _, st = self.app.get("/api/state") + self.assertEqual(st["settings"]["default_layout"], "") + + +class NoSteamTemplatesTest(unittest.TestCase): + """Without Steam's templates folder a layout is still kept, and nothing is created in Steam.""" + + def test_saved_without_templates(self): + home = Path(tempfile.mkdtemp()) + self.addCleanup(shutil.rmtree, home, True) + steam = home / "Steam" + (steam / "userdata").mkdir(parents=True) + env = {k: v for k, v in os.environ.items() if not k.startswith("DECKDROP_")} + env.update(HOME=str(home), DECKDROP_CEF="0", DECKDROP_STEAM=str(steam)) + app = Running(ROOT / "tools" / "dev.py", env) + self.addCleanup(app.stop) + before = snapshot(steam) + req = urllib.request.Request(f"http://127.0.0.1:{app.port}/api/layouts/upload?name=A", + data=layout("A").encode(), method="PUT") + with urllib.request.urlopen(req, timeout=30) as r: + res = json.loads(r.read()) + self.assertFalse(res["template"]) + status, listed = app.get("/api/layouts") + self.assertFalse(listed["templates"]) + self.assertEqual([x["name"] for x in listed["layouts"]], ["A"]) + self.assertEqual(snapshot(steam), before) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_user_data.py b/tests/test_user_data.py index 3dc718a..92ce4d0 100644 --- a/tests/test_user_data.py +++ b/tests/test_user_data.py @@ -11,7 +11,8 @@ 2. the user presses "update" (POST /api/update of the old version) pointing at the new build; 3. checks: the update touched no file but the program and its .bak; the new version, started on the same home, shows the same settings and games, accepts the old PIN and - media password, and after it writes state.json every stored key keeps its value. + media password, and after it writes state.json every stored key keeps its value; the + controller layouts saved in DeckDrop are listed again, byte for byte, and offered to Steam. CI runs this as its own required step, before every release too. @@ -55,6 +56,19 @@ } +LAYOUT_ID = "0a1b2c3d" +LAYOUT = """"controller_mappings" +{ +\t"title"\t\t"NAME" +\t"controller_type"\t\t"controller_neptune" +\t"group" +\t{ +\t\t"id"\t\t"0" +\t} +} +""" + + def version_key(v): return tuple(int(x) for x in re.findall(r"\d+", v)) @@ -207,7 +221,18 @@ def check(self, label, old_program, new_build, url, home): # the service has no DECKDROP_* overrides: everything lives at the default paths in HOME env = {k: v for k, v in os.environ.items() if not k.startswith("DECKDROP_")} env.update(HOME=str(home), DECKDROP_NO_RESTART="1", DECKDROP_CEF="0", - DECKDROP_STEAM=str(home / "no-steam")) + DECKDROP_STEAM=str(home / "Steam")) + templates = home / "Steam" / "controller_base" / "templates" + templates.mkdir(parents=True) + (templates / "controller_neptune_wasd.vdf").write_text(LAYOUT.replace("NAME", "#Title"), "utf-8") + # controller layouts saved in DeckDrop, the files the layouts page keeps + layouts = home / ".config" / "deckdrop" / "layouts" + layouts.mkdir(parents=True) + (layouts / f"{LAYOUT_ID}.vdf").write_text(LAYOUT.replace("NAME", "#Title"), "utf-8") + (layouts / f"{LAYOUT_ID}.json").write_text(json.dumps( + {"name": "Для новелл", "game": "Моя игра", "source": "1/x/controller_neptune.vdf", "created": 1700000000}, + ensure_ascii=False), "utf-8") + kept_layouts = snapshot(layouts, program) # 1. the user sets up the old version through its web page old = Running(program, dict(env, DECKDROP_PIN=FIRST_PIN)) @@ -275,8 +300,15 @@ def check(self, label, old_program, new_build, url, home): spare.mkdir() (spare / "g.exe").write_bytes(b"MZ") new.post_ok("/api/game/import", {"path": str(spare)}) + status, ly = new.get("/api/layouts") + self.assertEqual(status, 200, f"{label}: layouts page") + self.assertEqual([(x["id"], x["name"], x["template"]) for x in ly["layouts"]], + [(LAYOUT_ID, "Для новелл", True)], f"{label}: saved layout lost") finally: new.stop() + self.assertEqual(snapshot(layouts, program), kept_layouts, f"{label}: saved layouts rewritten") + self.assertIn('"DeckDrop: Для новелл"', + (templates / f"controller_neptune_deckdrop_{LAYOUT_ID}.vdf").read_text("utf-8")) saved = json.loads(state_file.read_text("utf-8")) want = dict(stored, vndb_auto=True) diff --git a/tests/ui_check.py b/tests/ui_check.py index 50425f9..d99bb2f 100644 --- a/tests/ui_check.py +++ b/tests/ui_check.py @@ -16,8 +16,12 @@ from playwright.sync_api import sync_playwright sys.path.insert(0, str(Path(__file__).resolve().parent)) +from test_layouts import layout, shortcuts_vdf # noqa: E402 from test_user_data import Running # noqa: E402 +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "src")) +from deckdrop.steam import layouts as steam_layouts # noqa: E402 + ROOT = Path(__file__).resolve().parent.parent PROGRAM = Path(sys.argv[1] if len(sys.argv) > 1 else ROOT / "dist" / "deckdrop.py") KEY_LIKE = re.compile(r"\b[a-z]+(?:\.[a-z_]+)+\b") @@ -25,21 +29,81 @@ "manifest.json", "state.json", "github.com"} # the last two: paths and links in the self-check +# Steam as the Deck showed it: its own controls are controller 15, and a layout is selected only +# with all five arguments of SetSelectedConfigForApp, for that controller +STEAM_STUB = """ +window.ControllerStore = {m_nLastValidActiveControllerIndex: LAST, m_controllerList: CONTROLLERS}; +const chosen = {}; +window.SteamClient = {Input: { + async GetConfigForAppAndController(appid, index) { + return index === 15 ? {bConfigurationEnabled: true, unControllerIndex: 15, URL: chosen[appid] || 'autosave:///x/controller_neptune.vdf', + Title: chosen[appid] ? 'DeckDrop: VN' : 'Gamepad'} + : {bConfigurationEnabled: false, unControllerIndex: index}; + }, + async SetSelectedConfigForApp(appid, index, url, preview, how) { + if (!STUCK && index === 15 && preview === false && how === 1) chosen[appid] = url; + }, +}}; +""" + + +def steam_calls(browser): + """The expressions DeckDrop sends to Steam, run in a browser against the stand-in above.""" + problems = [] + deck = '[{strName: "Steam Deck Controller", nControllerIndex: 15, eControllerType: 4}]' + url = steam_layouts.template_url("0a1b2c3d") + cases = (("deck", deck, "15", "false", {"ok": True, "index": 15, "url": url, "title": "DeckDrop: VN"}), + ("only the last active one", "[]", "15", "false", {"ok": True, "index": 15, "url": url}), + ("steam does not switch", deck, "15", "true", {"ok": False, "reason": "not_switched", "index": 15}), + ("no controller", "[]", "undefined", "false", {"ok": False, "reason": "no_controller"})) + for label, controllers, last, stuck, want in cases: + page = browser.new_page() + page.add_init_script(STEAM_STUB.replace("CONTROLLERS", controllers).replace("LAST", last).replace("STUCK", stuck)) + page.goto("about:blank") + cur = page.evaluate(steam_layouts._js(steam_layouts.CURRENT_JS, 42)) + got = page.evaluate(steam_layouts._js(steam_layouts.APPLY_JS, 42, url)) + got = {k: v for k, v in got.items() if k in want} + after = page.evaluate(steam_layouts._js(steam_layouts.CURRENT_JS, 42)) + if got != want: + problems.append(f"apply, {label}: {got} != {want}") + if last == "15" and (cur or {}).get("title") != "Gamepad": + problems.append(f"current, {label}: {cur}") + if want["ok"] and (after or {}).get("url") != url: + problems.append(f"current after apply: {after}") + if label == "no controller" and cur is not None: + problems.append(f"current without a controller: {cur}") + page.close() + print("steam calls:", problems or "ok") + return problems + + def main(): home = Path(tempfile.mkdtemp()) game = home / "Games" / "Cool Game" game.mkdir(parents=True) (game / "Game.exe").write_bytes(b"MZ") + # the game is in Steam and has a controller layout there, so its page offers to save it + steam = home / "Steam" + (steam / "userdata" / "123" / "config").mkdir(parents=True) + (steam / "userdata" / "123" / "config" / "shortcuts.vdf").write_bytes( + shortcuts_vdf("Cool Game", game / "Game.exe", 3000000001)) + cfg = steam / "steamapps" / "common" / "Steam Controller Configs" / "123" / "config" / "cool game" + cfg.mkdir(parents=True) + (cfg / "controller_neptune.vdf").write_text(layout("#Title"), "utf-8") + (steam / "controller_base" / "templates").mkdir(parents=True) (home / ".steam").mkdir() # the Deck's Steam is in Russian (home / ".steam" / "registry.vdf").write_text('"Registry" { "HKCU" { "Software" { "Valve" { "Steam" ' '{ "language" "russian" } } } } }', "utf-8") env = {k: v for k, v in os.environ.items() if not k.startswith("DECKDROP_")} - env.update(HOME=str(home), DECKDROP_CEF="0", DECKDROP_PIN="1234", DECKDROP_STEAM=str(home / "no-steam")) + env.update(HOME=str(home), DECKDROP_CEF="0", DECKDROP_PIN="1234", DECKDROP_STEAM=str(steam), + DECKDROP_UPDATE_URL="http://127.0.0.1:9/deckdrop.py") # the self-check: not the GitHub release app = Running(PROGRAM, env) failed = [] try: with sync_playwright() as p: browser = p.chromium.launch(executable_path=os.environ.get("CHROMIUM") or None) + if steam_calls(browser): + failed.append("steam calls") # no choice on the device: Steam's language, even from an English browser; then a device set to English for locale, choice, lang in (("en-US", None, "ru"), ("ru-RU", "en", "en")): ctx = browser.new_context(locale=locale, viewport={"width": 390, "height": 844}) @@ -83,6 +147,16 @@ def main(): page.click(".gcard") page.wait_for_timeout(1000) seen.append(page.inner_text("body")) + # the game's layout: save it to DeckDrop, then it is listed in Settings + page.click("[data-lysave]") + page.fill("#mf0", f"Layout {lang}") + page.click("#mok") + page.wait_for_selector(".toast.ok", timeout=10000) + seen.append(page.inner_text("#toasts")) + page.click("#tabSettings") + page.wait_for_selector(f"#lyList >> text=Layout {lang}", timeout=10000) + seen.append(page.inner_text("#sLayouts")) + page.click("#tabGames") page.click("#gpBack") page.click("#importGame") page.wait_for_timeout(500) diff --git a/tools/screenshots.py b/tools/screenshots.py index 4524e2a..3a69e29 100644 --- a/tools/screenshots.py +++ b/tools/screenshots.py @@ -36,7 +36,9 @@ ROOT = Path(__file__).resolve().parent.parent sys.path.insert(0, str(ROOT / "tools")) +sys.path.insert(0, str(ROOT / "tests")) import build # noqa: E402 +from fake_cdp import FakeSteam # noqa: E402 (Steam control, so the page offers to apply layouts) PIN = "1234" MEDIA_PASSWORD = "deckdrop" @@ -52,6 +54,17 @@ NEW_GAME = ("Starlight Harbor", "Starlight Harbor.exe") ARCHIVE = "Starlight Harbor.zip" USER = "10000001" +LAYOUT_GAME = "Paper Lanterns" +# controller layouts Steam has for LAYOUT_GAME: (file, title, based on) +LAYOUTS = [("controller_neptune.vdf", "#Title", "Gamepad with Mouse Trackpad"), + ("reading_0.vdf", {"ru": "Для чтения", "en": "Reading"}, None)] +SAVED = {"ru": ["Визуальные новеллы", "Шутеры"], "en": ["Visual novels", "Shooters"]} + + +def layout_vdf(title, based_on): + return (f'"controller_mappings"\n{{\n\t"version"\t\t"3"\n\t"title"\t\t"{title}"\n' + f'\t"controller_type"\t\t"controller_neptune"\n\t"localization"\n\t{{\n\t\t"english"\n\t\t{{\n' + f'\t\t\t"title"\t\t"{based_on or title}"\n\t\t}}\n\t}}\n\t"group"\n\t{{\n\t\t"id"\t\t"0"\n\t}}\n}}\n') def font(size, bold=True): @@ -166,6 +179,12 @@ def demo_home(home, lang): for f in (shots / name, shots / "thumbnails" / name): os.utime(f, (when, when)) (ud / "config" / "shortcuts.vdf").write_bytes(shortcuts_vdf(entries)) + # Steam's layouts for one game, and its templates folder + cfg = steam / "steamapps" / "common" / "Steam Controller Configs" / USER / "config" / LAYOUT_GAME.lower() + cfg.mkdir(parents=True) + for name, title, based_on in LAYOUTS: + (cfg / name).write_text(layout_vdf(title if isinstance(title, str) else title[lang], based_on), "utf-8") + (steam / "controller_base" / "templates").mkdir(parents=True) state = home / ".config" / "deckdrop" / "state.json" state.parent.mkdir(parents=True) state.write_text(json.dumps({"admin_pin": PIN, "added": added, "default_disk": "internal", @@ -243,8 +262,12 @@ def shoot(lang, out, chromium): program.write_text(build.build(), "utf-8") port = free_port() env = {k: v for k, v in os.environ.items() if not k.startswith("DECKDROP_")} - env.update(HOME=str(home), DECKDROP_PORT=str(port), DECKDROP_STEAM=str(steam), DECKDROP_CEF="0", - DECKDROP_DISKS=f"microSD={sd / 'Games'}") + fake = FakeSteam() + lantern = next(e for e in json.loads((home / ".config/deckdrop/state.json").read_text())["added"].items() + if LAYOUT_GAME in e[0]) + fake.current[lantern[1]["appid"]] = ("", "") + env.update(HOME=str(home), DECKDROP_PORT=str(port), DECKDROP_STEAM=str(steam), DECKDROP_CEF="1", + DECKDROP_CEF_PORT=str(fake.port), DECKDROP_DISKS=f"microSD={sd / 'Games'}") app = subprocess.Popen([sys.executable, str(program)], env=env, stdout=subprocess.DEVNULL, stderr=subprocess.STDOUT) url = f"http://127.0.0.1:{port}/" @@ -338,6 +361,32 @@ def frame(n=1, gap=0.0): page.wait_for_timeout(2500) png(page.screenshot(full_page=True, clip={"x": 0, "y": 0, "width": 390, "height": 1190}), out / "game.png", 780) + # phone: the game's controller layouts, two saved in DeckDrop, one applied + lantern_dir = str(Path(lantern[0]).parent) + q = f"game={urllib.parse.quote(lantern_dir)}&exe={urllib.parse.quote(Path(lantern[0]).name)}" + sources = json.loads(urllib.request.urlopen(url + "api/layouts/sources?" + q).read())["sources"] + ids = [post(port, "/api/layouts/save", {"game": lantern_dir, "exe": Path(lantern[0]).name, + "src": src["src"], "name": name})["id"] + for src, name in zip(sources, SAVED[lang])] + post(port, "/api/layouts/apply", {"game": lantern_dir, "exe": Path(lantern[0]).name, "id": ids[0]}) + # what Steam then reports for the game, as the Deck did + fake.selected.clear() + fake.current[lantern[1]["appid"]] = (f"template://controller_neptune_deckdrop_{ids[0]}.vdf", + "DeckDrop: " + SAVED[lang][0]) + post(port, "/api/settings", {"default_layout": ids[0]}) + page.goto(url + "#g=" + urllib.parse.quote(lantern_dir)) + page.wait_for_timeout(2500) + card = page.locator("#gpLayoutsCard") + card.scroll_into_view_if_needed() + page.wait_for_timeout(300) + png(card.screenshot(), out / "layouts.png", 780) + page.goto(url) + page.wait_for_timeout(1200) + page.click("#tabSettings") + page.wait_for_timeout(1200) + card = page.locator("#sLayouts") + card.scroll_into_view_if_needed() + png(card.screenshot(), out / "layouts-settings.png", 780) # phone: the gallery page.goto(url) page.click("#tabMedia") @@ -356,6 +405,7 @@ def frame(n=1, gap=0.0): finally: app.terminate() srv.shutdown() + fake.close() if own_home: shutil.rmtree(home, ignore_errors=True) print(f"{lang}: " + ", ".join(f"{f.name} {f.stat().st_size // 1024} KiB" for f in sorted(out.iterdir())))