В первой статье я рассказывал о Selective Remote — open-source клиенте удалённого доступа для macOS, который начинался с довольно конкретной задачи: хотелось нормально работать с RDP на Mac, в том числе с несколькими мониторами.

С тех пор проект заметно изменился.

То, что начиналось в основном как RDP-клиент, постепенно превратилось в рабочее пространство для повседневной работы с удалённой инфраструктурой: RDP, SSH, SFTP, SSH-туннели, Jump Host, Keychain, диагностика и независимые рабочие области.

На момент публикации актуальная версия — 0.22.0.

Вместо обычного changelog я хочу рассказать о том, что пришлось менять внутри проекта: почему простой SFTP превратился в отдельный Workspace, как появилась передача Server → Server, зачем терминалу понадобился контекст сервера и почему в некоторых местах SwiftUI пришлось дополнять AppKit.

Исходники по-прежнему открыты:

GitHub: https://github.com/PastFly/Selective-Remote

Страница проекта: https://pastfly.github.io/Selective-Remote/


С чего всё начиналось

Ранние версии Selective Remote были заметно проще.

Есть профиль подключения, внутри него — настройки RDP или SSH. Для RDP можно выбрать мониторы, настроить перенаправления устройств и подключиться. Для SSH постепенно появились терминал, SFTP и туннели.

Архитектура интерфейса в целом соответствовала модели:

выбрал сервер
      ↓
выбрал функцию
      ↓
подключился

Для нескольких серверов этого более чем достаточно.

Например, работа с дисплеями в версии 0.17.4 уже позволяла выбрать физические мониторы и вручную описать их расположение для Windows-сеанса.

Ранняя версия 0.17.4: выбор физических мониторов и виртуальная схема Windows.
Ранняя версия 0.17.4: выбор физических мониторов и виртуальная схема Windows.

Одна из ранних версий: RDP уже умел работать с несколькими дисплеями, но приложение всё ещё строилось вокруг конкретного выбранного профиля.

Но затем проект начал обрастать сценариями, которые плохо укладываются в схему «один выбранный профиль — одна функция».

Появились независимые SSH-терминалы, несколько терминалов одновременно, SFTP, отдельный Forwarding Manager, Jump Host, диагностика, управление ключами и одновременная работа с несколькими серверами.

В какой-то момент стало понятно, что главный вопрос уже не «какой сервер выбран в боковой панели», а «с каким сервером сейчас работает конкретная панель или вкладка».


SFTP: «давайте просто добавим файловый менеджер»

Наверное, SFTP оказался лучшим примером того, как небольшая функция постепенно превращается в отдельную подсистему.

Первая идея была стандартной:

Mac
 │
 │ SFTP
 ▼
Server

Слева локальные файлы, справа удалённые. Можно скачать файл, загрузить файл, создать каталог, удалить объект или поменять permissions.

В ранней версии это выглядело примерно так:

Ранняя версия 0.17.4: классический Mac ↔ Server SFTP.
Ранняя версия 0.17.4: классический Mac ↔ Server SFTP.

Первый двухпанельный SFTP: слева всегда этот Mac, справа — текущий SSH-сервер.

На практике быстро выяснилось, что одного списка файлов мало.

Если пользоваться SFTP каждый день, хочется вполне привычного поведения:

  • Cmd для множественного выбора;

  • Shift для диапазона;

  • контекстное меню;

  • двойной клик по каталогу;

  • сортировка и фильтр;

  • история пути;

  • создание папок и файлов;

  • rename;

  • POSIX permissions;

  • редактирование небольших текстовых файлов;

  • Drag & Drop;

  • очередь передач с реальным прогрессом.

И всё это должно одинаково работать для локальной и удалённой стороны.

Так SFTP Browser постепенно превратился в SFTP Workspace.


А зачем вообще одна из панелей обязана быть Mac?

Следующий вопрос оказался очень простым.

Если уже существует двухпанельный файловый менеджер:

Mac | Server

то почему первая панель обязательно должна быть Mac?

Почему нельзя сделать:

Server A | Server B

или:

Server A | Mac

или:

Mac | Server B

В 0.22.0 каждая панель SFTP Workspace получила собственный источник. Это может быть локальный Mac, сохранённый SSH/SFTP-профиль или временно указанный сервер.

