Files
Kang 557266fc44 feat: Enhance command context handling and shell integration
- Introduced `CommandContext` event to manage shell command contexts more effectively.
- Updated `SessionManager` to create and push command contexts based on shell program and arguments.
- Enhanced `SshShellIntegrationState` to capture shell identities and push command contexts during SSH sessions.
- Added support for dialect detection in WSL environments.
- Implemented a new catalog generation script for offline command completion using Carapace.
- Added performance benchmarks for command completion.
- Updated documentation and tests for the new completion features.
2026-11-06 08:30:53 +08:00

896 lines
33 KiB
Python
Executable File

#!/usr/bin/env python3
"""Build and package the native NyaTerm application for one Rust target."""
from __future__ import annotations
import os
import plistlib
import re
import shutil
import subprocess
import sys
import tarfile
import textwrap
import time
import tomllib
import zipfile
from dataclasses import dataclass
from pathlib import Path
import prepare_conpty
import windows_msi
import windows_portable
ROOT_DIR = Path(__file__).resolve().parents[2]
DIST_DIR = ROOT_DIR / "dist"
WORK_DIR = ROOT_DIR / "target" / "native-package"
RESOURCE_DIR = ROOT_DIR / "crates" / "nyaterm-app" / "resources"
ICON_DIR = RESOURCE_DIR / "icons"
LICENSE_PATH = ROOT_DIR / "LICENSE"
APP_NAME = "NyaTerm"
APP_BIN = "nyaterm"
# Helper processes the application spawns at runtime. `resolve_helper_path()`
# in nyaterm-remote-desktop only looks beside the running executable, so each
# supported helper must be packaged next to the application binary.
HELPER_BINS = tuple(Path(name).stem for name in windows_portable.LAYOUT["helper_files"])
CONPTY_LICENSE = Path(__file__).with_name("conpty-license.txt")
def helper_bins(target: str) -> tuple[str, ...]:
return HELPER_BINS
def conpty_files(target: str) -> tuple[Path, ...]:
if target not in windows_portable.LAYOUT["conpty_files"]:
raise ValueError(f"ConPTY is only packaged for Windows targets: {target}")
return tuple(
Path(name).relative_to("conpty")
for name in windows_portable.LAYOUT["conpty_files"][target]
)
def copy_conpty(destination: Path, target: str) -> list[Path]:
source_root = prepare_conpty.STAGING / "resources" / "windows" / "conpty"
copied = []
for relative in conpty_files(target):
source = source_root / relative
if not source.is_file():
raise FileNotFoundError(f"prepared ConPTY file not found: {source}")
output = destination / "conpty" / relative
output.parent.mkdir(parents=True, exist_ok=True)
shutil.copy2(source, output)
copied.append(output)
license_output = destination / "conpty" / "LICENSE.txt"
shutil.copy2(CONPTY_LICENSE, license_output)
copied.append(license_output)
return copied
PORTABLE_MARKER = windows_portable.LAYOUT["marker"]
@dataclass(frozen=True)
class PackageIdentity:
display_name: str
macos_identifier: str
windows_registry_name: str
desktop_id: str
@property
def url_schemes(self) -> tuple[str, ...]:
return (self.desktop_id, "ssh", "telnet") if self.desktop_id == "nyaterm" else (self.desktop_id,)
@property
def macos_bundle_name(self) -> str:
return f"{self.display_name}.app"
@property
def windows_registry_key(self) -> str:
return rf"Software\{self.windows_registry_name}"
@property
def windows_uninstall_key(self) -> str:
return rf"Software\Microsoft\Windows\CurrentVersion\Uninstall\{self.display_name}"
@property
def linux_desktop_file(self) -> str:
return f"{self.desktop_id}.desktop"
STABLE_IDENTITY = PackageIdentity("NyaTerm", "com.kang.nyaterm", "NyaTerm", "nyaterm")
PREVIEW_IDENTITY = PackageIdentity(
"NyaTerm Preview", "com.kang.nyaterm.preview", "NyaTermPreview", "nyaterm-preview"
)
# SemVer 2.0: prerelease numeric identifiers cannot have leading zeroes.
SEMVER = re.compile(
r"(?P<major>0|[1-9][0-9]*)\.(?P<minor>0|[1-9][0-9]*)\.(?P<patch>0|[1-9][0-9]*)"
r"(?:-(?P<pre>(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*)"
r"(?:\.(?:0|[1-9][0-9]*|[0-9]*[A-Za-z-][0-9A-Za-z-]*))*))?"
r"(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?"
)
def parse_version(raw: str) -> re.Match[str]:
match = SEMVER.fullmatch(normalize_version(raw))
if match is None:
raise ValueError(f"invalid release version: {raw}")
return match
def release_identity(version: str) -> PackageIdentity:
return PREVIEW_IDENTITY if parse_version(version).group("pre") else STABLE_IDENTITY
@dataclass(frozen=True)
class TargetInfo:
target: str
os_name: str
arch: str
@property
def label(self) -> str:
return f"{self.os_name}_{self.arch}"
TARGETS = {
"aarch64-apple-darwin": TargetInfo("aarch64-apple-darwin", "macos", "arm64"),
"x86_64-apple-darwin": TargetInfo("x86_64-apple-darwin", "macos", "x64"),
"aarch64-unknown-linux-gnu": TargetInfo(
"aarch64-unknown-linux-gnu", "linux", "arm64"
),
"x86_64-unknown-linux-gnu": TargetInfo(
"x86_64-unknown-linux-gnu", "linux", "x64"
),
"aarch64-pc-windows-msvc": TargetInfo(
"aarch64-pc-windows-msvc", "windows", "arm64"
),
"x86_64-pc-windows-msvc": TargetInfo(
"x86_64-pc-windows-msvc", "windows", "x64"
),
}
def run(
args: list[str],
*,
cwd: Path = ROOT_DIR,
env: dict[str, str] | None = None,
) -> None:
print("+", " ".join(args), flush=True)
subprocess.run(args, cwd=cwd, env=env, check=True)
def run_with_retry(args: list[str], *, attempts: int = 3, delay_seconds: float = 2.0) -> None:
if attempts < 1:
raise ValueError("attempts must be at least 1")
for attempt in range(1, attempts + 1):
try:
run(args)
return
except subprocess.CalledProcessError:
if attempt == attempts:
raise
time.sleep(delay_seconds)
def require_tool(name: str) -> str:
path = shutil.which(name)
if not path:
raise RuntimeError(f"required packaging tool not found: {name}")
return path
def workspace_version() -> str:
with (ROOT_DIR / "Cargo.toml").open("rb") as handle:
return tomllib.load(handle)["workspace"]["package"]["version"]
def normalize_version(raw: str) -> str:
return raw.strip().removeprefix("v").removeprefix("V")
def validate_version(raw: str, expected: str | None = None) -> str:
version = normalize_version(raw)
parse_version(version)
if expected is not None and version != expected:
raise ValueError(
f"release version {version} does not match Cargo workspace version {expected}"
)
return version
def validate_artifact_version(raw: str) -> str:
"""Validate a public filename label without changing package metadata."""
value = raw.strip()
if value == "main-snapshot":
return value
return validate_version(value)
def target_info(target: str) -> TargetInfo:
try:
return TARGETS[target]
except KeyError as error:
raise ValueError(f"unsupported release target: {target}") from error
def artifact_names(target: str, version: str) -> set[str]:
info = target_info(target)
prefix = f"{APP_NAME}_{validate_artifact_version(version)}_{info.label}"
if info.os_name == "macos":
return {f"{prefix}.dmg", f"{prefix}.app.tar.gz"}
if info.os_name == "linux":
return {f"{prefix}.AppImage", f"{prefix}.deb", f"{prefix}.rpm"}
names = {f"{prefix}_portable.zip", f"{prefix}-setup.exe"}
if includes_windows_msi(version):
names.add(f"{prefix}.msi")
return names
def includes_windows_msi(artifact_version: str) -> bool:
label = validate_artifact_version(artifact_version)
return label != "main-snapshot" and parse_version(label).group("pre") is None
def windows_msi_version(version: str) -> str:
parsed = parse_version(version)
if parsed.group("pre") is not None:
raise ValueError("MSI is only supported for Stable releases")
numeric = [int(parsed.group(key)) for key in ("major", "minor", "patch")]
if any(part > limit for part, limit in zip(numeric, (255, 255, 65535))):
raise ValueError(f"MSI version exceeds Windows Installer limits: {version}")
return ".".join(str(part) for part in numeric)
def cargo_target_dir() -> Path:
configured = os.environ.get("CARGO_TARGET_DIR")
if not configured:
return ROOT_DIR / "target"
path = Path(configured)
return path if path.is_absolute() else ROOT_DIR / path
def release_binary_path(target: str, name: str = APP_BIN) -> Path:
suffix = ".exe" if "windows" in target else ""
return cargo_target_dir() / target / "release" / f"{name}{suffix}"
def helper_binary_paths(target: str) -> list[Path]:
return [release_binary_path(target, name) for name in helper_bins(target)]
def build_binary(package: str, name: str, target: str) -> Path:
command = [
"cargo", "build", "-p", package, "--bin", name,
"--release", "--target", target, "--locked",
]
environment = None
if target.endswith("-windows-msvc"):
# Keep Rust and every C/C++ dependency on the same static CRT. The ATL
# shim must not make portable packages require a VC++ redistributable.
environment = os.environ.copy()
flags = environment.get("CARGO_ENCODED_RUSTFLAGS")
if flags is None:
flags = "\x1f".join(environment.get("RUSTFLAGS", "").split())
environment["CARGO_ENCODED_RUSTFLAGS"] = "\x1f".join(
filter(None, [flags, "-C", "target-feature=+crt-static"])
)
run(command, env=environment)
binary = release_binary_path(target, name)
if not binary.is_file():
raise FileNotFoundError(f"release executable not found: {binary}")
return binary
def build_application(target: str) -> Path:
binary = build_binary("nyaterm-app", APP_BIN, target)
for name in helper_bins(target):
build_binary(name, name, target)
return binary
def make_executable(path: Path) -> None:
path.chmod(path.stat().st_mode | 0o111)
def copy_helpers(destination: Path, target: str) -> list[Path]:
"""Copy helper binaries beside the application and return the copies."""
copied = []
for source in helper_binary_paths(target):
binary = destination / source.name
shutil.copy2(source, binary)
if "windows" not in target:
make_executable(binary)
copied.append(binary)
return copied
def copy_release_documents(destination: Path, version: str) -> None:
destination.mkdir(parents=True, exist_ok=True)
completion_licenses = ROOT_DIR / "crates/nyaterm-core/assets/completion"
documents = [LICENSE_PATH, completion_licenses / "NOTICE.txt", completion_licenses / "LICENSE.carapace", completion_licenses / "LICENSE.nucleo"]
(destination / "LICENSE").write_text("\n\n".join(path.read_text(encoding="utf-8") for path in documents), encoding="utf-8")
(destination / "VERSION").write_text(f"{version}\n", encoding="utf-8")
def reset_output() -> None:
if DIST_DIR.exists():
shutil.rmtree(DIST_DIR)
DIST_DIR.mkdir()
if WORK_DIR.exists():
shutil.rmtree(WORK_DIR)
WORK_DIR.mkdir(parents=True)
def archive_zip(source: Path, destination: Path) -> None:
with zipfile.ZipFile(
destination, "w", compression=zipfile.ZIP_DEFLATED, compresslevel=9
) as archive:
for path in sorted(source.rglob("*")):
archive.write(path, path.relative_to(source.parent))
def windows_numeric_version(version: str) -> str:
parsed = parse_version(version)
# Keep in step with nyaterm-app/build.rs. Build metadata never contributes;
# a final numeric prerelease identifier distinguishes preview releases.
prerelease = parsed.group("pre")
revision = prerelease.rsplit(".", 1)[-1] if prerelease else ""
numeric = [int(parsed.group(key)) for key in ("major", "minor", "patch")]
numeric.append(int(revision) if revision.isascii() and revision.isdecimal() else 0)
if any(part > 65535 for part in numeric):
raise ValueError(f"Windows version component exceeds 65535: {version}")
return ".".join(str(part) for part in numeric)
def nsis_path(path: Path) -> str:
return str(path.resolve()).replace("/", "\\")
def nsis_relative_path(path: Path) -> str:
return path.as_posix().replace("/", "\\")
def find_makensis() -> str:
found = shutil.which("makensis") or shutil.which("makensis.exe")
if found:
return found
raise RuntimeError("makensis not found; install NSIS before packaging Windows")
def create_windows_packages(
binary: Path, info: TargetInfo, version: str, artifact_version: str
) -> None:
identity = release_identity(version)
if includes_windows_msi(artifact_version):
windows_msi_version(version)
portable_root = WORK_DIR / windows_portable.LAYOUT["root"]
portable_root.mkdir()
shutil.copy2(binary, portable_root / "NyaTerm.exe")
copy_helpers(portable_root, info.target)
copy_conpty(portable_root, info.target)
(portable_root / PORTABLE_MARKER).touch()
placeholder = portable_root / windows_portable.LAYOUT["data_placeholder"]
placeholder.parent.mkdir()
placeholder.touch()
copy_release_documents(portable_root, version)
expected = set(windows_portable.payload_files(info.target)) | {
windows_portable.LAYOUT["data_placeholder"]
}
actual = {path.relative_to(portable_root).as_posix()
for path in portable_root.rglob("*") if path.is_file()}
if actual != expected:
raise RuntimeError(f"portable payload differs from contract: {actual ^ expected}")
portable_output = (
DIST_DIR / f"{APP_NAME}_{artifact_version}_{info.label}_portable.zip"
)
archive_zip(portable_root, portable_output)
installer_root = WORK_DIR / "windows-installer"
installer_root.mkdir()
shutil.copy2(binary, installer_root / "NyaTerm.exe")
installer_helpers = copy_helpers(installer_root, info.target)
installer_conpty = copy_conpty(installer_root, info.target)
copy_release_documents(installer_root, version)
shutil.copy2(ICON_DIR / "icon.ico", installer_root / "icon.ico")
nsis_indent = "\n" + " " * 14
helper_install = nsis_indent.join(
f'File "{nsis_path(path)}"' for path in installer_helpers
)
helper_uninstall = nsis_indent.join(
f'Delete "$INSTDIR\\{path.name}"' for path in installer_helpers
)
conpty_install = nsis_indent.join(
f'SetOutPath "$INSTDIR\\{nsis_relative_path(path.parent.relative_to(installer_root))}"\n'
f' File "{nsis_path(path)}"'
for path in installer_conpty
)
conpty_uninstall = nsis_indent.join(
f'Delete "$INSTDIR\\{nsis_relative_path(path.relative_to(installer_root))}"'
for path in installer_conpty
)
conpty_dirs = sorted(
{path.parent.relative_to(installer_root) for path in installer_conpty},
key=lambda path: len(path.parts),
reverse=True,
)
conpty_uninstall_dirs = nsis_indent.join(
f'RMDir "$INSTDIR\\{nsis_relative_path(path)}"'
for path in conpty_dirs
)
output = DIST_DIR / f"{APP_NAME}_{artifact_version}_{info.label}-setup.exe"
script = WORK_DIR / "nyaterm-installer.nsi"
protocol_command = r'$\"$INSTDIR\NyaTerm.exe$\" $\"%1$\"'
protocol_install = "\n".join(
f'WriteRegStr HKCU "Software\\Classes\\{scheme}{suffix}" "{name}" "{value}"'
for scheme in identity.url_schemes
for suffix, name, value in (
("", "", f"URL:{identity.display_name} Protocol"), ("", "URL Protocol", ""),
(r"\DefaultIcon", "", r"$INSTDIR\NyaTerm.exe,0"),
(r"\shell\open\command", "", protocol_command),
)
)
protocol_uninstall = "\n".join(
f'ReadRegStr $0 HKCU "Software\\Classes\\{scheme}\\shell\\open\\command" ""\n'
f'StrCmp $0 "{protocol_command}" 0 protocol_skip_{scheme}\n'
f'DeleteRegKey HKCU "Software\\Classes\\{scheme}"\nprotocol_skip_{scheme}:'
for scheme in identity.url_schemes
)
script.write_text(
textwrap.dedent(
rf"""
Unicode true
RequestExecutionLevel user
SetCompressor /SOLID lzma
!include "MUI2.nsh"
!define MUI_ICON "{nsis_path(ICON_DIR / 'icon.ico')}"
!define MUI_UNICON "{nsis_path(ICON_DIR / 'icon.ico')}"
Name "{identity.display_name}"
OutFile "{nsis_path(output)}"
InstallDir "$LOCALAPPDATA\Programs\{identity.display_name}"
InstallDirRegKey HKCU "{identity.windows_registry_key}" "InstallDir"
VIProductVersion "{windows_numeric_version(version)}"
VIFileVersion "{windows_numeric_version(version)}"
VIAddVersionKey "ProductName" "{identity.display_name}"
VIAddVersionKey "ProductVersion" "{version}"
VIAddVersionKey "FileVersion" "{version}"
VIAddVersionKey "FileDescription" "{identity.display_name} native GPUI terminal"
VIAddVersionKey "LegalCopyright" "Copyright Kang"
!insertmacro MUI_PAGE_WELCOME
!insertmacro MUI_PAGE_DIRECTORY
!insertmacro MUI_PAGE_INSTFILES
!define MUI_FINISHPAGE_RUN "$INSTDIR\NyaTerm.exe"
!define MUI_FINISHPAGE_RUN_TEXT "Launch {identity.display_name}"
!insertmacro MUI_PAGE_FINISH
!insertmacro MUI_UNPAGE_CONFIRM
!insertmacro MUI_UNPAGE_INSTFILES
!insertmacro MUI_LANGUAGE "English"
Section "{identity.display_name}" SecMain
SetOutPath "$INSTDIR"
Delete "$INSTDIR\nyaterm-activex-helper.exe"
File "{nsis_path(installer_root / 'NyaTerm.exe')}"
{helper_install}
File "{nsis_path(installer_root / 'LICENSE')}"
File "{nsis_path(installer_root / 'VERSION')}"
File "{nsis_path(installer_root / 'icon.ico')}"
{conpty_install}
WriteUninstaller "$INSTDIR\Uninstall.exe"
WriteRegStr HKCU "{identity.windows_registry_key}" "InstallDir" "$INSTDIR"
{protocol_install}
WriteRegStr HKCU "{identity.windows_uninstall_key}" "DisplayName" "{identity.display_name}"
WriteRegStr HKCU "{identity.windows_uninstall_key}" "DisplayVersion" "{version}"
WriteRegStr HKCU "{identity.windows_uninstall_key}" "DisplayIcon" "$INSTDIR\NyaTerm.exe"
WriteRegStr HKCU "{identity.windows_uninstall_key}" "UninstallString" "$\"$INSTDIR\Uninstall.exe$\""
CreateDirectory "$SMPROGRAMS\{identity.display_name}"
CreateShortcut "$SMPROGRAMS\{identity.display_name}\{identity.display_name}.lnk" "$INSTDIR\NyaTerm.exe" "" "$INSTDIR\icon.ico"
CreateShortcut "$DESKTOP\{identity.display_name}.lnk" "$INSTDIR\NyaTerm.exe" "" "$INSTDIR\icon.ico"
SectionEnd
Section "Uninstall"
Delete "$DESKTOP\{identity.display_name}.lnk"
Delete "$SMPROGRAMS\{identity.display_name}\{identity.display_name}.lnk"
RMDir "$SMPROGRAMS\{identity.display_name}"
Delete "$INSTDIR\NyaTerm.exe"
{helper_uninstall}
{conpty_uninstall}
{conpty_uninstall_dirs}
Delete "$INSTDIR\LICENSE"
Delete "$INSTDIR\VERSION"
Delete "$INSTDIR\icon.ico"
Delete "$INSTDIR\Uninstall.exe"
RMDir "$INSTDIR"
{protocol_uninstall}
DeleteRegKey HKCU "{identity.windows_uninstall_key}"
DeleteRegKey HKCU "{identity.windows_registry_key}"
SectionEnd
"""
).lstrip(),
encoding="utf-8",
)
run([find_makensis(), str(script)])
if includes_windows_msi(artifact_version):
create_windows_msi(installer_root, info, version, artifact_version)
def create_windows_msi(
installer_root: Path, info: TargetInfo, version: str, artifact_version: str
) -> None:
numeric = windows_msi_version(version)
source = WORK_DIR / "nyaterm-installer.wxs"
windows_msi.write_source(installer_root, source, numeric, info.arch)
output = DIST_DIR / f"{APP_NAME}_{artifact_version}_{info.label}.msi"
run([windows_msi.find_wix(), "build", "-arch", info.arch,
"-pdbtype", "none", "-o", str(output), str(source)])
def create_macos_packages(
binary: Path, info: TargetInfo, version: str, artifact_version: str
) -> None:
identity = release_identity(version)
bundle = WORK_DIR / identity.macos_bundle_name
macos_dir = bundle / "Contents" / "MacOS"
resources_dir = bundle / "Contents" / "Resources"
macos_dir.mkdir(parents=True)
resources_dir.mkdir(parents=True)
app_binary = macos_dir / "NyaTerm"
shutil.copy2(binary, app_binary)
make_executable(app_binary)
helper_binaries = copy_helpers(macos_dir, info.target)
# The macOS artwork includes the transparent margins used in Dock and Launchpad.
shutil.copy2(RESOURCE_DIR / "icons-macos" / "icon.icns", resources_dir / "icon.icns")
copy_release_documents(resources_dir, version)
plist = {
"CFBundleDevelopmentRegion": "en",
"CFBundleDisplayName": identity.display_name,
"CFBundleExecutable": "NyaTerm",
"CFBundleIconFile": "icon.icns",
"CFBundleIdentifier": identity.macos_identifier,
"CFBundleInfoDictionaryVersion": "6.0",
"CFBundleName": identity.display_name,
"CFBundlePackageType": "APPL",
"CFBundleShortVersionString": version,
"CFBundleURLTypes": [
{
"CFBundleTypeRole": "Viewer",
"CFBundleURLName": identity.macos_identifier,
"CFBundleURLSchemes": list(identity.url_schemes),
}
],
"CFBundleVersion": version,
"LSMinimumSystemVersion": "12.0",
"NSHighResolutionCapable": True,
"NSHumanReadableCopyright": "Copyright Kang",
}
with (bundle / "Contents" / "Info.plist").open("wb") as handle:
plistlib.dump(plist, handle, sort_keys=True)
codesign = require_tool("codesign")
# Sign inside out. A bundle-only signature seals extra Mach-O files in
# Contents/MacOS as resources instead of signing them as code, which makes
# the helper fail to launch under Gatekeeper.
for helper in helper_binaries:
run([codesign, "--force", "--sign", "-", "--timestamp=none", str(helper)])
run([codesign, "--force", "--deep", "--sign", "-", "--timestamp=none", str(bundle)])
tar_output = DIST_DIR / f"{APP_NAME}_{artifact_version}_{info.label}.app.tar.gz"
with tarfile.open(tar_output, "w:gz", compresslevel=9) as archive:
archive.add(bundle, arcname=identity.macos_bundle_name)
dmg_root = WORK_DIR / "dmg"
dmg_root.mkdir()
shutil.copytree(bundle, dmg_root / identity.macos_bundle_name, symlinks=True)
(dmg_root / "Applications").symlink_to("/Applications")
dmg_output = DIST_DIR / f"{APP_NAME}_{artifact_version}_{info.label}.dmg"
run_with_retry(
[
require_tool("hdiutil"),
"create",
"-volname",
identity.display_name,
"-srcfolder",
str(dmg_root),
"-ov",
"-format",
"UDZO",
str(dmg_output),
]
)
def linux_deb_arch(target: str) -> str:
return {
"x86_64-unknown-linux-gnu": "amd64",
"aarch64-unknown-linux-gnu": "arm64",
}[target]
def linux_rpm_arch(target: str) -> str:
return {
"x86_64-unknown-linux-gnu": "x86_64",
"aarch64-unknown-linux-gnu": "aarch64",
}[target]
def linux_appimage_arch(target: str) -> str:
return {
"x86_64-unknown-linux-gnu": "x86_64",
"aarch64-unknown-linux-gnu": "aarch64",
}[target]
def linux_rpm_version(version: str) -> tuple[str, str]:
parsed = parse_version(version)
upstream = ".".join(parsed.group(key) for key in ("major", "minor", "patch"))
prerelease = parsed.group("pre")
if prerelease is None:
return upstream, "1"
normalized = re.sub(r"[^0-9A-Za-z]+", ".", prerelease).strip(".")
return upstream, f"0.{normalized}"
def write_desktop_file(
path: Path, executable: str, identity: PackageIdentity = STABLE_IDENTITY
) -> None:
path.write_text(
textwrap.dedent(
f"""
[Desktop Entry]
Type=Application
Name={identity.display_name}
Comment=Native GPUI terminal and SSH client
Exec={executable} %U
Icon={identity.desktop_id}
StartupWMClass={identity.desktop_id}
Terminal=false
Categories=Development;TerminalEmulator;Network;
MimeType={"".join(f"x-scheme-handler/{scheme};" for scheme in identity.url_schemes)}
StartupNotify=true
"""
).lstrip(),
encoding="utf-8",
)
def copy_linux_icons(root: Path, identity: PackageIdentity = STABLE_IDENTITY) -> None:
icons = {
"32x32": "32x32.png",
"64x64": "64x64.png",
"128x128": "128x128.png",
"256x256": "256x256.png",
}
for size, source in icons.items():
destination = root / "usr" / "share" / "icons" / "hicolor" / size / "apps"
destination.mkdir(parents=True, exist_ok=True)
shutil.copy2(ICON_DIR / source, destination / f"{identity.desktop_id}.png")
def parse_dpkg_dependencies(output: str) -> str:
prefix = "shlibs:Depends="
for line in output.splitlines():
if line.startswith(prefix) and line[len(prefix) :].strip():
return line[len(prefix) :].strip()
raise RuntimeError("dpkg-shlibdeps did not report shlibs:Depends")
def linux_deb_dependencies(binaries: list[Path]) -> str:
scratch = WORK_DIR / "shlibdeps"
debian = scratch / "debian"
debian.mkdir(parents=True)
(debian / "control").write_text(
"Source: nyaterm\nPackage: nyaterm\nArchitecture: any\nDescription: NyaTerm\n",
encoding="utf-8",
)
# Helpers can pull shared libraries the application itself does not need.
command = [require_tool("dpkg-shlibdeps"), "-O"]
for binary in binaries:
command += ["-e", str(binary)]
output = subprocess.check_output(command, cwd=scratch, text=True)
return parse_dpkg_dependencies(output)
def create_linux_appimage(
binary: Path, info: TargetInfo, version: str, artifact_version: str
) -> None:
identity = release_identity(version)
appdir = WORK_DIR / "NyaTerm.AppDir"
usr_bin = appdir / "usr" / "bin"
usr_bin.mkdir(parents=True)
app_binary = usr_bin / APP_BIN
shutil.copy2(binary, app_binary)
make_executable(app_binary)
copy_helpers(usr_bin, info.target)
copy_release_documents(appdir / "usr" / "share" / "doc" / identity.desktop_id, version)
applications = appdir / "usr" / "share" / "applications"
applications.mkdir(parents=True)
write_desktop_file(applications / identity.linux_desktop_file, APP_BIN, identity)
shutil.copy2(applications / identity.linux_desktop_file, appdir / identity.linux_desktop_file)
shutil.copy2(ICON_DIR / "128x128.png", appdir / f"{identity.desktop_id}.png")
copy_linux_icons(appdir, identity)
apprun = appdir / "AppRun"
apprun.write_text(
'#!/bin/sh\nAPPDIR="${APPDIR:-$(dirname "$(readlink -f "$0")")}"\n'
'exec "$APPDIR/usr/bin/nyaterm" "$@"\n',
encoding="utf-8",
)
make_executable(apprun)
output = DIST_DIR / f"{APP_NAME}_{artifact_version}_{info.label}.AppImage"
env = os.environ.copy()
env["ARCH"] = linux_appimage_arch(info.target)
env.setdefault("APPIMAGE_EXTRACT_AND_RUN", "1")
run([require_tool("appimagetool"), str(appdir), str(output)], env=env)
def create_linux_deb(
binary: Path, info: TargetInfo, version: str, artifact_version: str
) -> None:
identity = release_identity(version)
root = WORK_DIR / "deb"
app_root = root / "opt" / identity.desktop_id
app_root.mkdir(parents=True)
app_binary = app_root / APP_BIN
shutil.copy2(binary, app_binary)
make_executable(app_binary)
helper_binaries = copy_helpers(app_root, info.target)
copy_release_documents(app_root, version)
copy_release_documents(root / "usr" / "share" / "doc" / identity.desktop_id, version)
applications = root / "usr" / "share" / "applications"
applications.mkdir(parents=True)
write_desktop_file(applications / identity.linux_desktop_file, f"/opt/{identity.desktop_id}/nyaterm", identity)
copy_linux_icons(root, identity)
control_dir = root / "DEBIAN"
control_dir.mkdir()
dependencies = linux_deb_dependencies([app_binary, *helper_binaries])
(control_dir / "control").write_text(
textwrap.dedent(
f"""
Package: {identity.desktop_id}
Version: {version.replace('-', '~')}
Section: utils
Priority: optional
Architecture: {linux_deb_arch(info.target)}
Maintainer: Kang <noreply@nyaterm.app>
Depends: {dependencies}
Recommends: libvulkan1 | mesa-vulkan-drivers
Description: NyaTerm native GPUI terminal and SSH client
Native terminal workspace with SSH, SFTP and remote operations.
"""
).lstrip(),
encoding="utf-8",
)
output = DIST_DIR / f"{APP_NAME}_{artifact_version}_{info.label}.deb"
run([require_tool("dpkg-deb"), "--build", "--root-owner-group", str(root), str(output)])
def create_linux_rpm(
binary: Path, info: TargetInfo, version: str, artifact_version: str
) -> None:
identity = release_identity(version)
rpm_root = WORK_DIR / "rpm"
top_dir = rpm_root / "rpmbuild"
payload = rpm_root / "payload"
for directory in ("BUILD", "BUILDROOT", "RPMS", "SOURCES", "SPECS", "SRPMS"):
(top_dir / directory).mkdir(parents=True)
app_root = payload / "opt" / identity.desktop_id
app_root.mkdir(parents=True)
app_binary = app_root / APP_BIN
shutil.copy2(binary, app_binary)
make_executable(app_binary)
copy_helpers(app_root, info.target)
copy_release_documents(app_root, version)
copy_release_documents(payload / "usr" / "share" / "doc" / identity.desktop_id, version)
applications = payload / "usr" / "share" / "applications"
applications.mkdir(parents=True)
write_desktop_file(applications / identity.linux_desktop_file, f"/opt/{identity.desktop_id}/nyaterm", identity)
copy_linux_icons(payload, identity)
rpm_version, rpm_release = linux_rpm_version(version)
payload_path = str(payload.resolve()).replace("%", "%%")
spec = textwrap.dedent(
f"""
Name: {identity.desktop_id}
Version: {rpm_version}
Release: {rpm_release}
Summary: NyaTerm native GPUI terminal and SSH client
License: Apache-2.0
URL: https://nyaterm.app
BuildArch: {linux_rpm_arch(info.target)}
%description
Native terminal workspace with SSH, SFTP and remote operations.
%install
rm -rf %{{buildroot}}
mkdir -p %{{buildroot}}
cp -a "{payload_path}/." %{{buildroot}}/
%files
/opt/{identity.desktop_id}
/usr/share/applications/{identity.linux_desktop_file}
/usr/share/icons/hicolor/*/apps/{identity.desktop_id}.png
/usr/share/doc/{identity.desktop_id}
"""
).lstrip()
spec_path = top_dir / "SPECS" / "nyaterm.spec"
spec_path.write_text(spec, encoding="utf-8")
run(
[
require_tool("rpmbuild"),
"--define",
f"_topdir {top_dir.resolve()}",
"--define",
"_build_id_links none",
"--define",
"debug_package %{nil}",
"-bb",
str(spec_path),
]
)
built = list((top_dir / "RPMS" / linux_rpm_arch(info.target)).glob("*.rpm"))
if len(built) != 1:
raise RuntimeError(f"expected one RPM artifact, found {len(built)}")
shutil.copy2(
built[0], DIST_DIR / f"{APP_NAME}_{artifact_version}_{info.label}.rpm"
)
def create_linux_packages(
binary: Path, info: TargetInfo, version: str, artifact_version: str
) -> None:
create_linux_appimage(binary, info, version, artifact_version)
create_linux_deb(binary, info, version, artifact_version)
create_linux_rpm(binary, info, version, artifact_version)
def main() -> None:
if len(sys.argv) != 2:
raise SystemExit("usage: package_native.py <rust-target>")
info = target_info(sys.argv[1])
expected_version = workspace_version()
version = validate_version(
os.environ.get("NYATERM_VERSION", expected_version), expected_version
)
artifact_version = validate_artifact_version(
os.environ.get("NYATERM_ARTIFACT_VERSION", version)
)
if info.os_name == "windows" and includes_windows_msi(artifact_version):
windows_msi_version(version)
reset_output()
print(f"==> Packaging {APP_NAME} {version} for {info.target}", flush=True)
binary = build_application(info.target)
if info.os_name == "macos":
create_macos_packages(binary, info, version, artifact_version)
elif info.os_name == "linux":
create_linux_packages(binary, info, version, artifact_version)
else:
create_windows_packages(binary, info, version, artifact_version)
actual = {path.name for path in DIST_DIR.iterdir() if path.is_file()}
expected = artifact_names(info.target, artifact_version)
if actual != expected:
raise RuntimeError(
f"package output mismatch: expected {sorted(expected)}, got {sorted(actual)}"
)
for path in sorted(DIST_DIR.iterdir()):
print(f"==> {path.name} ({path.stat().st_size} bytes)")
if __name__ == "__main__":
main()