///|
fn build_command_host(
manifest : @manifest.AppManifest,
command_extensions : Map[String, @proton_command.AppCommandExtensionSpec],
command_registrars? : Array[(@proton_command.CommandRegistrar) -> Unit raise] = [],
) -> CommandHostRuntime? raise AppRunError {
let host = @core.AppCommandHost::new(
expose_error_details=proton_dev_mode_enabled(),
)
let destroy_hooks : Array[CommandExtensionDestroyHook] = []
let mut has_commands = false
let registrar = @proton_command.CommandRegistrar::new(host)
for register in command_registrars {
register(registrar) catch {
error => {
ignore(close_command_host_runtime(host, destroy_hooks))
raise CommandExtensionLifecycleError(
ApplicationRegistrationFailed(detail=@debug.render(Repr(error))),
)
}
}
has_commands = true
}
let ordered_ids = enabled_command_extension_order(
manifest, command_extensions,
) catch {
error => {
ignore(close_command_host_runtime(host, destroy_hooks))
raise ConfigurationError(error)
}
}
for id in ordered_ids {
let spec = command_extensions[id]
destroy_hooks.push(CommandExtensionDestroyHook::{
extension_id: id,
callback: fn() raise { spec.destroy() },
})
spec.register(registrar) catch {
error => {
let primary = CommandExtensionLifecycleError(
RegistrationFailed(extension_id=id, detail=@debug.render(Repr(error))),
)
let cleanup_failures : Array[AppCleanupError] = []
for failure in close_command_host_runtime(host, destroy_hooks) {
cleanup_failures.push(CommandExtension(failure))
}
match cleanup_run_error(Some(primary), cleanup_failures) {
Some(combined) => raise combined
None => raise primary
}
}
}
has_commands = true
}
if has_commands {
host.seal_registrations()
Some(CommandHostRuntime::{ host, destroy_hooks, closed: false })
} else {
ignore(close_command_host_runtime(host, destroy_hooks))
None
}
}
///|
fn close_command_host_runtime(
host : @core.AppCommandHost,
destroy_hooks : Array[CommandExtensionDestroyHook],
) -> Array[CommandExtensionLifecycleError] {
let failures : Array[CommandExtensionLifecycleError] = []
let hook_count = destroy_hooks.length()
for index in 0..
failures.push(
DestroyFailed(
extension_id=hook.extension_id,
detail=@debug.render(Repr(error)),
),
)
}
}
host.close()
failures
}
///|
fn enabled_command_extension_order(
manifest : @manifest.AppManifest,
command_extensions : Map[String, @proton_command.AppCommandExtensionSpec],
) -> Array[String] raise AppConfigurationError {
let visit_state : Map[String, Int] = Map([])
let ordered : Array[String] = []
for id, _spec in command_extensions {
match manifest.extension_setting(id) {
Some(setting) if setting.is_enabled() =>
visit_enabled_command_extension(
id,
None,
manifest,
command_extensions,
visit_state,
ordered,
)
None => ()
_ => ()
}
}
ordered
}
///|
fn visit_enabled_command_extension(
id : String,
requested_by : String?,
manifest : @manifest.AppManifest,
command_extensions : Map[String, @proton_command.AppCommandExtensionSpec],
visit_state : Map[String, Int],
ordered : Array[String],
) -> Unit raise AppConfigurationError {
match visit_state.get(id) {
Some(2) => return
Some(1) => raise ExtensionDependencyCycle(extension_id=id)
_ => ()
}
let spec = require_enabled_command_extension(
id, requested_by, manifest, command_extensions,
)
visit_state[id] = 1
for dependency in spec.dependencies() {
visit_enabled_command_extension(
dependency,
Some(id),
manifest,
command_extensions,
visit_state,
ordered,
)
}
visit_state[id] = 2
ordered.push(id)
}
///|
fn require_enabled_command_extension(
id : String,
requested_by : String?,
manifest : @manifest.AppManifest,
command_extensions : Map[String, @proton_command.AppCommandExtensionSpec],
) -> @proton_command.AppCommandExtensionSpec raise AppConfigurationError {
match manifest.extension_setting(id) {
Some(setting) if setting.is_enabled() => ()
Some(_) =>
raise ExtensionUnavailable(
extension_id=id,
requested_by~,
state="disabled",
)
None =>
raise ExtensionUnavailable(
extension_id=id,
requested_by~,
state="missing",
)
}
match command_extensions.get(id) {
Some(spec) => spec
None =>
raise ExtensionUnavailable(
extension_id=id,
requested_by~,
state="unregistered",
)
}
}
///|
fn bridge_frontend_config_for_extension(
id : String,
spec : @proton_command.AppCommandExtensionSpec,
) -> BridgeFrontendConfig raise AppConfigurationError {
let extensions : Array[@native.BridgeExtensionConfig] = []
let initialization_units : Array[@native.BridgeInitializationUnit] = []
append_bridge_extension_frontend(id, spec, extensions, initialization_units)
BridgeFrontendConfig::{ extensions, initialization_units }
}
///|
fn append_bridge_extension_frontend(
id : String,
spec : @proton_command.AppCommandExtensionSpec,
extensions : Array[@native.BridgeExtensionConfig],
initialization_units : Array[@native.BridgeInitializationUnit],
) -> Unit raise AppConfigurationError {
let js_namespace = spec.js_namespace()
if command_proxy_name_is_invalid(js_namespace) {
raise InvalidJavaScriptNamespace(js_namespace~)
}
let apis : Array[String] = []
for api in spec.apis() {
let api_name = api.api_name()
if command_proxy_name_is_invalid(api_name) {
raise InvalidJavaScriptApi(js_namespace~, api_name~)
}
apis.push(api_name)
}
extensions.push(@native.BridgeExtensionConfig::new(js_namespace, apis~))
let scripts = spec.scripts()
for index, script in scripts {
initialization_units.push(
@native.BridgeInitializationUnit::new(
id,
"script-" + index.to_string(),
script,
),
)
}
}
///|
/// Reports whether a name cannot be exposed as a bridge proxy on the renderer
/// root object. `app` is reserved alongside `core` and `events` because the
/// bridge publishes granted application commands under it.
fn command_proxy_name_is_invalid(name : String) -> Bool {
name.trim().to_owned() == "" ||
name == "core" ||
name == "events" ||
name == "app" ||
@proton_command.app_command_js_property_is_reserved(name)
}
///|
fn forward_menu_command(
window : @native.Window,
command_id : String,
focused_window : Int64?,
) -> Unit raise AppRunError {
let fields : Map[String, Json] = { "command_id": command_id }
match focused_window {
Some(window) => fields["window"] = Json(window.to_string())
None => ()
}
window.emit_bridge_event_json(
bridge_event_json(
"frontend",
"menu.command",
Json::object(fields),
None,
None,
),
) catch {
error => raise native_run_error("forward menu command", error)
}
}
///|
fn bridge_event_json(
kind : String,
name : String,
payload : Json,
extension : String?,
page_instance : String?,
) -> String {
let fields : Map[String, Json] = {
"abi_version": Json::number(1),
"kind": kind,
"name": name,
"payload": payload,
}
match extension {
Some(value) => fields["extension"] = Json(value)
None => ()
}
match page_instance {
Some(value) => fields["page_instance"] = Json(value)
None => ()
}
Json::object(fields).stringify()
}
///|
fn typed_event_sender(
window : @native.Window,
page_instance : String?,
) -> async (@proton_contract.ContractRoute, String, Json) -> Unit noraise {
fn(route, name, payload) {
let event_json = if route.is_application() {
bridge_event_json("frontend", name, payload, None, page_instance)
} else {
match route.extension_namespace() {
Some(extension_namespace) =>
bridge_event_json(
"extension",
name,
payload,
Some(extension_namespace),
page_instance,
)
None => return
}
}
window.emit_bridge_event_json(event_json) catch {
_ => ()
}
}
}
///|
async fn dispatch_bridge_request(
window : @native.Window,
window_id : String,
permissions : WindowPermissionPolicy,
host : @core.AppCommandHost,
request : @native.BridgeRequest,
wake_signal : @core.RuntimeWakeSignal,
) -> @native.BridgeResponse {
let grant = match
permissions.grant_for_request(request.source_origin(), request.op()) {
Some(grant) => grant
None =>
return @native.BridgeResponse::Err(
request_id=request.request_id(),
code="permission_denied",
message="the page is not permitted to invoke this operation",
)
}
let result : Ref[@native.BridgeResponse?] = Ref(None)
@async.with_task_group(command_tasks => {
result.val = Some(
dispatch_bridge_request_in_command_scope(
window, window_id, grant, host, request, wake_signal, command_tasks,
),
)
command_tasks.return_immediately(())
})
result.val.unwrap()
}
///|
async fn dispatch_bridge_request_in_command_scope(
window : @native.Window,
window_id : String,
grant : ResolvedPermissionGrant,
host : @core.AppCommandHost,
request : @native.BridgeRequest,
wake_signal : @core.RuntimeWakeSignal,
command_tasks : @async.TaskGroup[Unit],
) -> @native.BridgeResponse {
let request_id = request.request_id()
let request_context = @core.AppCommandRequestContext::new(
window.id(),
command_tasks,
window_id~,
source_origin=request.source_origin(),
page_instance=request.page_instance(),
permission_extension=grant.extension_id,
permission_scope=grant.scope,
wake_signal~,
emit_event=typed_event_sender(window, request.page_instance()),
)
let response = host.dispatch_ipc_with_context(
request_context,
@ipc.IpcOpRequest::new(
request_id.to_string(),
request.op(),
request.payload(),
),
)
if response.is_ok() {
@native.BridgeResponse::Ok(request_id~, payload=response.body)
} else {
let message = match response.body {
String(text) => text
_ => response.body.stringify()
}
@native.BridgeResponse::Err(request_id~, code="op_failed", message~)
}
}
///|
fn default_bridge_page_policy() -> BridgePagePolicy {
BridgePagePolicy::{ entry_origin: None }
}
///|
fn bridge_page_policy_for_entry(
entry : @manifest.AppEntry,
) -> BridgePagePolicy raise AppConfigurationError {
match entry {
@manifest.AppEntry::Url(url) =>
BridgePagePolicy::{ entry_origin: Some(url_origin(url)) }
_ => default_bridge_page_policy()
}
}
///|
fn url_origin(url : String) -> String raise AppConfigurationError {
let (scheme_view, rest_view) = match url.split_once("://") {
Some(parts) => parts
None =>
raise InvalidEntryUrl(url~, reason="URL must include http:// or https://")
}
let scheme = scheme_view.to_owned().to_lower()
if scheme != "http" && scheme != "https" {
raise InvalidEntryUrl(url~, reason="URL must use http or https")
}
let rest = rest_view.to_owned()
if rest == "" {
raise InvalidEntryUrl(url~, reason="URL is missing a host")
}
let mut end = rest.length()
for delimiter in ["/", "?", "#"] {
match rest.find(delimiter) {
Some(index) if index < end => end = index
_ => ()
}
}
if end == 0 {
raise InvalidEntryUrl(url~, reason="URL is missing a host")
}
let authority = rest.unsafe_substring(start=0, end~).to_string()
if !entry_origin_authority_is_valid(authority) {
raise InvalidEntryUrl(url~, reason="URL has an invalid origin")
}
scheme + "://" + canonical_entry_origin_authority(authority, scheme, url)
}
///|
fn entry_origin_authority_is_valid(authority : String) -> Bool {
if authority == "" || authority.contains("@") {
return false
}
for ch in authority {
let code = ch.to_int()
if code <= 0x20 ||
code >= 0x7f ||
ch == '/' ||
ch == '?' ||
ch == '#' ||
ch == '"' ||
ch == '\\' {
return false
}
}
true
}
///|
fn canonical_entry_origin_authority(
authority : String,
scheme : String,
source_url : String,
) -> String raise AppConfigurationError {
if authority.has_prefix("[") {
let (host_part, suffix) = match authority.split_once("]") {
Some(parts) => parts
None =>
raise InvalidEntryUrl(
url=source_url,
reason="URL has an invalid IPv6 origin",
)
}
let host_text = host_part.to_owned()
let host = host_text
.unsafe_substring(start=1, end=host_text.length())
.to_string()
if host == "" {
raise InvalidEntryUrl(url=source_url, reason="URL host must not be empty")
}
if suffix != "" && !suffix.has_prefix(":") {
raise InvalidEntryUrl(
url=source_url,
reason="URL has an invalid IPv6 origin",
)
}
"[" +
host.to_lower() +
"]" +
canonical_entry_origin_port_suffix(suffix.to_owned(), scheme, source_url)
} else {
let first_colon = authority.find(":")
let last_colon = authority.rev_find(":")
if first_colon != last_colon {
raise InvalidEntryUrl(
url=source_url,
reason="IPv6 origins must use brackets",
)
}
match first_colon {
None => authority.to_lower()
Some(index) => {
let host = authority.unsafe_substring(start=0, end=index).to_string()
let port = authority
.unsafe_substring(start=index + 1, end=authority.length())
.to_string()
if host == "" {
raise InvalidEntryUrl(
url=source_url,
reason="URL host must not be empty",
)
}
if port == "" {
raise InvalidEntryUrl(
url=source_url,
reason="URL port must not be empty",
)
}
host.to_lower() +
canonical_entry_origin_port_suffix(port, scheme, source_url)
}
}
}
}
///|
fn canonical_entry_origin_port_suffix(
port : String,
scheme : String,
source_url : String,
) -> String raise AppConfigurationError {
if port == "" {
return ""
}
let text = if port.has_prefix(":") { port[1:].to_owned() } else { port }
if text == "" {
raise InvalidEntryUrl(url=source_url, reason="URL port must not be empty")
}
let value = @string.parse_int(text[:]) catch {
_ =>
raise InvalidEntryUrl(
url=source_url,
reason="URL port must be an integer: " + text,
)
}
if value <= 0 || value > 65535 {
raise InvalidEntryUrl(
url=source_url,
reason="URL port is out of range: " + text,
)
}
let default_port = if scheme == "http" { 80 } else { 443 }
if value == default_port {
""
} else {
":" + value.to_string()
}
}