mirror of
https://github.com/nyakang/nyaterm.git
synced 2026-09-22 08:01:31 +00:00
758 lines
27 KiB
Python
Executable File
758 lines
27 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
|
|
|
|
|
|
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
|
|
# of these must be packaged next to the application binary in every format.
|
|
HELPER_BINS = ("nyaterm-rdp-helper", "nyaterm-vnc-helper", "nyaterm-mcp")
|
|
PORTABLE_MARKER = "nyaterm-portable"
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class PackageIdentity:
|
|
display_name: str
|
|
macos_identifier: str
|
|
windows_registry_name: str
|
|
desktop_id: str
|
|
|
|
@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"}
|
|
return {f"{prefix}_portable.zip", f"{prefix}-setup.exe"}
|
|
|
|
|
|
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]
|
|
|
|
|
|
def build_binary(package: str, name: str, target: str) -> Path:
|
|
run(
|
|
[
|
|
"cargo",
|
|
"build",
|
|
"-p",
|
|
package,
|
|
"--bin",
|
|
name,
|
|
"--release",
|
|
"--target",
|
|
target,
|
|
"--locked",
|
|
]
|
|
)
|
|
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:
|
|
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)
|
|
shutil.copy2(LICENSE_PATH, destination / "LICENSE")
|
|
(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)
|
|
numeric = [int(parsed.group(key)) for key in ("major", "minor", "patch")] + [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 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)
|
|
portable_root = WORK_DIR / "NyaTerm-portable"
|
|
portable_root.mkdir()
|
|
shutil.copy2(binary, portable_root / "NyaTerm.exe")
|
|
copy_helpers(portable_root, info.target)
|
|
(portable_root / PORTABLE_MARKER).touch()
|
|
(portable_root / "data").mkdir()
|
|
(portable_root / "data" / ".keep").touch()
|
|
copy_release_documents(portable_root, version)
|
|
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)
|
|
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
|
|
)
|
|
|
|
output = DIST_DIR / f"{APP_NAME}_{artifact_version}_{info.label}-setup.exe"
|
|
script = WORK_DIR / "nyaterm-installer.nsi"
|
|
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
|
|
!insertmacro MUI_PAGE_FINISH
|
|
!insertmacro MUI_UNPAGE_CONFIRM
|
|
!insertmacro MUI_UNPAGE_INSTFILES
|
|
!insertmacro MUI_LANGUAGE "English"
|
|
|
|
Section "{identity.display_name}" SecMain
|
|
SetOutPath "$INSTDIR"
|
|
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')}"
|
|
WriteUninstaller "$INSTDIR\Uninstall.exe"
|
|
WriteRegStr HKCU "{identity.windows_registry_key}" "InstallDir" "$INSTDIR"
|
|
WriteRegStr HKCU "Software\Classes\{identity.desktop_id}" "" "URL:{identity.display_name} Protocol"
|
|
WriteRegStr HKCU "Software\Classes\{identity.desktop_id}" "URL Protocol" ""
|
|
WriteRegStr HKCU "Software\Classes\{identity.desktop_id}\DefaultIcon" "" "$INSTDIR\NyaTerm.exe,0"
|
|
WriteRegStr HKCU "Software\Classes\{identity.desktop_id}\shell\open\command" "" "$\"$INSTDIR\NyaTerm.exe$\" $\"%1$\""
|
|
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}
|
|
Delete "$INSTDIR\LICENSE"
|
|
Delete "$INSTDIR\VERSION"
|
|
Delete "$INSTDIR\icon.ico"
|
|
Delete "$INSTDIR\Uninstall.exe"
|
|
RMDir "$INSTDIR"
|
|
DeleteRegKey HKCU "Software\Classes\{identity.desktop_id}"
|
|
DeleteRegKey HKCU "{identity.windows_uninstall_key}"
|
|
DeleteRegKey HKCU "{identity.windows_registry_key}"
|
|
SectionEnd
|
|
"""
|
|
).lstrip(),
|
|
encoding="utf-8",
|
|
)
|
|
run([find_makensis(), str(script)])
|
|
|
|
|
|
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)
|
|
shutil.copy2(ICON_DIR / "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": [identity.desktop_id],
|
|
}
|
|
],
|
|
"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=x-scheme-handler/{identity.desktop_id};
|
|
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)
|
|
)
|
|
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()
|