///|
priv struct ConfigFields {
identifier : String
backend : BackendConfig?
frontend : FrontendConfig?
package_config : PackageConfig?
} derive (
ToJson(fields(package_config(rename="package"))),
FromJson(fields(package_config(rename="package"))),
)
///|
extend ConfigFields with ToJson::{to_json}
///|
/// Desktop host used to select project overrides, independently of build targets.
pub(all) enum Platform {
Macos
Windows
Linux
} derive(Eq, Debug)
///|
pub extend Platform with Eq::{equal, not_equal}
///|
pub extend Platform with Debug::{to_repr}
///|
/// Returns the operating system on which the configuration is being used.
pub fn current_platform() -> Platform {
match @async.platform {
MacOS => Macos
Windows => Windows
Linux => Linux
}
}
///|
fn Platform::key(self : Platform) -> String {
match self {
Macos => "macos"
Windows => "windows"
Linux => "linux"
}
}
///|
/// Reads project configuration and resolves the selected host's overrides.
pub fn AppConfig::load(
path : String,
platform~ : Platform = current_platform(),
) -> 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, platform~)
}
///|
/// Decodes project configuration. Defaults are applied after platform overrides.
pub fn AppConfig::parse(
source : String,
name~ : String = "proton.project.json",
platform~ : Platform = current_platform(),
) -> AppConfig raise AppConfigError {
let json = @json.parse(source) catch {
error => raise Syntax(name~, diagnostics=error.to_string())
}
AppConfig::decode(json, platform~)
}
///|
/// Resolves a project into a typed configuration without changing path strings.
/// All platform sections are checked, including those not selected by this host.
pub fn AppConfig::decode(
json : Json,
platform~ : Platform = current_platform(),
) -> AppConfig raise AppConfigError {
guard json is Object(fields) else { raise InvalidRoot }
check_config_fields(json, "proton.project.json")
let common : Map[String, Json] = Map([])
for key, value in fields {
if key != "platforms" {
common[key] = value
}
}
if fields.get("platforms") is Some(value) && !(value is Object(_)) {
raise InvalidField(path="platforms", expectation="must be an object")
}
let mut selected = None
if fields.get("platforms") is Some(Object(platforms)) {
for host in [Macos, Windows, Linux] {
if platforms.get(host.key()) is Some(patch) {
let resolved = decode_effective(merge_config(Json(common), patch))
if host == platform {
selected = Some(resolved)
}
}
}
}
selected.unwrap_or_else(() => decode_effective(Json(common)))
}
///|
// Merge only schema-checked objects. Arrays and null are replacement values.
fn merge_config(base : Json, patch : Json) -> Json {
guard base is Object(base) && patch is Object(patch) else { return patch }
let result = base.copy()
for key, value in patch {
result[key] = match result.get(key) {
Some(old) => merge_config(old, value)
None => value
}
}
Json(result)
}
///|
fn check_config_fields(
value : Json,
scope : String,
) -> Unit raise AppConfigError {
guard value is Object(fields) else {
// Type decoding reports non-object sections after the override is applied.
return
}
let scope_kind = if scope.has_prefix("platforms.") {
let parts = scope.split(".").to_array()
if parts.length() == 2 {
"override"
} else {
parts[2:].join(".")
}
} else {
scope
}
let allowed : Array[String] = match scope_kind {
"proton.project.json" =>
["identifier", "backend", "frontend", "package", "platforms"]
"platforms" => ["macos", "windows", "linux"]
"override" => ["backend", "frontend", "package"]
"backend" => ["path", "package"]
"frontend" => ["path", "dev_url", "dist", "before_dev", "before_build"]
"package" => {
let keys = [
"product_name", "formats", "icons", "prepare", "resources", "sign", "url_schemes",
"document_types", "output",
]
if scope == "package" {
keys.push("version")
}
if scope == "platforms.macos.package" {
keys.push("minimum_system_version")
}
if scope == "platforms.windows.package" {
keys.push("nsis_install_mode")
}
keys
}
_ if scope.has_suffix(".sign") => ["binaries"]
_ if scope.has_suffix(".document_types") => ["name", "extensions", "role"]
_ => return
}
if scope == "package" && fields.contains("platforms") {
raise InvalidField(
path="package.platforms",
expectation="has moved to root platforms..package; arrays now replace shared values",
)
}
validate_object_fields(fields, scope, allowed)
for key, child in fields {
let label = if scope == "proton.project.json" {
key
} else {
scope + "." + key
}
match child {
Array(items) if key == "document_types" =>
for item in items {
check_config_fields(item, label)
}
_ => check_config_fields(child, label)
}
}
if scope == "platforms" {
for key, child in fields {
guard child is Object(_) else {
raise InvalidField(
path="platforms." + key,
expectation="must be an object",
)
}
}
}
}
///|
fn decode_effective(value : Json) -> AppConfig raise AppConfigError {
guard value is Object(fields) else { raise InvalidRoot }
guard fields.contains("identifier") else {
raise MissingField(path="identifier")
}
let fields = fields.copy()
for key in ["backend", "frontend", "package"] {
if fields.get(key) is Some(Null) {
fields.remove(key)
}
}
if fields.get("frontend") is Some(Object(frontend)) {
let frontend = frontend.copy()
for key in ["path", "dev_url", "dist", "before_dev", "before_build"] {
if frontend.get(key) is Some(Null) {
frontend.remove(key)
}
}
fields["frontend"] = Json(frontend)
}
let package_json = match fields.get("package") {
Some(Object(fields)) => {
let result : Map[String, Json] = {
"formats": [],
"icons": [],
"resources": [],
"url_schemes": [],
"document_types": [],
"output": "dist",
}
for key, value in fields {
result[key] = value
}
for
key in [
"prepare", "sign", "minimum_system_version", "nsis_install_mode",
] {
if result.get(key) is Some(Null) {
result.remove(key)
}
}
if result.get("sign") is Some(Object(sign)) {
if !sign.contains("binaries") {
result["sign"] = { "binaries": [] }
}
}
if result.get("document_types") is Some(Array(items)) {
result["document_types"] = Json(
items.map(item => {
if item is Object(doc) && !doc.contains("role") {
let doc = doc.copy()
doc["role"] = "Viewer"
Json(doc)
} else {
item
}
}),
)
}
Json(result)
}
Some(value) => value
None => Json::null()
}
if package_json is Null {
fields.remove("package")
} else {
fields["package"] = package_json
}
let fields : ConfigFields = @json.from_json(Json(fields)) catch {
error =>
raise Syntax(name="proton.project.json", diagnostics=error.to_string())
}
let config = AppConfig(
fields.identifier,
backend=fields.backend,
frontend=fields.frontend,
package_config=fields.package_config,
)
// Required fields, array contents and command strings must also be valid in
// platform sections which are not selected on this machine.
config.validate()
{ ..config, identifier: validate_application_identifier(config.identifier), }
}
///|
/// Returns an independent effective configuration snapshot.
pub fn AppConfig::copy(self : AppConfig) -> AppConfig {
// Derived decoding copies arrays without selecting a platform again.
let fields : ConfigFields = @json.from_json(
ConfigFields::{
identifier: self.identifier,
backend: self.backend,
frontend: self.frontend,
package_config: self.package_config,
}.to_json(),
) catch {
_ => abort("invalid encoded AppConfig")
}
AppConfig(
fields.identifier,
backend=fields.backend,
frontend=fields.frontend,
package_config=fields.package_config,
)
}
///|
// Preserve the project wire format when embedding an effective configuration.
// Native settings remain under root platforms, never in common package fields.
pub impl ToJson for AppConfig with fn to_json(self) {
let result : Map[String, Json] = { "identifier": self.identifier }
if self.backend is Some(backend) {
result["backend"] = backend.to_json()
}
if self.frontend is Some(frontend) {
result["frontend"] = frontend.to_json()
}
if self.package_config is Some(package_config) {
guard! package_config.to_json() is Object(package_fields)
let package_fields = package_fields.copy()
package_fields.remove("minimum_system_version")
package_fields.remove("nsis_install_mode")
result["package"] = Json(package_fields)
let platforms : Map[String, Json] = Map([])
if package_config.minimum_system_version is Some(version) {
platforms["macos"] = { "package": { "minimum_system_version": version } }
}
if package_config.nsis_install_mode is Some(mode) {
platforms["windows"] = { "package": { "nsis_install_mode": mode } }
}
if !platforms.is_empty() {
result["platforms"] = Json(platforms)
}
}
Json(result)
}
///|
pub impl @json.FromJson for AppConfig with fn from_json(value, path) {
AppConfig::decode(value) catch {
error => raise @json.JsonDecodeError((path, error.message()))
}
}
///|
/// Checks semantic constraints on an effective configuration.
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"),
(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(config) {
nonempty(config.product_name, "package.product_name")
nonempty(config.version, "package.version")
nonempty(config.output, "package.output")
validate_paths(config.icons, "package.icons")
validate_paths(config.resources, "package.resources")
validate_sign(config.sign, "package.sign")
for format in config.formats {
nonempty(format, "package.formats")
}
if config.prepare is Some(value) {
nonempty(value, "package.prepare")
}
validate_url_schemes(config.url_schemes)
for doc in 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")
}
}
if config.minimum_system_version is Some(version) {
nonempty(version, "platforms.macos.package.minimum_system_version")
}
match config.nsis_install_mode {
None | Some("currentUser" | "perMachine" | "both") => ()
Some(value) =>
raise UnsupportedValue(
path="platforms.windows.package.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 {
// `dir/**` is the one supported pattern: a macOS signing tree.
let exact = if path.replace_all(old="\\", new="/").has_suffix("/**") {
path.unsafe_substring(start=0, end=path.length() - 3)
} else {
path
}
guard !exact.contains("*") else {
raise InvalidField(
path=label + ".binaries",
expectation="must be exact paths or dir/** trees",
)
}
}
}
}
///|
/// 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~)
}
}