///|
priv enum PackageHost {
Macos
Windows
Linux
} derive(Eq)
///|
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
}
}
///|
/// Packages an already-built executable using the current host's native tools.
pub async fn build(spec : PackageSpec) -> Array[String] {
build_with(spec, PackageCustomization::new(), fn(_layout) { () })
}
///|
/// Packages an executable and allows framework-specific staging before the
/// package is signed and converted into its requested artifacts.
pub async fn build_with(
spec : PackageSpec,
customization : PackageCustomization,
prepare : async (PackageLayout) -> Unit,
) -> Array[String] {
let spec = validate_spec(spec)
validate_customization(customization)
let host = current_package_host()
for format in spec.formats {
guard format_supported(host, format) else {
raise PackagePlanError::UnsupportedFormatForPlatform(
format=format.to_string(),
platform=host_name(host),
)
}
}
ensure_dir(spec.output)
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.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 {
let product_name = validate_product_name(spec.product_name)
let identifier = validate_identifier(spec.identifier)
let version = validate_package_version(spec.version)
guard spec.formats.length() > 0 else {
raise UnsupportedFormat(format="no format selected")
}
for payload in spec.payloads {
validate_relative_destination(payload.destination)
}
PackageSpec::{
..spec,
product_name,
identifier,
version,
sign: spec.sign || spec.notarize,
}
}
///|
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
}
///|
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))
if current_package_host() == Macos {
let code = @process.run("ditto", [source, destination]) catch {
error =>
raise PackageFileSystemError::Copy(
source~,
destination~,
detail=@debug.render(Repr(error)),
)
}
guard code == 0 else {
raise PackageToolError::Exit(tool="ditto", 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, 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=""")
}