gpii-universal
Version:
Cross platform, core components of the GPII personalization infrastructure.
443 lines (413 loc) • 21.1 kB
JavaScript
"use strict";
var fluid = require("infusion");
var gpii = fluid.registerNamespace("gpii");
fluid.registerNamespace("gpii.flowManager");
// A mixin grade applied to the lifecycleManager's session by the pspChannel
fluid.defaults("gpii.pspChannel.sessionBinder", {
modelRelay: {
pspChannel: {
source: "",
target: "{flowManager}.pspChannel.model",
singleTransform: {
type: "gpii.pspChannel.sessionToPSP",
pspChannel: "{pspChannel}"
},
// compensate for FLUID-6194
backward: "never",
forward: {
// avoid notifying the client for the init of LifecycleManagerSession, simplifies test and clients
excludeSource: "init"
}
}
},
modelListeners: {
// When PSP channel receives a setting change request from PSP clients, this model listener informs flowManager
// to apply this new setting.
updatePreferences: {
path: "{flowManager}.pspChannel.model.settingControls",
includeSource: "PSP",
funcName: "{flowManager}.lifecycleManager.applyPreferences",
args: ["{change}.value", "{pspChannel}.events.preferencesAppliedLocal"]
},
updatePrefsSetName: {
path: "{flowManager}.pspChannel.model.activePrefsSetName",
includeSource: "PSP",
funcName: "gpii.pspChannel.updatePrefsSetName",
args: ["{flowManager}.lifecycleManager", "{pspChannel}", "{change}.value"]
},
savePreferences: { // explicit save when the save button is clicked
path: "{flowManager}.pspChannel.model.saveButtonClickCount",
includeSource: "PSP",
listener: "gpii.pspChannel.savePreferences",
args: ["{flowManager}", "{lifecycleManager}", "{pspChannel}.events.preferencesAppliedLocal"]
}
}
});
/**
* The PSP channel maintains its own model of the PSP state, it contains the following data:
*
* "gpiiKey": "<GPII key>", // currently logged in GPII key - { type: "string" }
* "activePrefsSetName": "<preferences set name>", // currently applied preferences set - { type: "string" }
* "settingControls": { // any number of preferences with values and schemas
* "<preference URI 1>": { // settings URI, note that '.'s are interpreted as paths unless escaped
* "value": <value for preference>, // the value that the given preference has
* "schema": { ... } // schema for the preference
* },
* "<preference URI 2>": { .. },
* "<preference URI N>": { .. }
* },
* "preferences": { // Used for listing available preference sets and name of preference set
* "name": "My preference set",
* "contexts": {
* "<preferences set 1 name>": { // the name/id of the preferences set
* "name": "<human readable name>" // name to be displayed in the PSP
* }
* "<preferences set 2 name>": {...},
* "<preferences set n name>": {...}
* }
* },
* saveButtonClickCount // increments by 1 every time the save button is clicked
*/
fluid.defaults("gpii.pspChannel", {
gradeNames: ["fluid.modelComponent"],
settingsSchemaPath: "%gpii-universal/build/schemas/solution-schema-codex.json",
settingsSchema: "@expand:fluid.require({that}.options.settingsSchemaPath)",
members: {
outputBlocked: null,
// Holds schemas containing default preferences with values defined in the reset to default file.
// This static value is populated the first time handling a key in request.
defaultSettingControls: null
},
listeners: {
"{lifecycleManager}.events.onSessionStop": {
namespace: "pspChannel",
funcName: "gpii.pspChannel.sessionStop",
args: "{pspChannel}"
}
},
events: {
// Fired when preferences are applied to the local computer. Its main current purpose is to compose the aggregate event "preferencesApplied"
preferencesAppliedLocal: null,
// Fired when preferences are applied to the local computer and saved to the cloud.
preferencesApplied: {
events: {
preferencesAppliedLocal: "preferencesAppliedLocal",
preferencesSavedSuccess: "{gpii.flowManager}.events.preferencesSavedSuccess"
}
},
// Fired when the read of a preference completes successfully
preferenceReadSuccess: null,
// Fired when the read of a preference fails
preferenceReadFail: null
},
invokers: {
generateDefaultSettingControls: {
funcName: "gpii.pspChannel.generateDefaultSettingControls",
args: ["{flowManager}.defaultSettingsDataPromise", "{that}.options.settingsSchema"]
}
}
});
/**
* Calculate the schemas containing preferences with their default values from the reset to default file.
* @param {Object} defaultSettingsDataPromise - The value of flowManager.defaultSettingsDataPromise.
* @param {Object} schemas - The content of build/schemas/solution-schema-codex.json.
* @return {Object} - A collection of schemas for each preference defined in the reset to default file.
* Each default preference value defined in the reset to default file are populated into the corresponding "default"
* field in the output schema. An example:
* {
* "http://registry\\.gpii\\.net/common/cursorSize": {
* "schema": {
* "title": "Cursor Size",
* "description": "Cursor size",
* "type": "number",
* "default": 0.8, // This default value is from the reset to default file
* "minimum": 0,
* "maximum": 1,
* "multipleOf": 0.1
* },
* "liveness": "live"
* },
* ...
* }
*/
gpii.pspChannel.generateDefaultSettingControls = function (defaultSettingsDataPromise, schemas) {
var defaultSettingControls;
defaultSettingsDataPromise.then(function (defaultSettingsData) {
var defaultPreferences = fluid.get(defaultSettingsData.defaultSettings, ["contexts", "gpii-default", "preferences"]);
defaultSettingControls = {};
// Set the setting default values from reset to standard file to schema.default field for each setting in
// "settingControls" block.
fluid.each(defaultPreferences, function (defaultPrefsVal, defaultPrefsKey) {
var prefsInfo = gpii.pspChannel.getPreferenceInfo(schemas, defaultPrefsKey, defaultPrefsVal);
var schema = prefsInfo.schema;
if (schema) {
fluid.set(schema, ["default"], prefsInfo.prefsValue);
gpii.pspChannel.emitSettingControl(defaultSettingControls, schema, undefined, prefsInfo.prefsKeySegs, "live", prefsInfo.solutionName);
}
});
});
// Although defaultSettingControls is populated asynchronously in the promise callback function above, in practice,
// we know that if the architecture is organised properly, the interlock that prevents a PSP request before
// defaultSettingsDataPromise is resolved should ensure that this return is actually synchronous. The check here
// is to ensure that defaultSettingControls has been initialised before the function returns.
if (defaultSettingControls) {
return defaultSettingControls;
} else {
fluid.fail("PSPChannel: defaultSettingControls is not populated in time.");
}
};
/**
* Get the preference information required for generating the output for PSPChannel clients.
* @param {Object} solutionSchemas - The content of "build/schemas/solution-schema-codex.json"
* @param {String} prefsKey - A common term or an application term.
* @param {Primitive|Object} prefsValue - The value of the prefsKey. It could be the actual preference value
* or an object containing a nested preference path with the value.
* @return {Object} - The preference information.
*
* @typedef {Object} returnObject
* @property {Object} schema - The schema of the input preference.
* @property {String} solutionName - The solution name for the input preference.
* @property {String} presentedCommonTerm - The common term that is presented as the prefsKey or in the prefsValue.
* Returns undefined if no common term is presented.
* @property {Primitive|Object} prefsValue - The actual preference value.
* @property {Boolean} prefsKeySegs - The segments of preference keys. It can contain 1 or 2 elements depending on
* if there's a nested preference path.
*/
gpii.pspChannel.getPreferenceInfo = function (solutionSchemas, prefsKey, prefsValue) {
if (!solutionSchemas || !prefsKey || prefsValue === undefined) {
return undefined;
};
var thisSolutionSchema = fluid.get(solutionSchemas, [prefsKey]);
if (!thisSolutionSchema) {
return {};
}
// Actual handling when the schema for the input preference is found.
var togo = {};
if (gpii.matchMakerFramework.utils.isApplicationTerm(prefsKey)) {
var solutionName = fluid.get(thisSolutionSchema, ["title"]);
// Handle application terms
fluid.each(prefsValue, function (val, innerPrefsKey) {
var schema, presentedCommonTerm;
if (gpii.matchMakerFramework.utils.isCommonTerm(innerPrefsKey)) {
// When the inner pref key is a common term, use the schema of this common term.
// TODO: This if block should be removed at fixing https://issues.gpii.net/browse/GPII-4063
// when the support for "common terms scoped within applications" discontinues.
schema = fluid.get(solutionSchemas, [innerPrefsKey]);
delete schema.$schema;
presentedCommonTerm = innerPrefsKey;
} else {
// When the inner pref key is not a common term, find the schema of the corresponding inner key
// within th application schema.
schema = fluid.get(thisSolutionSchema, ["properties", innerPrefsKey]);
presentedCommonTerm = undefined;
}
togo = {
schema: schema,
solutionName: solutionName,
presentedCommonTerm: presentedCommonTerm,
prefsValue: val,
prefsKeySegs: [prefsKey, innerPrefsKey]
};
});
} else {
// Handle common terms
var schema = fluid.get(solutionSchemas, [prefsKey]);
delete schema.$schema;
togo = {
schema: schema,
solutionName: fluid.get(solutionSchemas, [prefsKey, "title"]),
presentedCommonTerm: prefsKey,
prefsValue: prefsValue,
prefsKeySegs: [prefsKey]
};
}
return togo;
};
gpii.pspChannel.updatePrefsSetName = function (lifecycleManager, pspChannel, newPrefsSetName) {
fluid.log("Received prefsSet update from PSP UI of ", newPrefsSetName);
// Abominable hack to avoid confusing client with numerous update messages. The ChangeApplier should really support
// "manifest transactions" / "vertical transactions"
pspChannel.outputBlocked = fluid.promise();
var clearBlock = function () {
delete pspChannel.outputBlocked;
};
pspChannel.outputBlocked.then(clearBlock, clearBlock);
pspChannel.applier.change([], null, "DELETE");
var promise = lifecycleManager.prefsSetNameChanged(newPrefsSetName);
if (promise) {
fluid.promise.follow(promise, pspChannel.outputBlocked);
} else {
pspChannel.outputBlocked.resolve();
}
};
gpii.pspChannel.filterSolution = function (solution) {
return {
name: solution.name,
settingsHandlers: fluid.transform(solution.settingsHandlers, function (oneHandler) {
return fluid.filterKeys(oneHandler, ["supportedSettings"]);
})
};
};
// Explicit save when the save button is clicked
gpii.pspChannel.savePreferences = function (flowManager, lifecycleManager, preferencesAppliedLocalEvent) {
// Grab current active lifecycle manager session
var userSession = lifecycleManager.getSession();
var gpiiKey = userSession.model.gpiiKey;
var preferences = userSession.model.preferences;
fluid.log("PSPChannel: explicit save for gpiiKey (", gpiiKey, "), with preferences: ", preferences);
flowManager.savePreferences(gpiiKey, preferences);
// At explicit save, preferencesAppliedLocal event will not be triggered. This event is only triggered when
// there is setting change that needs to be applied to the local computer. Directly firing this event is to
// trigger its parent aggregate event "preferencesApplied" to be fired.
preferencesAppliedLocalEvent.fire();
};
/** Emit an entry in the `settingControls` block for a single setting.
*
* @param {Object} settingControls - *This object will be modified by the function's action* One top-level member
* will be added to this object, with a key given by composing the argument `keySegs`. The value of the
* member will be a structure {SettingControl} consisting of:
* @member {Any} value - The actual value of the corresponding setting.
* @member {JSONSchema} schema - A JSON schema structure describing the value space of the setting.
* @member {String} [solutionName] - [optional] The solution name to which this setting is allocated in the preferences
* document, if there is one. If it is defined as a top-level common term, this member will be omitted.
* @member {String} liveness - The liveness value of the setting.
*/
gpii.pspChannel.emitSettingControl = function (settingControls, schema, prefVal, keySegs, liveness, solutionName) {
var fullKey = fluid.pathUtil.composeSegments.apply(null, keySegs);
if (schema) {
settingControls[fullKey] = {
schema: schema,
liveness: liveness
};
}
if (solutionName) {
fluid.set(settingControls, [fullKey, "solutionName"], solutionName);
}
if (prefVal !== null && prefVal !== undefined) {
fluid.set(settingControls, [fullKey, "value"], prefVal);
}
};
/** Transduces the session model held for the currently logged-on user in the LifecycleManager's session into the model
* structure which is suitable for shipping to the PSP over its bus. Each setting control output in the section
* `settingControls` will be dumped using the utility `gpii.pspChannel.emitSettingControl`. This is a model relay
* function which is run continuously as the session's model is updated.
*
* @param {Object} model - The LifecycleManager's session model.
* @param {ModelTransformSpec} transformSpec - The model transformation spec.
* @return {Object} The output model suitable for shipping to the PSP, including top-level members:
* - {Object} settingControls - A hash keyed by preference path, whose values are {SettingControl} objects.
* - {Object} preferences - A filtered skeleton of the user's preferences document, just containing the names of preferences sets (`contexts`).
*/
gpii.pspChannel.sessionToPSP = function (model, transformSpec) {
var that = transformSpec.pspChannel;
var schemas = that.options.settingsSchema;
// that.defaultSettingControls is generated at the system startup when "noUser" logs in. At then, all default
// settings data are ready.
if (!that.defaultSettingControls) {
that.defaultSettingControls = that.generateDefaultSettingControls();
}
var outModel = fluid.filterKeys(model, ["gpiiKey", "activePrefsSetName"]);
var settingControls = {};
var activePreferences = fluid.get(model, ["currentPreferences"]);
var activeSolutionIds, activeSolutions;
var applications = fluid.get(model, "activeConfiguration.inferredConfiguration.applications");
if (applications) {
activeSolutionIds = Object.keys(applications);
activeSolutions = fluid.filterKeys(model.solutionsRegistryEntries, activeSolutionIds);
}
fluid.each(activePreferences, function (prefsVal, prefsKey) {
var prefsInfo = gpii.pspChannel.getPreferenceInfo(schemas, prefsKey, prefsVal);
var liveness = gpii.matchMakerFramework.utils.getLeastLiveness(activeSolutions, prefsInfo.presentedCommonTerm);
gpii.pspChannel.emitSettingControl(settingControls, prefsInfo.schema, prefsInfo.prefsValue, prefsInfo.prefsKeySegs, liveness, prefsInfo.solutionName);
});
outModel.settingControls = fluid.extend(true, {}, settingControls, that.defaultSettingControls || {});
outModel.preferences = {
name: fluid.get(model, "preferences.name"),
contexts: fluid.transform(fluid.get(model, "preferences.contexts"), function (contextVal) {
return fluid.filterKeys(contextVal, ["name"]);
})
};
return outModel;
};
gpii.pspChannel.modelChangeListener = function (handler, pspChannel, value, oldValue, path, transaction) {
fluid.log("PSPChannel's PSP-facing modelChangeListener, sources are ", fluid.keys(transaction.sources));
if (!transaction.sources.PSP && !transaction.sources.SessionCleanup) {
fluid.log("Model change source is not PSP - candidate for update message");
if (pspChannel.outputBlocked) {
// Ensure that we queue just a single outgoing message for when the channel unblocks
if (!pspChannel.outputBlocked.queued) {
pspChannel.outputBlocked.queued = true;
pspChannel.outputBlocked.then(function () {
fluid.log("PSPChannel sending unblocked full update message", JSON.stringify(pspChannel.model, null, 2));
handler.sendTypedMessage("modelChanged", {path: [], type: "ADD", value: pspChannel.model});
});
}
} else {
var changes = fluid.modelPairToChanges(value, oldValue);
var hasDeletion = fluid.find(changes, function (change) {
return change.type === "DELETE";
});
if (hasDeletion) {
changes.forEach(function (change) {
handler.sendTypedMessage("modelChanged", change);
});
} else {
handler.sendTypedMessage("modelChanged", {path: [], type: "ADD", value: value});
}
}
}
};
gpii.pspChannel.sessionStop = function (pspChannel) {
pspChannel.applier.change("", null, "DELETE");
};
fluid.defaults("gpii.pspChannel.handler", {
gradeNames: ["kettle.request.ws"],
invokers: {
modelChangeListener: {
funcName: "gpii.pspChannel.modelChangeListener",
args: ["{that}", "{pspChannel}", "{arguments}.0", "{arguments}.1", "{arguments}.2", "{arguments}.4"]
// value, oldValue, pathSegs, transaction: http://docs.fluidproject.org/infusion/development/ChangeApplierAPI.html#programmatic-style-for-listening-to-changes
}
},
listeners: {
onBindWs: {
funcName: "gpii.pspChannel.bindWs",
args: ["{that}", "{pspChannel}"]
},
"{pspChannel}.events.preferencesApplied": {
funcName: "{that}.sendTypedMessage",
args: ["preferencesApplied"]
},
"{pspChannel}.events.preferenceReadSuccess": {
funcName: "{that}.sendTypedMessage",
args: ["preferenceReadSuccess"]
},
"{pspChannel}.events.preferenceReadFail": {
funcName: "{that}.sendTypedMessage",
args: ["preferenceReadFail"]
},
onReceiveMessage: {
funcName: "gpii.pspChannel.receiveMessage",
args: ["{arguments}.1", "{pspChannel}", "{lifecycleManager}"]
},
"onDestroy.unbindModel": {
func: "{pspChannel}.applier.modelChanged.removeListener",
args: ["{that}.id"]
}
}
});
gpii.pspChannel.bindWs = function (handler, pspChannel) {
pspChannel.applier.modelChanged.addListener("", handler.modelChangeListener, handler.id);
// Note that this is inconsistent with the Nexus' protocol, but is more correct - for example if the model consists
// purely of a primitive or is undefined, the initial Nexus message will break
handler.sendTypedMessage("modelChanged", {path: [], type: "ADD", value: pspChannel.model});
};
gpii.pspChannel.receiveMessage = function (message, pspChannel, lifecycleManager) {
fluid.log("pspChannel received a message: ", message);
if (message.type === "modelChanged") {
pspChannel.applier.change("", message.value, "ADD", "PSP");
}
if (message.type === "pullModel") {
lifecycleManager.readPreferences(message.value.settingControls, pspChannel.events.preferenceReadSuccess, pspChannel.events.preferenceReadFail);
}
};