Files
Kang 010f1e4215 feat(agents): add Windows RDP support and remove ActiveX helper
- Introduced `nyaterm-windows-rdp` for Windows Native RDP integration, detailing its architecture and isolation requirements.
- Removed the `nyaterm-activex-helper` crate, including all related files and dependencies, to streamline the project and focus on modern RDP implementations.
- Updated documentation in `AGENTS.md` to reflect these changes and clarify the new architecture.
- Adjusted `Cargo.toml` and `Cargo.lock` to remove references to the deleted ActiveX helper and include the new RDP crate.
- Enhanced CI workflows to configure MSVC for Windows builds, ensuring compatibility with the new RDP implementation.
2026-11-02 19:17:20 +08:00

173 lines
8.3 KiB
Python

"""WiX authoring and read-only Windows Installer database inspection."""
from __future__ import annotations
import json
import os
import shutil
import subprocess
import uuid
import xml.etree.ElementTree as ET
from pathlib import Path
WIX_VERSION = "6.0.2"
UPGRADE_CODE = "F4B0DCC1-48C8-53C7-A0CF-7F36C5F1B27F"
# This namespace and the relative installed paths are component identity contracts.
IDENTITY_NAMESPACE = uuid.UUID(UPGRADE_CODE)
WIX_NAMESPACE = "http://wixtoolset.org/schemas/v4/wxs"
def guid(identity: str) -> str:
return str(uuid.uuid5(IDENTITY_NAMESPACE, identity)).upper()
def product_code(version: str, architecture: str) -> str:
return guid(f"product:{architecture}:{version}")
def identifier(kind: str, relative: str) -> str:
return kind + uuid.uuid5(IDENTITY_NAMESPACE, relative).hex
def find_wix() -> str:
tool = shutil.which("wix") or shutil.which("wix.exe")
if not tool:
raise RuntimeError(f"WiX {WIX_VERSION} is required to package/verify Stable MSI files")
version = subprocess.check_output([tool, "--version"], text=True).strip()
if version.split("+", 1)[0] != WIX_VERSION:
raise RuntimeError(f"expected WiX {WIX_VERSION}, found {version}")
return tool
def write_source(staging: Path, output: Path, version: str, architecture: str) -> None:
"""Author only files in the shared installer staging tree, never user data."""
ET.register_namespace("", WIX_NAMESPACE)
def element(parent: ET.Element, tag: str, **attributes: str) -> ET.Element:
return ET.SubElement(parent, f"{{{WIX_NAMESPACE}}}{tag}", attributes)
root = ET.Element(f"{{{WIX_NAMESPACE}}}Wix")
package = element(
root, "Package", Name="NyaTerm", Manufacturer="kang", Version=version,
ProductCode=product_code(version, architecture), UpgradeCode=UPGRADE_CODE,
Scope="perMachine", Language="1033", InstallerVersion="500", Compressed="yes",
)
element(package, "MajorUpgrade", Schedule="afterInstallInitialize",
AllowDowngrades="no", AllowSameVersionUpgrades="no",
DowngradeErrorMessage="The same or a newer version of NyaTerm is already installed.")
upgrade = element(package, "Upgrade", Id=UPGRADE_CODE)
element(upgrade, "UpgradeVersion", Minimum=version, Maximum=version,
IncludeMinimum="yes", IncludeMaximum="yes", OnlyDetect="yes",
Property="NYATERM_SAME_VERSION_DETECTED")
element(package, "Launch", Condition="Installed OR NOT NYATERM_SAME_VERSION_DETECTED",
Message="This version of NyaTerm is already installed.")
element(package, "MediaTemplate", EmbedCab="yes")
element(package, "Icon", Id="NyaTermIcon", SourceFile=str((staging / "icon.ico").resolve()))
element(package, "Property", Id="ARPPRODUCTICON", Value="NyaTermIcon")
element(package, "Property", Id="ARPNOMODIFY", Value="1")
program_files = element(package, "StandardDirectory", Id="ProgramFiles64Folder")
install_dir = element(program_files, "Directory", Id="INSTALLDIR", Name="NyaTerm")
menu = element(package, "StandardDirectory", Id="ProgramMenuFolder")
element(menu, "Directory", Id="ApplicationProgramsFolder", Name="NyaTerm")
element(package, "StandardDirectory", Id="DesktopFolder")
feature = element(package, "Feature", Id="Application", Title="NyaTerm", Level="1")
directories = {Path("."): install_dir}
for source in sorted(staging.rglob("*")):
if not source.is_file():
continue
relative = source.relative_to(staging)
for parent in reversed(relative.parents):
if parent not in directories:
directories[parent] = element(
directories[parent.parent], "Directory",
Id=identifier("d", parent.as_posix()), Name=parent.name,
)
component_id = identifier("c", relative.as_posix())
component = element(
directories[relative.parent], "Component", Id=component_id,
Guid=guid(f"component:{architecture}:{relative.as_posix()}"), Bitness="always64",
)
file_id = identifier("f", relative.as_posix())
element(component, "File", Id=file_id, Source=str(source.resolve()),
Name=relative.name, KeyPath="yes")
element(feature, "ComponentRef", Id=component_id)
if relative.as_posix() == "NyaTerm.exe":
element(component, "RemoveFile", Id="RemoveLegacyActiveXHelper",
Name="nyaterm-activex-helper.exe", On="install")
for shortcut_id, directory in (("StartMenuShortcut", "ApplicationProgramsFolder"),
("DesktopShortcut", "DesktopFolder")):
element(component, "Shortcut", Id=shortcut_id, Directory=directory,
Name="NyaTerm", Target=f"[#{file_id}]", WorkingDirectory="INSTALLDIR",
Icon="NyaTermIcon")
element(component, "RemoveFolder", Id="RemoveStartMenuFolder",
Directory="ApplicationProgramsFolder", On="uninstall")
# Native helper commands perform ownership-aware registration/cleanup;
# Registry rows would blindly remove another app's replacement values.
element(package, "CustomAction", Id="RegisterUrlProtocols", FileRef=file_id,
ExeCommand="--register-url-protocols", Execute="deferred", Impersonate="no", Return="check")
element(package, "CustomAction", Id="UnregisterUrlProtocols", FileRef=file_id,
ExeCommand="--unregister-url-protocols", Execute="deferred", Impersonate="no", Return="check")
sequence = element(package, "InstallExecuteSequence")
element(sequence, "Custom", Action="RegisterUrlProtocols", After="InstallFiles",
Condition='NOT REMOVE~="ALL"')
element(sequence, "Custom", Action="UnregisterUrlProtocols", Before="RemoveFiles",
Condition='REMOVE~="ALL" AND NOT UPGRADINGPRODUCTCODE')
ET.indent(root)
ET.ElementTree(root).write(output, encoding="utf-8", xml_declaration=True)
TABLE_COLUMNS = {
"Property": ["Property", "Value"],
"Directory": ["Directory", "Directory_Parent", "DefaultDir"],
"Component": ["Component", "ComponentId", "Directory_", "Attributes", "KeyPath"],
"File": ["File", "Component_", "FileName"],
"Upgrade": ["UpgradeCode", "VersionMin", "VersionMax", "Attributes", "ActionProperty"],
"InstallExecuteSequence": ["Action", "Condition", "Sequence"],
"Shortcut": ["Shortcut", "Directory_", "Name", "Component_", "Target"],
"Registry": ["Registry", "Root", "Key", "Name", "Value", "Component_"],
"RemoveFile": ["FileKey", "Component_", "FileName", "DirProperty", "InstallMode"],
"CustomAction": ["Action", "Type", "Source", "Target"],
"LaunchCondition": ["Condition", "Description"],
}
def read_database(path: Path) -> dict:
# OpenDatabase mode 0 is read-only. No Installer.InstallProduct or msiexec.
script = r"""
$ErrorActionPreference = 'Stop'
$installer = New-Object -ComObject WindowsInstaller.Installer
$database = $installer.OpenDatabase($env:NYATERM_VERIFY_MSI, 0)
$columns = $env:NYATERM_MSI_COLUMNS | ConvertFrom-Json
$tables = @{}
$tableView = $database.OpenView('SELECT `Name` FROM `_Tables`')
$tableView.Execute()
$available = @()
while ($record = $tableView.Fetch()) { $available += $record.StringData(1) }
$tableView.Close()
foreach ($entry in $columns.PSObject.Properties) {
$rows = @()
if ($available -contains $entry.Name) {
$names = ($entry.Value | ForEach-Object { '`' + $_ + '`' }) -join ','
$view = $database.OpenView('SELECT ' + $names + ' FROM `' + $entry.Name + '`')
$view.Execute()
while ($record = $view.Fetch()) {
$row = @{}
for ($i = 0; $i -lt $entry.Value.Count; $i++) {
$row[$entry.Value[$i]] = $record.StringData($i + 1)
}
$rows += $row
}
$view.Close()
}
$tables[$entry.Name] = $rows
}
@{ template = $database.SummaryInformation(0).Property(7); tables = $tables } |
ConvertTo-Json -Depth 6 -Compress
"""
return json.loads(subprocess.check_output(
["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script],
text=True, env={**os.environ, "NYATERM_VERIFY_MSI": str(path.resolve()),
"NYATERM_MSI_COLUMNS": json.dumps(TABLE_COLUMNS)},
))