opendisk

Архитектура OpenDisk

Слои

  1. GUI (composeApp) — Compose Multiplatform for Desktop. Отвечает только за отображение состояния и пользовательский ввод. Никакой бизнес-логики монтирования здесь нет — только вызовы модуля rclone-bridge.

  2. rclone-bridge — Kotlin-модуль, инкапсулирующий всё общение с rclone:

    • RcloneProcess — запуск и остановка rclone rcd как дочернего процесса. Запуск асинхронный, поэтому есть awaitReady(): он опрашивает порт, пока rcd не начнёт принимать соединения, и падает с внятной ошибкой, если процесс умер. Вывод rcd перехватывается в кольцевой буфер (последние 200 строк) — именно его показываем пользователю, когда что-то пошло не так. stop() сначала просит процесс завершиться и только потом добивает: rclone должен успеть размонтировать то, что смонтировал.
    • RcloneTransport — как вызов доставляется до rclone. У RC API один и тот же вид везде («метод плюс объект на входе, объект на выходе»), а доставка разная: на десктопе рядом живёт процесс rclone rcd и вызов уходит к нему по HTTP (HttpRcloneTransport), на Android отдельный процесс поднять нельзя и rclone линкуется в приложение (см. раздел про мобильные платформы). Разбор ответов от этого различия не зависит и остаётся общим.

      Тело запроса транспорт сериализует сам, а не отдаёт объектом на откуп ktor-плагину ContentNegotiation: клиент в RcloneClient можно передать любой, и без установленного плагина отправка падает с невнятным “Fail to prepare request body”. Тесты по этой же причине работают через голый HttpClient без плагинов — иначе они проверяют не то, чем пользуется приложение.

    • RcloneClient — типобезопасная обёртка над RC API поверх транспорта:
      • config/create, config/delete, config/listremotes — управление облаками;
      • mount/mount, mount/unmount, mount/listmounts — монтирование;
      • core/stats, job/status, job/stop — прогресс и состояние операций;
      • core/version — заодно служит пробой живости rcd.

      Тело запроса собирается как JsonObject: у Map<String, Any?> с разнородными значениями нет сериализатора, и такой вызов падал бы в рантайме.

    • Ошибки rclone ({"error": ..., "status": 500}) разворачиваются в RcloneRcException с текстом причины — чтобы до GUI доходило “job not found”, а не голое “500”.

    GUI никогда не вызывает rclone напрямую через shell — только через этот модуль.

  3. rclone (встроенный бинарник) — не форкается и не патчится, используется “как есть”, но ставить его отдельно пользователю не нужно: он едет внутри дистрибутива OpenDisk.

    Как это устроено:

    • бинарник не хранится в git (это ~85 МБ на платформу) — Gradle-задача :composeApp:downloadRclone скачивает официальный архив с downloads.rclone.org на этапе сборки;
    • версия и SHA-256 всех платформенных архивов зафиксированы в composeApp/build.gradle.kts: если сумма не сошлась, сборка падает, а не собирает пакет с подменённым бинарником;
    • бинарник кладётся в appResources, откуда Compose Desktop переносит его внутрь установщика и отдаёт приложению путь через compose.application.resources.dir;
    • rclone под MIT, поэтому рядом с бинарником всегда лежит его лицензия (licenses/rclone-LICENSE.txt).

    В рантайме RcloneProcess.locate() ищет бинарник по приоритету:

    1. явное указание — свойство opendisk.rclone.path или переменная OPENDISK_RCLONE;
    2. встроенный в дистрибутив — основной сценарий;
    3. системный из PATH — для тех, у кого rclone уже стоит.

    GUI показывает, какой именно бинарник используется, — чтобы источник никогда не был для пользователя сюрпризом.

Монтирование и его драйвер

Само монтирование делает rclone, но ему нужен драйвер файловой системы: WinFsp на Windows, FUSE на Linux, macFUSE на macOS. WinFsp едет внутри дистрибутива (официальный подписанный установщик, ~2 МБ, скачивается на этапе сборки с проверкой SHA-256) — пользователь ставит его одной кнопкой из приложения, а не ищет по сайтам. Установка всё равно требует прав администратора, поэтому запускается через ShellExecute с глаголом runas: без него UAC просто не появится и установка молча провалится.

Успех определяется не кодом возврата msiexec, а повторной проверкой системы — пользователь может отменить UAC, и осмысленной ошибки в этом случае нет.

WinFsp распространяется под GPLv3 (со специальным исключением для FSD), поэтому рядом с установщиком в дистрибутив кладётся его лицензия, а сам установщик берётся неизменённым с официального релиза.

Две вещи, которые легко сделать неправильно

Доступность монтирования нельзя определять по mount/types. На Windows этот эндпоинт отвечает ["cmount"] даже когда WinFsp не установлен, а падает уже сама попытка монтирования — с сообщением «cannot find winfsp». Поэтому смотрим в систему напрямую: реестр HKLM\SOFTWARE\WOW6432Node\WinFsp и каталог установки.

