<# RexBIM — установка и обновление СКРИПТОМ, без прав администратора. Качает свежий выпуск с dl.rexbim.cloud и ставит плагин во все версии Revit, найденные на машине; если плагин уже стоит — обновляет его. Всё в профиле пользователя: %APPDATA%\Autodesk\Revit\Addins\<год> и %LOCALAPPDATA%\RexBIM. ⚠ Зачем скрипт, если есть RexBIM_Setup.exe. Установщик — НЕПОДПИСАННЫЙ exe, и Kaspersky Endpoint Security сносит его прямо на скачивании, никого не спрашивая. Скрипт делает ТО ЖЕ САМОЕ: тот же манифест, та же проверка подписи (fail-closed), та же механика подмены папки с сохранением пользовательских семейств, тот же апдейтер с ярлыками и та же запись в «Приложениях и возможностях». Разница одна — код едет текстом, который человек (и служба безопасности) читает глазами. Файл выкладывается на сервер как setup/install.ps1 (dist\Publish-Release.ps1). ⚠ Готовая строка запуска («скачать файл и выполнить его») живёт в README.md и в канале, а НЕ здесь. Причина не в опрятности: связка «загрузить в %TEMP% и запустить с обходом политики» — сама по себе известный признак загрузчика, и антивирусные эвристики цепляются за неё даже в комментарии. Ровно после того, как эти две команды вписали в шапку, AMSI один раз отказался грузить ЭТОТ файл со словами «содержит вредоносный код». Здесь — только ключи; проверка scripts\check-install-script.ps1 следит, что скрипт вообще загружается, и отдельно говорит, если его заблокировал антивирус. Ключи (файл уже скачан): install.ps1 install.ps1 -Years 2022,2025 install.ps1 -Source \\server\BIM\RexBIM install.ps1 -Quiet -NoAutostart install.ps1 -Uninstall [-Purge] Идёт семью этапами, каждый называет себя («[4/7] Скачиваю комплекты…»): без этого минутная пауза на скачивании выглядит как зависание. Версии Revit выбираются списком с галочками: ↑↓ — строка, Пробел — отметить, A — все/никого, цифра — строка по номеру, Enter — установить, Esc — отмена. Отмечено сразу всё, с чем есть работа, поэтому обычный ответ — один Enter. Список спрашивается ТОЛЬКО у живого человека: с -Quiet и с -Years скрипт ставит молча, а там, где клавиш не почитать (ввод перенаправлен, ISE, служба), спрашивает строкой. Коды возврата — те же, что у тихого режима RexBIM_Setup.exe (SilentArgs.cs), чтобы сценариям развёртывания не пришлось разбирать два набора: 0 — установлено; 2 — неверные аргументы; 3 — нет связи/манифеста; 4 — подпись манифеста не сошлась; 5 — Revit держит файлы; 6 — загрузка повреждена; 7 — установка сорвалась; 8 — подходящих версий Revit нет. Журнал — общий с установщиком: %LOCALAPPDATA%\RexBIM\setup.log. #> [CmdletBinding()] param( # Годы Revit: 2022,2025 или all. Пусто — все найденные на машине. [string[]]$Years, # Источник обновлений: адрес или ПАПКА (общий диск конторы). Пусто — dl.rexbim.cloud. [string]$Source = '', # Ручной прокси, например http://proxy.local:3128. Сохраняется в setup.config — # тот же файл, что читает и пишет RexBIM_Setup.exe. [string]$Proxy = '', [string]$ProxyUser = '', [string]$ProxyPassword = '', # Не создавать ярлык автозагрузки (ярлык меню «Пуск» кладётся всегда). [switch]$NoAutostart, # Ставить, даже если установлена та же версия. [switch]$Force, # Удаление вместо установки; -Purge стирает и настройки %APPDATA%\RexBIM. [switch]$Uninstall, [switch]$Purge, # Без вопросов и без «Enter для выхода» — для развёртывания. [switch]$Quiet, # Только определить функции и выйти: так скрипт гоняет scripts\check-install-script.ps1. [switch]$LoadFunctionsOnly ) # ==== Постоянные ==== # ⚠ Оба адреса — КОПИИ shared\Licensing\Endpoints.cs (Download и Telemetry): линковать # C# в скрипт нечем. Расхождение означает, что скриптовая установка уводит людей # на старый сервер или оставляет очередь статистики без приёмника; сверяется # scripts\check-install-script.ps1. $script:DefaultSource = 'https://dl.rexbim.cloud' $script:TelemetryEndpoint = 'https://api.rexbim.cloud/telemetry/v1/events' # ⚠ Открытый ключ выпуска — КОПИЯ shared\Licensing\ReleaseKey.cs. Им проверяется подпись # манифеста: без неё по каналу обновления приезжает исполняемый код неизвестно от кого # (HTTPS не спасает там, где в сети стоит прокси с расшифровкой TLS — в проектных конторах # это норма). Расхождение с ReleaseKey.cs означает «манифест не подписан» у каждого, кто # ставит скриптом; сверяется scripts\check-install-script.ps1. $script:PublicKeyX = 'cn4mdbunj7CfSO9N0MiOpQDcZyYyjs1CemrhqK2+qTg=' $script:PublicKeyY = '+vMsGToA84OUhGX5nn8lpAh1xhpTo79voQaazJ/Zxk8=' $script:KeyFingerprint = '47c1bf1a35b95862' # Пути. Отдельными переменными, а не выражениями по месту, ради проверки: она подменяет # их на песочные и гоняет установку с удалением по-настоящему, не трогая живых установок # Revit — тот же приём, что SetupPaths у установщика. $script:AddinsRoot = Join-Path $env:APPDATA 'Autodesk\Revit\Addins' $script:LocalDir = Join-Path $env:LOCALAPPDATA 'RexBIM' $script:StartupDir = [Environment]::GetFolderPath('Startup') $script:StartMenuDir = [Environment]::GetFolderPath('Programs') $script:SettingsDir = Join-Path $env:APPDATA 'RexBIM' $script:UninstallKey = 'Software\Microsoft\Windows\CurrentVersion\Uninstall\RexBIM' $script:DownloadDir = Join-Path $env:TEMP 'RexBIM-Setup' # Где искать установленный Revit, кроме папок Addins и реестра: «Revit <год>\Revit.exe». # Подменяется проверкой (пустой список), чтобы песочница не видела настоящий Revit. $script:RevitProgramRoots = @(@($env:ProgramW6432, $env:ProgramFiles) | Where-Object { $_ } | Select-Object -Unique | ForEach-Object { Join-Path $_ 'Autodesk' }) # Читать ли реестр установленных Revit. Проверка выключает: в песочнице только её годы. $script:ReadRevitRegistry = $true # Имена ярлыков знают и апдейтер (Tray.ConfirmExit подсказывает имя в окне выхода), # и установщик — менять только вместе с SetupPaths. $script:StartupShortcutName = 'RexBIM Updater.lnk' $script:StartMenuShortcutName = 'RexBIM Автообновление.lnk' $script:SelfName = 'RexBIM-Setup.ps1' # Путь к самому файлу: копия себя в %LOCALAPPDATA%\RexBIM нужна «Приложениям # и возможностям» — им нечем удалять продукт (exe кладёт туда себя же). $script:SelfPath = $PSCommandPath $script:QuietMode = [bool]$Quiet # Сколько всего этапов в текущем режиме — заголовок каждого печатает Write-RexStep. $script:StepTotal = 7 # ==== Мелочи: журнал и вывод ==== function Write-RexLog { param([string]$Level, [string]$Message) try { [void](New-Item -ItemType Directory -Force -Path $script:LocalDir -ErrorAction SilentlyContinue) $path = Join-Path $script:LocalDir 'setup.log' if ((Test-Path $path) -and (Get-Item $path).Length -gt 1MB) { $old = "$path.old" Remove-Item $old -Force -ErrorAction SilentlyContinue Move-Item $path $old -Force -ErrorAction SilentlyContinue } # UTF-8 БЕЗ BOM и та же разметка строки, что у SetupLog.cs: журнал общий, # человек шлёт в поддержку один файл. $line = '{0} {1} {2}{3}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message, [Environment]::NewLine [IO.File]::AppendAllText($path, $line, (New-Object System.Text.UTF8Encoding($false))) } catch { } } <# Заголовок этапа: «[3/7] Проверяю, что Revit закрыт». Человек должен понимать, что сейчас делается и сколько осталось, — иначе минутная пауза на скачивании выглядит как зависание. Нумерация та же по смыслу, что у чек-листа мастера в exe. #> function Write-RexStep { param([int]$Number, [string]$Title) Write-Host '' Write-Host (" [{0}/{1}] {2}" -f $Number, $script:StepTotal, $Title) -ForegroundColor Cyan Write-RexLog 'INFO' ("этап {0}/{1}: {2}" -f $Number, $script:StepTotal, $Title) } <# Русское склонение после числа: 1 модуль, 2 модуля, 5 модулей, 11 модулей. Мелочь, но «31 модулей на месте» читается как машинный лепет, а строку эту человек видит в самый нервный момент — сразу после установки. #> function Format-RexCount { param([int]$Count, [string]$One, [string]$Few, [string]$Many) $mod100 = $Count % 100 $mod10 = $Count % 10 if ($mod100 -ge 11 -and $mod100 -le 14) { return "$Count $Many" } if ($mod10 -eq 1) { return "$Count $One" } if ($mod10 -ge 2 -and $mod10 -le 4) { return "$Count $Few" } return "$Count $Many" } function Write-RexSay { param([string]$m) Write-Host " $m"; Write-RexLog 'INFO' $m } function Write-RexOk { param([string]$m) Write-Host " [OK] $m" -ForegroundColor Green; Write-RexLog 'INFO' $m } function Write-RexWarn { param([string]$m) Write-Host " [!] $m" -ForegroundColor Yellow; Write-RexLog 'WARN' $m } function Write-RexBad { param([string]$m) Write-Host " [X] $m" -ForegroundColor Red; Write-RexLog 'ERR ' $m } # ==== Поиск версий Revit ==== <# Годы из имён папок Addins и ключей реестра. Чистая функция: реестр пишет и «2022», и «Autodesk Revit 2024», а мусор вроде «Components» — мимо. #> function ConvertTo-RexYears { param([string[]]$Names) $found = New-Object 'System.Collections.Generic.SortedSet[string]' foreach ($name in $Names) { if ([string]::IsNullOrEmpty($name)) { continue } $m = [regex]::Match($name, '\b(20\d\d)\b') if (-not $m.Success) { continue } $year = [int]$m.Groups[1].Value if ($year -ge 2015 -and $year -le 2035) { [void]$found.Add($m.Groups[1].Value) } } return @($found) } <# Годы, названные человеком в -Years: «2022», «2022,2025», «2022;2025» — принимаем и то, и другое, и третье. Через ConvertTo-RexYears их прогонять НЕЛЬЗЯ: там правдоподобный диапазон 2015-2035 для разбора ЧУЖИХ имён (ключей реестра), а заказанный год — не догадка, а прямое указание. Правило то же, что в SilentArgs.cs у установщика: четыре знака и начинается на «20». #> function ConvertTo-RexRequestedYears { param([string[]]$Names) $years = New-Object 'System.Collections.Generic.SortedSet[string]' foreach ($name in $Names) { if ([string]::IsNullOrWhiteSpace($name)) { continue } foreach ($part in ($name -split '[,;]')) { $year = $part.Trim() if ($year -match '^20\d\d$') { [void]$years.Add($year) } } } return @($years) } <# Ответ на вопрос «что ставим» → список годов. Чистая функция, поэтому её гоняет проверка без всякого ввода. Пусто, «все», «all» — всё предложенное; «отмена», «q», «нет» — $null (пользователь передумал); иначе номера строк и/или годы через запятую, точку с запятой или пробел. Ничего не разобрано — пустой список: значит, спросим ещё раз, а не поставим наугад. #> function Resolve-RexYearChoice { param([string]$Answer, [string[]]$Offered) $text = ($Answer + '').Trim() if ($text.Length -eq 0) { return @($Offered) } if ($text -match '^(?i)(все|всё|all|a)$') { return @($Offered) } if ($text -match '^(?i)(отмена|отменить|выход|нет|q|n|exit)$') { return $null } $picked = New-Object 'System.Collections.Generic.SortedSet[string]' foreach ($token in ($text -split '[,;\s]+')) { if ($token.Length -eq 0) { continue } # Год пишут четырьмя знаками, номер строки — одним-двумя: путаницы нет. if ($token -match '^20\d\d$') { if ($Offered -contains $token) { [void]$picked.Add($token) } continue } if ($token -match '^\d{1,2}$') { $index = [int]$token if ($index -ge 1 -and $index -le $Offered.Count) { [void]$picked.Add($Offered[$index - 1]) } } } return @($picked) } <# Одна нажатая клавиша списка с галочками: состояние на входе — состояние на выходе. Вынесено чистой функцией НЕ ради красоты: живой список проверкой не прогнать — он ждёт настоящую клавиатуру. А ошибиться в нём легко (курсор за край, «отметить всё» при части отмеченных), и вылезло бы это только у пользователя. Здесь же всё проверяется без консоли — scripts\check-install-script.ps1. #> function Update-RexChoice { param( [string]$Key, # имя клавиши: UpArrow, DownArrow, Spacebar, Enter, Escape [char]$Char = ' ', # символ — для «A» и цифр [int]$Cursor = 0, [bool[]]$Checked = @() ) $count = @($Checked).Count $state = New-Object 'bool[]' $count for ($i = 0; $i -lt $count; $i++) { $state[$i] = $Checked[$i] } $result = [pscustomobject]@{ Cursor = $Cursor; Checked = $state; Done = $false; Cancel = $false } if ($count -eq 0) { return $result } switch ($Key) { # По кругу: на списке из трёх строк упереться в край и не понять, почему курсор # «залип», обиднее, чем перескочить. 'UpArrow' { $result.Cursor = ($Cursor - 1 + $count) % $count; return $result } 'DownArrow' { $result.Cursor = ($Cursor + 1) % $count; return $result } 'Home' { $result.Cursor = 0; return $result } 'End' { $result.Cursor = $count - 1; return $result } 'Spacebar' { $state[$Cursor] = -not $state[$Cursor]; return $result } 'Enter' { $result.Done = $true; return $result } 'Escape' { $result.Cancel = $true; return $result } } # «A» — отметить всё, а если уже всё отмечено — снять всё. «Ф» — та же клавиша # на русской раскладке: переключать её ради установщика никто не станет. if (@('a', 'A', 'ф', 'Ф') -contains [string]$Char) { $allChecked = $true for ($i = 0; $i -lt $count; $i++) { if (-not $state[$i]) { $allChecked = $false } } for ($i = 0; $i -lt $count; $i++) { $state[$i] = -not $allChecked } return $result } # Цифра — отметить строку по номеру, не двигая руку к стрелкам. if ([string]$Char -match '^[1-9]$') { $index = [int]::Parse([string]$Char) - 1 if ($index -lt $count) { $state[$index] = -not $state[$index] $result.Cursor = $index } } return $result } <# Одно нажатие. Отдельной функцией, чтобы проверка могла «нажать» за пользователя и прогнать ВЕСЬ цикл списка, а не только разбор клавиши: подменить [Console]::ReadKey нечем, а подменить функцию — можно. #> function Read-RexKey { return [Console]::ReadKey($true) } <# Есть ли настоящая клавиатура и консоль, которую можно перерисовывать. Нет (ввод перенаправлен, ISE, служба развёртывания) — спросим строкой, а не сломаемся. #> function Test-RexConsoleKeys { try { if ($Host.Name -ne 'ConsoleHost') { return $false } if ([Console]::IsInputRedirected) { return $false } if (-not [Environment]::UserInteractive) { return $false } $null = [Console]::CursorTop return $true } catch { return $false } } <# Список с галочками: ↑↓ — строка, Пробел — отметить, A — все/никого, цифра — по номеру, Enter — установить, Esc — отмена. Перерисовывается НА МЕСТЕ, поэтому не сыплет экраном. Отмечено по умолчанию всё, что можно поставить: обычный ответ — «ставь везде», и дойти до него человек должен одним Enter. #> function Select-RexYearsByKeys { param($Rows, [string]$Version) $doable = @('установится', 'обновится', 'переставится') $order = @() for ($i = 0; $i -lt @($Rows).Count; $i++) { if ($doable -contains $Rows[$i].State) { $order += $i } } if ($order.Count -eq 0) { return @() } $checked = New-Object 'bool[]' $order.Count for ($i = 0; $i -lt $order.Count; $i++) { $checked[$i] = $true } $cursor = 0 $top = $null $lines = @($Rows).Count + 4 while ($true) { if ($null -ne $top) { try { $Host.UI.RawUI.CursorPosition = $top } catch { } } Write-Host '' Write-Host (' {0,-7} {1,-14} {2}' -f 'Revit', 'установлено', "комплект $Version") for ($i = 0; $i -lt @($Rows).Count; $i++) { $row = $Rows[$i] $place = [Array]::IndexOf($order, $i) if ($place -lt 0) { # Строку, с которой делать нечего, и выбрать нельзя: ни рамки, ни номера. Write-Host (" {0,-7} {1,-14} {2} " -f $row.Year, $row.Installed, $row.State) ` -ForegroundColor DarkGray continue } $mark = ' ' $color = 'Gray' if ($place -eq $cursor) { $mark = '>'; $color = 'White' } $box = '[ ]' if ($checked[$place]) { $box = '[x]' } Write-Host (" {0} {1} {2} {3,-7} {4,-14} {5} " -f ` $mark, ($place + 1), $box, $row.Year, $row.Installed, $row.State) -ForegroundColor $color } Write-Host '' Write-Host ' ↑↓ строка · Пробел отметить · A все/никого · Enter установить · Esc отмена ' ` -ForegroundColor Yellow # Верх блока запоминаем ПОСЛЕ первой отрисовки: если консоль при этом прокрутилась, # посчитанная заранее строка уехала бы, и перерисовка смазала бы вывод. if ($null -eq $top) { try { $now = $Host.UI.RawUI.CursorPosition $now.X = 0 $now.Y = $now.Y - $lines if ($now.Y -ge 0) { $top = $now } } catch { } } $key = Read-RexKey $step = Update-RexChoice -Key ([string]$key.Key) -Char $key.KeyChar -Cursor $cursor -Checked $checked $cursor = $step.Cursor $checked = $step.Checked if ($step.Cancel) { return $null } if (-not $step.Done) { continue } $picked = @() for ($i = 0; $i -lt $order.Count; $i++) { if ($checked[$i]) { $picked += $Rows[$order[$i]].Year } } if ($picked.Count -gt 0) { return $picked } # Ничего не отмечено — Enter не должен молча означать «ставить нечего». Write-Host ' Ничего не отмечено: Пробел ставит галочку, Esc выходит. ' -ForegroundColor Yellow Start-Sleep -Milliseconds 900 } } <# Запасной ввод строкой — там, где клавиш не почитать (ввод перенаправлен, ISE, служба). Спрашивать можно только ПОСЛЕ манифеста: до него неизвестно, для каких лет вообще есть комплект, и предлагать выбор из того, чего нет, — обманывать человека. #> function Select-RexYearsByLine { param([string[]]$Offered) $hint = 'Enter выбирает все' if ($Offered.Count -eq 1) { $hint = "Enter ставит Revit $($Offered[0])" } foreach ($attempt in 1..3) { Write-Host '' Write-Host (" Что ставим? {0}; можно указать номера или годы через запятую; «отмена» отменяет выбор" -f $hint) ` -ForegroundColor Yellow $answer = '' try { $answer = Read-Host ' Выбор' } catch { return @($Offered) } $picked = Resolve-RexYearChoice -Answer $answer -Offered $Offered if ($null -eq $picked) { return $null } if ($picked.Count -gt 0) { return $picked } Write-RexWarn 'Не понял ответ. Наберите годы (2022, 2025) или номера из списка; Enter выбирает все.' } return @() } <# Все годы Revit на машине: папки Addins, реестр установленных продуктов и папки «Autodesk\Revit <год>» с Revit.exe в Program Files. Все источники нужны: папка Addins остаётся от давно удалённой версии, у свежего Revit, который этим пользователем ни разу не запускали, её ещё нет, а запись в реестре бывает потеряна (аудит 10.2026, DL-49). #> function Get-RexRevitYears { $names = @() try { if (Test-Path $script:AddinsRoot) { $names += @(Get-ChildItem $script:AddinsRoot -Directory -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Name) } } catch { } foreach ($programRoot in @($script:RevitProgramRoots)) { try { if (-not $programRoot -or -not (Test-Path $programRoot)) { continue } foreach ($dir in @(Get-ChildItem $programRoot -Directory -Filter 'Revit 20*' -ErrorAction SilentlyContinue)) { if (Test-Path (Join-Path $dir.FullName 'Revit.exe')) { $names += $dir.Name } } } catch { } } if (-not $script:ReadRevitRegistry) { return (ConvertTo-RexYears -Names $names) } try { # Явно 64-битная ветка: Revit 2022+ только 64-битный, а PowerShell бывает 32-битным. $hklm = [Microsoft.Win32.RegistryKey]::OpenBaseKey('LocalMachine', 'Registry64') try { $key = $hklm.OpenSubKey('SOFTWARE\Autodesk\Revit') if ($key) { $names += @($key.GetSubKeyNames()); $key.Close() } } finally { $hklm.Close() } } catch { } return (ConvertTo-RexYears -Names $names) } # ==== Настройки установщика (прокси) ==== <# setup.config — ТОТ ЖЕ файл, что у RexBIM_Setup.exe: настроенный однажды прокси не набирается заново, каким бы способом человек ни ставил. Пароль в файле — DPAPI. #> function Get-RexSetupConfig { $cfg = [pscustomobject]@{ Proxy = ''; User = ''; Password = '' } try { $path = Join-Path $script:LocalDir 'setup.config' if (-not (Test-Path $path)) { return $cfg } foreach ($raw in [IO.File]::ReadAllLines($path)) { $line = $raw.Trim() if ($line.Length -eq 0 -or $line.StartsWith('#')) { continue } $eq = $line.IndexOf('=') if ($eq -le 0) { continue } $key = $line.Substring(0, $eq).Trim().ToLowerInvariant() $val = $line.Substring($eq + 1).Trim() switch ($key) { 'proxy' { $cfg.Proxy = $val } 'proxyuser' { $cfg.User = $val } 'proxypass' { $cfg.Password = (Unprotect-RexSecret $val) } } } } catch { } return $cfg } function Save-RexSetupConfig { param([string]$ProxyUrl, [string]$User, [string]$Password) try { [void](New-Item -ItemType Directory -Force -Path $script:LocalDir) $text = "# Настройки установщика RexBIM`n" + "proxy=$ProxyUrl`n" + "proxyUser=$User`n" + "proxyPass=$(Protect-RexSecret $Password)`n" [IO.File]::WriteAllText((Join-Path $script:LocalDir 'setup.config'), $text, (New-Object System.Text.UTF8Encoding($false))) } catch { } } <# Значение ключа из файла вида key=value (updater.config, setup.config) или пустая строка. Пара к ReadConfigValue установщика. #> function Get-RexConfigValue { param([string]$Path, [string]$Key) try { if (-not (Test-Path $Path)) { return '' } foreach ($raw in [IO.File]::ReadAllLines($Path)) { $line = $raw.Trim() if ($line.Length -eq 0 -or $line.StartsWith('#')) { continue } $eq = $line.IndexOf('=') if ($eq -le 0) { continue } if ($line.Substring(0, $eq).Trim() -eq $Key) { return $line.Substring($eq + 1).Trim() } } } catch { } return '' } function Protect-RexSecret { param([string]$Value) if ([string]::IsNullOrEmpty($Value)) { return '' } try { Add-Type -AssemblyName System.Security -ErrorAction SilentlyContinue $bytes = [Text.Encoding]::UTF8.GetBytes($Value) return [Convert]::ToBase64String([Security.Cryptography.ProtectedData]::Protect($bytes, $null, 'CurrentUser')) } catch { return '' } } function Unprotect-RexSecret { param([string]$Stored) if ([string]::IsNullOrEmpty($Stored)) { return '' } try { Add-Type -AssemblyName System.Security -ErrorAction SilentlyContinue $bytes = [Security.Cryptography.ProtectedData]::Unprotect([Convert]::FromBase64String($Stored), $null, 'CurrentUser') return [Text.Encoding]::UTF8.GetString($bytes) } catch { return '' } # чужой профиль или битая строка — просто пусто } # ==== updater.config: слияние, а не перезапись ==== <# Ключ строки updater.config в нижнем регистре или '' (комментарий, пустая, без «=»). manifestUrl — прежнее имя source: апдейтер читает их как один ключ (UpdaterConfig.LoadFrom). #> function Get-RexConfigLineKey { param([string]$Line) $text = ($Line + '').Trim() if ($text.Length -eq 0 -or $text.StartsWith('#')) { return '' } $eq = $text.IndexOf('=') if ($eq -le 0) { return '' } $key = $text.Substring(0, $eq).Trim().ToLowerInvariant() if ($key -eq 'manifesturl') { $key = 'source' } return $key } <# Новые строки updater.config из прежних. Чистая функция: её гоняет проверка без диска. ⚠ Раньше скрипты писали файл заново: выключенное человеком автообновление включалось само, канал, интервал и прокси пропадали, контора с общей папкой уезжала на сервер (аудит 10.2026, DL-10). Правила те же, что у SetupEngine.WriteUpdaterConfig установщика: все строки остаются на месте, включая чужие ключи и комментарии; source меняется, когда источник задан явно (-Source) или в файле его нет; telemetry пишется, только если его нет: свою правку переустановка не затирает; proxy, proxyUser, proxyPass меняются только при явном прокси (-SetProxy), пустое значение снимает ключ: отсутствие ключа у апдейтера значит «системный прокси». Ключ, который меняем, встаёт на место своей первой строки, повторы его убираются (апдейтер берёт последнее значение, и старый повтор перебил бы новое). $Lines = $null: файла нет, пишется как раньше: источник, раз в час, stable. #> function Merge-RexUpdaterConfigLines { param( [AllowNull()][string[]]$Lines, [string]$Source = '', [string]$DefaultSource = '', [string]$Telemetry = '', [bool]$SetProxy = $false, [string]$ProxyUrl = '', [string]$ProxyUser = '', # Уже защищённый пароль («dpapi:…»): шифрует Save-RexUpdaterConfig, функция чистая. [string]$ProxyPass = '' ) $explicit = -not [string]::IsNullOrWhiteSpace($Source) $effective = if ($explicit) { $Source.Trim() } else { $DefaultSource } if ($null -eq $Lines) { $fresh = @('# Конфигурация апдейтера RexBIM', "source=$effective", 'checkMinutes=60', 'channel=stable') if (-not [string]::IsNullOrWhiteSpace($Telemetry)) { $fresh += "telemetry=$Telemetry" } if ($SetProxy -and -not [string]::IsNullOrWhiteSpace($ProxyUrl)) { $fresh += "proxy=$($ProxyUrl.Trim())" if (-not [string]::IsNullOrWhiteSpace($ProxyUser)) { $fresh += "proxyUser=$($ProxyUser.Trim())" } if (-not [string]::IsNullOrWhiteSpace($ProxyPass)) { $fresh += "proxyPass=$ProxyPass" } } return ,([string[]]$fresh) } # Что сейчас в файле: последнее значение ключа, как читает апдейтер. $current = @{} foreach ($line in $Lines) { $key = Get-RexConfigLineKey $line if (-not $key) { continue } $text = $line.Trim() $current[$key] = $text.Substring($text.IndexOf('=') + 1).Trim() } # Ключ → новая строка; $null — ключ снять. $set = [ordered]@{} if ($explicit -or [string]::IsNullOrWhiteSpace($current['source'])) { if (-not [string]::IsNullOrWhiteSpace($effective)) { $set['source'] = "source=$effective" } } if ([string]::IsNullOrWhiteSpace($current['telemetry']) -and -not [string]::IsNullOrWhiteSpace($Telemetry)) { $set['telemetry'] = "telemetry=$Telemetry" } if ($SetProxy) { $user = ($ProxyUser + '').Trim() $set['proxy'] = if ([string]::IsNullOrWhiteSpace($ProxyUrl)) { $null } else { "proxy=$($ProxyUrl.Trim())" } $set['proxyuser'] = if ($user.Length -eq 0) { $null } else { "proxyUser=$user" } $set['proxypass'] = if ($user.Length -eq 0 -or [string]::IsNullOrWhiteSpace($ProxyPass)) { $null } else { "proxyPass=$ProxyPass" } } $result = New-Object 'System.Collections.Generic.List[string]' $written = @{} foreach ($line in $Lines) { $key = Get-RexConfigLineKey $line if ($key -and $set.Contains($key)) { if (-not $written.ContainsKey($key)) { $written[$key] = $true if ($null -ne $set[$key]) { $result.Add($set[$key]) } } continue } $result.Add($line) } # Хвостовые пустые строки не держим: дописанный ключ иначе встал бы после пустоты. while ($result.Count -gt 0 -and [string]::IsNullOrWhiteSpace($result[$result.Count - 1])) { $result.RemoveAt($result.Count - 1) } foreach ($key in $set.Keys) { if (-not $written.ContainsKey($key) -and $null -ne $set[$key]) { $result.Add($set[$key]) } } return ,([string[]]$result.ToArray()) } <# Пароль прокси для updater.config: «dpapi:» + base64 DPAPI текущего пользователя, ровно как Proxy.Protect апдейтера. В setup.config тот же пароль лежит без приставки (Protect-RexSecret): форматы двух файлов разные, и апдейтер без приставки пароль не расшифрует. #> function Protect-RexDpapi { param([string]$Value) $protected = Protect-RexSecret $Value if (-not $protected) { return '' } return 'dpapi:' + $protected } <# Слить и записать updater.config. Возвращает $null или текст беды для человека. Нечитаемый файл не трогаем: переписать его умолчаниями значит потерять настройки. Запись через временный файл и замену, UTF-8 без BOM, как UpdaterConfig.SaveTo. #> function Save-RexUpdaterConfig { param( [string]$Path, [string]$Source = '', [string]$DefaultSource = '', [string]$Telemetry = '', [bool]$SetProxy = $false, [string]$ProxyUrl = '', [string]$ProxyUser = '', [string]$ProxyPassword = '' ) $lines = $null if (Test-Path -LiteralPath $Path) { foreach ($attempt in 1..5) { try { $lines = [IO.File]::ReadAllLines($Path); break } catch { if ($attempt -eq 5) { Write-RexLog 'WARN' ('updater.config не прочитан, не переписываю: ' + $_.Exception.Message) return 'прежние настройки автообновления не читаются, они оставлены как были' } Start-Sleep -Milliseconds 100 } } if ($null -eq $lines) { $lines = @() } } $problem = $null $pass = '' if ($SetProxy -and -not [string]::IsNullOrWhiteSpace($ProxyUser) -and -not [string]::IsNullOrEmpty($ProxyPassword)) { $pass = Protect-RexDpapi $ProxyPassword # Открытым текстом пароль в файл не кладём: лучше без него. if (-not $pass) { $problem = 'пароль прокси не сохранился, впишите его в настройках автообновления' } } $merged = Merge-RexUpdaterConfigLines -Lines $lines -Source $Source -DefaultSource $DefaultSource ` -Telemetry $Telemetry -SetProxy $SetProxy -ProxyUrl $ProxyUrl -ProxyUser $ProxyUser -ProxyPass $pass $tmp = "$Path.tmp" try { [void](New-Item -ItemType Directory -Force -Path (Split-Path -Parent $Path)) $text = (($merged | ForEach-Object { $_ }) -join "`n") + "`n" [IO.File]::WriteAllText($tmp, $text, (New-Object System.Text.UTF8Encoding($false))) # [NullString]: голый $null PowerShell передаёт в .NET как пустую строку, и Replace # отвечает «путь имеет недопустимую форму». if (Test-Path -LiteralPath $Path) { [IO.File]::Replace($tmp, $Path, [NullString]::Value, $true) } else { [IO.File]::Move($tmp, $Path) } } catch { Remove-Item $tmp -Force -ErrorAction SilentlyContinue Write-RexLog 'WARN' ('updater.config не записан: ' + $_.Exception.Message) return "настройки автообновления не записались ($Path)" } return $problem } # ==== Сеть ==== <# TLS 1.2/1.3 явно: на части машин .NET по умолчанию всё ещё предлагает 1.0, и сервер молча обрывает соединение — «нет связи» без единой подробности. #> function Initialize-RexTls { foreach ($name in @('Tls12', 'Tls13')) { try { $value = [Enum]::Parse([Net.SecurityProtocolType], $name) [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor $value } catch { } } } function Test-RexUrl { param([string]$Text) return ($Text -match '^(?i)https?://') } <# Прокси как IWebProxy или $null (системный). Логин и пароль — ОТДЕЛЬНО, а не в URL: адрес http://user:pass@host WebProxy принимает молча, но учётные данные из него не берёт — запросы уходят без авторизации, и прокси отвечает 407. #> function New-RexProxy { param([string]$Url, [string]$User, [string]$Password) if ([string]::IsNullOrWhiteSpace($Url)) { return $null } try { $proxy = New-Object System.Net.WebProxy($Url.Trim()) if (-not [string]::IsNullOrEmpty($User)) { $proxy.Credentials = New-Object System.Net.NetworkCredential($User, $Password) } else { $proxy.UseDefaultCredentials = $true # NTLM-прокси работает учёткой Windows } return $proxy } catch { return $null } } function Set-RexRequestProxy { param($Request, $Proxy) try { if ($Proxy) { $Request.Proxy = $Proxy } else { $system = [Net.WebRequest]::GetSystemWebProxy() $system.Credentials = [Net.CredentialCache]::DefaultCredentials $Request.Proxy = $system } } catch { } } <# Приводит источник к пути ИМЕННО манифеста: указали папку — дописываем имя файла. Сетевую папку человек называет папкой, а не файлом внутри неё. #> function Get-RexManifestPath { param([string]$SourceValue) if ([string]::IsNullOrWhiteSpace($SourceValue)) { return $SourceValue } if (Test-RexUrl $SourceValue) { if ($SourceValue -match '(?i)\.json$') { return $SourceValue } return ($SourceValue.TrimEnd('/') + '/manifest.json') } $path = $SourceValue.Trim().TrimEnd('"') try { if (Test-Path $path -PathType Container) { return (Join-Path $path 'manifest.json') } } catch { } return $path } <# Полный адрес файла из манифеста: в манифесте лежит ОТНОСИТЕЛЬНОЕ имя, поэтому один и тот же манифест работает и с сайта, и с общего диска. #> function Resolve-RexEntryPath { param([string]$ManifestPath, [string]$Entry) if ([string]::IsNullOrWhiteSpace($Entry)) { return $Entry } if (Test-RexUrl $Entry) { return $Entry } if (Test-RexUrl $ManifestPath) { try { return (New-Object Uri((New-Object Uri($ManifestPath)), $Entry)).ToString() } catch { return $Entry } } try { if ([IO.Path]::IsPathRooted($Entry)) { return $Entry } $dir = Split-Path -Parent $ManifestPath if ([string]::IsNullOrEmpty($dir)) { return $Entry } return (Join-Path $dir $Entry) } catch { return $Entry } } function Get-RexText { param([string]$Path, $Proxy) if (-not (Test-RexUrl $Path)) { return [IO.File]::ReadAllText($Path) } $request = [Net.WebRequest]::Create($Path) $request.Timeout = 60000 try { $request.UserAgent = 'RexBIM-Setup' } catch { } Set-RexRequestProxy -Request $request -Proxy $Proxy $response = $request.GetResponse() try { $reader = New-Object IO.StreamReader($response.GetResponseStream(), [Text.Encoding]::UTF8) try { return $reader.ReadToEnd() } finally { $reader.Dispose() } } finally { $response.Close() } } <# Скачивание с прогрессом. Источник-папка просто копируется — контора без своего сервера раздачи ставит с общего диска той же командой. #> function Invoke-RexDownload { param([string]$Url, [string]$Target, $Proxy, [string]$Title) if (-not (Test-RexUrl $Url)) { Copy-Item $Url $Target -Force; return } $request = [Net.WebRequest]::Create($Url) $request.Timeout = 120000 try { $request.ReadWriteTimeout = 300000 } catch { } try { $request.UserAgent = 'RexBIM-Setup' } catch { } Set-RexRequestProxy -Request $request -Proxy $Proxy $response = $request.GetResponse() try { $total = $response.ContentLength # ⚠ Имена локальных переменных НЕ должны совпадать с именами параметров даже # регистром: в PowerShell $target и $Target — одно и то же имя, а у параметра # объявлен тип [string]. Поток, положенный в такую переменную, молча # превращается в строку — и падает уже на .Write/.Dispose «у String нет метода». $netStream = $response.GetResponseStream() $fileStream = [IO.File]::Create($Target) try { $buffer = New-Object byte[] 81920 $wrote = 0L $ticks = 0 while ($true) { $read = $netStream.Read($buffer, 0, $buffer.Length) if ($read -le 0) { break } $fileStream.Write($buffer, 0, $read) $wrote += $read $ticks++ # Прогресс раз в ~1,3 МБ: рисовать его на каждые 80 КБ дороже самой загрузки. if ($Title -and -not $script:QuietMode -and $total -gt 0 -and ($ticks % 16) -eq 0) { Write-Progress -Activity $Title -PercentComplete ([int](100 * $wrote / $total)) ` -Status ('{0:N1} из {1:N1} МБ' -f ($wrote / 1MB), ($total / 1MB)) } } } finally { $fileStream.Dispose(); $netStream.Dispose() } } finally { $response.Close() } if ($Title -and -not $script:QuietMode) { Write-Progress -Activity $Title -Completed } } # ==== Манифест и подпись ==== function Get-RexJsonString { param([string]$Json, [string]$Key) $m = [regex]::Match($Json, '"' + [regex]::Escape($Key) + '"\s*:\s*"([^"]*)"') if ($m.Success) { return $m.Groups[1].Value } return '' } function Get-RexJsonNumber { param([string]$Json, [string]$Key) $m = [regex]::Match($Json, '"' + [regex]::Escape($Key) + '"\s*:\s*(\d+)') if ($m.Success) { return [long]$m.Groups[1].Value } return [long]0 } <# Текст из-за пределов подписи для показа в консоли: управляющие (\p{Cc}) и форматные (\p{Cf}: смена направления письма, невидимые разделители) знаки становятся пробелом, повторы пробелов схлопываются. Чистая функция, её гоняет проверка. #> function ConvertTo-RexPlainText { param([string]$Text) if ([string]::IsNullOrEmpty($Text)) { return '' } $clean = [regex]::Replace($Text, '[\p{Cc}\p{Cf}]', ' ') return ([regex]::Replace($clean, ' {2,}', ' ')).Trim() } <# Разбор манифеста — регулярками, ровно как Manifest.cs: одна и та же простая форма читается тремя разными программами, и заводить ради неё разбор JSON незачем. ⚠ Секция updater вырезается ПЕРВОЙ: внутри неё свои "version", "sha256" и "size", и первый попавшийся "version" в файле иначе зависел бы от порядка ключей — а порядок ключей в JSON обещать нельзя. #> function ConvertFrom-RexManifest { param([string]$Json) $m = [pscustomobject]@{ Version = ''; Channel = 'stable'; ReleasedAt = ''; MinUpdaterVersion = '' Signature = ''; SignatureV2 = ''; Updater = $null; Notes = '' Years = @{} } $work = $Json $upd = [regex]::Match($work, '"updater"\s*:\s*\{(.*?)\}', 'Singleline') if ($upd.Success) { $body = $upd.Groups[1].Value $url = Get-RexJsonString $body 'url' if ($url) { $m.Updater = [pscustomobject]@{ Url = $url Sha256 = (Get-RexJsonString $body 'sha256').ToLowerInvariant() Size = (Get-RexJsonNumber $body 'size') Version = (Get-RexJsonString $body 'version') } } $work = $work.Remove($upd.Index, $upd.Length) } $m.Version = Get-RexJsonString $work 'version' $m.ReleasedAt = Get-RexJsonString $work 'releasedAt' $m.Signature = Get-RexJsonString $work 'signature' $m.SignatureV2 = Get-RexJsonString $work 'signatureV2' $m.MinUpdaterVersion = Get-RexJsonString $work 'minUpdaterVersion' $channel = Get-RexJsonString $work 'channel' if ($channel) { $m.Channel = $channel } # «Коротко: …» из CHANGELOG — строка ДЛЯ ПОКАЗА. В подпись не входит и никаких # действий по ней не делается, поэтому обрезаем: строка состояния не место для простыни. # Строка вне подписи, поэтому управляющие и невидимые знаки вычищаются: перевод строки, # возврат каретки или escape-последовательность иначе перерисовали бы консоль чужим # текстом, а знак смены направления письма переставил бы слова (аудит 10.2026, DL-48). $m.Notes = ConvertTo-RexPlainText (Get-RexJsonString $work 'notes') if ($m.Notes.Length -gt 300) { $m.Notes = $m.Notes.Substring(0, 300) } foreach ($year in [regex]::Matches($work, '"(20\d\d)"\s*:\s*\{(.*?)\}', 'Singleline')) { $body = $year.Groups[2].Value $url = Get-RexJsonString $body 'url' if (-not $url) { continue } $m.Years[$year.Groups[1].Value] = [pscustomobject]@{ Url = $url Sha256 = (Get-RexJsonString $body 'sha256').ToLowerInvariant() Size = (Get-RexJsonNumber $body 'size') } } return $m } <# Канонический текст подписи, формат v2. ДОЛЖЕН совпадать байт в байт с ManifestSignature.BuildPayloadV2 (C#) и Get-RexManifestPayloadV2 (dist\Signing.psm1): подписывается не JSON, а этот плоский текст — иначе порядок ключей, отступ или CRLF ломают проверку молча и только у пользователя. Разделитель строк — LF, завершающего перевода строки нет, годы по возрастанию, sha256 в нижнем регистре. #> function Get-RexPayloadV2 { param($Manifest) $lines = @( 'RexBIM-manifest-v2' "version=$($Manifest.Version)" "channel=$($Manifest.Channel)" "releasedAt=$($Manifest.ReleasedAt)" "minUpdaterVersion=$($Manifest.MinUpdaterVersion)" ) if ($Manifest.Updater -and $Manifest.Updater.Sha256) { $lines += "updater=$($Manifest.Updater.Sha256.ToLowerInvariant()):$($Manifest.Updater.Size):$($Manifest.Updater.Version)" } foreach ($year in ($Manifest.Years.Keys | Sort-Object)) { $entry = $Manifest.Years[$year] $lines += "$year=$($entry.Sha256.ToLowerInvariant()):$($entry.Size)" } return [string]::Join("`n", $lines) } <# Проверка подписи ECDSA P-256 + SHA-256 встроенным открытым ключом. Ключ импортируется координатами точки: ImportSubjectPublicKeyInfo в .NET Framework нет. Любая беда (кривой base64, не тот размер, подпись не сошлась) — это $false, а не исключение: для установки все эти случаи означают одно — манифесту не верим. #> function Test-RexManifestSignature { param( [string]$Payload, [string]$Signature, [string]$KeyX = $script:PublicKeyX, [string]$KeyY = $script:PublicKeyY ) if ([string]::IsNullOrEmpty($Payload) -or [string]::IsNullOrEmpty($Signature)) { return $false } if ([string]::IsNullOrEmpty($KeyX) -or [string]::IsNullOrEmpty($KeyY)) { return $false } try { $sig = [Convert]::FromBase64String($Signature.Trim()) $x = [Convert]::FromBase64String($KeyX.Trim()) $y = [Convert]::FromBase64String($KeyY.Trim()) # P-256: координата — ровно 32 байта, подпись r||s — ровно 64. if ($x.Length -ne 32 -or $y.Length -ne 32 -or $sig.Length -ne 64) { return $false } $point = New-Object System.Security.Cryptography.ECPoint $point.X = $x $point.Y = $y $parameters = New-Object System.Security.Cryptography.ECParameters $parameters.Curve = [System.Security.Cryptography.ECCurve+NamedCurves]::nistP256 $parameters.Q = $point $parameters.Validate() $ecdsa = [System.Security.Cryptography.ECDsa]::Create($parameters) try { $bytes = (New-Object System.Text.UTF8Encoding($false)).GetBytes($Payload) return $ecdsa.VerifyData($bytes, $sig, [System.Security.Cryptography.HashAlgorithmName]::SHA256) } finally { $ecdsa.Dispose() } } catch { return $false } } <# Манифест целиком: прочитать, разобрать, проверить подпись. Возвращает объект с полями Manifest / Path / Code / Message; Code 0 — годен. Подпись обязана быть формата v2 (fail-closed): манифест только с v1 либо старый, либо собран не нашим конвейером — принимать его нельзя, по этому каналу едет исполняемый код. #> function Get-RexManifest { param([string]$SourceValue, $Proxy, [string]$KeyX = $script:PublicKeyX, [string]$KeyY = $script:PublicKeyY) $path = Get-RexManifestPath $SourceValue $json = $null try { $json = Get-RexText -Path $path -Proxy $Proxy } catch { return [pscustomobject]@{ Manifest = $null; Path = $path; Code = 3; Message = "Не удалось связаться с сервером обновлений ($path).`n" + " Проверьте доступ в интернет и настройки прокси.`n" + " Подробность: $($_.Exception.Message)" } } $manifest = ConvertFrom-RexManifest -Json $json if (-not $manifest.Version) { return [pscustomobject]@{ Manifest = $null; Path = $path; Code = 3; Message = 'Сервер ответил, но манифест обновлений не читается (в нём нет версии).' } } if (-not $manifest.SignatureV2) { return [pscustomobject]@{ Manifest = $null; Path = $path; Code = 4; Message = 'Манифест обновлений без подписи формата v2. Установка остановлена.' } } if (-not (Test-RexManifestSignature -Payload (Get-RexPayloadV2 $manifest) -Signature $manifest.SignatureV2 -KeyX $KeyX -KeyY $KeyY)) { return [pscustomobject]@{ Manifest = $null; Path = $path; Code = 4; Message = "Подпись манифеста обновлений не прошла проверку. Установка остановлена.`n" + " Так бывает при подмене трафика (например, прокси с расшифровкой TLS отдал свою страницу)." } } return [pscustomobject]@{ Manifest = $manifest; Path = $path; Code = 0; Message = '' } } # ==== Установленное: версия и занятость ==== <# Первые до четырёх числовых частей версии: «1.11.5» и «1.11.5.0» обязаны сравниваться как равные, а «1.2.0» — быть МЕНЬШЕ «1.10.0» (строкой это не так). #> function ConvertTo-RexVersion { param([string]$Text) if ([string]::IsNullOrWhiteSpace($Text)) { return $null } $numbers = @() foreach ($part in ($Text.Trim() -split '[\.\+\- ]')) { $value = 0 if (-not [int]::TryParse($part, [ref]$value)) { break } $numbers += $value if ($numbers.Count -eq 4) { break } } if ($numbers.Count -eq 0) { return $null } while ($numbers.Count -lt 4) { $numbers += 0 } return (New-Object Version($numbers[0], $numbers[1], $numbers[2], $numbers[3])) } function Get-RexInstalledVersion { param([string]$Year) try { $dll = Join-Path (Join-Path $script:AddinsRoot $Year) 'RexBIM.dll' if (-not (Test-Path $dll)) { return $null } $info = (Get-Item $dll).VersionInfo $text = $info.FileVersion if (-not $text) { $text = $info.ProductVersion } return (ConvertTo-RexVersion $text) } catch { return $null } } function Test-RexFileLocked { param([string]$Path) if (-not (Test-Path $Path)) { return $false } try { $stream = [IO.File]::Open($Path, 'Open', 'ReadWrite', 'None') $stream.Dispose() return $false } catch { return $true } } <# Занятый файл в папке года или $null. Решает именно проба файла, а не имя процесса: имя не видит Revit в сеансе другого пользователя и фоновые службы Autodesk, а загруженную сборку Windows держит до конца процесса. #> function Get-RexLockedFile { param([string]$Dir) try { $core = Join-Path $Dir 'RexBIM.dll' if (Test-RexFileLocked $core) { return $core } $modules = Join-Path $Dir 'RexBIM\modules' if (Test-Path $modules) { foreach ($dll in (Get-ChildItem $modules -Filter *.dll -File -ErrorAction SilentlyContinue)) { if (Test-RexFileLocked $dll.FullName) { return $dll.FullName } } } return $null } catch { return $Dir # не смогли проверить — считаем занятым: лучше отложить, чем испортить } } # ==== Установка комплекта ==== function Remove-RexDir { param([string]$Path) try { if (Test-Path $Path) { Remove-Item $Path -Recurse -Force -ErrorAction Stop } } catch { } } <# Семейства пользователя из старой установки — в новую. Он правит их под свой стандарт, и снести чужую работу молча нельзя. Одноимённые из комплекта не трогаем: там свежая версия, а пользовательский файл лежит рядом под своим именем. #> function Copy-RexUserFamilies { param([string]$OldHome, [string]$NewHome) try { $oldFamilies = Join-Path $OldHome 'families' if (-not (Test-Path $oldFamilies)) { return } $newFamilies = Join-Path $NewHome 'families' [void](New-Item -ItemType Directory -Force -Path $newFamilies) foreach ($file in (Get-ChildItem $oldFamilies -Filter *.rfa -File -ErrorAction SilentlyContinue)) { $target = Join-Path $newFamilies $file.Name if (-not (Test-Path $target)) { Copy-Item $file.FullName $target -Force } } } catch { } # семейства — не повод рушить обновление } <# Ставит КОМПЛЕКТ: ядро RexBIM.dll плюс папка RexBIM\ (модули, библиотеки, семейства). Возвращает $null при успехе или текст ошибки. ⚠ Почему папкой целиком, а не пофайлово: прерванное на середине пофайловое обновление оставляет смесь версий, а ModuleLoader версии не сверяет — грузит что лежит, и ломается молча. Поэтому: развернуть в RexBIM.new рядом, потом подменить переименованиями (в пределах тома они атомарны). Модуль, убранный из новой версии, при этом исчезает — подпапка заменяется, а не сливается. #> function Install-RexPackage { param( [string]$Dir, [string]$Zip = '', [string]$AddinFallback = '', # Папка вместо архива: офлайн-комплект (plugins\<год> рядом с Install-RexBIM.ps1) # ставится той же подменой с откатом, что и скачанный архив. [string]$FromDir = '' ) $homeDir = Join-Path $Dir 'RexBIM' $staging = "$homeDir.new" $backup = "$homeDir.bak" $core = Join-Path $Dir 'RexBIM.dll' $tempCore = "$core.new" $coreBackup = "$core.bak" # Что уже сделано: от этого зависит, что возвращать при сбое. Порядок и правила те же, # что у Installer.InstallLocked апдейтера (аудит 10.2026, DL-04). $homeSwapped = $false $coreReplaced = $false try { [void](New-Item -ItemType Directory -Force -Path $Dir) Remove-RexDir $staging # Прошлая установка оборвалась между двумя переименованиями (окно закрыли, машину # выключили): папки RexBIM нет, рабочая копия только в .bak. Сначала вернуть её: # иначе строкой ниже .bak стёрся бы, и при сбое распаковки не осталось бы ни старых # модулей, ни новых. Не вышло вернуть: ставить дальше нельзя, .bak остаётся целым. if (-not (Test-Path $homeDir) -and (Test-Path $backup)) { [IO.Directory]::Move($backup, $homeDir) } Remove-RexDir $backup Remove-RexDir "$homeDir.failed" # хвост прошлого отката, см. Invoke-RexRollBack Remove-Item $tempCore -Force -ErrorAction SilentlyContinue # Ядро тоже могло застрять на полпути File.Replace: прежнее ушло в .bak, новое # не встало. Стирать .bak тогда значит остаться без ядра вовсе. if (-not (Test-Path $core) -and (Test-Path $coreBackup)) { [IO.File]::Move($coreBackup, $core) } Remove-Item $coreBackup -Force -ErrorAction SilentlyContinue if ($FromDir) { Copy-Item -LiteralPath $FromDir -Destination $staging -Recurse -Force -ErrorAction Stop } else { [System.IO.Compression.ZipFile]::ExtractToDirectory($Zip, $staging) } # Архив обязан содержать и ядро, и подпапку. Нет — это не наш комплект, # и рабочую установку лучше не трогать. $newCore = Join-Path $staging 'RexBIM.dll' $newHome = Join-Path $staging 'RexBIM' if (-not (Test-Path $newCore) -or -not (Test-Path $newHome)) { Remove-RexDir $staging return 'в комплекте нет RexBIM.dll или папки RexBIM' } Copy-RexUserFamilies -OldHome $homeDir -NewHome $newHome # 1) Подпапка: старую в сторону, новую на её место. if (Test-Path $homeDir) { [IO.Directory]::Move($homeDir, $backup) } [IO.Directory]::Move($newHome, $homeDir) $homeSwapped = $true # 2) Ядро: замена файла. Прежнее ядро держим в .bak до конца: сорвётся что-то # дальше, его есть чем вернуть. [IO.File]::Copy($newCore, $tempCore, $true) if (Test-Path $core) { [IO.File]::Replace($tempCore, $core, $coreBackup, $true) } else { [IO.File]::Move($tempCore, $core) } $coreReplaced = $true # 3) .addin рядом — без него Revit не увидит плагин. Предпочитаем файл из самого # комплекта: манифест надстройки мог поменяться вместе с версией. $packageAddin = Join-Path $staging 'RexBIM.addin' $addin = '' if (Test-Path $packageAddin) { $addin = $packageAddin } elseif ($AddinFallback -and (Test-Path $AddinFallback)) { $addin = $AddinFallback } if ($addin) { [IO.File]::Copy($addin, (Join-Path $Dir 'RexBIM.addin'), $true) } Remove-RexDir $staging Remove-RexDir $backup Remove-Item $coreBackup -Force -ErrorAction SilentlyContinue return $null } catch { $message = $_.Exception.Message Invoke-RexRollBack -Dir $Dir -HomeSwapped $homeSwapped -CoreReplaced $coreReplaced Remove-RexDir $staging return $message } } <# Откат сорвавшейся установки к прежней версии целиком: и ядро, и папка RexBIM. Копия Installer.RollBack апдейтера. ⚠ Раньше папка возвращалась, только если её вовсе не было на месте. Сбой замены ядра ПОСЛЕ замены папки (антивирус держит RexBIM.dll.new, Revit стартовал после пробы, кончилось место) оставлял новые модули при старом ядре: ту самую смесь версий, от которой защищает установка папкой (аудит 10.2026, DL-04). Порядок: сначала ядро, потом папка. Ядро вернуть не вышло: папку не трогаем, новое ядро с новыми модулями лучше старых модулей при новом ядре. Новая папка уходит в сторону переименованием, а не удалением: удаление может застрять на середине и не дать вернуть старую. #> function Invoke-RexRollBack { param([string]$Dir, [bool]$HomeSwapped, [bool]$CoreReplaced) $homeDir = Join-Path $Dir 'RexBIM' $backup = "$homeDir.bak" $failed = "$homeDir.failed" $core = Join-Path $Dir 'RexBIM.dll' $coreBackup = "$core.bak" $coreIsOld = $true try { if ($CoreReplaced) { # Ядро заменено, прежнее в .bak. Прежнего не было (первая установка): возвращать # нечего, папку тогда тоже оставляем новой. if (Test-Path $coreBackup) { [IO.File]::Copy($coreBackup, $core, $true) } else { $coreIsOld = $false } } elseif (-not (Test-Path $core) -and (Test-Path $coreBackup)) { # File.Replace сорвался на полпути: прежнее ядро ушло в .bak, новое не встало. [IO.File]::Move($coreBackup, $core) } } catch { $coreIsOld = $false } if ($coreIsOld) { try { if ($HomeSwapped -and (Test-Path $backup)) { Remove-RexDir $failed [IO.Directory]::Move($homeDir, $failed) [IO.Directory]::Move($backup, $homeDir) Remove-RexDir $failed } elseif (-not (Test-Path $homeDir) -and (Test-Path $backup)) { [IO.Directory]::Move($backup, $homeDir) } } catch { # Новую папку убрали, а старую вернуть не вышло: хотя бы новую на место, # ядро без папки модулей хуже смеси версий. try { if (-not (Test-Path $homeDir) -and (Test-Path $failed)) { [IO.Directory]::Move($failed, $homeDir) } } catch { } } } Remove-Item "$core.new" -Force -ErrorAction SilentlyContinue if (Test-Path $core) { Remove-Item $coreBackup -Force -ErrorAction SilentlyContinue } } <# Сверка того, что РЕАЛЬНО лежит на месте после установки, с тем, что было в комплекте. Копирование само по себе об этом не скажет: оно отработало без ошибки. А Kaspersky Endpoint Security сносит неподписанные .dll молча и уже ПОСЛЕ распаковки — установка отчиталась бы об успехе, а Revit не нашёл бы ни ядра, ни модулей, и виноватым выглядел бы плагин. Возвращает $null, если всё на месте, или текст расхождения. #> function Test-RexLanded { param([string]$Dir, [string]$Zip = '', [string]$FromDir = '') if (-not (Test-Path (Join-Path $Dir 'RexBIM.dll'))) { return 'ядра RexBIM.dll нет на месте сразу после установки' } $expected = 0 if ($FromDir) { $expected = @(Get-ChildItem (Join-Path $FromDir 'RexBIM\modules') -Filter *.dll -File -ErrorAction SilentlyContinue).Count } else { try { $archive = [System.IO.Compression.ZipFile]::OpenRead($Zip) # Разделитель в именах записей — И обратная косая, И прямая: комплекты собирает # Compress-Archive (dist\Build-Release.ps1), а он на Windows пишет пути через # обратную. Шаблон только с прямой не нашёл бы НИ ОДНОГО модуля — и проверка # молча ничего бы не проверяла. try { $expected = @($archive.Entries | Where-Object { $_.FullName -match '^RexBIM[\\/]modules[\\/][^\\/]+\.dll$' }).Count } finally { $archive.Dispose() } } catch { } } $actual = @(Get-ChildItem (Join-Path $Dir 'RexBIM\modules') -Filter *.dll -File -ErrorAction SilentlyContinue).Count if ($actual -eq 0) { return 'папка модулей пуста' } if ($expected -gt 0 -and $actual -lt $expected) { return "модулей в комплекте $expected, на месте $actual" } return $null } # ==== Апдейтер, ярлыки, реестр ==== function Stop-RexUpdater { # Только ТОТ процесс, что запущен ИМЕННО из $script:LocalDir\RexBIM.Updater.exe # (сравнение путём, не именем) — раньше матчило ЛЮБОЙ процесс «RexBIM.Updater» # в системе. НАЙДЕНО 14.09.2026: check-install-script.ps1 подменяет $script:LocalDir # на песочную папку (см. комментарий у объявления выше), но эта функция путь # игнорировала и дотягивалась до настоящего живого watch-экземпляра пользователя — # PID менялся после каждого check-all.ps1 (правила §12b). Тот же баг в продакшене: # на терминальном сервере матч по имени убил бы апдейтер ДРУГОГО пользователя. try { $target = Join-Path $script:LocalDir 'RexBIM.Updater.exe' Get-Process -Name 'RexBIM.Updater' -ErrorAction SilentlyContinue | ForEach-Object { $path = $null try { $path = $_.MainModule.FileName } catch { } # чужая сессия/права — не наш if ($path -and $path -eq $target) { try { $_.Kill(); [void]$_.WaitForExit(3000) } catch { } } } } catch { } } <# Ярлык на апдейтер (без аргументов — watch-режим). WScript.Shell: другого способа сделать .lnk из скрипта нет. #> function New-RexShortcut { param([string]$Path, [string]$Target, [string]$WorkingDirectory, [string]$Description) try { [void](New-Item -ItemType Directory -Force -Path (Split-Path -Parent $Path)) $shell = New-Object -ComObject WScript.Shell $shortcut = $shell.CreateShortcut($Path) $shortcut.TargetPath = $Target $shortcut.WorkingDirectory = $WorkingDirectory $shortcut.WindowStyle = 7 # свёрнуто/скрыто $shortcut.Description = $Description $shortcut.Save() return $true } catch { Write-RexLog 'WARN' ('ярлык ' + (Split-Path -Leaf $Path) + ': ' + $_.Exception.Message) return $false } } <# Копия скрипта в %LOCALAPPDATA%\RexBIM: ею продукт удаляется из «Приложений и возможностей» — так же, как exe кладёт туда себя деинсталлятором. ⚠ Копируем ФАЙЛ, а не пишем в него собственный текст из памяти. Скрипт, который выкладывает сам себя, — известный признак червя, и антивирусные эвристики цепляются именно за него: AMSI один раз уже отказался грузить этот файл целиком. Обычное копирование файла столько же пользы и не выглядит самовоспроизведением. Запущенному без файла (через iex) копировать нечего — тогда записи в «Приложениях и возможностях» не будет: удалять её было бы нечем. #> function Save-RexSelfCopy { if ([string]::IsNullOrWhiteSpace($script:SelfPath) -or -not (Test-Path $script:SelfPath)) { Write-RexLog 'WARN' 'запуск без файла на диске — копия для удаления не положена' return '' } try { [void](New-Item -ItemType Directory -Force -Path $script:LocalDir) $path = Join-Path $script:LocalDir $script:SelfName # Уже запущены из папки установки (переустановка тем же файлом) — копировать некуда. if ([IO.Path]::GetFullPath($path) -eq [IO.Path]::GetFullPath($script:SelfPath)) { return $path } Copy-Item $script:SelfPath $path -Force return $path } catch { Write-RexLog 'WARN' ('копия скрипта для удаления: ' + $_.Exception.Message) return '' } } <# Разворачивает апдейтер из УСТАНОВЛЕННОГО комплекта (RexBIM\updater\ едет внутри каждого архива), пишет updater.config, ярлыки и запускает его. Всё в профиле пользователя, без UAC. #> function Install-RexUpdater { param( [string[]]$Years, # Готовый exe апдейтера (офлайн-комплект: updater\RexBIM.Updater.exe). Пусто — из # установленного комплекта года, RexBIM\updater\. [string]$UpdaterFrom = '', # Источник, заданный ЯВНО (-Source). Пусто — в настройках остаётся прежний. [string]$SourceValue = '', # Источник для файла, где его нет: тот, откуда сейчас ставили. [string]$FallbackSource = '', [bool]$Autostart = $true, [bool]$Launch = $true, # Прокси, заданный ЯВНО (-Proxy): $null — настройки прокси апдейтера не трогать. $UpdaterProxy = $null ) [void](New-Item -ItemType Directory -Force -Path $script:LocalDir) $updaterExe = Join-Path $script:LocalDir 'RexBIM.Updater.exe' $updaterSource = '' if ($UpdaterFrom -and (Test-Path $UpdaterFrom)) { $updaterSource = $UpdaterFrom } $addinSource = '' foreach ($year in $Years) { $dir = Join-Path $script:AddinsRoot $year $candidate = Join-Path $dir 'RexBIM\updater\RexBIM.Updater.exe' if (-not $updaterSource -and (Test-Path $candidate)) { $updaterSource = $candidate } $addin = Join-Path $dir 'RexBIM.addin' if (-not $addinSource -and (Test-Path $addin)) { $addinSource = $addin } } if ($updaterSource) { try { Copy-Item $updaterSource $updaterExe -Force } catch { Write-RexWarn ('Программа автообновления не обновилась, работает прежняя: ' + $_.Exception.Message) } } else { Write-RexLog 'WARN' 'в комплекте не нашлось RexBIM\updater\RexBIM.Updater.exe' } # Манифест надстройки рядом с апдейтером — его фолбэк при установке комплектов. if ($addinSource) { try { Copy-Item $addinSource (Join-Path $script:LocalDir 'RexBIM.addin') -Force } catch { } } # Настройки апдейтера СЛИВАЮТСЯ с прежними, а не пишутся заново (аудит 10.2026, DL-10): # источник меняется только явным -Source (или если его не было), прокси только явным # -Proxy, выключенное автообновление, канал, интервал и свой адрес статистики остаются. # Адрес приёмника статистики пишется, только если его нет. Согласия ключ не подменяет: # сбор включается галочкой в «Личном кабинете», без неё очереди нет. $configPath = Join-Path $script:LocalDir 'updater.config' $setProxy = $null -ne $UpdaterProxy $proxyUrl = ''; $proxyUser = ''; $proxyPassword = '' if ($setProxy) { $proxyUrl = $UpdaterProxy.Url; $proxyUser = $UpdaterProxy.User; $proxyPassword = $UpdaterProxy.Password } $configProblem = Save-RexUpdaterConfig -Path $configPath -Source $SourceValue -DefaultSource $FallbackSource ` -Telemetry $script:TelemetryEndpoint -SetProxy $setProxy -ProxyUrl $proxyUrl -ProxyUser $proxyUser ` -ProxyPassword $proxyPassword if ($configProblem) { Write-RexWarn ('Автообновление: ' + $configProblem + '.') } # Ярлык меню «Пуск» — ВСЕГДА: это единственный способ найти апдейтер поиском Windows # и поднять его после «Выхода» из трея. Ярлык автозагрузки — по ключу; при -NoAutostart # снимается и оставшийся от прошлой установки, иначе переустановкой нельзя было бы # ВЫКЛЮЧИТЬ автозапуск. [void](New-RexShortcut -Path (Join-Path $script:StartMenuDir $script:StartMenuShortcutName) ` -Target $updaterExe -WorkingDirectory $script:LocalDir ` -Description 'Автообновление плагина RexBIM (значок в трее; устанавливает, когда Revit закрыт)') $startupLink = Join-Path $script:StartupDir $script:StartupShortcutName if ($Autostart) { [void](New-RexShortcut -Path $startupLink -Target $updaterExe -WorkingDirectory $script:LocalDir ` -Description 'Автообновление плагина RexBIM (устанавливает, когда Revit закрыт)') } else { Remove-Item $startupLink -Force -ErrorAction SilentlyContinue } if ($Launch -and (Test-Path $updaterExe)) { # Без -WindowStyle Hidden: апдейтер и так живёт значком в трее, консоли у него нет, # а «запустить скрытно» — лишний повод для эвристики антивируса. try { Start-Process -FilePath $updaterExe -WorkingDirectory $script:LocalDir } catch { Write-RexLog 'WARN' ('апдейтер не запустился: ' + $_.Exception.Message) } } return $updaterExe } <# Запись в «Приложениях и возможностях». HKCU, поэтому без UAC. EstimatedSize — DWORD в КИЛОБАЙТАХ: в байтах список программ покажет размер в тысячу раз больше. #> function Register-RexUninstall { param([string]$Version, [long]$InstalledBytes, [string]$UninstallCommand, [string]$QuietCommand, [string]$Icon) try { $key = 'HKCU:\' + $script:UninstallKey if (-not (Test-Path $key)) { [void](New-Item -Path $key -Force) } $strings = @{ DisplayName = 'RexBIM' DisplayVersion = $Version Publisher = 'RexBIM Solutions' DisplayIcon = $Icon InstallLocation = $script:LocalDir UninstallString = $UninstallCommand QuietUninstallString = $QuietCommand URLInfoAbout = 'https://rexbim.cloud' HelpLink = 'https://t.me/rexbim_chat' InstallDate = (Get-Date -Format 'yyyyMMdd') } foreach ($name in $strings.Keys) { New-ItemProperty -Path $key -Name $name -Value $strings[$name] -PropertyType String -Force | Out-Null } New-ItemProperty -Path $key -Name 'NoModify' -Value 1 -PropertyType DWord -Force | Out-Null New-ItemProperty -Path $key -Name 'NoRepair' -Value 1 -PropertyType DWord -Force | Out-Null $kb = [int][Math]::Min([int]::MaxValue, [Math]::Max(1, [Math]::Floor($InstalledBytes / 1024))) New-ItemProperty -Path $key -Name 'EstimatedSize' -Value $kb -PropertyType DWord -Force | Out-Null } catch { Write-RexLog 'WARN' ('реестр: ' + $_.Exception.Message) } } <# Команда удаления для «Приложений и возможностей». Если рядом лежит RexBIM_Uninstall.exe (машину когда-то ставили установщиком) — зовём его: у него настоящее окно с галочкой «стереть настройки». Скриптовая копия остаётся запасным вариантом. #> function Get-RexUninstallCommands { param([string]$SelfCopy) $exe = Join-Path $script:LocalDir 'RexBIM_Uninstall.exe' if (Test-Path $exe) { return [pscustomobject]@{ Command = "`"$exe`""; Quiet = "`"$exe`" /S"; Icon = $exe } } if (-not $SelfCopy) { return $null } $powershell = Join-Path $env:SystemRoot 'System32\WindowsPowerShell\v1.0\powershell.exe' $base = "`"$powershell`" -NoProfile -ExecutionPolicy Bypass -File `"$SelfCopy`" -Uninstall" return [pscustomobject]@{ Command = $base; Quiet = "$base -Quiet" Icon = (Join-Path $script:LocalDir 'RexBIM.Updater.exe') } } # ==== Установка целиком ==== function Invoke-RexInstall { param( [string[]]$RequestedYears, [string]$SourceValue = '', $Proxy = $null, [bool]$Autostart = $true, [bool]$LaunchUpdater = $true, [bool]$ForceReinstall = $false, # Спрашивать, во что ставить. Проверки зовут функцию напрямую и ждут работы # без вопросов, поэтому по умолчанию — молча. [bool]$Interactive = $false, [string]$KeyX = $script:PublicKeyX, [string]$KeyY = $script:PublicKeyY, # Прокси, заданный человеком ЯВНО (-Proxy), для настроек апдейтера: # объект с полями Url, User, Password. $null — прокси апдейтера не трогать. $UpdaterProxy = $null ) # Источник, названный явно, переписывает источник апдейтера; не названный (обычный # запуск) оставляет прежний: контора на своей папке не уезжает на сервер (DL-10). $explicitSource = '' if (-not [string]::IsNullOrWhiteSpace($SourceValue)) { $explicitSource = $SourceValue.Trim() } if ([string]::IsNullOrWhiteSpace($SourceValue)) { $SourceValue = $script:DefaultSource } $script:StepTotal = 7 # ==== 1. Сервер и подпись ==== Write-RexStep 1 'Связываюсь с сервером обновлений' Write-RexSay "Источник: $SourceValue" $fetched = Get-RexManifest -SourceValue $SourceValue -Proxy $Proxy -KeyX $KeyX -KeyY $KeyY if ($fetched.Code -ne 0) { Write-RexBad $fetched.Message; return $fetched.Code } $manifest = $fetched.Manifest # Скрипт ставит только стабильный выпуск, как установщик и апдейтер: канал входит # в подпись, но подпись сама не говорит, ЧТО подписано. Бета, по ошибке выложенная # в общий источник, иначе встала бы каждому, кто ставит скриптом (аудит 10.2026, DL-48). if ($manifest.Channel -ne 'stable') { Write-RexBad ("В источнике лежит выпуск $($manifest.Version) не для всех (канал «$($manifest.Channel)»).`n" + " Ставить его скриптом нельзя. Повторите позже или напишите в поддержку.") return 3 } Write-RexOk "Подпись манифеста проверена (ключ выпуска $($script:KeyFingerprint))" $when = '' if ($manifest.ReleasedAt) { $when = ', выпуск ' + (($manifest.ReleasedAt -replace 'T', ' ') -replace 'Z', ' UTC') } Write-RexSay "Свежая версия: $($manifest.Version), канал $($manifest.Channel)$when" if ($manifest.Notes) { Write-RexSay "Что нового: $($manifest.Notes)" } # ==== 2. Версии Revit и выбор ==== Write-RexStep 2 'Смотрю, какие версии Revit есть на машине' # Какие годы рассматривать: заказанные ключом или все найденные (папки Addins плюс реестр). $wanted = @() $explicit = $false if ($RequestedYears -and @($RequestedYears).Count -gt 0 -and -not (@($RequestedYears) -contains 'all') -and -not (@($RequestedYears) -contains 'All')) { $wanted = @(ConvertTo-RexRequestedYears -Names $RequestedYears) if ($wanted.Count -eq 0) { Write-RexBad "В -Years не разобран ни один год: $($RequestedYears -join ' ')" return 2 } $explicit = $true } else { $wanted = @(Get-RexRevitYears) if ($wanted.Count -eq 0) { Write-RexBad 'На машине не найдено ни одной версии Revit (папки Addins и реестр пусты).' return 8 } } # Таблица «что нашли и что с этим будет» — ДО того, как что-то трогать. Человек # должен видеть, во что вмешаются, а не узнавать об этом из отчёта постфактум. $latest = ConvertTo-RexVersion $manifest.Version $rows = @() foreach ($year in $wanted) { $installed = Get-RexInstalledVersion $year # Тремя числами, как в манифесте: «стоит 1.7.0.0, будет 1.7.0» читается как разные # версии, хотя это одна и та же. $shown = 'нет' if ($installed) { $shown = '{0}.{1}.{2}' -f $installed.Major, $installed.Minor, $installed.Build } $state = 'обновится' if (-not $manifest.Years.ContainsKey($year)) { $state = 'нет в выпуске' } elseif (-not $installed) { $state = 'установится' } elseif ($latest -and $installed -ge $latest) { $state = 'уже свежее' if ($ForceReinstall) { $state = 'переставится' } } $rows += [pscustomobject]@{ Year = $year; Installed = $shown; State = $state } } $doable = @('установится', 'обновится', 'переставится') $offered = @($rows | Where-Object { $doable -contains $_.State } | ForEach-Object { $_.Year }) # Живой список с галочками рисует себя сам и перерисовывает на месте, поэтому # статическую таблицу печатаем только там, где выбирать не будут: с -Quiet, с явным # -Years и когда клавиш не почитать. $withKeys = $Interactive -and -not $explicit -and $offered.Count -gt 0 -and (Test-RexConsoleKeys) if (-not $withKeys) { Write-Host '' Write-Host (' {0,-7} {1,-14} {2}' -f 'Revit', 'установлено', "комплект $($manifest.Version)") $number = 0 foreach ($row in $rows) { $mark = ' ' $color = 'DarkGray' if ($doable -contains $row.State) { $number++; $mark = (' {0,2}) ' -f $number); $color = 'Gray' } Write-Host ("{0}{1,-7} {2,-14} {3}" -f $mark, $row.Year, $row.Installed, $row.State) -ForegroundColor $color } } if ($offered.Count -eq 0) { if (@($rows | Where-Object { $_.State -eq 'уже свежее' }).Count -gt 0) { Write-Host '' Write-RexOk 'Везде уже стоит свежая версия, делать нечего.' Write-RexSay 'Переставить принудительно можно ключом -Force.' return 0 } Write-RexBad "Не нашлось ни одной версии Revit, для которой есть комплект $($manifest.Version)." return 8 } # Выбор спрашиваем только у живого человека и только когда он не назвал годы сам. $target = $offered if ($Interactive -and -not $explicit) { if ($withKeys) { $target = Select-RexYearsByKeys -Rows $rows -Version $manifest.Version } else { $target = Select-RexYearsByLine -Offered $offered } if ($null -eq $target) { Write-Host ''; Write-RexSay 'Отменено, ничего не тронуто.'; return 0 } if (@($target).Count -eq 0) { Write-RexBad 'Ни одна версия не выбрана, ставить нечего.' return 8 } } Write-Host '' Write-RexOk ('Ставим для Revit: ' + ($target -join ', ')) # ==== 3. Занятость файлов ==== # Решает проба файла, а не имя процесса: имя не видит Revit в сеансе другого # пользователя и фоновые службы Autodesk. Проверяем ДО скачивания — качать # по полтора мегабайта на год, чтобы упереться в открытый Revit, незачем. Write-RexStep 3 'Проверяю, что Revit закрыт и файлы свободны' foreach ($year in $target) { $locked = Get-RexLockedFile (Join-Path $script:AddinsRoot $year) if ($locked) { Write-RexBad ("Файлы плагина заняты. Похоже, Revit ещё открыт.`n" + " Закройте все окна Revit и повторите.`n" + " Занятый файл: $locked") return 5 } } Write-RexOk 'Файлы свободны' # ==== 4. Загрузка ==== # Размер и sha256 проверяются по ПОДПИСАННОМУ манифесту, поэтому совпадение хеша # доказывает и целостность, и подлинность архива. $totalMb = 0.0 foreach ($year in $target) { $totalMb += [Math]::Max(0, $manifest.Years[$year].Size) / 1MB } Write-RexStep 4 ('Скачиваю комплекты: {0} шт., около {1:N1} МБ' -f @($target).Count, $totalMb) [void](New-Item -ItemType Directory -Force -Path $script:DownloadDir) $zips = @{} foreach ($year in $target) { $entry = $manifest.Years[$year] $zip = Join-Path $script:DownloadDir "RexBIM_$year.zip" $ready = $false if (Test-Path $zip) { # Уже скачан этим или прошлым запуском — не качаем заново. $ready = ((Get-FileHash $zip -Algorithm SHA256).Hash.ToLowerInvariant() -eq $entry.Sha256) } if ($ready) { Write-RexSay "Revit ${year}: комплект уже скачан, беру готовый." } if (-not $ready) { Write-RexSay ('Revit {0}: качаю {1:N1} МБ…' -f $year, ($entry.Size / 1MB)) $url = Resolve-RexEntryPath -ManifestPath $fetched.Path -Entry $entry.Url $temp = "$zip.part" try { Invoke-RexDownload -Url $url -Target $temp -Proxy $Proxy -Title "RexBIM $($manifest.Version): Revit $year" } catch { Remove-Item $temp -Force -ErrorAction SilentlyContinue Write-RexBad ("Не удалось скачать комплект Revit $year.`n" + " Проверьте доступ в интернет и настройки прокси.`n" + " Подробность: $($_.Exception.Message)") return 3 } $size = (Get-Item $temp).Length if ($entry.Size -gt 0 -and $size -ne $entry.Size) { Remove-Item $temp -Force -ErrorAction SilentlyContinue Write-RexBad ("Комплект Revit $year скачался не целиком ($size из $($entry.Size) байт).`n" + " Чаще всего это прокси, отдавший свою страницу вместо файла. Попробуйте ещё раз.") return 6 } if ((Get-FileHash $temp -Algorithm SHA256).Hash.ToLowerInvariant() -ne $entry.Sha256) { Remove-Item $temp -Force -ErrorAction SilentlyContinue Write-RexBad ("Комплект Revit $year повреждён при загрузке (контрольная сумма не совпала).`n" + " Попробуйте ещё раз; если повторяется, напишите в поддержку.") return 6 } Remove-Item $zip -Force -ErrorAction SilentlyContinue Move-Item $temp $zip -Force Write-RexOk "Revit ${year}: скачано, размер и контрольная сумма сошлись" } $zips[$year] = $zip } # ==== 5. Установка ==== Write-RexStep 5 'Устанавливаю плагин' # Перед подменой файлов гасим апдейтер: его exe сейчас будет заменён. Stop-RexUpdater $installedBytes = 0L $done = @() foreach ($year in $target) { $dir = Join-Path $script:AddinsRoot $year Write-RexSay "Revit ${year}: разворачиваю комплект в $dir" $failure = Install-RexPackage -Dir $dir -Zip $zips[$year] -AddinFallback (Join-Path $script:LocalDir 'RexBIM.addin') if ($failure) { Write-RexBad "Установка для Revit $year не удалась.`n Подробность: $failure" return 7 } $installedBytes += [Math]::Max(0, $manifest.Years[$year].Size) $done += $year Write-RexOk "Revit ${year}: комплект развёрнут" } # ==== 6. Сверка ==== # Отдельным этапом и ПОСЛЕ всех установок: антивирус забирает неподписанные .dll # не мгновенно, и файлы первого года могут исчезнуть, пока ставится второй. # Копирование само по себе об этом не скажет — оно отработало без ошибки. Write-RexStep 6 'Сверяю, что файлы действительно на месте' foreach ($year in $done) { $dir = Join-Path $script:AddinsRoot $year $trouble = Test-RexLanded -Dir $dir -Zip $zips[$year] if ($trouble) { Write-RexBad ("Revit ${year}: файлы не на месте сразу после установки ($trouble).`n" + " Почти наверняка их забрал антивирус: комплект не подписан.`n" + " Добавьте в исключения папку $dir и повторите установку.") return 7 } $modules = @(Get-ChildItem (Join-Path $dir 'RexBIM\modules') -Filter *.dll -File -ErrorAction SilentlyContinue).Count Write-RexOk ("Revit {0}: ядро и {1} на месте" -f $year, (Format-RexCount $modules 'модуль' 'модуля' 'модулей')) } # ==== 7. Обвязка ==== # Апдейтер, ярлыки, реестр. Плагин уже стоит и работает, поэтому сбой обвязки # не роняет установку — но и молчать о нём нельзя. Write-RexStep 7 'Настраиваю автообновление и запись в «Программах»' try { $updaterExe = Install-RexUpdater -Years $done -SourceValue $explicitSource -FallbackSource $SourceValue ` -Autostart $Autostart -Launch $LaunchUpdater -UpdaterProxy $UpdaterProxy if (Test-Path $updaterExe) { if ($Autostart) { Write-RexOk 'Автообновление включено (проверка раз в час, установка при закрытом Revit)' } else { Write-RexOk 'Автообновление установлено; запуск при входе в Windows выключен' } } else { Write-RexWarn ('Автообновление не установлено: программа исчезла сразу после копирования, похоже, ' + 'её забрал антивирус. Плагин работает, но новые версии придётся ставить вручную.') } $selfCopy = Save-RexSelfCopy $commands = Get-RexUninstallCommands -SelfCopy $selfCopy if ($commands) { Register-RexUninstall -Version $manifest.Version -InstalledBytes $installedBytes ` -UninstallCommand $commands.Command -QuietCommand $commands.Quiet -Icon $commands.Icon } } catch { Write-RexLog 'ERR ' ('обвязка (апдейтер/реестр): ' + $_.Exception.Message) Write-RexWarn ('Плагин установлен, но автообновление или запись в «Приложениях» настроились не до конца: ' + $_.Exception.Message) } foreach ($zip in $zips.Values) { Remove-Item $zip -Force -ErrorAction SilentlyContinue } Write-RexLog 'INFO' ("установлено $($manifest.Version) для: " + ($done -join ', ')) Write-Host '' Write-Host ' ==== Готово ====' -ForegroundColor Green Write-RexOk ("RexBIM $($manifest.Version) установлен для Revit: " + ($done -join ', ')) Write-RexSay 'Запустите Revit: инструменты появятся на вкладке RexBIM.' Write-RexSay "Журнал установки: $(Join-Path $script:LocalDir 'setup.log')" return 0 } # ==== Установка из распакованного комплекта (Install-RexBIM.ps1) ==== <# Снять с файлов отметку «скачано из интернета» (поток Zone.Identifier). Архив, распакованный Проводником, передаёт её каждому файлу, а копирование переносит её на место установки; Revit такую сборку может отказаться грузить, а Windows спрашивает про каждый скрипт (аудит 10.2026, DL-49). Не вышло: не беда, отметки может и не быть. #> function Unblock-RexTree { param([string]$Path) try { if (-not (Test-Path -LiteralPath $Path)) { return } Get-ChildItem -LiteralPath $Path -Recurse -File -ErrorAction SilentlyContinue | Unblock-File -ErrorAction SilentlyContinue } catch { } } <# Годы, для которых в комплекте есть сборка: plugins\<год>\RexBIM.dll. #> function Get-RexKitYears { param([string]$KitDir) $years = @() $plugins = Join-Path $KitDir 'plugins' foreach ($dir in @(Get-ChildItem -LiteralPath $plugins -Directory -ErrorAction SilentlyContinue)) { if ($dir.Name -match '^20\d\d$' -and (Test-Path (Join-Path $dir.FullName 'RexBIM.dll'))) { $years += $dir.Name } } return @($years | Sort-Object) } <# Установка из распакованного архива RexBIM-<версия>-Setup.zip: plugins\<год>, updater\ и RexBIM.addin рядом. Та же механика, что у установки с сервера: подмена папки с откатом, сохранение семейств, сверка, слияние настроек апдейтера, запись в «Приложениях». Раньше Install-RexBIM.ps1 делал всё своим кодом, и каждая правка установщика расходилась с ним (аудит 10.2026, DL-10, DL-16, DL-49). Коды те же, что у установки с сервера: 0, 2 (комплект не целиком), 5, 7, 8. #> function Invoke-RexKitInstall { param( [string]$KitDir, [string[]]$RequestedYears, # Источник, заданный явно (-Source). Пусто — прежний, а в новых настройках dl.rexbim.cloud. [string]$SourceValue = '', [bool]$NoUpdater = $false, [bool]$Autostart = $true, [bool]$LaunchUpdater = $true ) $script:StepTotal = 4 $addin = Join-Path $KitDir 'RexBIM.addin' $plugins = Join-Path $KitDir 'plugins' Write-RexStep 1 'Проверяю комплект и версии Revit' if (-not (Test-Path $addin) -or -not (Test-Path $plugins)) { Write-RexBad 'Рядом со скриптом нет RexBIM.addin или папки plugins. Распакуйте архив целиком и запустите снова.' return 2 } Unblock-RexTree $KitDir $kitYears = @(Get-RexKitYears $KitDir) if ($kitYears.Count -eq 0) { Write-RexBad 'В папке plugins нет ни одной сборки. Распакуйте архив целиком и запустите снова.' return 2 } $explicit = $RequestedYears -and @($RequestedYears).Count -gt 0 if ($explicit) { $wanted = @(ConvertTo-RexRequestedYears -Names $RequestedYears) if ($wanted.Count -eq 0) { Write-RexBad "В -Years не разобран ни один год: $($RequestedYears -join ' ')"; return 2 } } else { $wanted = @(Get-RexRevitYears) if ($wanted.Count -eq 0) { Write-RexBad ("Не нашёл установленного Revit: нет ни папок надстроек в $($script:AddinsRoot), " + "ни записи об установке, ни Revit.exe в Program Files.`n" + " Если Revit стоит, назовите год сами, например: -Years 2022") return 8 } } $target = @() foreach ($year in $wanted) { if ($kitYears -contains $year) { $target += $year } else { Write-RexWarn "Revit ${year}: в комплекте нет сборки для этой версии, пропускаю" } } if ($target.Count -eq 0) { Write-RexBad ("Ни для одной найденной версии Revit ($($wanted -join ', ')) в комплекте нет сборки. " + "В комплекте: $($kitYears -join ', ').") return 8 } Write-RexOk ('Ставим для Revit: ' + ($target -join ', ')) Write-RexStep 2 'Проверяю, что Revit закрыт и файлы свободны' foreach ($year in $target) { $locked = Get-RexLockedFile (Join-Path $script:AddinsRoot $year) if ($locked) { Write-RexBad ("Файлы плагина заняты. Похоже, Revit ещё открыт.`n" + " Закройте все окна Revit и повторите.`n" + " Занятый файл: $locked") return 5 } } Write-RexOk 'Файлы свободны' Write-RexStep 3 'Устанавливаю плагин' if (-not $NoUpdater) { Stop-RexUpdater } $done = @() $installedBytes = 0L foreach ($year in $target) { $dir = Join-Path $script:AddinsRoot $year $from = Join-Path $plugins $year $failure = Install-RexPackage -Dir $dir -FromDir $from -AddinFallback $addin if ($failure) { Write-RexBad "Установка для Revit $year не удалась.`n Подробность: $failure"; return 7 } Unblock-RexTree $dir $trouble = Test-RexLanded -Dir $dir -FromDir $from if ($trouble) { Write-RexBad ("Revit ${year}: файлы не на месте сразу после установки ($trouble).`n" + " Почти наверняка их забрал антивирус: комплект не подписан.`n" + " Добавьте в исключения папку $dir и повторите установку.") return 7 } try { $installedBytes += (Get-ChildItem -LiteralPath $from -Recurse -File -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum).Sum } catch { } $ver = (Get-Item (Join-Path $dir 'RexBIM.dll')).VersionInfo.FileVersion Write-RexOk "Revit $year (версия $ver)" $done += $year } Write-RexStep 4 'Настраиваю автообновление и запись в «Программах»' $version = '' try { $version = (Get-Item (Join-Path (Join-Path $plugins $done[0]) 'RexBIM.dll')).VersionInfo.FileVersion } catch { } if ($version) { $version = (($version -split '\.')[0..2]) -join '.' } if ($NoUpdater) { Write-RexSay 'Автообновление не ставлю, как просили.' } else { $updaterFrom = Join-Path $KitDir 'updater\RexBIM.Updater.exe' if (-not (Test-Path $updaterFrom)) { Write-RexWarn 'В комплекте нет программы автообновления: новые версии придётся ставить вручную.' } else { try { # Настройки из комплекта (контора кладёт свой updater.config рядом) — основа для # нового файла; прежние настройки человека важнее, их не трогаем. $kitConfig = Join-Path $KitDir 'updater\updater.config' $configPath = Join-Path $script:LocalDir 'updater.config' if ((Test-Path $kitConfig) -and -not (Test-Path $configPath)) { [void](New-Item -ItemType Directory -Force -Path $script:LocalDir) Copy-Item -LiteralPath $kitConfig -Destination $configPath -Force } $updaterExe = Install-RexUpdater -Years $done -UpdaterFrom $updaterFrom -SourceValue $SourceValue ` -FallbackSource $script:DefaultSource -Autostart $Autostart -Launch $LaunchUpdater Unblock-RexTree $script:LocalDir if (Test-Path $updaterExe) { $where = Get-RexConfigValue -Path $configPath -Key 'source' Write-RexOk "Автообновление включено: проверка раз в час, установка при закрытом Revit. Откуда: $where" } else { Write-RexWarn ('Автообновление не установлено: программа исчезла сразу после копирования, похоже, ' + 'её забрал антивирус. Плагин работает, но новые версии придётся ставить вручную.') } } catch { Write-RexLog 'ERR ' ('автообновление (офлайн): ' + $_.Exception.Message) Write-RexWarn ('Плагин установлен, но автообновление настроилось не до конца: ' + $_.Exception.Message) } } } try { $selfCopy = Save-RexSelfCopy $commands = Get-RexUninstallCommands -SelfCopy $selfCopy if ($commands -and $version) { Register-RexUninstall -Version $version -InstalledBytes $installedBytes ` -UninstallCommand $commands.Command -QuietCommand $commands.Quiet -Icon $commands.Icon } } catch { Write-RexLog 'WARN' ('реестр (офлайн): ' + $_.Exception.Message) } Write-RexLog 'INFO' ("установлено из комплекта $version для: " + ($done -join ', ')) Write-Host '' Write-Host ' ==== Готово ====' -ForegroundColor Green Write-RexOk ("RexBIM $version установлен для Revit: " + ($done -join ', ')) Write-RexSay 'Запустите Revit: инструменты появятся на вкладке RexBIM.' Write-RexSay "Журнал установки: $(Join-Path $script:LocalDir 'setup.log')" return 0 } # ==== Удаление ==== # Что установка и её откат оставляют в папке года и в %LOCALAPPDATA%\RexBIM. Списки — копия # UninstallEngine установщика: удаление скриптом обязано снимать то же, что удаление из # «Приложений» (аудит 10.2026, DL-47). Раньше скрипт снимал только ядро и RexBIM\, а RexBIM.bak, # RexBIM.dll.bak, метки апдейтера и запись реестра оставались, и в конце всегда было «удалён». $script:YearFiles = @('RexBIM.dll', 'RexBIM.addin', 'RexBIM.pdb', 'RexBIM.dll.bak', 'RexBIM.dll.new') $script:YearFolders = @('RexBIM', 'RexBIM.bak', 'RexBIM.new', 'RexBIM.failed') $script:LocalFiles = @('RexBIM-Setup.ps1', 'RexBIM.addin', 'updater.log', 'updater.log.old', 'updater.config.tmp', 'available-notified.txt', 'notes-shown.txt', 'manifest-floor.txt', 'selfupdate.txt', 'selfupdate-trial.txt', 'updater.config', 'setup.config', 'RexBIM_Uninstall.exe', 'RexBIM.Uninstall.exe') # Куда удаление переносит семейства из RexBIM\families: «Документы\RexBIM\Семейства\<год>», как # установщик (SetupPaths.KeptFamiliesRoot). Пусто — по умолчанию; при подменённом AddinsRoot # (проверка) рядом с песочными Addins, чтобы семейство песочного года не уехало в настоящие # «Документы» (ДНК 42). $script:KeptFamiliesDir = '' $script:DefaultAddinsRoot = $script:AddinsRoot function Get-RexKeptFamiliesRoot { if ($script:KeptFamiliesDir) { return $script:KeptFamiliesDir } if ($script:AddinsRoot -ne $script:DefaultAddinsRoot) { return (Join-Path (Split-Path -Parent ($script:AddinsRoot.TrimEnd('\', '/'))) 'Семейства RexBIM') } return (Join-Path ([Environment]::GetFolderPath('MyDocuments')) 'RexBIM\Семейства') } <# Свободное имя в папке: занято — «имя (2).rfa», «имя (3).rfa»… #> function Get-RexFreeName { param([string]$Dir, [string]$Name) $dest = Join-Path $Dir $Name $stem = [IO.Path]::GetFileNameWithoutExtension($Name) $ext = [IO.Path]::GetExtension($Name) for ($n = 2; Test-Path -LiteralPath $dest; $n++) { $dest = Join-Path $Dir ("$stem ($n)$ext") } return $dest } <# Семейства года (из RexBIM\families и RexBIM.bak\families) — в «Документы\RexBIM\Семейства\<год>». Установка и обновление их бережно переносят, а удаление стирало без вопроса (аудит 10.2026, DL-46). Отличить своё семейство от пришедшего с комплектом по файлу нельзя, поэтому переносятся все .rfa: лишняя копия дешевле потерянной правки человека. Не перенёсся файл: остаётся на месте, папка года не удалится и попадёт в остатки, а не пропадёт молча. Возвращает число перенесённых. #> function Move-RexKeptFamilies { param([string]$YearDir) $moved = 0 $target = Join-Path (Get-RexKeptFamiliesRoot) (Split-Path -Leaf $YearDir) foreach ($homeName in @('RexBIM', 'RexBIM.bak')) { $families = Join-Path (Join-Path $YearDir $homeName) 'families' if (-not (Test-Path -LiteralPath $families)) { continue } foreach ($file in @(Get-ChildItem -LiteralPath $families -Filter *.rfa -File -ErrorAction SilentlyContinue)) { try { [void](New-Item -ItemType Directory -Force -Path $target -ErrorAction Stop) [IO.File]::Move($file.FullName, (Get-RexFreeName -Dir $target -Name $file.Name)) $moved++ } catch { Write-RexLog 'WARN' ("семейство $($file.FullName): " + $_.Exception.Message) } } } if ($moved -gt 0) { Write-RexLog 'INFO' ("семейства $(Split-Path -Leaf $YearDir) перенесены в ${target}: $moved") } return $moved } <# Удалить файл или папку; не вышло и $Leftovers задан — записать в остатки. #> function Remove-RexTracked { param([string]$Path, $Leftovers = $null) try { if (Test-Path -LiteralPath $Path) { Remove-Item -LiteralPath $Path -Recurse -Force -ErrorAction Stop } } catch { } if ($null -ne $Leftovers -and (Test-Path -LiteralPath $Path)) { [void]$Leftovers.Add($Path) } } <# Папки годов, где есть хоть что-то от RexBIM. Раньше только с ядром: год, где после сорвавшейся установки осталась папка RexBIM\ без RexBIM.dll, не чистился вовсе. #> function Get-RexInstalledYearDirs { $dirs = @() try { if (-not (Test-Path $script:AddinsRoot)) { return @() } foreach ($dir in (Get-ChildItem $script:AddinsRoot -Directory -ErrorAction SilentlyContinue)) { $hit = $false foreach ($name in ($script:YearFiles + $script:YearFolders)) { if (Test-Path -LiteralPath (Join-Path $dir.FullName $name)) { $hit = $true; break } } if ($hit) { $dirs += $dir.FullName } } } catch { } return $dirs } <# Удаление. Коды: 0 — удалено, 5 — Revit держит файлы, 7 — удалилось не всё (запись в «Приложениях» тогда остаётся, удаление можно повторить). #> function Invoke-RexUninstall { param([bool]$PurgeSettings = $false) $script:StepTotal = 4 Write-RexStep 1 'Проверяю, что Revit закрыт' $dirs = @(Get-RexInstalledYearDirs) # Занятые файлы означают открытый Revit: удаление наполовину хуже, чем отказ. foreach ($dir in $dirs) { $locked = Get-RexLockedFile $dir if ($locked) { Write-RexBad ("Файлы плагина заняты. Похоже, Revit ещё открыт.`n" + " Закройте все окна Revit и повторите.`n" + " Занятый файл: $locked") return 5 } } Write-RexOk 'Файлы свободны' $leftovers = New-Object 'System.Collections.Generic.List[string]' Write-RexStep 2 'Останавливаю автообновление' Stop-RexUpdater Remove-RexTracked (Join-Path $script:StartupDir $script:StartupShortcutName) $leftovers Remove-RexTracked (Join-Path $script:StartMenuDir $script:StartMenuShortcutName) Write-RexStep 3 'Убираю плагин из Revit' $removed = @() $kept = 0 foreach ($dir in $dirs) { $kept += Move-RexKeptFamilies -YearDir $dir foreach ($name in $script:YearFiles) { Remove-RexTracked (Join-Path $dir $name) $leftovers } foreach ($name in $script:YearFolders) { Remove-RexTracked (Join-Path $dir $name) $leftovers } $removed += (Split-Path -Leaf $dir) } if ($kept -gt 0) { Write-RexOk ("Семейства из папки плагина ($kept шт.) перенесены в папку $(Get-RexKeptFamiliesRoot)") } Write-RexStep 4 'Убираю автообновление и следы установки' # Exe апдейтера, оставшийся на месте, держит папку: это остаток, а не мелочь. Remove-RexTracked (Join-Path $script:LocalDir 'RexBIM.Updater.exe') $leftovers foreach ($name in $script:LocalFiles) { Remove-RexTracked (Join-Path $script:LocalDir $name) } # Копии exe апдейтера от самообновления (.old, .bad, .new) тоже держат папку. foreach ($extra in @(Get-ChildItem -Path $script:LocalDir -Filter 'RexBIM.Updater.exe.*' -File -ErrorAction SilentlyContinue)) { Remove-RexTracked $extra.FullName } Remove-RexTracked (Join-Path $script:LocalDir 'pending') Remove-RexTracked $script:DownloadDir if ($PurgeSettings) { Write-RexSay 'Стираю настройки…' Remove-RexTracked $script:SettingsDir if (Test-Path $script:SettingsDir) { Write-RexWarn "Настройки стёрты не все: часть файлов в $($script:SettingsDir) занята. Их можно удалить вручную." } } Write-Host '' # Запись «Приложений и возможностей» снимается, только когда файлов плагина не осталось: # иначе повторить удаление было бы нечем. if ($leftovers.Count -gt 0) { foreach ($path in $leftovers) { Write-RexLog 'WARN' ('не удалилось: ' + $path) } $shown = @($leftovers | Select-Object -First 6) $more = '' if ($leftovers.Count -gt 6) { $more = "`n и ещё $($leftovers.Count - 6)" } Write-RexBad ("Удалилось не всё. Эти файлы заняты другой программой или к ним нет доступа:`n " + ($shown -join "`n ") + $more + "`n" + " Закройте Revit и программы, которые могут держать эти файлы, и запустите удаление ещё раз.") Write-RexLog 'INFO' "удаление не завершено, осталось: $($leftovers.Count)" return 7 } try { Remove-Item ('HKCU:\' + $script:UninstallKey) -Recurse -Force -ErrorAction Stop } catch { } $fate = if ($PurgeSettings) { 'стёрты' } else { 'сохранены' } Write-RexLog 'INFO' "удаление завершено (годов: $($removed.Count), настройки $fate, семейств перенесено: $kept)" if ($removed.Count -gt 0) { Write-RexOk ('RexBIM удалён из Revit: ' + ($removed -join ', ')) } else { Write-RexWarn 'Плагин нигде не найден, удалять было нечего.' } return 0 } # ==== Точка входа ==== function Invoke-RexMain { Write-Host '' if ($Uninstall) { Write-Host '==== Удаление RexBIM ====' -ForegroundColor Cyan } else { Write-Host '==== Установка RexBIM ====' -ForegroundColor Cyan } if ($PSVersionTable.PSVersion.Major -lt 5) { Write-RexBad "Нужен Windows PowerShell 5.1 или новее (сейчас $($PSVersionTable.PSVersion))." return 2 } Write-RexLog 'INFO' ('скрипт запущен: ' + $(if ($Uninstall) { 'удаление' } else { 'установка' })) if ($Uninstall) { return (Invoke-RexUninstall -PurgeSettings ([bool]$Purge)) } Initialize-RexTls # Сборка для zip: в Windows PowerShell 5.1 сама она не подгружается. Add-Type -AssemblyName System.IO.Compression.FileSystem -ErrorAction SilentlyContinue # Прокси: заданный ключом сохраняем (следующий запуск и exe-установщик возьмут его сами), # иначе берём из setup.config, иначе системный. $config = Get-RexSetupConfig if ($Proxy) { Save-RexSetupConfig -ProxyUrl $Proxy -User $ProxyUser -Password $ProxyPassword $config = [pscustomobject]@{ Proxy = $Proxy; User = $ProxyUser; Password = $ProxyPassword } } $webProxy = New-RexProxy -Url $config.Proxy -User $config.User -Password $config.Password if ($config.Proxy) { Write-RexSay "Прокси: $($config.Proxy)" } Write-RexSay 'Устанавливая RexBIM, вы принимаете условия лицензии: https://rexbim.cloud' # Прокси для апдейтера — только названный ключом -Proxy (DL-10): прокси из setup.config # мог быть нужен одному установщику, а апдейтер уже настроен человеком у себя. $updaterProxy = $null if ($Proxy) { $updaterProxy = [pscustomobject]@{ Url = $Proxy; User = $ProxyUser; Password = $ProxyPassword } } # Источник передаём как есть: пустой Invoke-RexInstall заменит сервером по умолчанию, # но источник апдейтера тогда оставит прежним (DL-10). # Спрашивать, во что ставить, можно только у живого человека: с -Quiet ставим молча # (развёртывание), с -Years — то, что назвали ключом. return (Invoke-RexInstall -RequestedYears $Years -SourceValue $Source -Proxy $webProxy ` -Autostart (-not $NoAutostart) -ForceReinstall ([bool]$Force) -Interactive (-not $Quiet) ` -UpdaterProxy $updaterProxy) } # Проверке нужны только функции — песочницу она собирает сама и зовёт их руками. if ($LoadFunctionsOnly) { return } $code = 7 try { $code = Invoke-RexMain } catch { Write-RexBad ('Непредвиденная ошибка: ' + $_.Exception.Message) Write-RexLog 'ERR ' ($_.Exception.ToString()) $code = 7 } Write-Host '' if (-not $Quiet) { try { Read-Host 'Enter для выхода' | Out-Null } catch { } } exit $code