Что такое аддон
Аддон — это manifest.json и один JS-файл. Приложение MyHub исполняет код в изолированной песочнице: без файловой системы, процессов и доступа к базе приложения. В сеть аддон ходит только по HTTPS и только на домены из своего манифеста, а к библиотеке пользователя — только в пределах разрешений, которые пользователь подтвердил при установке.
Здесь описан тип extension — расширение, которое добавляет места в интерфейс приложения и может работать с библиотекой. Пример такого аддона — официальная синхронизация списка аниме с Шикимори.
Аддоны распространяются через официальный каталог MyHub: он открыт у каждого пользователя во вкладке «Аддоны → Маркетплейс», а обновления оттуда приходят сами. Как попасть в каталог — в разделе «Публикация».
Быстрый старт
Самый простой аддон — счётчик на странице тайтла. Создайте папку с двумя файлами:
my-counter/
manifest.json
my-counter.js{
"id": "my-counter",
"name": "My counter",
"version": "1.0.0",
"sdk": 2,
"type": "extension",
"author": "You",
"lang": "en",
"description": { "ru": "Счётчик на странице тайтла", "en": "A counter on a title page" },
"network": { "allowed_domains": [] },
"contributes": [
{ "point": "item.section", "id": "counter", "icon": "exposure_plus_1",
"label": { "ru": "Счётчик", "en": "Counter" } }
],
"payload_file": "my-counter.js"
}class Provider {
render({ ctx }) {
const key = 'count:' + ctx.item.item_id;
const count = storage.get(key) || 0;
return {
ui: { type: 'row', align: 'between', children: [
{ type: 'text', text: String(count) },
{ type: 'button', label: '+1', icon: 'add', action: { id: 'inc' } },
] },
};
}
onAction({ ctx, action }) {
if (action.id !== 'inc') return {};
const key = 'count:' + ctx.item.item_id;
storage.set(key, (storage.get(key) || 0) + 1);
return { effects: [{ type: 'toast', text: 'OK', tone: 'success' }] };
}
}Установите его в приложении: «Аддоны» → «Установить аддон» → «Для разработчиков» → «Выбрать manifest.json» и укажите файл. Код подставится из payload_file. После правок установите файл заново: новая версия заменит старую, настройки и данные storage сохранятся.
Ошибки кода видны в журнале приложения со строками [addon:id]; туда же попадает console.log.
Манифест
| Что | Описание |
|---|---|
id | Уникальный идентификатор: строчная латиница, цифры и дефис, от 2 до 41 символа. |
name | Название для пользователя. |
version | Версия в формате semver: 1.0.0, 1.1.0-beta. Обновление предлагается, только когда версия в каталоге больше установленной. |
sdk | Всегда 2. |
type | extension. Можно не указывать: тип выводится из contributes. |
author, lang | Автор и основной язык (ru, en) — для карточки в каталоге. |
description | Описание по языкам: ru, en. |
icon | Публичный HTTPS-адрес или встроенная картинка PNG или WebP до 32 КиБ. SVG не принимается. При разработке можно положить файл рядом и указать icon_file. |
network.allowed_domains | Домены для запросов: точный хост или *.example.com (любой поддомен). Расширению без сети хватит пустого списка. |
contributes | Места в интерфейсе — раздел «Места в интерфейсе». |
permissions, library_kinds, schedule | Доступ к библиотеке, фоновая работа и аккаунт с сайта — разделы ниже. |
user_config | Поля настроек аддона — раздел «Настройки и секреты». |
payload_file, payload | При разработке — payload_file, имя JS-файла рядом с манифестом. В каталоге код встраивается в манифест полем payload. Размер кода — до 1 МиБ. |
Манифест с ошибкой не установится: приложение скажет «Неверный манифест», причина — в журнале приложения.
Места в интерфейсе
Каждое место — элемент contributes: point, id, подпись label по языкам, icon из Material Symbols и необязательные kinds и order. До 16 мест на аддон, id уникален внутри аддона.
| Что | Описание |
|---|---|
item.section | Секция на странице тайтла. Нужна label. |
item.actions | Кнопка под обложкой тайтла. |
item.info | Блок фактов на странице тайтла. |
library.strip | Полоса в библиотеке. Нужна label. |
settings.tab | Вкладка в настройках приложения. Нужны label и icon. |
kinds ограничивает места на странице тайтла видами: game, anime, movie, tv. Места — такое же разрешение, как домены: пользователь видит их в окне установки, и обновление, которое добавляет новое место, снова спрашивает согласия.
render и onAction
class Provider {
// point, contribution — which place; state — what you returned last time (null at first)
render({ point, contribution, ctx, state }) { /* → { ui, state? } */ }
onAction({ point, contribution, ctx, state, action, form }) { /* → { ui?, state?, effects? } */ }
}- Каждый вызов идёт в свежей песочнице: между вызовами в памяти ничего не живёт. Что нужно помнить, возвращайте в
state(до 16 КиБ, приложение передаст его обратно) или кладите вstorage. ctx.lang— язык интерфейса,ruилиen; тексты возвращайте уже на нём. На странице тайтла естьctx.itemс полямиitem_id,kind,title, в библиотеке —ctx.library.kind, во вкладке настроек —ctx.settings.online.renderвозвращаетui— дерево узлов — илиnull, если место сейчас не нужно показывать.onActionполучаетaction—idи необязательноеvalueкнопки или переключателя — иformсо значениями полей. Если в ответе нетui, приложение само вызоветrenderс новымstate.- Ошибку сообщайте исключением с кодом из раздела «Ограничения и ошибки»: такие коды пользователь увидит понятным текстом.
Узлы интерфейса
Неизвестный узел или узел без обязательного поля пропускается и не ломает место. Лимиты: глубина до 12, до 500 узлов, ответ до 256 КиБ, текст до 2000 символов.
| Что | Поля | Описание |
|---|---|---|
stack | children, gap | Колонка дочерних узлов. |
row | children, gap, wrap, align | Строка дочерних узлов. |
grid | children, columns | Сетка на 2–4 колонки. |
divider | — | Разделитель. |
heading | text, level | Заголовок. |
text | text, tone, size | Текст; tone — цвет, size — размер. |
image | url, alt, ratio, width | Картинка по публичному HTTPS-адресу. |
badge | label, tone, icon | Метка. |
chips | items | Набор чипов, до 32. |
progress | value, label | Полоса прогресса от 0 до 1. |
empty | title, text, icon | Пустое состояние. |
item-card | item_id, caption | Карточка тайтла из библиотеки пользователя. |
button | label, action, icon, variant, disabled | Кнопка; нажатие вызывает onAction с её action. |
input | name, label, value, placeholder, multiline, max | Поле ввода, значение приходит в form. |
toggle | name, label, value, action | Переключатель. |
select | name, options, label, value, action | Выпадающий список. |
segmented | name, options, value, action | Переключатель вариантов, от 2 до 8. |
Эффекты
onAction может вернуть до 8 эффектов в effects.
| Что | Поля | Описание |
|---|---|---|
toast | text, tone | Всплывающее сообщение. |
open_modal | title, ui, size | Модальное окно с деревом узлов; его действия приходят в тот же onAction. |
close_modal | — | Закрыть модальное окно. |
confirm | title, text, action, confirm_label, danger | Спросить подтверждение; согласие отправляет action. |
navigate | path | Переход внутри приложения: только /library, /addons и /app-settings. |
refresh | contribution | Перерисовать место этого же аддона. |
open_url | url | Открыть ссылку в браузере — только HTTPS на домен из allowed_domains. |
Хранилище
storage есть у любого аддона. Методы синхронные, значения — любой JSON.
| Что | Описание |
|---|---|
storage.get(key) | Значение или null. |
storage.set(key, value, { ttl }) | Записать; ttl — срок жизни в секундах. |
storage.remove(key) | Удалить ключ. |
storage.keys(prefix) | Ключи с префиксом, по алфавиту. |
- Данные свои у каждой пары «аккаунт MyHub + аддон». Обновление и выключение аддона их не трогают, удаление — стирает.
- Значение до 64 КиБ, до 1000 ключей и до 1 МиБ на аддон; сверх — ошибка
storage_full. Запись сохраняется сразу, даже если метод потом упал.
Библиотека
Разрешения перечисляются в манифесте. Пользователь видит их в окне установки, а обновление, которое просит больше, снова спрашивает согласия.
"permissions": ["library:read", "library:write", "background"],
"library_kinds": ["anime"],
"schedule": { "every_minutes": 30 }library:read— читать библиотеку.library:write— менять статусы, прогресс, оценки и избранное (требуетlibrary:read).library:delete— просить убрать тайтлы; пользователь каждый раз подтверждает сам.library_kinds— виды тайтлов, которые видит аддон:game,anime,movie,tv. Тайтлов других видов для аддона нет вовсе.
Чтение
| Что | Описание |
|---|---|
library.list({ kind, status, offset, limit }) | Страница библиотеки: items и total, до 500 строк за раз. |
library.get(itemId) | Строка тайтла или null. |
library.externalIds(itemIds) | Внешние идентификаторы тайтлов: MyAnimeList, Шикимори, AniList, IMDb, TMDB, Steam и другие. |
library.findByExternal(source, ids) | Найти тайтлы каталога MyHub по внешним идентификаторам, до 100 за раз. |
Строка: item_id, kind, title, status, episodes_watched, episodes_total, episodes_aired, rating, favorite, private, airing.
Запись
| Что | Описание |
|---|---|
library.setStatus(itemId, status) | Статус из набора вида тайтла. |
library.setProgress(itemId, episodes) | Сколько серий просмотрено — у аниме и сериалов. |
library.setRating(itemId, rating) | Оценка от 1 до 10. |
library.setFavorite(itemId, on) | Избранное: явное true или false. |
library.remove(itemIds) | Попросить убрать тайтлы: пользователь увидит список и решит сам. |
Запись возвращает true, если изменение поставлено в очередь, и false, если значение уже такое. Изменения применяются сразу и уходят на сервер через ту же очередь, что и действия самого пользователя, — в том числе без сети.
Фоновая работа
С разрешением background приложение вызывает аддон без открытого интерфейса: по расписанию schedule.every_minutes (от 15 до 1440, по умолчанию 60) и пачками событий об изменениях библиотеки.
class Provider {
async onSchedule({ reason }) { /* reason: 'startup' | 'interval' */ }
async onLibraryEvents({ events, overflow }) {
// events: [{ item_id, kind, type: 'status' | 'progress' | 'rating' | 'favorite' | 'removed',
// value, origin: 'user' | 'addon', at }]
}
}- События — изменения на этом устройстве: от пользователя, плеера и других аддонов. Свои изменения аддон не получает.
- События не сохраняются между запусками приложения, поэтому синхронизация обязана уметь полную сверку в
onSchedule— события её только ускоряют. - Одновременно идёт не больше одного фонового вызова аддона. Три ошибки подряд ставят фоновую работу на паузу на час.
Аккаунт с сайта
Если пользователь привязал сервис на myhub.media (настройки → «Платформы»), аддону не нужен свой вход: приложение выдаст ему короткоживущий токен этой привязки. Пользователю ничего не нужно вводить. Сейчас так можно с Шикимори — разрешение account:shikimori.
"network": { "allowed_domains": ["shikimori.io"] },
"permissions": ["account:shikimori"]
const account = await accounts.get('shikimori');
// { provider, token, expiresAt, userId, username } or null
const me = await fetch('https://shikimori.io/api/users/whoami', {
headers: { Authorization: 'Bearer ' + account.token },
});
if (me.status === 401) await accounts.get('shikimori', { fresh: true });- Токен выдаёт сервер MyHub; долгоживущие ключи остаются на сервере. Приложение держит токен в памяти до истечения;
fresh: trueпросит новый — например, после ответа 401 от сервиса. - С разрешением на аккаунт в
allowed_domainsмогут быть только домены этого сервиса: токен уходит только ему. null— сервис не привязан. Покажите подсказку и кнопку с эффектомnavigateна/app-settings?section=links.- Не пишите токен в журнал и не кладите его в
storage.
Настройки и секреты
Поля настроек описываются в user_config.fields; приложение рисует для них форму в карточке аддона, а код читает значения из config.
"user_config": { "fields": [
{ "name": "api_key", "type": "secret", "label": { "ru": "Ключ API", "en": "API key" } },
{ "name": "compact", "type": "toggle", "label": { "ru": "Компактно", "en": "Compact" } }
] }| Что | Описание |
|---|---|
text | Строка. |
select | Выбор из options. |
toggle | Флажок. |
secret | Секрет — ключ API или пароль приложения. Хранится в системном хранилище учётных данных, а не в базе приложения; значение по умолчанию запрещено. |
- Токены, которые аддон получил сам, храните через
secrets.get,secrets.setиsecrets.remove: это то же системное хранилище, до 1000 символов на значение. - Не показывайте секреты в интерфейсе и не пишите их в журнал — только «задан» или «не задан».
Сеть
fetch работает как в браузере, но только по HTTPS и только на домены из allowed_domains. Методы — GET, POST, PUT, PATCH, DELETE; заголовок Authorization и запросы на запись уходят только на домены манифеста.
- Ответ до 4 МиБ, таймаут 15 секунд, до 3 одновременных запросов на один хост и до 64 запросов за вызов. Текст ответа уже декодирован из кодировки страницы.
parseHTMLразбирает HTML и даётselectпо CSS-селектору,text,attrиhtml. Ещё доступныsleep,atob,btoa,TextEncoder,TextDecoder.- Нет файловой системы, локальной сети, cookie и аккаунта MyHub в запросах. Песочница — ES2020 без
URL,setTimeoutи модулей: один файл, адреса собираются строками.
Ограничения и ошибки
Один вызов — до 64 МиБ памяти, до 20 секунд работы кода (ожидание сети не в счёт) и до 90 секунд целиком. Зависший код останавливается, приложение продолжает работать.
Ошибку сообщайте исключением с кодом. Пользователь увидит понятный текст для таких кодов:
source_timeout
source_unavailable
source_forbidden
source_rate_limited
source_busy
source_too_large
source_moved
source_changed
invalid_request
domain_not_allowed
storage_full
permission_denied
library_conflict
not_in_library
account_changed
account_expired
account_unavailableЛюбое другое исключение покажется как общая ошибка аддона, а его текст попадёт в журнал приложения.
Публикация
Официальный каталог ведёт команда MyHub. Чтобы предложить аддон, напишите нам через страницу поддержки и пришлите папку с манифестом и кодом. Мы проверяем, что аддон делает то, что заявляет, и просит только нужный ему доступ.
В каталог не принимаются аддоны, которые дают доступ к фильмам, сериалам, аниме, играм или другому контенту без разрешения правообладателей, обходят защиту сайтов или собирают данные пользователей сверх нужного для работы.
Новая версия: поднимите version и пришлите изменения. После публикации пользователи получат обновление сами; если оно просит новый доступ, приложение снова спросит их согласия.