Статус «подключено» нельзя определять по полю Fs из mount/listmounts. Его формат зависит от бэкенда: смонтировав disk:, для local получим disk://?/E:/путь, а для alias — вообще разрешённый путь без имени облака. Поэтому приложение запоминает точки монтирования, которые само же и создало, и сверяется по MountPoint. Это корректно, потому что rcd — дочерний процесс приложения и умирает вместе с ним: все живые маунты созданы в текущей сессии.

Встроенный rclone и права на него

Бинарник rclone едет внутри дистрибутива. На Linux он приезжает в пакет с правами 644, то есть неисполняемым: права теряются внутри jpackage, и повлиять на это из сборки не удалось — ни настройкой прав при копировании ресурсов, ни chmod после него.

Приложение обходится само. RcloneProcess.bundled() сначала пробует снять бит выполнения на месте, но в /opt, принадлежащем root, обычному пользователю это не удастся. Тогда бинарник копируется в ~/.cache/opendisk, где права наши, и запускается оттуда. Копия делается один раз и переиспользуется: признаком служит совпадение размера с файлом в каталоге установки.

Это стоит ~85 МБ в домашнем каталоге на Linux и не нужно на Windows, где права файлов роли не играют. Если jpackage однажды начнёт сохранять права, обходной путь можно будет убрать — в сборке есть шаг, который показывает права на rclone в готовом пакете.

Хранение конфигурации

Используем родной rclone.conf, включая встроенное шифрование паролем. OpenDisk не хранит пароли и токены облаков в собственном формате — это исключает дублирование логики шифрования и известные грабли самопального хранения секретов. Настройки остаются общими с консольным rclone: пользователь может править их привычным способом.

RcloneConfigFile.default() повторяет правила поиска самого rclone:

  1. переменная RCLONE_CONFIG, если задана;
  2. Windows — %APPDATA% clone clone.conf;
  3. остальные ОС — $XDG_CONFIG_HOME/rclone/rclone.conf, иначе ~/.config/rclone/rclone.conf;
  4. если ничего из этого нет, но есть старый ~/.rclone.conf — берём его.

Зашифрованный конфиг

Зашифрованный файл rclone открывает маркером RCLONE_ENCRYPT_V0: — по нему и определяем, что потребуется пароль, ещё до запуска rcd.

Два момента, которые легко упустить:

Отдельная тонкость: rcd поднимает порт и отвечает на core/version даже тогда, когда конфиг ему недоступен — расшифровка откладывается до первого обращения к эндпоинтам config. Поэтому awaitReady() в этом случае успешен, и проверять доступность конфига нужно явно через RcloneClient.ensureConfigReadable(): он разворачивает отказ расшифровки в RcloneConfigLockedException, на который GUI реагирует запросом пароля, а не сообщением “что-то сломалось”.

RC API — используемые эндпоинты (черновой список, уточняется на Этапе 0)

Эндпоинт Назначение
config/listremotes получить список настроенных облаков
config/get детали конкретного облака
config/create добавить новое облако (провайдер + параметры)
config/update изменить параметры
config/delete удалить облако
mount/mount смонтировать remote в точку монтирования
mount/unmount размонтировать
mount/listmounts список активных монтирований
core/stats статистика передачи (для индикатора в трее)
job/status статус долгих операций (например, OAuth-логин)

Монтирование по ОС

Модель кэширования (ответ на “не скачивать всё”)

rclone поддерживает --vfs-cache-mode с уровнями:

В GUI это будет один переключатель на вкладке настроек каждого подключённого облака.

Мобильные платформы (Android, iOS)

Десктопная схема (rclone rcd + монтирование через FUSE/WinFsp) на телефонах не применима: Android и iOS не дают приложениям без root/jailbreak примонтировать произвольную ФС, видимую всем остальным приложениям — это осознанное ограничение безопасности обеих платформ, а не то, что можно обойти в коде.

Вместо этого используется общее ядро rclone, но другая “обвязка” сверху:

Android — DocumentsProvider

iOS — File Provider Extension

Итоговое сравнение по платформам

Платформа Видимость диска Механизм Виден ли произвольным приложениям напрямую
Windows Буква диска в проводнике WinFsp Да, как обычный диск
Linux Точка монтирования FUSE Да, как обычная папка
macOS Том в Finder macFUSE Да, как обычный том
Android Источник в «Файлах» DocumentsProvider Через системный диалог выбора файла
iOS Источник в «Файлах» File Provider Extension Через системный диалог выбора файла

Структура репозитория

opendisk/
├── composeApp/              # GUI-приложение (Compose Multiplatform, десктоп)
│   └── src/
│       ├── commonMain/      # общий код UI и бизнес-логики
│       └── desktopMain/     # платформенные точки входа (Linux/Win/macOS)
├── rclone-bridge/           # обёртка над rclone RC API для десктопа (Этап 1)
├── android/                 # DocumentsProvider + UI (появится на Этапе 6)
├── ios/                     # File Provider Extension + UI (появится на Этапе 7)
├── docs/                    # документация
├── .github/workflows/       # CI (появится на Этапе 5)
├── ROADMAP.md
├── README.md
└── LICENSE