Версия 0.22.0: глобальный SFTP Workspace с независимыми источниками панелей.
Версия 0.22.0: глобальный SFTP Workspace с независимыми источниками панелей.

Текущий SFTP Workspace: независимые панели, вкладки и переключение источника каждой стороны. На скриншоте используются тестовые адреса из диапазонов, зарезервированных для документации.

Получился сценарий, который для администрирования оказался заметно удобнее старой модели.

Допустим, нужно перенести конфигурацию со старого сервера на новый:

old-server:/etc/application
              ↓
new-server:/etc/application

Раньше естественный путь выглядел так:

Server A → Mac
закрыть/переключить соединение
Mac → Server B

Теперь пользователь просто открывает два сервера в соседних панелях.


Server → Server: как копировать и не передавать credentials между серверами

После появления двух удалённых панелей возник вопрос: как именно переносить данные между ними?

Самый прямой вариант — заставить Server A подключаться к Server B:

Server A ─────────► Server B

Но тогда одному серверу каким-то образом нужны реквизиты второго. Мне не хотелось строить такую схему.

В Selective Remote используется промежуточный staging на Mac:

Server A
   │
   │ stage 1
   ▼
temporary staging on Mac
   │
   │ stage 2
   ▼
Server B

То есть логически пользователь выполняет:

Server A → Server B

а физически операция состоит из:

A → Mac
Mac → B

Временная staging-копия создаётся с ограниченными правами и удаляется после завершения передачи.

Основное преимущество такого подхода для меня — один сервер не получает credentials другого.


Из-за этого пришлось переделать и Transfer Queue

Для обычной передачи Mac → Server всё просто:

progress = transferredBytes / totalBytes

Но Server → Server уже состоит из двух стадий.

Очередь должна понимать не только source и destination, но и текущий phase:

Server A → Mac
Mac → Server B
Done

В результате Transfer Queue в новом Workspace хранит реальное количество переданных байт и показывает процент, скорость, ETA и текущий этап.

Для операций доступны Pause, Resume, Cancel и Retry.

Это тот случай, когда пользовательская функция «перетащить файл на соседний сервер» потребовала заметно больше внутреннего состояния, чем ожидалось.


Drag & Drop: код компилируется, тесты зелёные, мышь не согласна

Одним из самых неприятных мест оказался Drag & Drop между двумя серверными панелями.

Мне хотелось обычного поведения:

  1. выделить файл на Server A;

  2. зажать левую кнопку мыши;

  3. перетащить его в Server B;

  4. начать передачу.

Первая реализация использовала стандартный SwiftUI-подход:

.draggable(token)

и:

.dropDestination(for: String.self) { items, location in
    ...
}

Это выглядело логично.

Код компилировался.

Автоматические проверки проходили.

Но в реальном интерфейсе удержание мыши не запускало drag-сессию так, как ожидалось.

В итоге интерфейс остался на SwiftUI, а начало внутренней drag-сессии пришлось опустить ближе к AppKit.

Упрощённо строка файла получила bridge:

.background {
    SFTPWorkspaceAppKitDragMonitor(
        token: dragToken,
        isDirectory: entry.isDirectory
    ) {
        if !remote.selectedEntryIDs.contains(entry.id) {
            remote.selectedEntryIDs = [entry.id]
            selectionAnchor = entry.id
        }
    }
    .allowsHitTesting(false)
}

Дальше цепочка стала выглядеть примерно так:

SwiftUI row
    ↓
AppKit mouse event monitor
    ↓
NSDraggingSession
    ↓
Workspace drop handler
    ↓
Transfer Queue

Это хорошо показывает одну особенность macOS-разработки: иногда высокоуровневого SwiftUI API полностью достаточно, а иногда для ожидаемого desktop-поведения приходится спускаться на уровень AppKit.


Swift 6 добавил ещё один нюанс

После перехода к AppKit-монитору появилась проблема с actor isolation.

NSEvent нельзя было просто бездумно протащить внутрь MainActor.assumeIsolated.

В рабочем варианте между callback и MainActor передаётся адрес объекта, а уже на MainActor восстанавливается unretained-ссылка:

let eventAddress = UInt(
    bitPattern: Unmanaged
        .passUnretained(event)
        .toOpaque()
)

let shouldConsume = MainActor.assumeIsolated {
    guard let pointer =
        UnsafeRawPointer(bitPattern: eventAddress)
    else {
        return false
    }

    let mainEvent = Unmanaged<NSEvent>
        .fromOpaque(pointer)
        .takeUnretainedValue()

    return handleMouseEvent(mainEvent)
}

