///|
priv struct RawConfig {
executable : String?
product_name : String?
identifier : String?
version : String?
formats : Array[String]
output : String?
payloads : Array[@package.Payload]
icons : Array[String]
url_schemes : Array[String]
document_types : Array[@package.DocumentType]
sign : Bool
notarize : Bool
}
///|
fn empty_config() -> RawConfig {
RawConfig::{
executable: None,
product_name: None,
identifier: None,
version: None,
formats: [],
output: None,
payloads: [],
icons: [],
url_schemes: [],
document_types: [],
sign: false,
notarize: false,
}
}
///|
fn command() -> @argparse.Command {
@argparse.Command(
"proton_package",
about="Package an already-built desktop executable",
flags=[
FlagArg("sign", about="Sign the packaged application"),
FlagArg("notarize", about="Sign and notarize a macOS application"),
],
options=[
OptionArg("config", about="JSON package specification"),
OptionArg("executable", about="Already-built application executable"),
OptionArg("product-name", about="Application display name"),
OptionArg("identifier", about="Reverse-DNS application identifier"),
OptionArg("version", about="Application version"),
OptionArg(
"format",
action=Append,
about="Output format: app, zip, dmg, nsis, or appimage; may be repeated",
),
OptionArg("output", about="Artifact output directory"),
OptionArg("icon", action=Append, about="Platform icon; may be repeated"),
OptionArg(
"url-scheme",
action=Append,
about="Registered URL scheme; may be repeated",
),
],
)
}
///|
fn string_field(fields : Map[String, Json], name : String) -> String? {
match fields.get(name) {
Some(String(value)) => Some(value)
Some(_) => abort("package config field " + name + " must be a string")
None => None
}
}
///|
fn bool_field(fields : Map[String, Json], name : String) -> Bool {
match fields.get(name) {
Some(True) => true
Some(False) | None => false
Some(_) => abort("package config field " + name + " must be a boolean")
}
}
///|
fn string_array_field(
fields : Map[String, Json],
name : String,
) -> Array[String] {
match fields.get(name) {
None => []
Some(Array(values)) =>
values.map(fn(value) {
match value {
String(value) => value
_ => abort("package config field " + name + " must contain strings")
}
})
Some(_) => abort("package config field " + name + " must be an array")
}
}
///|
fn resolve_config_path(base : String, value : String) -> String {
let path : @path.Path = value
if path.is_absolute() {
path.resolve().to_string()
} else {
@path.Path(base).join(path).resolve().to_string()
}
}
///|
fn payloads_field(
fields : Map[String, Json],
base : String,
) -> Array[@package.Payload] raise @package.PackagePlanError {
guard fields.get("payloads") is Some(Array(values)) else { return [] }
values.map(value => {
guard value is Object(payload) else {
abort("package config payload must be an object")
}
guard payload
is { "source": String(source), "destination": String(destination), .. } else {
abort("package config payload requires source and destination strings")
}
let location = match payload.get("location") {
Some(String(value)) => @package.PayloadLocation::parse(value)
None => @package.PayloadLocation::Resources
Some(_) => abort("package config payload location must be a string")
}
@package.Payload::new(
resolve_config_path(base, source),
destination,
location~,
)
})
}
///|
fn document_types_field(
fields : Map[String, Json],
) -> Array[@package.DocumentType] {
guard fields.get("document_types") is Some(Array(values)) else { return [] }
values.map(fn(value) {
guard value is Object(document_type) else {
abort("package config document_type must be an object")
}
guard document_type
is { "name": String(name), "extensions": Array(extension_values), .. } else {
abort("package config document_type requires name and extensions")
}
let extensions = extension_values.map(fn(value) {
match value {
String(value) => value
_ => abort("document_type extensions must contain strings")
}
})
let role = match document_type.get("role") {
Some(String(role)) => role
None => "Viewer"
Some(_) => abort("document_type role must be a string")
}
@package.DocumentType::new(name, extensions, role~)
})
}
///|
async fn read_config(path : String) -> RawConfig {
let absolute = @path.Path(path).resolve()
let text = @async_fs.read_file(absolute.to_string()).text() catch {
error =>
abort("failed to read package config: " + @debug.render(Repr(error)))
}
let json = @json.parse(text) catch {
error => abort("invalid package config JSON: " + @debug.render(Repr(error)))
}
guard json is Object(fields) else {
abort("package config root must be an object")
}
guard fields.get("schema_version") is Some(Number(1.0, ..)) else {
abort("package config requires schema_version 1")
}
let base = absolute.dirname().to_string()
RawConfig::{
executable: string_field(fields, "executable").map(fn(path) {
resolve_config_path(base, path)
}),
product_name: string_field(fields, "product_name"),
identifier: string_field(fields, "identifier"),
version: string_field(fields, "version"),
formats: string_array_field(fields, "formats"),
output: string_field(fields, "output").map(fn(path) {
resolve_config_path(base, path)
}),
payloads: payloads_field(fields, base),
icons: string_array_field(fields, "icons").map(fn(path) {
resolve_config_path(base, path)
}),
url_schemes: string_array_field(fields, "url_schemes"),
document_types: document_types_field(fields),
sign: bool_field(fields, "sign"),
notarize: bool_field(fields, "notarize"),
}
}
///|
fn first(values : Map[String, Array[String]], name : String) -> String? {
match values.get(name) {
Some([value, ..]) => Some(value)
_ => None
}
}
///|
fn values(values : Map[String, Array[String]], name : String) -> Array[String] {
values.get(name).unwrap_or([])
}
///|
fn required(value : String?, name : String) -> String {
match value {
Some(value) => value
None => abort("missing required option --" + name)
}
}
///|
fn prefer(primary : String?, fallback : String?) -> String? {
match primary {
Some(value) => Some(value)
None => fallback
}
}
///|
async fn run(args : ArrayView[String]) -> Unit {
let matches = @argparse.parse(command(), argv=args, env=Map([])) catch {
error => abort(error.to_string())
}
let config = match first(matches.values, "config") {
Some(path) => read_config(path)
None => empty_config()
}
let executable = prefer(
first(matches.values, "executable"),
config.executable,
)
let product_name = prefer(
first(matches.values, "product-name"),
config.product_name,
)
let identifier = prefer(
first(matches.values, "identifier"),
config.identifier,
)
let version = prefer(first(matches.values, "version"), config.version)
let output = prefer(first(matches.values, "output"), config.output).unwrap_or(
"dist",
)
let format_values = values(matches.values, "format")
let formats = (if format_values.length() > 0 {
format_values
} else {
config.formats
}).map(value => @package.PackageFormat::parse(value))
let icon_values = values(matches.values, "icon")
let icons = if icon_values.length() > 0 { icon_values } else { config.icons }
let scheme_values = values(matches.values, "url-scheme")
let url_schemes = if scheme_values.length() > 0 {
scheme_values
} else {
config.url_schemes
}
let spec = @package.PackageSpec::new(
@path.Path(required(executable, "executable")).resolve().to_string(),
required(product_name, "product-name"),
required(identifier, "identifier"),
required(version, "version"),
formats,
@path.Path(output).resolve().to_string(),
payloads=config.payloads,
icons=icons.map(fn(path) { @path.Path(path).resolve().to_string() }),
url_schemes~,
document_types=config.document_types,
sign=matches.flags.get("sign").unwrap_or(false) || config.sign,
notarize=matches.flags.get("notarize").unwrap_or(false) || config.notarize,
)
for artifact in @package.build(spec) {
println(artifact)
}
}
///|
async fn main {
run(@env.args()[1:]) catch {
error => {
println("error: " + error.to_string())
@sys.exit(1)
}
}
}