pebble/sdk/include/_pkjs_message_wrapper.js
2025-01-27 11:38:16 -08:00

705 lines
23 KiB
JavaScript

/**
* Copyright 2024 Google LLC
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
(function() {
var utf8 = require('utf8');
var POSTMESSAGE_DEBUG = false;
// Super simple polyfill for Array.from() that only deals with a Uint8Array:
var arrayFromUint8Array = Array.from ? Array.from : function(uint8Array) {
return [].slice.call(uint8Array);
};
function debugLog() {
if (POSTMESSAGE_DEBUG) {
console.log.apply(console, arguments);
}
}
function createHandlersList() {
var pos = 0;
var handlers = [];
return {
add : function(handler) {
handlers.push(handler);
},
clear : function() {
handlers = [];
pos = 0;
},
isEmpty : function() {
return (handlers.length == 0);
},
remove : function(handler) {
var idx = handlers.indexOf(handler);
if (idx < 0) { return; } // Not registered
if (idx < pos) { pos = Math.max(pos - 1, 0); } // We've iterated past it, and it's been removed
handlers.splice(idx, 1);
},
newIterator : function() {
pos = 0; // new iterator, reset position
return {
next : function() {
if (pos < handlers.length) {
return handlers[pos++];
} else {
return undefined;
}
}
}
}
}
}
var EVENTS = {};
var _callHandler = function(handler, event_name, callback_data) {
var msg = { type: event_name };
if (callback_data !== undefined) {
msg.data = callback_data;
}
handler(msg);
};
var _callHandlersForEvent = function(event_name, callback_data) {
var handler;
if (!(event_name in EVENTS)) {
return;
}
var it = EVENTS[event_name].newIterator();
while ((handler = it.next())) {
_callHandler(handler, event_name, callback_data);
}
}
var _isPostMessageEvent = function(event_name) {
return (['message', 'postmessageerror',
'postmessageconnected', 'postmessagedisconnected'].indexOf(event_name)) > -1;
}
var __Pebble = Pebble;
// Create a new object with its prototype pointing to the original, using
// Object.create(). This way, we can rely on JavaScript's prototype chain
// traversal to make all properties on the original object "just work".
// Note however, that these won't be "own properties", so when using
// `for .. in`, Pebble.keys(), Object.getOwnPropertyNames(), etc. these
// "delegated properties" will not be found.
Pebble = Object.create(Pebble);
for (var attr in __Pebble) {
if (!__Pebble.hasOwnProperty(attr)) {
continue;
}
// Attributes of Pebble which can be bound, should be bound to the original object
if (__Pebble[attr].bind) {
Pebble[attr] = __Pebble[attr].bind(__Pebble);
} else {
Pebble[attr] = __Pebble[attr];
}
}
// Ensure that all exported functions exist.
["addEventListener", "removeEventListener", "showSimpleNotificationOnPebble",
"sendAppMessage", "getTimelineToken", "timelineSubscribe",
"timelineUnsubscribe", "timelineSubscriptions", "getActiveWatchInfo",
"getAccountToken", "getWatchToken", "appGlanceReload"].forEach(
function(elem, idx, arr) {
if ((elem in Pebble) || ((typeof __Pebble[elem]) !== 'function')) {
// This function has already been copied over or doesn't actually exist.
return;
}
Pebble[elem] = __Pebble[elem].bind(__Pebble);
}
);
// sendAppMessage is not supported, make it undefined so a user will get a
// "not a function" error, and can check `typeof Pebble.sendAppMessage === 'function'`
// to test for support.
Pebble["sendAppMessage"] = undefined;
// The rocky implementation!
function _scheduleAsyncPostMessageError(jsonString, reason) {
_callHandlersForEvent('postmessageerror', JSON.parse(jsonString));
console.error("postMessage() failed. Reason: " + reason);
}
Pebble.postMessage = function(obj) {
_out.sendObject(obj);
};
var on = function(event_name, handler) {
if (typeof(handler) !== 'function') {
throw TypeError("Handler for event expected, received " + typeof(handler));
}
if (!(event_name in EVENTS)) {
EVENTS[event_name] = createHandlersList();
}
EVENTS[event_name].add(handler);
if ((event_name == "postmessageconnected" && _control.state == ControlStateSessionOpen) ||
(event_name == "postmessagedisconnected" && _control.state != ControlStateSessionOpen)) {
_callHandler(handler, event_name);
}
};
Pebble.addEventListener = function(event_name, handler) {
if (_isPostMessageEvent(event_name)) {
return on(event_name, handler);
} else if (event_name == 'appmessage') {
throw Error("App Message not supported with Rocky.js apps. See Pebble.postMessage()");
} else {
return __Pebble.addEventListener(event_name, handler);
}
};
// Alias to the overridden implementation:
Pebble.on = Pebble.addEventListener;
var off = function(event_name, handler) {
if (handler === undefined) {
throw TypeError('Not enough arguments (missing handler)');
}
if (event_name in EVENTS) {
EVENTS[event_name].remove(handler);
}
}
Pebble.removeEventListener = function(event_name, handler) {
if (_isPostMessageEvent(event_name)) {
off(event_name, handler);
} else {
return __Pebble.removeEventListener(event_name, handler);
}
}
// Alias to the overridden implementation:
Pebble.off = Pebble.removeEventListener;
/*********************************************************************************
* postMessage(): Outbound object and control message queuing, sending & chunking.
********************************************************************************/
var _out = new Sender();
function Sender() {
this.controlQueue = [];
this.objectQueue = [];
this._currentMessageType = undefined;
this._failureCount = 0;
this._offsetBytes = 0;
this._chunkPayloadSize = 0;
this._resetCurrent = function() {
this._currentMessageType = undefined;
this._failureCount = 0;
this._offsetBytes = 0;
this._chunkPayloadSize = 0;
};
this._getNextMessageType = function() {
if (this.controlQueue.length > 0) {
return "control";
} else if (this.objectQueue.length > 0) {
return "object";
}
// No messages remaining
return undefined;
};
// Begin sending the next prioritized message
this._sendNext = function() {
if (this._currentMessageType !== undefined) {
return; // Already something in flight
}
var type = this._getNextMessageType();
if (type === undefined) {
return; // No message to send
}
if (type === "control") {
this._currentMessageType = type;
this._trySendNextControl();
} else if (type === "object") {
this._currentMessageType = type;
this._trySendNextChunk();
}
};
//////////////////////////////////////////////////////////////////////////////
// Sender: Control Message Handling
//////////////////////////////////////////////////////////////////////////////
this._controlSuccess = function() {
this.controlQueue.shift();
this._resetCurrent();
this._sendNext();
};
this._controlFailure = function(e) {
this._failureCount++;
var willRetry = (this._failureCount <= 3);
if (willRetry) {
setTimeout(this._trySendNextControl.bind(this), 1000); // 1s retry
} else {
debugLog("Failed to send control message: " + e +
", entering disconnected state.");
this.controlQueue.shift();
this._resetCurrent();
_control.enter(ControlStateDisconnected);
this._sendNext();
}
};
this._trySendNextControl = function() {
var msg = this.controlQueue[0];
__Pebble.sendAppMessage(msg,
this._controlSuccess.bind(this),
this._controlFailure.bind(this));
};
//////////////////////////////////////////////////////////////////////////////
// Sender: Object Message Handling
//////////////////////////////////////////////////////////////////////////////
this._createDataObject = function(obj) {
// Store obj as UTF-8 encoded JSON string into .data:
var native_str_msg;
try {
native_str_msg = JSON.stringify(obj);
} catch(e) {
throw Error("First argument must be JSON-serializable.");
}
// ECMA v5.1, 15.12.3, Note 5: Values that do not have a JSON
// representation (such as undefined and functions) do not produce a
// String. Instead they produce the undefined value.
if (native_str_msg === undefined) {
throw TypeError(
"Argument at index 0 is not a JSON.stringify()-able object");
}
var utf8_str_msg = utf8.encode(native_str_msg);
var data = [];
for (var i = 0; i < utf8_str_msg.length; i++) {
data.push(utf8_str_msg.charCodeAt(i));
}
data.push(0); // zero-terminate
return {
obj: obj,
data: data,
json: native_str_msg,
};
};
this._completeObject = function(failureReasonOrUndefined) {
var completeObject = this.objectQueue.shift();
this._resetCurrent();
if (failureReasonOrUndefined === undefined) {
debugLog("Complete!");
} else {
_scheduleAsyncPostMessageError(completeObject.json, failureReasonOrUndefined);
}
};
this._chunkSuccess = function(e) {
var data = this.objectQueue[0].data;
debugLog("Sent " + this._chunkPayloadSize + " of " + data.length + " bytes");
this._offsetBytes += this._chunkPayloadSize;
if (this._offsetBytes === data.length) {
this._completeObject();
this._sendNext();
} else {
this._trySendNextChunk();
}
};
this._chunkFailure = function(e) {
this._failureCount++;
var willRetry = (this._failureCount <= 3);
console.error("Chunk failed to send (willRetry=" + willRetry + "): " + e);
if (willRetry) {
setTimeout(this._trySendNextChunk.bind(this), 1000); // 1s retry
} else {
this._completeObject("Too many failed transfer attempts");
this._sendNext();
}
};
this._trySendNextChunk = function() {
if (this._getNextMessageType() !== "object") {
// This is no longer our highest priority outgoing message.
// Send that message instead, and this message will be left in the queue
// andrestarted when appropriate.
this._resetCurrent();
this._sendNext();
return;
}
if (!_control.isSessionOpen()) {
// Make sure to start over if session is closed while chunks have been
// sent for the head object:
this._offsetBytes = 0;
this._chunkFailure("Session not open. Hint: check out the \"postmessageconnected\" event.");
return;
}
var data = this.objectQueue[0].data;
var sizeRemaining = data.length - this._offsetBytes;
debugLog("Sending next chunk, sizeRemaining: " + sizeRemaining);
this._chunkPayloadSize =
Math.min(_control.protocol.tx_chunk_size, sizeRemaining);
var n;
var isFirst = (this._offsetBytes === 0);
var isFirstBit;
if (isFirst) {
isFirstBit = (1 << 7);
n = data.length;
} else {
isFirstBit = 0;
n = this._offsetBytes;
}
var chunk = [
n & 255,
(n >> 8) & 255,
(n >> 16) & 255,
((n >> 24) & ~(1 << 7)) | isFirstBit
];
var chunkPayload = data.slice(
this._offsetBytes, this._offsetBytes + this._chunkPayloadSize);
Array.prototype.push.apply(chunk, chunkPayload);
debugLog("Sending Chunk Size: " + this._chunkPayloadSize);
__Pebble.sendAppMessage({ControlKeyChunk: chunk},
this._chunkSuccess.bind(this),
this._chunkFailure.bind(this));
};
//////////////////////////////////////////////////////////////////////////////
// Sender: Public Interface
//////////////////////////////////////////////////////////////////////////////
this.sendObject = function(obj) {
debugLog("Queuing up object message: " + JSON.stringify(obj));
var dataObj = this._createDataObject(obj);
this.objectQueue.push(dataObj)
this._sendNext();
};
this.sendControl = function(obj) {
debugLog("Sending control message: " + JSON.stringify(obj));
this.controlQueue.push(obj);
this._sendNext();
}
};
/*****************************************************************************
* postMessage(): Receiving chunks of inbound objects and reassembly
****************************************************************************/
var _in = new ChunkReceiver();
function ChunkReceiver() {
this.utf8_json_string = "";
this.total_size_bytes = 0;
this.received_size_bytes = 0;
this.handleChunkReceived = function handleChunkReceived(chunk) {
if (!chunk) {
return false;
}
var isExpectingFirst = (this.utf8_json_string.length === 0);
if (chunk.is_first != isExpectingFirst) {
console.error(
"Protocol out of sync! chunk.is_first=" + chunk.is_first +
" isExpectingFirst=" + isExpectingFirst);
return false;
}
if (chunk.is_first) {
this.total_size_bytes = chunk.total_size_bytes;
this.received_size_bytes = 0;
} else {
if (this.received_size_bytes != chunk.offset_bytes) {
console.error(
"Protocol out of sync! received_size_bytes=" +
this.received_size_bytes + " chunk.offset_bytes=" + chunk.offset_bytes);
return false;
}
if (this.received_size_bytes + chunk.data.length > this.total_size_bytes) {
console.error(
"Protocol out of sync! received_size_bytes=" + this.received_size_bytes +
" chunk.data.length=" + chunk.data.length +
" total_size_bytes=" + this.total_size_bytes);
return false;
}
}
debugLog("Received (" + this.received_size_bytes + " / " +
this.total_size_bytes + " bytes)");
debugLog("Payload size: " + chunk.data.length);
this.received_size_bytes += chunk.data.length;
var isLastChunk = (this.received_size_bytes == this.total_size_bytes);
var isLastChunkZeroTerminated = undefined;
if (isLastChunk) {
isLastChunkZeroTerminated = (chunk.data[chunk.data.length - 1] === 0);
}
// Copy the received data over:
var end = isLastChunk ? chunk.data.length - 1 : chunk.data.length;
for (var i = 0; i < end; i++) {
this.utf8_json_string += String.fromCharCode(chunk.data[i]);
}
if (isLastChunk) {
if (isLastChunkZeroTerminated) {
var json_string = utf8.decode(this.utf8_json_string);
var data;
try {
data = JSON.parse(json_string);
} catch (e) {
console.error(
"Dropping message, failed to parse JSON with error: " + e +
" (json_string=" + json_string + ")");
}
if (data !== undefined) {
_callHandlersForEvent('message', data);
}
} else {
console.error("Last Chunk wasn't zero terminated! Dropping message.");
}
this.utf8_json_string = "";
}
return true;
}
}
/*****************************************************************************
* postMessage() Session Control Protocol
****************************************************************************/
var ControlStateDisconnected = "ControlStateDisconnected";
var ControlStateAwaitingResetCompleteRemoteInitiated = "ControlStateAwaitingResetCompleteRemoteInitiated";
var ControlStateAwaitingResetCompleteLocalInitiated = "ControlStateAwaitingResetCompleteLocalInitiated";
var ControlStateSessionOpen = "ControlStateSessionOpen";
var ControlKeyResetRequest = "ControlKeyResetRequest";
var ControlKeyResetComplete = "ControlKeyResetComplete";
var ControlKeyChunk = "ControlKeyChunk";
var ControlKeyUnsupportedError = "ControlKeyUnsupportedError";
function _unpackResetCompleteMessage(data) {
debugLog("Got ResetComplete: " + data);
return {
min_version : data[0],
max_version : data[1],
max_tx_chunk_size : (data[2] << 8) | (data[3]),
max_rx_chunk_size : (data[4] << 8) | (data[5]),
};
};
function _unpackChunk(data) {
//debugLog("Got Chunk: " + data);
if (data.length <= 4) {
console.error("Chunk data too short to be valid!");
return;
}
var is_first_bit = (1 << 7);
var is_first = (is_first_bit === (data[3] & is_first_bit));
var chunk = {
is_first : is_first
};
var msbyte = (~is_first_bit) & data[3];
var num31bits = (msbyte << 24) | (data[2] << 16) | (data[1] << 8) | data[0];
if (is_first) {
chunk.total_size_bytes = num31bits;
} else {
chunk.offset_bytes = num31bits;
}
chunk.data = data.slice(4);
return chunk;
}
function _remoteProtocolValidateAndSet(remote) {
debugLog("Remote min: " + remote.min_version);
debugLog("Remote max: " + remote.max_version);
if (remote.min_version == undefined || remote.max_version == undefined ||
remote.min_version > PROTOCOL.max_version || remote.max_version < PROTOCOL.min_version) {
return false;
}
_control.protocol = {
version : Math.min(remote.max_version, PROTOCOL.max_version),
tx_chunk_size : Math.min(remote.max_rx_chunk_size, PROTOCOL.max_tx_chunk_size),
rx_chunk_size : Math.min(remote.max_tx_chunk_size, PROTOCOL.max_rx_chunk_size),
};
return true;
};
function _sendControlMessage(msg) {
_out.sendControl(msg);
}
function _controlSendResetComplete() {
var data = new Uint8Array(6);
data[0] = PROTOCOL.min_version;
data[1] = PROTOCOL.max_version;
data[2] = PROTOCOL.max_tx_chunk_size >> 8;
data[3] = PROTOCOL.max_tx_chunk_size;
data[4] = PROTOCOL.max_rx_chunk_size >> 8;
data[5] = PROTOCOL.max_rx_chunk_size;
_sendControlMessage({ ControlKeyResetComplete : arrayFromUint8Array(data) });
}
function _controlSendResetRequest() {
_sendControlMessage({ ControlKeyResetRequest : 0 });
}
function _controlSendUnsupportedError() {
_sendControlMessage({ ControlKeyUnsupportedError : 0 });
}
var ControlHandlers = {
ControlStateDisconnected : function(payload) {
},
ControlStateAwaitingResetCompleteRemoteInitiated : function(payload) {
if (ControlKeyResetComplete in payload) {
var remote_protocol = _unpackResetCompleteMessage(payload[ControlKeyResetComplete]);
// NOTE: This should *always* be true, we should never receive a
// ResetComplete response from the Remote in this state since it already
// knows it is unsupported
if (_remoteProtocolValidateAndSet(remote_protocol)) {
_control.enter(ControlStateSessionOpen);
}
} else if (ControlKeyResetRequest in payload) {
_control.enter(ControlStateAwaitingResetCompleteRemoteInitiated); // Re-enter this state
} else if (ControlKeyChunk in payload) {
_control.enter(ControlStateAwaitingResetCompleteLocalInitiated);
} else if (ControlKeyUnsupportedError in payload) {
throw Error("Unsupported protocol error: " + payload[ControlKeyUnsupportedError]);
}
},
ControlStateAwaitingResetCompleteLocalInitiated : function(payload) {
if (ControlKeyResetComplete in payload) {
var remote_protocol = _unpackResetCompleteMessage(payload[ControlKeyResetComplete]);
debugLog("Remote Protocol: " + remote_protocol);
if (_remoteProtocolValidateAndSet(remote_protocol)) {
debugLog("OK Remote protocol...");
_controlSendResetComplete();
_control.enter(ControlStateSessionOpen);
} else {
_controlSendUnsupportedError();
}
} else {
; // Ignore, we're in this state because we already sent a ResetRequest
}
},
ControlStateSessionOpen : function(payload) {
if (ControlKeyChunk in payload) {
var chunk = _unpackChunk(payload[ControlKeyChunk]);
if (false === _in.handleChunkReceived(chunk)) {
_control.enter(ControlStateAwaitingResetCompleteLocalInitiated);
}
} else if (ControlKeyResetRequest in payload) {
_control.enter(ControlStateAwaitingResetCompleteRemoteInitiated);
} else {
// FIXME: This could be an UnsupportedError, we probably don't want to
// keep on trying to negotiate protocol
_control.enter(ControlStateAwaitingResetCompleteLocalInitiated);
}
},
};
var ControlTransitions = {
ControlStateDisconnected : function(from_state) {
_control.resetProtocol();
_control.state = ControlStateAwaitingResetCompleteRemoteInitiated;
},
ControlStateAwaitingResetCompleteRemoteInitiated : function(from_state) {
_control.resetProtocol();
_control.state = ControlStateAwaitingResetCompleteRemoteInitiated;
_controlSendResetComplete();
},
ControlStateAwaitingResetCompleteLocalInitiated : function(from_state) {
if (from_state != ControlStateAwaitingResetCompleteLocalInitiated) {
// Coming from elsewhere, send the ResetRequest
_controlSendResetRequest();
}
_control.resetProtocol();
_control.state = ControlStateAwaitingResetCompleteLocalInitiated;
},
ControlStateSessionOpen : function(from_state) {
_control.state = ControlStateSessionOpen;
_callHandlersForEvent('postmessageconnected');
},
};
var PROTOCOL = {
min_version : 1,
max_version : 1,
max_tx_chunk_size : 1000,
max_rx_chunk_size : 1000,
};
var _control = {
state : ControlStateDisconnected,
handle : function(msg) {
debugLog("Handle " + this.state + "(" + JSON.stringify(msg.payload) + "}");
ControlHandlers[this.state](msg.payload);
},
enter : function(to_state) {
debugLog("Enter " + this.state + " ===> " + to_state);
var prev_state = this.state;
ControlTransitions[to_state](this.state);
if (prev_state == ControlStateSessionOpen && to_state != ControlStateSessionOpen) {
_callHandlersForEvent('postmessagedisconnected');
}
},
isSessionOpen: function() {
return (this.state === ControlStateSessionOpen);
},
resetProtocol: function() {
this.protocol = {
version : 0,
tx_chunk_size : 0,
rx_chunk_size : 0,
};
},
protocol : {
version : 0,
tx_chunk_size : 0,
rx_chunk_size : 0,
},
};
__Pebble.addEventListener('appmessage', function(msg) {
_control.handle(msg);
});
__Pebble.addEventListener('ready', function(e) {
_control.enter(ControlStateAwaitingResetCompleteLocalInitiated);
});
})();