# 🤖 AGENTS.md — Руководство по разработке плагинов для Steam Deck (Decky Loader)

> **Назначение документа:** Этот справочник предназначен для разработчиков и AI-агентов (Cursor, Windsurf, Antigravity, Claude, ChatGPT, GitHub Copilot). Он содержит исчерпывающие правила, архитектурные шаблоны, готовые примеры кода и предостережения для быстрого и безошибочного создания плагинов для Steam Deck под управлением **Decky Loader** (актуально для SteamOS 3.5, 3.6+ и Decky Loader v3.x).

---

## 📑 Оглавление

1. [Обзор архитектуры плагина](#1-обзор-архитектуры-плагина)
2. [Файловая структура проекта](#2-файловая-структура-проекта)
3. [Манифесты плагина (plugin.json & package.json)](#3-манифесты-плагина-pluginjson--packagejson)
4. [Конфигурация сборки (TypeScript & Rollup)](#4-конфигурация-сборки-typescript--rollup)
5. [Frontend: разработка интерфейса (@decky/ui & React)](#5-frontend-разработка-интерфейса-deckyui--react)
   - [Точка входа definePlugin](#точка-входа-defineplugin)
   - [Компоненты интерфейса SteamOS](#компоненты-интерфейса-steamos)
   - [Вызовы методов бэкенда (ServerAPI)](#вызовы-методов-бэкенда-serverapi)
   - [Слушатели событий и Toast-уведомления](#слушатели-событий-и-toast-уведомления)
6. [Backend: логика на Python (main.py)](#6-backend-логика-на-python-mainpy)
   - [Класс Plugin и жизненный цикл](#класс-plugin-и-жизненный-цикл)
   - [Встроенный модуль decky_plugin и пути](#встроенный-модуль-decky_plugin-и-пути)
   - [Выполнение системных команд в SteamOS](#выполнение-системных-команд-в-steamos)
   - [Критический фикс SteamOS 3.6+ / Python 3.13](#критический-фикс-steamos-36--python-313)
7. [Сборка, упаковка в ZIP и тестирование](#7-сборка-упаковка-в-zip-и-тестирование)
8. [Комплаенс и ограничения](#8-комплаенс-и-ограничения)

---

## 🏗️ 1. Обзор архитектуры плагина

Плагин Decky Loader состоит из двух изолированных частей, общающихся по межпроцессному каналу (IPC):

```
┌────────────────────────────────────────────────────────────────┐
│ Steam Deck (Game Mode / Gamescope)                             │
│                                                                │
│  ┌──────────────────────────────┐                              │
│  │ Frontend (React / TypeScript)│                              │
│  │ Встраивается в QAM (···)     │                              │
│  │ UI на компонентах @decky/ui  │                              │
│  └──────────────┬───────────────┘                              │
│                 │ ServerAPI.callPluginMethod('method', args)   │
│                 │ decky_plugin.emit('event', data)             │
│                 ▼                                              │
│  ┌──────────────────────────────┐                              │
│  │ Backend (Python 3 / asyncio) │                              │
│  │ Демон main.py (Plugin class) │                              │
│  │ Системные вызовы, bash, FS   │                              │
│  └──────────────────────────────┘                              │
└────────────────────────────────────────────────────────────────┘
```

1. **Frontend (UI):** Бандл React/TypeScript (`dist/index.js`), который загружается Steam-клиентом в боковое меню быстрого доступа (Quick Access Menu, вызывается кнопкой `···` на Steam Deck). Отвечает за рендеринг кнопок, тумблеров, слайдеров и обработку ввода с геймпада.
2. **Backend (Server):** Python-скрипт (`main.py`), запускаемый процессом `PluginLoader` в отдельном изолированном окружении. Имеет доступ к файловой системе SteamOS, системным утилитам, `systemctl`, `sysfs` и сети.

---

## 📁 2. Файловая структура проекта

Эталонная структура исходного кода плагина:

```
my-decky-plugin/
├── .vscode/                 # Настройки IDE (рекомендуется)
├── assets/
│   ├── logo.png            # Квадратный логотип плагина (512x512 PNG)
│   └── preview.png         # Скриншот для магазина (1920x1080 или 1280x800 PNG)
├── src/
│   └── index.tsx           # Основной UI плагина (точка входа фронтенда)
├── bin/                    # Опционально: скомпилированные бинарники (Linux x86_64)
├── py_modules/             # Опционально: сторонние Python-библиотеки (wheels/чистый код)
├── dist/                   # Создаётся автоматически при сборке
│   └── index.js            # Собранный бандл фронтенда
├── main.py                 # Основной бэкенд на Python 3
├── package.json            # Зависимости NPM, версия, скрипты сборки
├── plugin.json             # Главный манифест плагина для Decky Loader
├── tsconfig.json           # Конфигурация TypeScript
├── rollup.config.js        # Конфигурация бандлера Rollup
└── README.md               # Документация плагина
```

---

## 📜 3. Манифесты плагина (plugin.json & package.json)

### `plugin.json` (Манифест загрузчика Decky)

> ⚠️ **Критично:** Название плагина в `plugin.json` должно совпадать с названием в `package.json` и корневой папкой при упаковке.

```json
{
  "name": "My Plugin",
  "author": "Ваш Никнейм",
  "flags": [],
  "api_version": 1,
  "publish": {
    "tags": ["utility", "customization"],
    "description": "Краткое и понятное описание того, что делает ваш плагин на Steam Deck.",
    "image": "https://deckyloader.ru/store/images/myplugin.png"
  }
}
```

* **`flags`**:
  * `[]` (пусто) — плагин запускается от обычного непривилегированного пользователя `deck`.
  * `["root"]` — плагин запускается с правами `root`. **Используйте только при реальной необходимости** (например, управление системными демонами `systemctl`, запись в `/sys/class/...`, настройка сетевых интерфейсов). Для 90% задач root не требуется!
* **`api_version`**: Всегда указывайте `1` (актуальный стандарт Decky Loader v3.x).
* **`tags`**: Теги для фильтрации в магазине (`utility`, `system`, `media`, `tools`, `audio`, `games`).

### `package.json` (NPM манифест)

```json
{
  "name": "my-plugin",
  "version": "1.0.0",
  "description": "Описание плагина",
  "scripts": {
    "build": "rollup -c",
    "watch": "rollup -c -w"
  },
  "dependencies": {
    "@decky/ui": "^3.25.1",
    "@decky/api": "^3.25.1"
  },
  "devDependencies": {
    "@rollup/plugin-commonjs": "^28.0.0",
    "@rollup/plugin-json": "^6.1.0",
    "@rollup/plugin-node-resolve": "^15.2.3",
    "@rollup/plugin-replace": "^6.0.1",
    "@rollup/plugin-typescript": "^12.1.0",
    "@types/react": "^18.3.11",
    "react": "^18.3.1",
    "rollup": "^4.24.0",
    "tslib": "^2.7.0",
    "typescript": "^5.6.3"
  },
  "pnpm": {
    "onlyBuiltDependencies": []
  }
}
```

---

## ⚙️ 4. Конфигурация сборки (TypeScript & Rollup)

### `tsconfig.json`

```json
{
  "compilerOptions": {
    "target": "ESNext",
    "module": "ESNext",
    "lib": ["DOM", "ESNext"],
    "jsx": "react-jsx",
    "moduleResolution": "node",
    "strict": true,
    "esModuleInterop": true,
    "skipLibCheck": true,
    "forceConsistentCasingInFileNames": true,
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true
  },
  "include": ["src/**/*"]
}
```

### `rollup.config.js`

```javascript
import commonjs from '@rollup/plugin-commonjs';
import json from '@rollup/plugin-json';
import resolve from '@rollup/plugin-node-resolve';
import replace from '@rollup/plugin-replace';
import typescript from '@rollup/plugin-typescript';
import { defineConfig } from 'rollup';

export default defineConfig({
  input: 'src/index.tsx',
  output: {
    file: 'dist/index.js',
    format: 'esm',
    exports: 'default',
  },
  plugins: [
    replace({
      preventAssignment: true,
      'process.env.NODE_ENV': JSON.stringify('production'),
    }),
    resolve({
      browser: true,
    }),
    commonjs(),
    json(),
    typescript({
      tsconfig: './tsconfig.json',
      noEmit: false,
      outDir: 'dist',
    }),
  ],
  // React и библиотеки Decky предоставляются хостом SteamOS, не бандлим их внутрь!
  external: ['react', 'react-dom', '@decky/ui', '@decky/api'],
});
```

---

## 🎨 5. Frontend: разработка интерфейса (@decky/ui & React)

Фронтенд плагина работает внутри SteamOS Big Picture / Game Mode. Все компоненты должны быть адаптированы под управление с геймпада (D-Pad, стики, кнопки A/B). Библиотека `@decky/ui` предоставляет нативные компоненты, полностью повторяющие стиль SteamOS.

### Точка входа `definePlugin` (`src/index.tsx`)

```tsx
import {
  ButtonItem,
  definePlugin,
  PanelSection,
  PanelSectionRow,
  ServerAPI,
  SliderField,
  ToggleField,
  DropdownItem,
  TextField,
  toaster,
} from '@decky/ui';
import { FC, useState, useEffect } from 'react';
import { FaCog, FaGamepad } from 'react-icons/fa';

// Основной компонент содержимого в боковом меню QAM
const Content: FC<{ serverApi: ServerAPI }> = ({ serverApi }) => {
  const [isEnabled, setIsEnabled] = useState<boolean>(false);
  const [volume, setVolume] = useState<number>(50);
  const [statusText, setStatusText] = useState<string>('Загрузка...');

  // Загрузка начальных настроек при открытии панели
  useEffect(() => {
    (async () => {
      try {
        const status = await serverApi.callPluginMethod<Record<string, never>, { status: string }>(
          'get_status',
          {}
        );
        if (status.success) {
          setStatusText(status.result.status);
        }
      } catch (err) {
        setStatusText('Ошибка соединения');
      }
    })();
  }, [serverApi]);

  const handleToggle = async (val: boolean) => {
    setIsEnabled(val);
    const res = await serverApi.callPluginMethod<{ enabled: boolean }, { message: string }>(
      'set_enabled',
      { enabled: val }
    );
    if (res.success) {
      toaster.toast({
        title: 'My Plugin',
        body: res.result.message,
        duration: 3000,
      });
    }
  };

  const handleAction = async () => {
    toaster.toast({
      title: 'Действие выполнено',
      body: `Текущая громкость: ${volume}%`,
    });
  };

  return (
    <PanelSection title="Основные настройки">
      <PanelSectionRow>
        <div style={{ color: '#8b9bb4', fontSize: '12px' }}>
          Статус бэкенда: <b>{statusText}</b>
        </div>
      </PanelSectionRow>

      <PanelSectionRow>
        <ToggleField
          label="Включить модуль"
          description="Активирует фоновую обработку"
          checked={isEnabled}
          onChange={handleToggle}
        />
      </PanelSectionRow>

      <PanelSectionRow>
        <SliderField
          label="Уровень мощности"
          value={volume}
          min={0}
          max={100}
          step={5}
          notchCount={10}
          notchTicksVisible={true}
          showValue={true}
          onChange={(val) => setVolume(val)}
        />
      </PanelSectionRow>

      <PanelSectionRow>
        <ButtonItem layout="below" onClick={handleAction}>
          Применить параметры
        </ButtonItem>
      </PanelSectionRow>
    </PanelSection>
  );
};

// Экспорт плагина через definePlugin
export default definePlugin((serverApi: ServerAPI) => {
  return {
    title: <div className="DeckyPluginTitle">My Plugin</div>,
    icon: <FaGamepad />,
    content: <Content serverApi={serverApi} />,
    onDismount() {
      // Очистка таймеров, отписка от событий при выгрузке плагина
    },
  };
});
```

### Справочник UI-компонентов `@decky/ui`

| Компонент | Назначение | Пример использования |
| :--- | :--- | :--- |
| `PanelSection` | Группа настроек с заголовком | `<PanelSection title="Настройки">...</PanelSection>` |
| `PanelSectionRow` | Строка внутри секции (обеспечивает отступы) | `<PanelSectionRow><ToggleField ... /></PanelSectionRow>` |
| `ToggleField` | Тумблер Вкл / Выкл | `checked={val}`, `onChange={(newVal) => ...}` |
| `SliderField` | Ползунок числового значения | `min={0}`, `max={100}`, `step={1}`, `showValue={true}` |
| `ButtonItem` | Нативная кнопка SteamOS | `<ButtonItem onClick={...}>Текст</ButtonItem>` |
| `DropdownItem` | Выпадающий список вариантов | `selectedOption={opt}`, `onChange={(newOpt) => ...}` |
| `TextField` | Текстовое поле ввода | Автоматически вызывает экранную клавиатуру Steam! |
| `SteamSpinner` | Индикатор загрузки | `<SteamSpinner background="transparent" />` |

### Двусторонняя связь в реальном времени (Events)

Бэкенд на Python может отправлять события на фронтенд в любой момент (например, при завершении фоновой задачи):

```tsx
// На фронтенде (React):
useEffect(() => {
  const unregister = serverApi.routerHook.addPatch ? null : undefined; // поддержка версий
  
  // Подписка на событие из Python (decky_plugin.emit)
  const removeListener = serverApi.addEventListener('task_completed', (data: { progress: number }) => {
    toaster.toast({
      title: 'Задача завершена',
      body: `Прогресс: ${data.progress}%`,
    });
  });

  return () => {
    // Обязательно отписываемся при размонтировании!
    serverApi.removeEventListener('task_completed', removeListener);
  };
}, [serverApi]);
```

---

## 🐍 6. Backend: логика на Python (main.py)

Бэкенд выполняется в отдельном процессе Python 3, управляемом демоном `PluginLoader`.

### Базовый класс `Plugin` (`main.py`)

```python
import os
import sys
import asyncio
import subprocess
import decky_plugin

class Plugin:
    # 1. Жизненный цикл плагина
    async def _main(self):
        """Вызывается один раз при старте или установке плагина."""
        decky_plugin.logger.info("My Plugin успешно запущен!")
        # Инициализация настроек, запуск фоновых задач
        asyncio.create_task(self._background_monitor())

    async def _unload(self):
        """Вызывается при остановке, перезагрузке или удалении плагина."""
        decky_plugin.logger.info("My Plugin останавливается...")
        # Корректное завершение фоновых демонов, закрытие сокетов

    # 2. Экспортируемые методы (вызываются через serverApi.callPluginMethod)
    async def get_status(self) -> dict:
        """Возвращает статус плагина на фронтенд."""
        return {"status": "Работает стабильно"}

    async def set_enabled(self, enabled: bool) -> dict:
        """Принимает аргументы от фронтенда."""
        decky_plugin.logger.info(f"Состояние переключено: {enabled}")
        # Пример отправки события на фронтенд в реальном времени:
        await decky_plugin.emit("task_completed", {"progress": 100})
        return {"message": f"Модуль {'включён' if enabled else 'выключен'}"}

    # 3. Вспомогательная фоновая задача
    async def _background_monitor(self):
        while True:
            await asyncio.sleep(60)
            decky_plugin.logger.info("Мониторинг параметров...")
```

### Встроенные переменные путей (`decky_plugin`)

> 🛑 **ЖЕСТКОЕ ПРАВИЛО:** Файловая система SteamOS в Game Mode смонтирована в режиме **только для чтения (read-only rootfs)**. Попытка записать что-либо в `/usr/`, `/etc/`, `/opt/` вызовет ошибку `Read-only file system`!

Всегда используйте официальные пути из `decky_plugin`:

```python
import os
import decky_plugin

# 1. Папка постоянных настроек (JSON конфиги):
# Путь: /home/deck/homebrew/settings/<PluginName>/
settings_file = os.path.join(decky_plugin.DECKY_PLUGIN_SETTINGS_DIR, "config.json")

# 2. Папка временных рабочих данных (кэш, скачанные архивы, сокеты):
# Путь: /home/deck/homebrew/data/<PluginName>/
temp_dir = decky_plugin.DECKY_PLUGIN_RUNTIME_DIR

# 3. Папка логов:
# Путь: /home/deck/homebrew/logs/<PluginName>/
log_dir = decky_plugin.DECKY_PLUGIN_LOG_DIR

# 4. Папка с файлами самого плагина:
# Путь: /home/deck/homebrew/plugins/<PluginName>/
plugin_dir = decky_plugin.DECKY_PLUGIN_DIR
```

### Безопасный запуск консольных утилит и команд

Для вызова консольных утилит SteamOS используйте асинхронный `asyncio.create_subprocess_exec` (чтобы не блокировать UI интерфейса):

```python
async def run_shell_command(self, cmd: str) -> str:
    try:
        proc = await asyncio.create_subprocess_shell(
            cmd,
            stdout=asyncio.subprocess.PIPE,
            stderr=asyncio.subprocess.PIPE
        )
        stdout, stderr = await proc.communicate()
        if proc.returncode != 0:
            decky_plugin.logger.error(f"Ошибка команды: {stderr.decode()}")
            return ""
        return stdout.decode().strip()
    except Exception as e:
        decky_plugin.logger.error(f"Исключение при вызове {cmd}: {e}")
        return ""
```

---

### ⚠️ Критический фикс: Импорт библиотек на SteamOS 3.6+ (Python 3.13)

В Decky Loader демон упакован через PyInstaller. На свежих версиях SteamOS (3.6 и новее) системный Python обновлён до версии 3.13, из-за чего попытка импортировать стандартные модули (`http.server`, `urllib.request`, `email`, `sqlite3`) может завершиться ошибкой:
`ModuleNotFoundError: No module named 'http.server'`

**Обязательное решение:** Добавьте этот блок в самое начало вашего `main.py` перед остальными импортами:

```python
import sys
import glob

# Динамическое обнаружение и подключение стандартной библиотеки Python в SteamOS
def _ensure_stdlib_paths():
    patterns = [
        "/usr/lib/python3*/",
        "/usr/lib64/python3*/",
        "/usr/lib/python3*/*-packages/",
    ]
    for pattern in patterns:
        for path in sorted(glob.glob(pattern), reverse=True):
            if path not in sys.path:
                sys.path.append(path)

_ensure_stdlib_paths()
```

---

## 📦 7. Сборка, упаковка в ZIP и тестирование

### Шаг 1: Сборка фронтенда

Используйте `pnpm` (или `npm`):

```bash
pnpm install
pnpm run build
# Итог: файл dist/index.js успешно создан
```

### Шаг 2: Правильная структура релизного ZIP-архива

> 🛑 **ЧАСТАЯ ОШИБКА:** Нельзя архивировать файлы прямо в корень архива!
> Внутри архива **ОБЯЗАТЕЛЬНО** должна быть одна корневая папка с точным названием плагина из `plugin.json`!

Пример структуры архива `MyPlugin.zip`:
```
MyPlugin.zip
└── MyPlugin/
    ├── plugin.json
    ├── package.json
    ├── main.py
    ├── dist/
    │   └── index.js
    ├── assets/
    │   ├── logo.png
    │   └── preview.png
    └── bin/             (если есть бинарники)
```

### Скрипт быстрой сборки архива (`build_zip.sh`)

Создайте в корне проекта файл `build_zip.sh`:

```bash
#!/usr/bin/env bash
set -e

PLUGIN_NAME="MyPlugin"
ZIP_NAME="${PLUGIN_NAME}.zip"

echo "🔨 Сборка фронтенда..."
pnpm run build

echo "📦 Формирование структуры релиза..."
rm -rf /tmp/${PLUGIN_NAME} ${ZIP_NAME}
mkdir -p /tmp/${PLUGIN_NAME}/dist
mkdir -p /tmp/${PLUGIN_NAME}/assets

# Копирование обязательных файлов
cp plugin.json /tmp/${PLUGIN_NAME}/
cp package.json /tmp/${PLUGIN_NAME}/
cp main.py /tmp/${PLUGIN_NAME}/
cp dist/index.js /tmp/${PLUGIN_NAME}/dist/

# Копирование опциональных директорий
[ -d assets ] && cp -r assets/* /tmp/${PLUGIN_NAME}/assets/
[ -d bin ] && cp -r bin /tmp/${PLUGIN_NAME}/
[ -d py_modules ] && cp -r py_modules /tmp/${PLUGIN_NAME}/

echo "🗜️ Создание ZIP-архива..."
cd /tmp
zip -r "${ZIP_NAME}" "${PLUGIN_NAME}" -x "*.git*" "*node_modules*"
cd -
mv /tmp/${ZIP_NAME} ./

echo "✅ Релизный архив готов: ${ZIP_NAME}"
sha256sum "${ZIP_NAME}"
```

### Быстрый деплой на Steam Deck для тестирования

Если на вашем Steam Deck включён SSH (`passwd deck`, включение службы SSH в настройках разработчика):

```bash
# Перенос собранного плагина прямо в директорию Decky Loader на Steam Deck:
rsync -avz --exclude='node_modules' --exclude='.git' ./ deck@steamdeck.local:/home/deck/homebrew/plugins/MyPlugin/

# Перезапуск службы загрузчика на Deck для применения изменений:
ssh deck@steamdeck.local "sudo systemctl restart plugin_loader"
```

---

## ⚖️ 8. Комплаенс и ограничения

Каталог **Decky Loader Россия (`deckyloader.ru`)** создан для того, чтобы пользователи Steam Deck в РФ и СНГ имели стабильный, быстрый доступ к качественным расширениям с полной независимостью от блокировок CDN.

### 🚫 Правила законодательства РФ (КРИТИЧЕСКИ ВАЖНО):
> В каталоге `deckyloader.ru` **строго запрещены** любые плагины, связанные с:
> * Обходом блокировок Роскомнадзора, DPI, ТСПУ;
> * VPN, VLESS, Shadowsocks, Clash, WARP, Zapret, ByeDPI, Tor;
> * Проксированием общего интернет-трафика.
> 
> Плагины нарушающие данное правило **не будут приняты** в каталог ни при каких обстоятельствах.

