Заводим Directum RX на macOS или как я заставил корпоративный комбайн жить на M5

16 2

[От редакции: статья отражает авторский опыт, и не может быть основанием для вопросов или претензий к компании Directum.]

Зачем вообще это нужно

По сути, особо незачем. DirectumRX прекрасно живёт на привычных Windows и Linux. Но, во-первых, как человеку убежденному, что каждый обязан разбираться в том, на что тратит большую часть жизни,  вопрос "а можно ли?" уже давно не давал покоя. А если нельзя, то почему, и что нужно сделать, чтобы стало «льзя».

Во-вторых, маководы среди разработчиков Директума подавляющее меньшинство, но такие, как я, всё же встречаются. А macOS вместе с железкой, на которой она работает, развивается семимильными шагами: мобильный чип уже так вымахал, что соревноваться ему остаётся разве с вечностью. Я даже "продал" MacBook товарищу, показав, что его проект, который на мощном десктопе собирался несколько минут, на ноутбуке от батареи собрался за 20 секунд. Но мы отвлеклись.

Так вот: этих мощностей вполне хватит и тяжёлому DirectumRX вместе со всеми сервисами и студией разработки. Заодно проснулся спортивный интерес наглядно замерить эту разницу.

Сразу отвечу на главный вопрос: можно ли?
Спойлер: можно. Правда, вендор общается только с Windows и Linux, но нас это не остановит.

Исходные такие:

  • DirectumRX 26.2
  • MacBook Pro M5 (16ГБ, macOS 27)
  • ПК Ryzen 9 9950X3D, RTX 5090, 64ГБ DDR5-6000, NVMe SSD PCIe Gen 5 2ТБ, Windows 11
  • Ловкие руки и зоркие глаза
Начнем с точки входа

Обычно ею является утилита DirectumLauncher и набор консольных команд в файлах do.sh. Для визуального режима установки это работает следующим образом:

  • Маленький статический Go бинарь DirectumLauncher, который просто зовёт do.sh
  • do.sh поднимает встроенный Python 3.14 и запускает src/main.py
  • Дальше - плагины на fire, в том числе веб-установщик на Flask

А дальше всё зависит от ОС: лаунчер смотрит на систему и выбирает идентификатор рантайма (rid) - win-x64 или linux-x64. На Windows сервисы живут в IIS, на Linux - в Docker. Есть ещё три хостовые .NET-утилиты (encryptor, certificate-tool, DBConverter) и пачка ELF-инструментов в tools/. Вот там-то и спрятаны основные проблемы для macOS.

А может просто перекладывать ELF в Mach-O?

ELF - бинарь Linux
Mach-O - бинарь Darwin, ядра, которое использует macOS

Это первый вопрос, который пришел в голову. Ответ: нет, и не надо. ELF и Mach-O - это не два контейнера с одинаковым содержимым: внутри разные системные вызовы, разные соглашения, а ещё разные архитектуры (AMD64 и ARM). Переупаковать без исходников - это уже не порт, а реверс-инжиниринг.

Поэтому я выбрал иную стратегию "не меняй бинарник, меняй где он запускается":

  1. Сервисы Directum и база живут в Linux-контейнерах (рид остаётся linux-x64).
  2. Linux-утилиты запускаются в одноразовых контейнерах под Rosetta 2.
  3. Всё, что можно взять нативно (7z, openssl) - берём из Homebrew (аналог APT на macOS).

Если сократить до одной фразы: мы не учим Mac запускать Linux-бинарники, а просто даём им их Linux.

Поехали

Сначала - маленькие правки, чтобы лаунчер вообще понял, что он на маке. Добавил is_macos(), ветку в do.sh (ищет Python 3.14 в трёх местах) и небольшую зависимость rpds-py для веб-установщика.

Вот так лаунчер узнаёт, что он на маке, и при этом продолжает думать, что пишет под Linux (runtime_methods.py):

