///|
/// The desktop platform supported by the current host-native packager.
pub(all) enum PackageHost {
Macos
Windows
Linux
} derive(Eq)
///|
pub extend PackageHost with Eq::{not_equal, equal}
///|
/// Returns the desktop platform of the current packaging host.
pub async fn current_package_host() -> PackageHost {
if @path.sep == '\\' {
Windows
} else if path_exists("/System/Library/CoreServices/SystemVersion.plist") {
Macos
} else {
Linux
}
}
///|
/// Validates and trims the version used by package metadata and paths.
pub fn validate_package_version(
value : String,
) -> String raise PackagePlanError {
let value = value.trim().to_owned()
guard value != "" else {
raise InvalidVersion(version=value, reason="must not be empty")
}
for char in value {
let code = char.to_int()
if code < 32 || code == 127 {
raise InvalidVersion(
version=value,
reason="contains an ASCII control character",
)
}
if char == '/' || char == '\\' {
raise InvalidVersion(version=value, reason="contains a path separator")
}
}
value
}
///|
fn host_name(host : PackageHost) -> String {
match host {
Macos => "macOS"
Windows => "Windows"
Linux => "Linux"
}
}
///|
fn format_supported(host : PackageHost, format : PackageFormat) -> Bool {
match (host, format) {
(Macos, App | Zip | Dmg) => true
(Windows, App | Zip | Nsis) => true
(Linux, AppImage) => true
_ => false
}
}
///|
/// Resolves an explicit format list, or the current host's defaults when the
/// list is empty, and rejects formats that the current host cannot build.
pub async fn resolve_package_formats(
formats : Array[PackageFormat],
) -> Array[PackageFormat] {
resolve_package_formats_for_host(formats, current_package_host())
}
///|
fn resolve_package_formats_for_host(
formats : Array[PackageFormat],
host : PackageHost,
) -> Array[PackageFormat] raise PackagePlanError {
let formats = if formats.length() > 0 {
formats.copy()
} else {
match host {
Macos | Windows => [PackageFormat::App, PackageFormat::Zip]
Linux => [PackageFormat::AppImage]
}
}
let seen : Map[String, Unit] = Map([])
for format in formats {
let name = format.to_string()
guard !seen.contains(name) else {
raise PackagePlanError::DuplicateFormat(format=name)
}
seen[name] = ()
guard format_supported(host, format) else {
raise PackagePlanError::UnsupportedFormatForPlatform(
format=name,
platform=host_name(host),
)
}
}
formats
}
///|
/// Packages an already-built executable using the current host's native tools.
pub async fn build(spec : PackageSpec) -> Array[String] {
build_with(spec, PackageCustomization(), fn(_layout) { () })
}
///|
/// Packages an executable and allows framework-specific staging before the
/// package is signed and converted into its requested artifacts.
/// The packager owns the staging directory: it copies the executable and payloads,
/// calls `prepare` with their final layout, then signs and creates the artifacts.
/// Preparation must finish all framework-specific writes before returning.
pub async fn build_with(
spec : PackageSpec,
customization : PackageCustomization,
prepare : async (PackageLayout) -> Unit,
) -> Array[String] {
let spec = validate_spec(spec)
validate_customization(customization)
let formats = resolve_package_formats(spec.formats)
let spec = PackageSpec::{ ..spec, formats, }
let host = current_package_host()
ensure_dir(spec.output)
let lock = @async_fs.open(
path_join(spec.output, ".proton-package.lock"),
mode=ReadWrite,
create_mode=OpenOrCreate,
)
defer lock.close()
lock.lock(Exclusive)
defer lock.unlock()
match host {
Macos => package_macos(spec, customization, prepare)
Windows => package_windows(spec, customization, prepare)
Linux => package_linux(spec, customization, prepare)
}
}
///|
fn validate_customization(
customization : PackageCustomization,
) -> Unit raise PackagePlanError {
for property in customization.macos_plist_strings {
guard property.key != "LSMinimumSystemVersion" else {
raise InvalidPayload(
detail="use macos_minimum_system_version to configure LSMinimumSystemVersion",
)
}
guard property.key.trim().length() > 0 else {
raise InvalidPayload(detail="macOS plist property key must not be empty")
}
}
for target in customization.macos_sign_targets {
validate_relative_destination(target.path)
}
for target in customization.windows_sign_targets {
validate_relative_destination(target)
}
for target in customization.windows_verify_targets {
validate_relative_destination(target)
}
match customization.linux_resources_path {
Some(path) => validate_relative_destination(path)
None => ()
}
}
///|
fn validate_spec(spec : PackageSpec) -> PackageSpec raise PackagePlanError {
if spec.macos_minimum_system_version is Some(value) {
validate_macos_minimum_version(value)
}
let product_name = validate_product_name(spec.product_name)
let identifier = validate_identifier(spec.identifier)
let version = validate_package_version(spec.version)
let url_schemes = validate_url_schemes(spec.url_schemes)
for payload in spec.payloads {
validate_relative_destination(payload.destination)
}
PackageSpec::{
..spec,
product_name,
identifier,
version,
url_schemes,
sign: spec.sign || spec.notarize,
}
}
///|
fn validate_url_schemes(
schemes : Array[String],
) -> Array[String] raise PackagePlanError {
let normalized : Array[String] = []
for scheme in schemes {
let scheme = scheme.trim().to_owned().to_lower()
guard scheme.length() > 0 else {
raise InvalidUrlScheme(scheme~, reason="value is required")
}
for index, char in scheme {
let valid = if index == 0 {
char.is_ascii_alphabetic()
} else {
char.is_ascii_alphabetic() ||
char.is_ascii_digit() ||
char == '+' ||
char == '-' ||
char == '.'
}
guard valid else {
raise InvalidUrlScheme(
scheme~,
reason="contains an unsupported character",
)
}
}
guard !normalized.contains(scheme) else {
raise InvalidUrlScheme(scheme~, reason="is declared more than once")
}
normalized.push(scheme)
}
normalized
}
///|
/// Validates and trims an application display name for native package metadata.
pub fn validate_product_name(value : String) -> String raise PackagePlanError {
let value = value.trim().to_owned()
guard value != "" else {
raise InvalidProductName(reason="value is required")
}
for char in value {
let code = char.to_int()
if code < 32 || code == 127 {
raise InvalidProductName(reason="contains an ASCII control character")
}
if char == '/' || char == '\\' || char == ':' {
raise InvalidProductName(
reason="contains a path-unsafe character: " + char.to_string(),
)
}
}
value
}
///|
/// Validates and trims a reverse-DNS application identifier.
pub fn validate_identifier(value : String) -> String raise PackagePlanError {
let value = value.trim().to_owned()
let components : Array[StringView] = value.split(".").collect()
guard components.length() >= 2 else {
raise InvalidIdentifier(
identifier=value,
reason="expected a reverse-DNS bundle identifier",
)
}
for component in components {
guard component.length() > 0 else {
raise InvalidIdentifier(
identifier=value,
reason="contains an empty component",
)
}
let mut at_start = true
for char in component {
if at_start {
guard char.is_ascii_alphabetic() || char.is_ascii_digit() else {
raise InvalidIdentifier(
identifier=value,
reason="a component must start with an ASCII letter or digit",
)
}
at_start = false
}
guard char.is_ascii_alphabetic() || char.is_ascii_digit() || char == '-' else {
raise InvalidIdentifier(
identifier=value,
reason="contains an unsupported character",
)
}
}
}
value
}
///|
fn validate_relative_destination(value : String) -> Unit raise PackagePlanError {
let normalized = value.replace_all(old="\\", new="/")
let path : @path.Path = normalized
guard normalized != "" && !path.is_absolute() else {
raise InvalidPayload(detail="destination must be a non-empty relative path")
}
for component in normalized.split("/") {
guard component != ".." else {
raise InvalidPayload(
detail="destination must stay inside its package area",
)
}
}
}
///|
fn executable_name(product_name : String) -> String {
let mut output = ""
let mut previous_dash = false
for char in product_name.trim().to_owned().to_lower() {
if char.is_ascii_alphabetic() ||
char.is_ascii_digit() ||
char == '-' ||
char == '_' {
output = output + char.to_string()
previous_dash = false
} else if !previous_dash && output != "" {
output = output + "-"
previous_dash = true
}
}
while output.has_suffix("-") {
output = output
.unsafe_substring(start=0, end=output.length() - 1)
.to_string()
}
if output == "" {
"app"
} else {
output
}
}
///|
fn first_icon(spec : PackageSpec, suffix : String) -> String? {
for icon in spec.icons {
if icon.to_lower().has_suffix(suffix) {
return Some(icon)
}
}
None
}
///|
async fn path_exists(path : String) -> Bool {
@async_fs.exists(path)
}
///|
async fn path_is_file(path : String) -> Bool {
path_exists(path) && @async_fs.kind(path) is Regular
}
///|
async fn path_is_dir(path : String) -> Bool {
path_exists(path) && @async_fs.kind(path) is Directory
}
///|
fn host_path(path : String) -> @path.Path {
if @path.sep == '\\' {
path.replace_all(old="/", new="\\")
} else {
path
}
}
///|
fn path_join(base : String, child : String) -> String {
host_path(base).join(host_path(child)).to_string()
}
///|
fn path_parent(path : String) -> String {
host_path(path).dirname().to_string()
}
///|
fn resolve_path(base : String, path : String) -> String {
let candidate = host_path(path)
if candidate.is_absolute() {
candidate.resolve().to_string()
} else {
host_path(base).join(candidate).resolve().to_string()
}
}
///|
async fn ensure_dir(path : String) -> Unit {
guard !path_exists(path) else { return }
let parent = path_parent(path)
if parent != path && !path_exists(parent) {
ensure_dir(parent)
}
@async_fs.mkdir(path) catch {
error => {
guard !path_exists(path) else { return }
raise PackageFileSystemError::CreateDirectory(
path~,
detail=@debug.render(Repr(error)),
)
}
}
}
///|
async fn remove_tree(path : String) -> Unit {
guard path_exists(path) else { return }
if path_is_file(path) {
@async_fs.remove(path) catch {
error =>
raise PackageFileSystemError::Remove(
path~,
detail=@debug.render(Repr(error)),
)
}
} else {
@async_fs.rmdir(path, recursive=true) catch {
error =>
raise PackageFileSystemError::Remove(
path~,
detail=@debug.render(Repr(error)),
)
}
}
}
///|
async fn copy_file(source : String, destination : String) -> Unit {
guard path_is_file(source) else {
raise PackagePlanError::MissingInput(path=source)
}
ensure_dir(path_parent(destination))
// Copy with the host tool wherever file modes matter: a single-file payload
// such as the CEF helper must stay executable, and the byte copy below
// creates every file with default permissions.
let tool = match current_package_host() {
Macos => Some("ditto")
Linux => Some("cp")
Windows => None
}
if tool is Some(tool) {
let code = @process.run(tool, [source, destination]) catch {
error =>
raise PackageFileSystemError::Copy(
source~,
destination~,
detail=@debug.render(Repr(error)),
)
}
guard code == 0 else {
raise PackageToolError::Exit(tool~, code~, detail=source)
}
return
}
let data = @async_fs.read_file(source) catch {
error =>
raise PackageFileSystemError::Read(
path=source,
detail=@debug.render(Repr(error)),
)
}
@async_fs.write_file(destination, data, create_mode=CreateOrTruncate) catch {
error =>
raise PackageFileSystemError::Write(
path=destination,
detail=@debug.render(Repr(error)),
)
}
}
///|
async fn copy_tree(source : String, destination : String) -> Unit {
if path_is_file(source) {
return copy_file(source, destination)
}
guard path_is_dir(source) else {
raise PackageFileSystemError::InvalidSource(path=source)
}
ensure_dir(path_parent(destination))
let (tool, args) = match current_package_host() {
Windows =>
(
"robocopy",
[
source, destination, "/E", "/COPY:DAT", "/DCOPY:DAT", "/NFL", "/NDL", "/NJH",
"/NJS", "/NP", "/R:2", "/W:1",
],
)
Macos => ("ditto", [source, destination])
Linux => ("cp", ["-R", source, destination])
}
let (code, _) = @process.collect_output_merged(tool, args) catch {
error =>
raise PackageFileSystemError::Copy(
source~,
destination~,
detail=@debug.render(Repr(error)),
)
}
guard code == 0 || (tool == "robocopy" && code < 8) else {
raise PackageToolError::Exit(tool~, code~, detail=source)
}
}
///|
async fn copy_payloads(
spec : PackageSpec,
resources : String,
libraries : String,
executables : String,
) -> Unit {
for payload in spec.payloads {
let root = match payload.location {
Resources => resources
Libraries => libraries
Executables => executables
}
copy_tree(payload.source, path_join(root, payload.destination))
}
}
///|
async fn write_text(path : String, text : String) -> Unit {
ensure_dir(path_parent(path))
@async_fs.write_file(path, @utf8.encode(text), create_mode=CreateOrTruncate) catch {
error =>
raise PackageFileSystemError::Write(
path~,
detail=@debug.render(Repr(error)),
)
}
}
///|
async fn promote(source : String, destination : String) -> Unit {
let backup = destination + ".backup"
if path_exists(backup) {
if path_exists(destination) {
remove_tree(backup)
} else {
rename_package_path(backup, destination)
}
}
let had_destination = path_exists(destination)
if had_destination {
rename_package_path(destination, backup)
}
{
errdefer (if had_destination && path_exists(backup) {
rename_package_path(backup, destination) catch {
rollback_error =>
raise PackageFileSystemError::Copy(
source~,
destination~,
detail="package promotion failed and restore failed: " +
@debug.render(Repr(rollback_error)),
)
}
})
rename_package_path(source, destination)
}
if had_destination {
remove_tree(backup) catch {
_ => ()
}
}
}
///|
async fn rename_package_path(source : String, destination : String) -> Unit {
@async_fs.rename(source, destination) catch {
error =>
raise PackageFileSystemError::Copy(
source~,
destination~,
detail=@debug.render(Repr(error)),
)
}
}
///|
fn xml_escape(text : String) -> String {
text
.replace_all(old="&", new="&")
.replace_all(old="<", new="<")
.replace_all(old=">", new=">")
.replace_all(old="\"", new=""")
}
///|
fn validate_macos_minimum_version(
value : String,
) -> Unit raise PackagePlanError {
let components = value.split(".").collect()
guard components.length() >= 2 && components.length() <= 3 else {
raise InvalidPayload(detail="invalid macOS minimum version: " + value)
}
for index, component in components {
guard !component.is_empty() else {
raise InvalidPayload(detail="invalid macOS minimum version: " + value)
}
let mut nonzero = false
for char in component {
guard char >= '0' && char <= '9' else {
raise InvalidPayload(detail="invalid macOS minimum version: " + value)
}
if char != '0' {
nonzero = true
}
}
guard index != 0 || nonzero else {
raise InvalidPayload(detail="invalid macOS minimum version: " + value)
}
}
}