///|
pub fn load_project_config_from_file(
path : String,
) -> LoadedProjectConfig raise ProjectConfigError {
let base_dir = config_base_dir(path)
load_project_config_from_text(
read_project_config_text(path),
base_dir,
name=path,
)
}
///|
/// Parses project JSON text at an I/O boundary, then validates the resulting
/// native `Json` value.
pub fn load_project_config_from_text(
source : String,
base_dir : String,
name? : String = "proton.project.json",
) -> LoadedProjectConfig raise ProjectConfigError {
let json = @json.parse(source) catch {
error => raise Syntax(name~, diagnostics=error.to_string())
}
load_project_config_from_json(json, base_dir)
}
///|
/// Validates an already parsed Proton project JSON document without
/// serializing it through an intermediate string.
pub fn load_project_config_from_json(
json : Json,
base_dir : String,
) -> LoadedProjectConfig raise ProjectConfigError {
LoadedProjectConfig::new(decode_project_config(json, base_dir))
}
///|
fn read_project_config_text(path : String) -> String raise ProjectConfigError {
@mbfs.read_file_to_string(path, encoding="utf8") catch {
error => raise FileRead(path~, detail=@debug.render(Repr(error)))
}
}
///|
fn decode_project_config(
json : Json,
base_dir : String,
) -> ProjectConfig raise ProjectConfigError {
match json {
Object(fields) => {
validate_project_top_level_fields(fields)
let window = required_project_window(fields)
let entry = required_project_entry(fields, base_dir)
let windows = optional_project_windows(fields, base_dir)
let permissions = optional_project_permissions(fields, windows)
let debug = optional_project_debug(fields)
let single_instance = optional_bool_config_field(
fields, "single_instance", "single_instance", false,
)
let backend = optional_project_backend(fields, base_dir)
let metadata_base_dir = match backend {
Some(backend) => backend.path()
None => base_dir
}
let metadata = decode_project_metadata(fields, metadata_base_dir)
if single_instance && metadata.identifier is None {
raise InvalidField(
path="proton.project.json.identifier",
expectation="is required when single_instance is true",
)
}
let frontend = optional_project_frontend(fields, base_dir)
let bundle = optional_project_bundle(fields, base_dir)
let updater = optional_project_updater(fields)
ProjectConfig::new(
base_dir,
window,
entry,
metadata,
windows~,
backend~,
frontend~,
bundle~,
updater~,
permissions~,
debug~,
single_instance~,
)
}
_ => raise InvalidRoot
}
}
///|
fn validate_project_top_level_fields(
fields : Map[String, Json],
) -> Unit raise ProjectConfigError {
let errors : Array[ProjectConfigError] = []
for key, value in fields {
match key {
"window"
| "entry"
| "windows"
| "permissions"
| "debug"
| "single_instance" => ()
"product_name" | "identifier" =>
validate_optional_non_empty_string(value, key) catch {
error => errors.push(error)
}
"backend" | "frontend" | "bundle" | "updater" =>
validate_optional_object(value, key) catch {
error => errors.push(error)
}
"extensions" =>
errors.push(
UnsupportedField(
path="proton.project.json.extensions",
reason="is not supported; declare extensions in MoonBit code with .extension(...).",
),
)
other => errors.push(UnknownField(path="proton.project.json." + other))
}
}
if errors.length() > 0 {
if errors.length() == 1 {
raise errors[0]
}
raise Validation(errors~)
}
}
///|
fn required_project_window(
fields : Map[String, Json],
) -> ProjectWindowConfig raise ProjectConfigError {
decode_project_window(required_config_field(fields, "window"), "window")
}
///|
fn decode_project_window(
value : Json,
label : String,
) -> ProjectWindowConfig raise ProjectConfigError {
guard value is Object(window_fields) else {
raise InvalidField(
path="proton.project.json." + label,
expectation="must be an object",
)
}
validate_object_fields(window_fields, label, [
"title", "width", "height", "size_hint", "titlebar_style",
])
let title = required_string_config_field(
window_fields,
"title",
label + ".title",
)
let width = required_positive_int_config_field(
window_fields,
"width",
label + ".width",
)
let height = required_positive_int_config_field(
window_fields,
"height",
label + ".height",
)
let size_hint = parse_project_size_hint(
optional_string_config_field(
window_fields,
"size_hint",
label + ".size_hint",
),
label=label + ".size_hint",
)
let titlebar_style = parse_project_titlebar_style(
optional_string_config_field(
window_fields,
"titlebar_style",
label + ".titlebar_style",
),
label=label + ".titlebar_style",
)
ProjectWindowConfig::new(title, width, height, size_hint~, titlebar_style~)
}
///|
fn parse_project_titlebar_style(
titlebar_style : String?,
label? : String = "window.titlebar_style",
) -> ProjectTitlebarStyle raise ProjectConfigError {
match titlebar_style {
None => TitlebarDefault
Some(titlebar_style) =>
match normalize_keyword(titlebar_style) {
"default" => TitlebarDefault
"overlay" => TitlebarOverlay
_ => raise UnsupportedValue(path=label, value=titlebar_style)
}
}
}
///|
fn parse_project_size_hint(
size_hint : String?,
label? : String = "window.size_hint",
) -> ProjectWindowSizeHint raise ProjectConfigError {
match size_hint {
None => WindowSizeNone
Some(size_hint) =>
match normalize_keyword(size_hint) {
"none" => WindowSizeNone
"min" => WindowSizeMin
"max" => WindowSizeMax
"fixed" => WindowSizeFixed
_ => raise UnsupportedValue(path=label, value=size_hint)
}
}
}
///|
fn required_project_entry(
fields : Map[String, Json],
base_dir : String,
) -> ProjectEntry raise ProjectConfigError {
decode_project_entry(
required_config_field(fields, "entry"),
base_dir,
"entry",
)
}
///|
fn decode_project_entry(
value : Json,
base_dir : String,
label : String,
) -> ProjectEntry raise ProjectConfigError {
guard value is Object(entry_fields) else {
raise InvalidField(
path="proton.project.json." + label,
expectation="must be an object",
)
}
validate_object_fields(entry_fields, label, ["kind", "value"])
build_project_entry_from_parts(
required_string_config_field(entry_fields, "kind", label + ".kind"),
required_string_config_field(entry_fields, "value", label + ".value"),
base_dir,
label~,
)
}
///|
fn build_project_entry_from_parts(
kind : String,
value : String,
base_dir : String,
label? : String = "entry",
) -> ProjectEntry raise ProjectConfigError {
let normalized_kind = normalize_keyword(kind)
let rebased_value = match normalized_kind {
"file" | "asset" => rebase_project_path(value, base_dir, label + ".value")
_ => value
}
match normalized_kind {
"html" => ProjectEntry::Html(rebased_value)
"url" => ProjectEntry::Url(rebased_value)
"file" => ProjectEntry::File(rebased_value)
"asset" => ProjectEntry::Asset(rebased_value)
_ => raise UnsupportedValue(path=label + ".kind", value=kind)
}
}
///|
fn optional_project_windows(
fields : Map[String, Json],
base_dir : String,
) -> Array[ProjectAppWindowConfig] raise ProjectConfigError {
let values = match find_config_field(fields, "windows") {
None => return []
Some(Array(values)) => values
Some(_) =>
raise InvalidField(
path="proton.project.json.windows",
expectation="must be an array",
)
}
let windows : Array[ProjectAppWindowConfig] = []
let ids : Map[String, Unit] = Map([])
for index, value in values {
let label = "windows[" + index.to_string() + "]"
guard value is Object(window_fields) else {
raise InvalidField(
path="proton.project.json." + label,
expectation="must be an object",
)
}
validate_object_fields(window_fields, label, [
"id", "window", "entry", "open_on_start",
])
let id = required_string_config_field(window_fields, "id", label + ".id")
.trim()
.to_owned()
guard id != "" else {
raise InvalidField(path=label + ".id", expectation="must not be empty")
}
guard id != "main" else {
raise UnsupportedValue(path=label + ".id", value=id)
}
guard !ids.contains(id) else {
raise DuplicateValue(path="windows", value=id)
}
ids[id] = ()
let window = decode_project_window(
required_config_field(window_fields, "window"),
label + ".window",
)
let entry = decode_project_entry(
required_config_field(window_fields, "entry"),
base_dir,
label + ".entry",
)
let open_on_start = optional_bool_config_field(
window_fields,
"open_on_start",
label + ".open_on_start",
true,
)
windows.push(ProjectAppWindowConfig::new(id, window, entry, open_on_start~))
}
windows
}
///|
fn optional_project_permissions(
fields : Map[String, Json],
windows : Array[ProjectAppWindowConfig],
) -> Array[ProjectPermissionGrant] raise ProjectConfigError {
let values = match find_config_field(fields, "permissions") {
None => return []
Some(Array(values)) => values
Some(_) =>
raise InvalidField(
path="proton.project.json.permissions",
expectation="must be an array",
)
}
let window_ids : Map[String, Unit] = { "main": () }
for window in windows {
window_ids[window.id] = ()
}
let grants : Array[ProjectPermissionGrant] = []
let identities : Map[String, Unit] = Map([])
for index, value in values {
let label = "permissions[" + index.to_string() + "]"
guard value is Object(grant_fields) else {
raise InvalidField(
path="proton.project.json." + label,
expectation="must be an object",
)
}
validate_object_fields(grant_fields, label, [
"window", "origin", "extension", "scope",
])
let window = required_string_config_field(
grant_fields,
"window",
label + ".window",
)
.trim()
.to_owned()
guard window_ids.contains(window) else {
raise UnsupportedValue(path=label + ".window", value=window)
}
let origin = required_string_config_field(
grant_fields,
"origin",
label + ".origin",
)
.trim()
.to_owned()
guard origin == "app" || origin == "entry" else {
raise UnsupportedValue(path=label + ".origin", value=origin)
}
let extension = required_string_config_field(
grant_fields,
"extension",
label + ".extension",
)
.trim()
.to_owned()
guard extension != "" else {
raise InvalidField(
path=label + ".extension",
expectation="must not be empty",
)
}
let identity = window + "\u{0}" + origin + "\u{0}" + extension
guard !identities.contains(identity) else {
raise DuplicateValue(
path="permissions",
value=window + "/" + origin + "/" + extension,
)
}
identities[identity] = ()
let scope = match find_config_field(grant_fields, "scope") {
None => Json::empty_object()
Some(Object(_) as scope) => scope
Some(_) =>
raise InvalidField(
path="proton.project.json." + label + ".scope",
expectation="must be an object",
)
}
grants.push(ProjectPermissionGrant::new(window, origin, extension, scope~))
}
grants
}
///|
fn optional_project_debug(
fields : Map[String, Json],
) -> Int raise ProjectConfigError {
match find_config_field(fields, "debug") {
None => 0
Some(True) => 1
Some(False) => 0
Some(_) =>
raise InvalidField(
path="proton.project.json.debug",
expectation="must be a bool",
)
}
}
///|
fn decode_project_metadata(
fields : Map[String, Json],
base_dir : String,
) -> ProjectMetadata raise ProjectConfigError {
let product_name = optional_string_config_field(
fields, "product_name", "product_name",
)
let identifier = optional_string_config_field(
fields, "identifier", "identifier",
)
let moon_mod = read_moon_mod_metadata(base_dir)
let fallback_product_name = required_project_window(fields).title
ProjectMetadata::new(
product_name.unwrap_or(fallback_product_name),
identifier~,
version=moon_mod.version,
description=moon_mod.description,
license=moon_mod.license,
)
}
///|
fn optional_project_backend(
fields : Map[String, Json],
base_dir : String,
) -> ProjectBackendConfig? raise ProjectConfigError {
match find_config_field(fields, "backend") {
None => None
Some(Object(backend_fields)) => {
validate_object_fields(backend_fields, "backend", ["path", "package"])
Some(
ProjectBackendConfig::new(
rebase_project_path(
required_string_config_field(backend_fields, "path", "backend.path"),
base_dir,
"backend.path",
),
required_string_config_field(
backend_fields, "package", "backend.package",
),
),
)
}
Some(_) =>
raise InvalidField(
path="proton.project.json.backend",
expectation="must be an object",
)
}
}
///|
fn optional_project_frontend(
fields : Map[String, Json],
base_dir : String,
) -> ProjectFrontendConfig? raise ProjectConfigError {
match find_config_field(fields, "frontend") {
None => None
Some(Object(frontend_fields)) => {
validate_object_fields(frontend_fields, "frontend", [
"dev_url", "dist", "before_dev", "before_build", "path",
])
let path = optional_rebased_string_config_field(
frontend_fields, "path", "frontend.path", base_dir,
)
let frontend_base = path.unwrap_or(base_dir)
Some(
ProjectFrontendConfig::new(
dev_url=optional_string_config_field(
frontend_fields, "dev_url", "frontend.dev_url",
),
dist=optional_rebased_string_config_field(
frontend_fields, "dist", "frontend.dist", frontend_base,
),
before_dev=optional_string_config_field(
frontend_fields, "before_dev", "frontend.before_dev",
),
before_build=optional_string_config_field(
frontend_fields, "before_build", "frontend.before_build",
),
path~,
),
)
}
Some(_) =>
raise InvalidField(
path="proton.project.json.frontend",
expectation="must be an object",
)
}
}
///|
fn optional_project_bundle(
fields : Map[String, Json],
base_dir : String,
) -> ProjectBundleConfig? raise ProjectConfigError {
match find_config_field(fields, "bundle") {
None => None
Some(Object(bundle_fields)) => {
validate_object_fields(bundle_fields, "bundle", [
"active", "targets", "icon", "resources", "sign", "url_schemes", "document_types",
"output",
])
let active = optional_bool_config_field(
bundle_fields, "active", "bundle.active", false,
)
let targets = optional_string_array_config_field(
bundle_fields, "targets", "bundle.targets",
)
let icon = optional_string_array_config_field(
bundle_fields, "icon", "bundle.icon",
)
for index, path in icon {
icon[index] = rebase_project_path(
path,
base_dir,
"bundle.icon[" + index.to_string() + "]",
)
}
let resources = optional_string_array_config_field(
bundle_fields, "resources", "bundle.resources",
)
for index, path in resources {
resources[index] = rebase_project_path(
path,
base_dir,
"bundle.resources[" + index.to_string() + "]",
)
}
let sign = optional_project_sign(bundle_fields, base_dir)
let url_schemes = optional_string_array_config_field(
bundle_fields, "url_schemes", "bundle.url_schemes",
)
let document_types = optional_project_document_types(bundle_fields)
let output = rebase_project_path(
optional_string_config_field(bundle_fields, "output", "bundle.output").unwrap_or(
"dist",
),
base_dir,
"bundle.output",
)
validate_project_bundle_targets(targets)
validate_url_schemes(url_schemes)
Some(
ProjectBundleConfig::new(
active~,
targets~,
icon~,
resources~,
sign~,
url_schemes~,
document_types~,
output~,
),
)
}
Some(_) =>
raise InvalidField(
path="proton.project.json.bundle",
expectation="must be an object",
)
}
}
///|
/// Decodes the optional `updater` block.
///
/// `public_keys` is a list from the first release rather than a single value:
/// distributing a reserve key before it is needed is what lets a compromised
/// signing key be abandoned without a transition release, and a transition
/// release is exactly what a lost key makes impossible to produce.
fn optional_project_updater(
fields : Map[String, Json],
) -> ProjectUpdaterConfig? raise ProjectConfigError {
match find_config_field(fields, "updater") {
None => None
Some(Object(updater_fields)) => {
validate_object_fields(updater_fields, "updater", [
"active", "endpoint", "public_keys", "check_on_launch", "freshness_days",
])
let active = optional_bool_config_field(
updater_fields, "active", "updater.active", true,
)
let endpoint = optional_string_config_field(
updater_fields, "endpoint", "updater.endpoint",
).unwrap_or("")
let public_keys = optional_string_array_config_field(
updater_fields, "public_keys", "updater.public_keys",
)
let check_on_launch = optional_bool_config_field(
updater_fields, "check_on_launch", "updater.check_on_launch", true,
)
let freshness_days = match
find_config_field(updater_fields, "freshness_days") {
None => 30
Some(_) =>
required_positive_int_config_field(
updater_fields, "freshness_days", "updater.freshness_days",
)
}
if active {
guard endpoint.has_prefix("https://") else {
raise InvalidField(
path="proton.project.json.updater.endpoint",
expectation="must be an https URL",
)
}
// An empty trusted list is a configuration error, never an invitation
// to skip verification.
guard public_keys.length() > 0 else {
raise InvalidField(
path="proton.project.json.updater.public_keys",
expectation="must list at least one trusted key",
)
}
}
Some(
ProjectUpdaterConfig::new(
endpoint,
public_keys,
active~,
check_on_launch~,
freshness_days~,
),
)
}
Some(_) =>
raise InvalidField(
path="proton.project.json.updater",
expectation="must be an object",
)
}
}
///|
fn optional_project_sign(
fields : Map[String, Json],
base_dir : String,
) -> ProjectSignConfig? raise ProjectConfigError {
match find_config_field(fields, "sign") {
None => None
Some(Object(sign_fields)) => {
validate_object_fields(sign_fields, "bundle.sign", ["binaries"])
let binaries = optional_string_array_config_field(
sign_fields, "binaries", "bundle.sign.binaries",
)
for index, path in binaries {
if path.contains("*") {
raise InvalidField(
path="proton.project.json.bundle.sign.binaries[" +
index.to_string() +
"]",
expectation="must name one file; glob patterns are not supported",
)
}
binaries[index] = rebase_project_path(
path,
base_dir,
"bundle.sign.binaries[" + index.to_string() + "]",
)
}
Some(ProjectSignConfig::new(binaries~))
}
Some(_) =>
raise InvalidField(
path="proton.project.json.bundle.sign",
expectation="must be an object",
)
}
}
///|
fn optional_project_document_types(
fields : Map[String, Json],
) -> Array[ProjectDocumentType] raise ProjectConfigError {
let values = match find_config_field(fields, "document_types") {
None => return []
Some(Array(values)) => values
Some(_) =>
raise InvalidField(
path="bundle.document_types",
expectation="must be an array",
)
}
let document_types : Array[ProjectDocumentType] = []
for index, value in values {
let label = "bundle.document_types[" + index.to_string() + "]"
guard value is Object(document_fields) else {
raise InvalidField(path=label, expectation="must be an object")
}
validate_object_fields(document_fields, label, [
"name", "extensions", "role",
])
let name = required_string_config_field(
document_fields,
"name",
label + ".name",
)
let extensions = optional_string_array_config_field(
document_fields,
"extensions",
label + ".extensions",
)
guard extensions.length() > 0 else {
raise InvalidField(
path=label + ".extensions",
expectation="must contain at least one extension",
)
}
let seen_extensions : Map[String, Unit] = Map([])
for extension in extensions {
let normalized = extension.to_lower()
guard normalized != "" &&
!normalized.contains(".") &&
!normalized.contains("/") &&
!normalized.contains("\\") else {
raise UnsupportedValue(path=label + ".extensions", value=extension)
}
guard !seen_extensions.contains(normalized) else {
raise DuplicateValue(path=label + ".extensions", value=extension)
}
seen_extensions[normalized] = ()
}
let role = optional_string_config_field(
document_fields,
"role",
label + ".role",
).unwrap_or("Viewer")
guard role == "Editor" ||
role == "Viewer" ||
role == "Shell" ||
role == "None" else {
raise UnsupportedValue(path=label + ".role", value=role)
}
document_types.push(ProjectDocumentType::new(name, extensions, role~))
}
document_types
}
///|
fn validate_url_schemes(
schemes : Array[String],
) -> Unit raise ProjectConfigError {
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="bundle.url_schemes", value=scheme)
}
guard !seen.contains(normalized) else {
raise DuplicateValue(path="bundle.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_project_bundle_targets(
targets : Array[String],
) -> Unit raise ProjectConfigError {
let seen : Map[String, Unit] = Map([])
for target in targets {
guard target == "app" || target == "zip" || target == "dmg" else {
raise UnsupportedValue(path="bundle.targets", value=target)
}
guard !seen.contains(target) else {
raise DuplicateValue(path="bundle.targets", value=target)
}
seen[target] = ()
}
}
///|
fn validate_optional_non_empty_string(
value : Json,
key : String,
) -> Unit raise ProjectConfigError {
match value {
String(text) =>
if text == "" {
raise InvalidField(
path="proton.project.json." + key,
expectation="must not be empty",
)
}
_ =>
raise InvalidField(
path="proton.project.json." + key,
expectation="must be a string",
)
}
}
///|
fn validate_optional_object(
value : Json,
key : String,
) -> Unit raise ProjectConfigError {
match value {
Object(_) => ()
_ =>
raise InvalidField(
path="proton.project.json." + key,
expectation="must be an object",
)
}
}
///|
fn validate_object_fields(
fields : Map[String, Json],
label : String,
allowed : Array[String],
) -> Unit raise ProjectConfigError {
let errors : Array[ProjectConfigError] = []
for key, _ in fields {
if !allowed.contains(key) {
errors.push(UnknownField(path=label + "." + key))
}
}
if errors.length() > 0 {
if errors.length() == 1 {
raise errors[0]
}
raise Validation(errors~)
}
}
///|
fn required_config_field(
fields : Map[String, Json],
key : String,
) -> Json raise ProjectConfigError {
match find_config_field(fields, key) {
Some(value) => value
None => raise MissingField(path="proton.project.json." + key)
}
}
///|
fn required_string_config_field(
fields : Map[String, Json],
key : String,
label : String,
) -> String raise ProjectConfigError {
match find_config_field(fields, key) {
Some(String(value)) =>
if value == "" {
raise InvalidField(path=label, expectation="must not be empty")
} else {
value
}
Some(_) => raise InvalidField(path=label, expectation="must be a string")
None => raise MissingField(path=label)
}
}
///|
fn optional_string_config_field(
fields : Map[String, Json],
key : String,
label : String,
) -> String? raise ProjectConfigError {
match find_config_field(fields, key) {
None => None
Some(String(value)) =>
if value == "" {
raise InvalidField(path=label, expectation="must not be empty")
} else {
Some(value)
}
Some(_) => raise InvalidField(path=label, expectation="must be a string")
}
}
///|
fn optional_rebased_string_config_field(
fields : Map[String, Json],
key : String,
label : String,
base_dir : String,
) -> String? raise ProjectConfigError {
match optional_string_config_field(fields, key, label) {
None => None
Some(path) => Some(rebase_project_path(path, base_dir, label))
}
}
///|
fn optional_bool_config_field(
fields : Map[String, Json],
key : String,
label : String,
default : Bool,
) -> Bool raise ProjectConfigError {
match find_config_field(fields, key) {
None => default
Some(True) => true
Some(False) => false
Some(_) => raise InvalidField(path=label, expectation="must be a bool")
}
}
///|
fn optional_string_array_config_field(
fields : Map[String, Json],
key : String,
label : String,
) -> Array[String] raise ProjectConfigError {
match find_config_field(fields, key) {
None => []
Some(Array(items)) => {
let values : Array[String] = []
for item in items {
match item {
String(value) =>
if value == "" {
raise InvalidField(
path=label,
expectation="must not contain empty strings",
)
} else {
values.push(value)
}
_ =>
raise InvalidField(path=label, expectation="must be a string array")
}
}
values
}
Some(_) =>
raise InvalidField(path=label, expectation="must be a string array")
}
}
///|
fn required_positive_int_config_field(
fields : Map[String, Json],
key : String,
label : String,
) -> Int raise ProjectConfigError {
match find_config_field(fields, key) {
Some(json) => {
let value : Int = @json.from_json(json) catch {
_ => raise InvalidField(path=label, expectation="must be an int")
}
if value <= 0 {
raise InvalidField(path=label, expectation="must be positive")
}
value
}
None => raise MissingField(path=label)
}
}
///|
fn find_config_field(fields : Map[String, Json], key : String) -> Json? {
fields.get(key)
}
///|