///|
/// Reads a complete application configuration without resolving its paths.
pub fn AppConfig::load(path : String) -> AppConfig raise AppConfigError {
let source = @mbfs.read_file_to_string(path, encoding="utf8") catch {
error => raise FileRead(path~, detail=@debug.render(Repr(error)))
}
AppConfig::parse(source, name=path)
}
///|
/// Decodes the project JSON format, applying defaults and semantic validation.
pub fn AppConfig::parse(
source : String,
name? : String = "proton.project.json",
) -> AppConfig raise AppConfigError {
let json = @json.parse(source) catch {
error => raise Syntax(name~, diagnostics=error.to_string())
}
AppConfig::decode(json)
}
///|
/// Decodes a complete configuration. Relative paths remain relative.
pub fn AppConfig::decode(json : Json) -> AppConfig raise AppConfigError {
guard json is Object(_) else { raise InvalidRoot }
let json = prepare_config_json(json, "proton.project.json")
let config : AppConfig = @json.from_json(json) catch {
error =>
raise Syntax(name="proton.project.json", diagnostics=error.to_string())
}
config.validate()
{ ..config, identifier: validate_application_identifier(config.identifier), }
}
///|
/// Returns an independent configuration snapshot.
pub fn AppConfig::copy(self : AppConfig) -> AppConfig {
// The derived decoder allocates fresh arrays and nested records.
@json.from_json(self.to_json()) catch {
_ => abort("invalid encoded AppConfig")
}
}
///|
// Derive owns type decoding. This pass only checks field names and supplies
// defaults that are part of the project file format.
fn prepare_config_json(
value : Json,
scope : String,
) -> Json raise AppConfigError {
guard value is Object(fields) else { return value }
let (allowed, defaults) : (Array[String], Map[String, Json]) = match scope {
"proton.project.json" =>
(["identifier", "backend", "frontend", "package"], {})
"backend" => (["path", "package"], {})
"frontend" =>
(["path", "dev_url", "dist", "before_dev", "before_build"], {})
"package" =>
(
[
"product_name", "version", "formats", "icons", "prepare", "resources",
"sign", "url_schemes", "document_types", "output", "platforms",
],
{
"formats": [],
"icons": [],
"resources": [],
"url_schemes": [],
"document_types": [],
"output": "dist",
"platforms": {},
},
)
"package.platforms" => (["macos", "windows", "linux"], {})
"package.document_types" =>
(["name", "extensions", "role"], { "role": "Viewer" })
_ if scope.has_suffix(".sign") => (["binaries"], { "binaries": [] })
"package.platforms.macos" =>
(
["formats", "resources", "sign", "minimum_system_version"],
{ "resources": [] },
)
"package.platforms.windows" =>
(
["formats", "resources", "sign", "nsis_install_mode"],
{ "resources": [] },
)
"package.platforms.linux" =>
(["formats", "resources", "sign"], { "resources": [] })
_ => return value
}
validate_object_fields(fields, scope, allowed)
let result = defaults
for key, value in fields {
let child = if scope == "proton.project.json" {
key
} else {
scope + "." + key
}
result[key] = match value {
Array(items) if child == "package.document_types" =>
ToJson::to_json(items.map(item => prepare_config_json(item, child)))
Object(_) => prepare_config_json(value, child)
_ => value
}
}
ToJson::to_json(result)
}
///|
/// Checks semantic constraints independently of JSON decoding.
pub fn AppConfig::validate(self : AppConfig) -> Unit raise AppConfigError {
ignore(validate_application_identifier(self.identifier))
if self.backend is Some(backend) {
nonempty(backend.path, "backend.path")
nonempty(backend.app_package, "backend.package")
}
if self.frontend is Some(frontend) {
for (value, field) in [(frontend.path, "path"), (frontend.dist, "dist")] {
if value is Some(value) {
nonempty(value, "frontend." + field)
}
}
for
(value, field) in [
(frontend.dev_url, "dev_url"),
(frontend.before_dev, "before_dev"),
(frontend.before_build, "before_build"),
] {
if value is Some(value) {
nonempty(value, "frontend." + field)
}
}
}
if self.package_config is Some(package_config) {
nonempty(package_config.product_name, "package.product_name")
nonempty(package_config.version, "package.version")
nonempty(package_config.output, "package.output")
validate_paths(package_config.icons, "package.icons")
validate_paths(package_config.resources, "package.resources")
validate_sign(package_config.sign, "package.sign")
for format in package_config.formats {
nonempty(format, "package.formats")
}
if package_config.prepare is Some(value) {
nonempty(value, "package.prepare")
}
validate_url_schemes(package_config.url_schemes)
for doc in package_config.document_types {
nonempty(doc.name, "package.document_types.name")
nonempty(doc.role, "package.document_types.role")
guard !doc.extensions.is_empty() else {
raise MissingField(path="package.document_types.extensions")
}
for ext in doc.extensions {
nonempty(ext, "package.document_types.extensions")
}
}
let platforms = package_config.platforms
for
(platform, value) in [
("macos", platforms.macos),
("windows", platforms.windows),
("linux", platforms.linux),
] {
if value is Some(value) {
let label = "package.platforms." + platform
validate_paths(value.resources, label + ".resources")
validate_sign(value.sign, label + ".sign")
if value.formats is Some(formats) {
for format in formats {
nonempty(format, label + ".formats")
}
}
if value.minimum_system_version is Some(version) {
guard platform == "macos" else {
raise UnknownField(path=label + ".minimum_system_version")
}
nonempty(version, label + ".minimum_system_version")
}
if value.nsis_install_mode is Some(_) && platform != "windows" {
raise UnknownField(path=label + ".nsis_install_mode")
}
match value.nsis_install_mode {
None | Some("currentUser" | "perMachine" | "both") => ()
Some(value) =>
raise UnsupportedValue(path=label + ".nsis_install_mode", value~)
}
}
}
}
}
///|
fn nonempty(value : String, field : String) -> Unit raise AppConfigError {
guard value.trim() != "" else {
raise InvalidField(path=field, expectation="must not be empty")
}
}
///|
fn validate_paths(
paths : Array[String],
label : String,
) -> Unit raise AppConfigError {
for path in paths {
nonempty(path, label)
}
}
///|
fn validate_sign(
sign : SignConfig?,
label : String,
) -> Unit raise AppConfigError {
if sign is Some(sign) {
validate_paths(sign.binaries, label + ".binaries")
for path in sign.binaries {
guard !path.contains("*") else {
raise InvalidField(
path=label + ".binaries",
expectation="must not contain glob patterns",
)
}
}
}
}
///|
/// Validates and trims a canonical reverse-DNS application identifier.
pub fn validate_application_identifier(
value : String,
) -> String raise AppConfigError {
let value = value.trim().to_owned()
let components : Array[StringView] = value.split(".").collect()
guard components.length() >= 2 else {
raise InvalidField(
path="identifier",
expectation="must be a reverse-DNS application identifier",
)
}
for component in components {
guard component.length() > 0 else {
raise InvalidField(
path="identifier",
expectation="must not contain 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 InvalidField(
path="identifier",
expectation="components must start with an ASCII letter or digit",
)
}
at_start = false
}
guard char.is_ascii_alphabetic() || char.is_ascii_digit() || char == '-' else {
raise InvalidField(
path="identifier",
expectation="must contain only ASCII letters, digits, hyphens, and dots",
)
}
}
}
value
}
///|
fn validate_url_schemes(schemes : Array[String]) -> Unit raise AppConfigError {
let seen : Map[String, Unit] = Map([])
for scheme in schemes {
let normalized = scheme.to_lower()
guard url_scheme_is_valid(normalized) else {
raise UnsupportedValue(path="package.url_schemes", value=scheme)
}
guard !seen.contains(normalized) else {
raise DuplicateValue(path="package.url_schemes", value=scheme)
}
seen[normalized] = ()
}
}
///|
fn url_scheme_is_valid(scheme : String) -> Bool {
guard scheme.length() > 0 else { return false }
for index, ch in scheme {
let letter = (ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z')
if index == 0 {
guard letter else { return false }
} else {
guard letter ||
(ch >= '0' && ch <= '9') ||
ch == '+' ||
ch == '-' ||
ch == '.' else {
return false
}
}
}
true
}
///|
fn validate_object_fields(
fields : Map[String, Json],
label : String,
allowed : Array[String],
) -> Unit raise AppConfigError {
let errors : Array[AppConfigError] = []
for key, _ in fields {
if !allowed.contains(key) {
errors.push(UnknownField(path=label + "." + key))
}
}
raise_errors(errors)
}
///|
fn raise_errors(errors : Array[AppConfigError]) -> Unit raise AppConfigError {
if errors.length() == 1 {
raise errors[0]
}
if errors.length() > 1 {
raise Validation(errors~)
}
}