Не самый очевидный объём кода для функции «перетащить файлик мышкой», но после этого внутренний Server → Server Drag & Drop стал вести себя нормально.


Терминал превратился в Workspace

С терминалом произошла похожая история.

Когда терминал один, контекст очевиден:

Profile A
   └── Terminal

Но затем появились вкладки, split-панели и grid layout. В одном окне одновременно могут жить независимые SSH-сессии разных серверов.

Версия 0.22.0: Terminal Workspace, несколько панелей и темы терминала.
Версия 0.22.0: Terminal Workspace, несколько панелей и темы терминала.

Текущий Terminal Workspace: несколько SSH-панелей, независимые соединения, темы терминала и подсказки команд. Все адреса на изображении заменены на тестовые.

Теперь понятие «активный сервер» нельзя брать просто из выделенного пункта бокового меню.

Контекст должен идти от активной terminal tab или pane.

Это оказалось особенно важно для интеграции с SFTP.


Smart Links: из терминала сразу в SFTP нужного сервера

В терминале появились Smart Links.

Например, если текущая SSH-сессия работает с Server B и в терминале встречается путь:

/etc/nginx/nginx.conf

приложение может открыть соответствующий путь в SFTP именно для Server B.

Логика выглядит примерно так:

active Terminal tab
       │
       │ server context
       ▼
    Smart Link
       │
       │ remote path
       ▼
  SFTP Workspace
       │
       ▼
same server + requested path

Для этого терминалу больше не нужно знать, какое именно SFTP View отображается на экране.

Он просто отправляет запрос в общий Workspace:

globalSFTPWorkspace.requestOpen(
    connection: tab.connection,
    path: path
)

А Workspace уже решает, какую панель или вкладку активировать.


Одна реализация SFTP вместо двух

До появления общего Workspace в приложении фактически существовали два варианта SFTP:

Global SFTP Workspace

и:

SSH profile → SFTP Browser

Это неудобная архитектура.

Любое исправление грозит превратиться в:

починили здесь
     ↓
забыли повторить там

Поэтому SFTP внутри конкретного SSH-профиля тоже был переведён на общий Workspace.

Если пользователь открывает SFTP из Host A, текущий сервер подставляется автоматически, но соседнюю панель всё равно можно переключить на Mac или на Host B.

То есть профильный SFTP больше не является отдельной урезанной реализацией.


Forwarding тоже перестал быть настройкой внутри одного профиля

Похожая эволюция произошла с SSH-туннелями.

В старых версиях туннели логично жили внутри SSH-профиля.

Но когда туннелей стало больше и понадобились независимые runtime-сессии, удобнее оказался отдельный Forwarding Manager.

Версия 0.22.0: отдельный Forwarding Manager.
Версия 0.22.0: отдельный Forwarding Manager.

Local, Remote и Dynamic/SOCKS5 forwarding теперь управляются в отдельном Workspace. Справа можно посмотреть маршрут, параметры SSH и состояние конкретного туннеля.

Менеджер показывает активные и остановленные туннели, их типы, локальные адреса, назначения, используемый SSH-host и время работы.

При этом туннель может использовать как сохранённый профиль, так и независимое подключение.


SSH Agent Forwarding: маленькая настройка, которую лучше не включать автоматически

В 0.22.0 появился SSH Agent Forwarding.

На уровне команды это знакомый:

ssh -A

Но я специально оставил его выключенным по умолчанию.

Он применяется только к интерактивному Terminal-подключению и только если пользователь явно разрешил forwarding для профиля.

Для подобных функций мне ближе explicit opt-in, чем автоматическое включение ради удобства.


Диагностика как отдельная часть продукта

Чем больше становилось подсистем, тем сложнее было отвечать на вопрос пользователя:

почему именно у меня это не подключается?

Проблема может быть не только в самом RDP или SSH.

Она может оказаться в helper-приложении, разрешениях macOS, библиотеке камеры, раскладке мониторов, доступности OpenSSH или локальной конфигурации.

Поэтому диагностика постепенно выросла в отдельный Центр.

Версия 0.22.0: Центр диагностики.
Версия 0.22.0: Центр диагностики.

Диагностика проверяет приложение, окружение и используемые подсистемы до запуска реального RDP/SSH-сеанса.

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


