mirror of
https://github.com/nyakang/nyaterm.git
synced 2026-09-22 08:01:31 +00:00
- Added `AppFlavor` enum to distinguish between stable and preview application identities. - Updated various components to utilize the current application flavor for configuration, logging, and UI elements. - Enhanced the update mechanism to ensure compatibility with the respective application flavor. - Implemented tests to validate flavor-specific behavior and configuration isolation. - Modified native menus and title bars to reflect the correct application name based on the current flavor.
742 lines
26 KiB
Python
Executable File
742 lines
26 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 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 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)}"
|
|
VIAddVersionKey "ProductName" "{identity.display_name}"
|
|
VIAddVersionKey "ProductVersion" "{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(
|
|
[
|
|
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()
|