def is_macos() -> bool:
    return platforms.OperatingSystem.Current() is platforms.OperatingSystem.MACOSX

 def get_rid_by_os() -> str:
    # Для macOS возвращается linux-x64, сервисы и образы работают в Linux-контейнерах
    if platforms.OperatingSystem.IsWindows():
        return RuntimeIdentifiersBase.WINDOWS

    return RuntimeIdentifiersBase.LINUX

А в do.sh появилась отдельная ветка для Darwin: она ищет Python 3.14 и не трогает Linux-часть:

 #!/bin/bash
 dp0="$(dirname "$(readlink -f "$0")")"

if [[ "$(uname -s)" == "Darwin" ]]; then
  is_python_ok() {
    [[ -x "$1" ]] && "$1" -c 'import sys; sys.exit(0 if sys.version_info >= (3, 14) else 1)' 2>/dev/null
  }
  ...
  "$python_bin_path" -s "$dp0/src/main.py" "$@"
  exit $?
fi

Но самый вкусный кусок - обёртка для утилит. Вызывающий код по-прежнему собирает команду «запусти encryptor», но вместо самого encryptor подставляется маленький bash-скрипт-переводчик, который делает docker run --platform linux/amd64. В скрипте всё, что нужно: монтирование каталогов, рабочая папка и --add-host, чтобы утилита могла достучаться до базы на хосте.

Подмена делается в одном месте, в ToolBase, чтобы не править каждую утилиту отдельно (tool_base.py):

    @property
    def run_in_linux_container(self) -> bool:
        return False

    def _adapt_exe_for_host(self, exe_path: str) -> str:
        if self.run_in_linux_container and runtime_methods.is_macos():
            return linux_container_runner.create_wrapper(exe_path)
        return exe_path

Так как некоторые утилиты являются ELF-бинарниками, обойдем это переносом их запуска в контейнере. У трёх утилит (encryptor, certificate-tool, DBConverter) флаг включается одной строкой:

 class EncryptorTool(ToolBase):
    @property
    def run_in_linux_container(self) -> bool:
        return True

И вот то, что получается - сам скрипт-переводчик encryptor.container.sh (пути указаны для примера):

#!/bin/bash
exec docker run --rm -i --platform linux/amd64 \
  --add-host host.docker.internal:host-gateway \
  --add-host <имя-машины>:<ip> \
  -e HOME=/tmp -e DOTNET_BUNDLE_EXTRACT_BASE_DIR=/tmp/.net \
  -w "$PWD" -v /Users/me/DirectumLauncher:/Users/me/DirectumLauncher \
  --entrypoint /Users/me/DirectumLauncher/etc/.../encryptor \
  mcr.microsoft.com/dotnet/runtime-deps:8.0 "$@"

И вот тут алилуя, ./do.sh enc encrypt выдал:

Бинарь запускается, можно двигаться дальше.

Так, по мелочи, но очень важно

Если бы macOS был Linux, на этом бы всё закончилось. Но он не Linux, и лаунчер уже на первом шаге спотыкается о бытовуху:

  • inotify и sysctl. Лаунчер хочет крутить пределы inotify, а у мака его нет. Пометил эти действия not_for_macos, а проверку "запущено от root" делаю no-op.
  • SIP. В /usr/bin не положишь симлинк на лаунчер, поэтому точка входа уезжает в /opt/homebrew/bin. Автодополнение заточено под bash, а у мака zsh - отключил.
  • Сертификаты. В контейнеры нужно прокинуть корневые CA, а файла /etc/ssl/certs/ca-certificates.crt тут нет. Вытащил из Keychain через security find-certificate.
  • Часовой пояс. /etc/localtime на маке - симлинк куда-то в /var/db, и Docker его так просто не смонтирует. Просто копирую файл в etc/host_files.
  • Сокет Docker. У Docker Desktop, OrbStack и Colima он в разных местах. Научил лаунчер смотреть в docker context и в трёх-четырёх стандартных папках.
  • Проверка ОС. Перед деплоем лаунчер требовал Linux и "64-битную x86_64". На маке эти проверки пропускаются, а версия Docker проверяется по-прежнему.

    Вот как это выглядит в коде.

