Documentation archive · Archivo de documentación
Original recorded source; its language and historical claims are preserved. Download original file
/* koru v1 protocol: one origin-bound MessagePort owned by the main frame.
Outside the app origin (a desktop browser or `koru dev`) calls run against
web implementations instead, so the same code works in both places. */
(() => {
"use strict";
if (typeof window === "undefined" || window.Koru) return;
const origin = "https://app.koru.invalid";
const local = location.origin === origin;
// A remote page inside a koru app with server.bridge: the app marks its user
// agent and, when the page finishes loading, sends either the native channel
// (bridge origins) or "koru-none" (any other page).
const embedded =
!local &&
window === window.top &&
/ KoruApp\/1(?:\s|$)/.test(String((window.navigator || {}).userAgent || ""));
let native = local;
const pending = new Map();
let channel = null,
nextId = 0;
let readyResolve,
settled = false;
const ready = new Promise((resolve) => {
readyResolve = () => {
settled = true;
resolve();
};
});
function fail(code, message) {
const error = new Error(message);
error.code = code;
return error;
}
function rejectAll(code, message) {
for (const entry of pending.values()) {
clearTimeout(entry.timer);
entry.cleanup();
entry.reject(fail(code, message));
}
pending.clear();
}
window.addEventListener("message", (event) => {
if (
embedded &&
!channel &&
event.isTrusted &&
event.source === null &&
event.data === "koru-none" &&
event.ports.length === 0
) {
readyResolve();
return;
}
if (
!(local || embedded) ||
window !== window.top ||
!event.isTrusted ||
event.source !== null ||
event.data !== "koru-ready" ||
event.ports.length !== 1
)
return;
if (channel) {
channel.close();
rejectAll("CHANNEL_REPLACED", "Native channel replaced");
}
channel = event.ports[0];
native = true;
channel.onmessage = (event) => {
if (typeof event.data !== "string" || event.data.length > 131072) return;
let reply;
try {
reply = JSON.parse(event.data);
} catch (ignored) {
return;
}
if (!reply || typeof reply.id !== "string") return;
const entry = pending.get(reply.id);
if (!entry) return;
pending.delete(reply.id);
clearTimeout(entry.timer);
entry.cleanup();
if (reply.error && typeof reply.error.code === "string")
entry.reject(
fail(reply.error.code, String(reply.error.message || "Native error")),
);
else if (Object.prototype.hasOwnProperty.call(reply, "result"))
entry.resolve(reply.result);
else entry.reject(fail("INVALID_RESPONSE", "Invalid native response"));
};
channel.onmessageerror = () =>
rejectAll("CHANNEL_ERROR", "Native channel error");
channel.start();
readyResolve();
});
if (!local && !embedded) readyResolve();
const storageKey = /^[A-Za-z0-9_.:-]{1,128}$/;
function webStore() {
try {
return window.localStorage;
} catch (ignored) {
throw fail("STORAGE_ERROR", "Browser storage is unavailable");
}
}
function key(params) {
if (typeof params.key !== "string" || !storageKey.test(params.key))
throw fail("INVALID_REQUEST", "Invalid storage key");
return "koru:" + params.key;
}
function integer(value, fallback, min, max) {
const n = value === undefined ? fallback : value;
if (!Number.isInteger(n) || n < min || n > max)
throw fail("INVALID_REQUEST", "Invalid integer parameter");
return n;
}
function webPermission() {
const N = window.Notification;
return { status: N && N.permission === "granted" ? "granted" : "denied" };
}
const shown = new Map();
const alarms = new Map();
let wakeLock = null;
const web = {
"device.info": () => {
const nav = window.navigator || {};
return {
model: "Web browser",
manufacturer: String(nav.vendor || ""),
osVersion: String(nav.userAgent || ""),
sdk: 0,
};
},
"app.info": () => {
const dev = window.__KORU_APP__ || {};
return {
appId: String(dev.appId || "web"),
name: String(dev.name || (window.document && document.title) || ""),
version: String(dev.version || "0.0.0"),
versionCode: Number(dev.versionCode || 0),
};
},
"storage.get": (params) => {
const value = webStore().getItem(key(params));
return value === null ? {} : { value };
},
"storage.set": (params) => {
const k = key(params);
if (typeof params.value !== "string")
throw fail("INVALID_REQUEST", "Storage values must be strings");
webStore().setItem(k, params.value);
return {};
},
"storage.remove": (params) => {
webStore().removeItem(key(params));
return {};
},
"storage.clear": () => {
const store = webStore();
for (let i = store.length - 1; i >= 0; i--) {
const k = store.key(i);
if (k && k.startsWith("koru:")) store.removeItem(k);
}
return {};
},
"vibration.vibrate": (params) => {
const duration = integer(params.duration, 100, 1, 10000);
const nav = window.navigator || {};
if (typeof nav.vibrate === "function") nav.vibrate(duration);
return {};
},
"share.text": async (params) => {
if (typeof params.text !== "string")
throw fail("INVALID_REQUEST", "Share text must be a string");
const nav = window.navigator || {};
if (typeof nav.share === "function") {
try {
await nav.share({ text: params.text });
} catch (error) {
if (!error || error.name !== "AbortError")
throw fail("NATIVE_ERROR", "Browser share failed");
}
} else if (nav.clipboard && typeof nav.clipboard.writeText === "function")
await nav.clipboard.writeText(params.text);
else throw fail("UNAVAILABLE", "This browser cannot share text");
return {};
},
"notifications.permission": () => webPermission(),
"notifications.requestPermission": async () => {
const N = window.Notification;
if (N && N.permission === "default") await N.requestPermission();
return webPermission();
},
"notifications.show": (params) => {
const id = integer(params.id, 1, 0, 2147483647);
if (typeof params.title !== "string" || typeof params.body !== "string")
throw fail("INVALID_REQUEST", "Notification title and body required");
if (webPermission().status !== "granted")
throw fail("PERMISSION_DENIED", "Notifications are disabled");
shown.get(id) && shown.get(id).close();
shown.set(id, new window.Notification(params.title, { body: params.body }));
return {};
},
// Web alarms only fire while the page stays open; the APK uses AlarmManager.
"alarms.schedule": (params) => {
const id = integer(params.id, 1, 0, 2147483647);
if (
typeof params.title !== "string" ||
typeof params.body !== "string" ||
typeof params.at !== "number" ||
!Number.isFinite(params.at)
)
throw fail("INVALID_REQUEST", "Alarm needs title, body and at");
clearTimeout(alarms.get(id));
alarms.set(
id,
setTimeout(
() => {
alarms.delete(id);
if (webPermission().status === "granted")
new window.Notification(params.title, { body: params.body });
},
Math.max(0, Math.min(params.at - Date.now(), 2147483647)),
),
);
return {};
},
"alarms.cancel": (params) => {
const id = integer(params.id, 1, 0, 2147483647);
clearTimeout(alarms.get(id));
alarms.delete(id);
return {};
},
"alarms.list": () => ({ ids: [...alarms.keys()] }),
"browser.open": (params) => {
let url;
try {
url = new window.URL(params.url);
} catch (ignored) {
throw fail("INVALID_REQUEST", "Invalid URL");
}
if (!/^(https?|mailto|tel|sms|geo|market):$/.test(url.protocol))
throw fail("INVALID_REQUEST", "Unsupported URL scheme");
window.open(url.href, "_blank", "noopener");
return {};
},
"screen.keepAwake": async (params) => {
if (typeof params.enabled !== "boolean")
throw fail("INVALID_REQUEST", "enabled must be a boolean");
const nav = window.navigator || {};
if (!params.enabled) {
if (wakeLock) await wakeLock.release();
wakeLock = null;
return {};
}
if (!nav.wakeLock || typeof nav.wakeLock.request !== "function")
throw fail("UNAVAILABLE", "This browser cannot keep the screen awake");
if (!wakeLock) wakeLock = await nav.wakeLock.request("screen");
return {};
},
"clipboard.writeText": async (params) => {
if (typeof params.text !== "string")
throw fail("INVALID_REQUEST", "Clipboard text must be a string");
const clip = (window.navigator || {}).clipboard;
if (!clip || typeof clip.writeText !== "function")
throw fail("UNAVAILABLE", "This browser cannot write the clipboard");
await clip.writeText(params.text);
return {};
},
"clipboard.readText": async () => {
const clip = (window.navigator || {}).clipboard;
if (!clip || typeof clip.readText !== "function")
throw fail("UNAVAILABLE", "This browser cannot read the clipboard");
const value = await clip.readText();
return value ? { value } : {};
},
"notifications.cancel": (params) => {
const id = integer(params.id, 1, 0, 2147483647);
shown.get(id) && shown.get(id).close();
shown.delete(id);
return {};
},
};
function webInvoke(method, params) {
const dev = window.__KORU_APP__;
const plugin = method.slice(0, method.indexOf("."));
const enabled =
!dev || !Array.isArray(dev.plugins) || dev.plugins.includes(plugin);
if (!enabled || !Object.prototype.hasOwnProperty.call(web, method))
return Promise.reject(
fail(
"METHOD_NOT_FOUND",
enabled
? "No web implementation for " + method
: "Plugin " + plugin + " is not enabled in koru.json",
),
);
return new Promise((resolve) => resolve(web[method](params)));
}
const Koru = Object.freeze({
ready,
platform: local || embedded ? "android" : "web",
invoke(method, params = {}, options = {}) {
if (
typeof method !== "string" ||
!/^[a-z][a-z0-9_]{0,31}\.[a-zA-Z][a-zA-Z0-9_]{0,63}$/.test(method) ||
!params ||
typeof params !== "object" ||
Array.isArray(params)
)
return Promise.reject(
fail("INVALID_REQUEST", "Invalid method or params"),
);
if (options.signal && options.signal.aborted)
return Promise.reject(fail("CANCELLED", "Request cancelled"));
const timeout = options.timeout === undefined ? 10000 : options.timeout;
if (!Number.isFinite(timeout) || timeout < 1 || timeout > 120000)
return Promise.reject(fail("INVALID_REQUEST", "Invalid timeout"));
if (pending.size >= 256)
return Promise.reject(fail("BUSY", "Too many pending requests"));
nextId = (nextId % 2147483647) + 1;
const id = String(nextId);
let payload;
try {
payload = JSON.stringify({ id, method, params });
} catch (ignored) {
return Promise.reject(
fail("INVALID_REQUEST", "Params must be serializable"),
);
}
if (payload.length > 65535)
return Promise.reject(fail("INVALID_REQUEST", "Request too large"));
if (!native) {
// An embedded page learns whether it has the bridge when it loads.
if (embedded && !settled)
return ready.then(() => Koru.invoke(method, params, options));
return webInvoke(method, params);
}
return new Promise((resolve, reject) => {
const cancel = () => {
const entry = pending.get(id);
if (!entry) return;
pending.delete(id);
clearTimeout(entry.timer);
cleanup();
reject(
fail(
"CANCELLED",
"Request cancelled; dispatched native actions cannot be undone",
),
);
};
const cleanup = () =>
options.signal && options.signal.removeEventListener("abort", cancel);
const timer = setTimeout(() => {
pending.delete(id);
cleanup();
reject(fail("TIMEOUT", "Native request timed out"));
}, timeout);
pending.set(id, { resolve, reject, timer, cleanup });
options.signal &&
options.signal.addEventListener("abort", cancel, { once: true });
ready.then(() => {
if (pending.has(id)) {
try {
channel.postMessage(payload);
} catch (ignored) {
pending.delete(id);
clearTimeout(timer);
cleanup();
reject(fail("CHANNEL_ERROR", "Cannot send native request"));
}
}
});
});
},
});
Object.defineProperty(window, "Koru", {
value: Koru,
writable: false,
configurable: false,
});
window.addEventListener("pagehide", () => {
rejectAll("PAGE_CLOSED", "Page closed");
channel && channel.close();
channel = null;
});
})();