RDP никуда не исчез

Несмотря на количество работы вокруг SSH и SFTP, RDP остаётся исходной причиной появления Selective Remote.

Сейчас приложение поддерживает, в частности:

  • оконный режим;

  • настоящий macOS fullscreen;

  • Retina;

  • несколько мониторов;

  • ручную раскладку дисплеев;

  • RDP Gateway;

  • clipboard;

  • звук;

  • микрофон;

  • камеру;

  • принтеры;

  • локальные каталоги;

  • Smart Reconnect.

Интересно, что часть интерфейса с виртуальной схемой мониторов появилась довольно рано, но внутренний runtime и обработка полноэкранных/многомониторных сценариев продолжали дорабатываться ещё много версий.


Самое важное изменение почти не видно на скриншотах

Это тесты.

Чем теснее связывались Terminal, SFTP, Forwarding и профильные настройки, тем легче было исправить одну функцию и незаметно сломать другую.

Например:

исправляем SFTP
      ↓
ломаем переход из Terminal

или:

меняем профильный SSH
      ↓
ломаем Forwarding

или особенно неприятный вариант:

кнопка всё ещё есть
      ↓
UI выглядит правильно
      ↓
но действие вызывает старый session

После последних изменений полный прогон содержит 232 теста в шести suites.

Конечно, автоматические тесты не заменяют ручную проверку UI.

История с Drag & Drop это хорошо показала: код компилировался, тесты были зелёными, но реальное удержание левой кнопки мыши всё равно пришлось проверять вручную.

Сейчас выпуск версии выглядит примерно так:

implementation
      ↓
git diff --check
      ↓
node --check terminal-host.js
      ↓
swift test
      ↓
manual smoke test
      ↓
build / sign / notarize
      ↓
GitHub Actions
      ↓
release

Даже production иногда напоминает, что он распределённый

Во время выпуска 0.22.0 код успешно прошёл сборку, подпись и notarization.

После этого GitHub API ответил при публикации Release Assets:

503 Service Unavailable

Повторный запуск workflow завершился успешно.

Позже почти то же самое произошло при настройке GitHub Pages:

build: success
deploy: 503 Service Unavailable

И после повторного запуска сайт всё-таки опубликовался.

Хорошее напоминание о том, что иногда проблема действительно находится не в последнем коммите.


Что получилось в итоге

Если сравнить проект с первой публикацией, главное изменение даже не в количестве функций.

Изменилась сама модель приложения.

Раньше она была ближе к:

Profiles
   │
   ├── RDP
   └── SSH

Сейчас скорее так:

                    Selective Remote
                           │
        ┌──────────────────┼───────────────────┐
        │                  │                   │
       RDP             Terminal             SFTP
        │              Workspace           Workspace
        │                  │                   │
        └──────────── Server Context ──────────┘
                           │
                 ┌─────────┴──────────┐
                 │                    │
             Forwarding            Keychain

Приложение постепенно перестало быть набором экранов конкретного профиля и стало системой связанных рабочих областей.

Для меня это, пожалуй, самое существенное изменение после первой статьи.


Что дальше

Список задач, конечно, никуда не исчез.

Хочется дальше улучшать SFTP, развивать Terminal Workspace, продолжать работу с RDP и несколькими дисплеями, улучшать диагностику и постепенно убирать места, где SwiftUI приходится слишком настойчиво убеждать вести себя как полноценное desktop-приложение.

При этом хочется сохранить Selective Remote бесплатным и открытым.


Вместо заключения

Когда я начинал Selective Remote, задача звучала примерно так:

хочу нормально открыть RDP на Mac.

Теперь в одном приложении можно держать несколько RDP-сессий, параллельно работать с SSH-терминалами разных серверов, переносить файлы между двумя SFTP-хостами и управлять SSH-туннелями.

Не уверен, что это лучший пример контроля scope pet-project.

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

Если хочется попробовать приложение, посмотреть код или поковырять реализацию:

GitHub:
https://github.com/PastFly/Selective-Remote

Страница проекта:
https://pastfly.github.io/Selective-Remote/

Последний Release:
https://github.com/PastFly/Selective-Remote/releases/latest

Проект распространяется по MIT License.

Буду рад баг-репортам, идеям и особенно реальным сценариям использования — они обычно оказываются полезнее абстрактного списка «что ещё добавить».