inotify и root (sysctl.py):

 @action
 @runtime_methods.not_for_windows
 @runtime_methods.not_for_macos
 @Help.show_only_linux
 def set_inotify_instances_limit(new_limit: int = 65536) -> None:
...
 def _check_run_as_root() -> None:
    if runtime_methods.is_macos():
        return
     if os.geteuid() != 0:

Проверка требования Linux (deploy_condition_utils.py):

    if current_os is not platforms.OperatingSystem.MACOSX:
        if current_os is not platforms.OperatingSystem.LINUX:
            errors.append("Error: Required linux operating system'")
        if current_architecture is not platforms.Architecture.x86_64:
            errors.append("Error: Required 64 bit OS'")

SIP и точка входа (update_entrypoint.py):

def _get_entrypoint_dirs() -> list[str]:
    if runtime_methods.is_macos():
        existing = [d for d in ('/opt/homebrew/bin', '/usr/local/bin') if os.path.isdir(d)]
        return existing or ['/usr/local/bin']
    return [os.path.join('/', 'usr', 'bin')]

Сертификаты из Keychain (host_mappings.py):

    result = subprocess.run(['security', 'find-certificate', '-a', '-p', *MACOS_KEYCHAINS],
                            capture_output=True, text=True, timeout=120, check=False)
...
    if runtime_methods.is_macos():
        return {_get_macos_ca_bundle(): {'bind': r'/etc/ssl/certs/ca-certificates.crt', 'mode': 'ro'}}

Сокет Docker (docker_tools.py):

_MACOS_DOCKER_SOCKET_CANDIDATES = (
    '~/.docker/run/docker.sock',         # Docker Desktop
    '~/.orbstack/run/docker.sock',       # OrbStack
    '~/.colima/default/docker.sock',     # Colima
    '~/.rd/docker.sock',                 # Rancher Desktop
)
...
 class docker_client(ContextDecorator):
     def __enter__(self) -> DockerClient:
        ensure_docker_host()
         self.client = docker.from_env(timeout=300)

 

Заводим фронтенд

Веб-установщик. В дистрибутиве есть ./DirectumLauncher --host=0.0.0.0, но это ELF, и на маке он не заведется. На самом деле функционал его прост: он находит do.sh и вызывает действие installer run. Значит, на маке пишем то же самое, но просто руками: ./do.sh installer run --port=5001 (порт выбираем отличный от 5000, потому что на macOS он занят AirPlay Receiver, ловим форбидден). Внутри - Flask с socketio и статика. 

Development Studio. Уже поинтереснее. Внутри пакета Electron 41 + .NET 8. JS и управляемые dll переносимы как есть, они кроссплатформенные, непереносимы только сам Electron, ELF-хост и три нативных библиотеки (SkiaSharp, HarfBuzzSharp, Magick.NET). Поэтому был сконструирован скрипт, который собирает DevelopmentStudio.app следующим образом:

  1. Берётся Electron для macOS нужной версии.
  2. В него помещается app.asar и каталог bin из Linux-пакета.
  3. Заменяются Linux-библиотеки на .dylib из NuGet.
  4. ELF-хост заменяется маленьким скриптом, который запускает бэкенд через dotnet.
  5. Cнимается карантин и лепится подпись ad-hoc, иначе macOS AArch64 его просто не запустит.

Плагин получил патч: на маке он сам вызывает сборку, помнит из какого пакета собрано и делает ярлык в ~/Applications. Репозитории git настраиваются в блоке CONFIGURATIONS секции DevelopmentStudioDesktop (@GIT_ROOT_DIRECTORY и REPOSITORIES).

В плагине всё сводится к одной ветке if is_macos() (development_studio_desktop.py):

        if is_macos():
            return self._get_exe_path_on_macos(force_extract)
