UNPKG

gpii-universal

Version:

Cross platform, core components of the GPII personalization infrastructure.

443 lines (413 loc) 21.1 kB
"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); } };