...
        if not os.path.isfile(executable_path) or not self._is_macos_app_actual(package_path):
            exit_code = process.try_execute(f'bash "{_MACOS_BUILD_SCRIPT}" "{package_path}" "{self._bin_path}"')
            if exit_code != 0 or not os.path.isfile(executable_path):
                raise RuntimeError(f"Cannot build {_MACOS_APP_NAME} for macOS (exit code: {exit_code}).")
            self._write_macos_build_info(package_path)

А замена ELF-хоста - это маленький скрипт, который скрипт сборки кладёт на место Sungero.DevelopmentStudio.Desktop.Host:

#!/bin/bash
DIR="$(cd "$(dirname "$0")" && pwd)"

export DOTNET_ROLL_FORWARD="${DOTNET_ROLL_FORWARD:-Major}"
exec "$DOTNET" "$DIR/Sungero.DevelopmentStudio.Desktop.Host.dll" "$@"

Запускаем получившееся приложение. Выглядит неплохо.

 

Запуск и проверка

Чтобы не плодить TL;DR процесс установки не будет продемонстрирован, он ничем не отличается от такового на Linux. Продемонстрирую что контейнеры поднялись и веб-клиент доступен.

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

Попробуем создать и опубликовать простую модификацию в перекрытии карточки сотрудника. Просто отправим сообщение "Hello, world!". 

Сработало!

 

Замеры времени

Небольшая оговорочка: сравнивать мак с Windows-машиной не совсем корректно. На Windows сервисы поднимает ServiceRunner, контейнеров нет, всё нативное. Плюс MacBook всё время работал от батареи. Но от того и результаты и интереснее. Проценты считаем относительно времени на Windows.
Импорт базового решения + публикация. Секундомер - с момента выбора базового пакета для импорта до зелёной галочки "Публикация завершена" и возврата работоспособности веб-клиента.

  • Windows ПК: 4 минуты 27 секунд
  • MacBook: 3 минуты 32 секунды (быстрее на 21%)

Разница почти в минуту, а за минуту можно сделать столько всего полезного!
Экспорт базового пакета. Секундомер - c момента нажатия кнопки экспорта до того, как студия предложит выбрать папку для сохранения пакета.

  • Windows ПК: 1 минута 14 секунд
  • MacBook: 9 секунд (быстрее на 88%)

Вот это уже звучит как заявка на конспирологические теории о том, где зарыты рабочие часы.
Запуск студии. Секундомер - с момента запуска студии до конца skeleton-анимации и полной инициализации.

  • Windows ПК: 23 секунды
  • MacBook: 20 секунд (быстрее на 13%)

Похоже на погрешность, но всё-таки тоже результат.

Что по итогу? Мак оказался быстрее Windows во всех трёх замерах, от 13% до 88%, несмотря на то, что сервисы у него идут в контейнерах под бинарной трансляцией, а некоторые утилиты под эмуляцией через Docker. Замеры, естественно, не лабораторные, но направление понятно.

Внимание, спасибо за внимание

Важно. В 2020-м году Apple выпустила свои чипы на архитектуре ARM64, заменив тем самым Intel x86_64. И компания считает, что софт, который за 6 лет не был адаптирован разработчиками под новую для ОС архитектуру как интернет - не нужен. Для того чтобы переход был не такой резкий, был выпущен бинарный транслятор Rosetta 2, но по заявлению Apple он будет полностью вырезан (ну или сильно ограничен) в 2027 году, начиная с macOS 28. Это сделает невозможным работу того, что было описано в этой статье, alas. Хотя затронет ли это решение Docker не уточняется, поживем - увидим.

16
Авторизуйтесь, чтобы оценить материал.
1
Даниил Синев

Отличная статья, как раз хотел купить макбук

Виталий Томко

давно хотел попробовать, очень полезно!
спасибо)

Авторизуйтесь, чтобы написать комментарий