blob: c75a3f8a0e0873cf38886f0a92c86cb3bea87758 [file] [log] [blame]
// Copyright 2020 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
const help_app = {
handler: new helpAppUi.mojom.PageHandlerRemote()
};
// Set up a page handler to talk to the browser process.
helpAppUi.mojom.PageHandlerFactory.getRemote().createPageHandler(
help_app.handler.$.bindNewPipeAndPassReceiver());
// Set up an index remote to talk to Local Search Service.
/** @type {!chromeos.localSearchService.mojom.IndexRemote} */
const indexRemote =
chromeos.localSearchService.mojom.Index.getRemote();
/**
* Talks to the search handler. Use for updating the content for launcher
* search.
*
* @type {!chromeos.helpApp.mojom.SearchHandlerRemote}
*/
const searchHandlerRemote = chromeos.helpApp.mojom.SearchHandler.getRemote();
const GUEST_ORIGIN = 'chrome-untrusted://help-app';
const MAX_STRING_LEN = 9999;
const guestFrame =
/** @type {!HTMLIFrameElement} */ (document.createElement('iframe'));
guestFrame.src = `${GUEST_ORIGIN}${location.pathname}`;
document.body.appendChild(guestFrame);
// Cached result whether Local Search Service is enabled.
/** @type {Promise<boolean>} */
const isLssEnabled =
help_app.handler.isLssEnabled().then(result => result.enabled);
// Cached result of whether Launcher Search is enabled.
/** @type {Promise<boolean>} */
const isLauncherSearchEnabled =
help_app.handler.isLauncherSearchEnabled().then(result => result.enabled);
/**
* @param {string} s
* @return {!mojoBase.mojom.String16Spec}
*/
function toString16(s) {
return /** @type {!mojoBase.mojom.String16Spec} */ (
{data: Array.from(truncate(s), c => c.charCodeAt())});
}
const TITLE_ID = 'title';
const BODY_ID = 'body';
const CATEGORY_ID = 'main-category';
const SUBCATEGORY_ID = 'subcategory';
const SUBHEADING_ID = 'subheading';
/**
* A pipe through which we can send messages to the guest frame.
* Use an undefined `target` to find the <iframe> automatically.
* Do not rethrow errors, since handlers installed here are expected to
* throw exceptions that are handled on the other side of the pipe (in the guest
* frame), not on this side.
*/
const guestMessagePipe = new MessagePipe(
'chrome-untrusted://help-app', /*target=*/ undefined,
/*rethrowErrors=*/ false);
guestMessagePipe.registerHandler(Message.OPEN_FEEDBACK_DIALOG, () => {
return help_app.handler.openFeedbackDialog();
});
guestMessagePipe.registerHandler(Message.SHOW_PARENTAL_CONTROLS, () => {
help_app.handler.showParentalControls();
});
guestMessagePipe.registerHandler(
Message.ADD_OR_UPDATE_SEARCH_INDEX, async (message) => {
if (!(await isLssEnabled)) {
return;
}
const data_from_app =
/** @type {!Array<!helpApp.SearchableItem>} */ (message);
const data_to_send = data_from_app.map(searchable_item => {
/** @type {!Array<!chromeos.localSearchService.mojom.Content>} */
const contents = [
{
id: TITLE_ID,
content: toString16(searchable_item.title),
weight: 1.0,
},
{
id: CATEGORY_ID,
content: toString16(searchable_item.mainCategoryName),
weight: 0.1,
},
];
if (searchable_item.subcategoryNames) {
for (let i = 0; i < searchable_item.subcategoryNames.length; ++i) {
contents.push({
id: SUBCATEGORY_ID + i,
content: toString16(searchable_item.subcategoryNames[i]),
weight: 0.1,
});
}
}
// If there are subheadings, use those instead of the body.
if (searchable_item.subheadings) {
for (let i = 0; i < searchable_item.subheadings.length; ++i) {
contents.push({
id: SUBHEADING_ID + i,
content: toString16(searchable_item.subheadings[i]),
weight: 0.4,
});
}
} else if (searchable_item.body) {
contents.push({
id: BODY_ID,
content: toString16(searchable_item.body),
weight: 0.2,
});
}
return {
id: searchable_item.id,
contents,
locale: searchable_item.locale,
};
});
return indexRemote.addOrUpdate(data_to_send);
});
guestMessagePipe.registerHandler(Message.CLEAR_SEARCH_INDEX, async () => {
if (!(await isLssEnabled)) {
return;
}
return indexRemote.clearIndex();
});
guestMessagePipe.registerHandler(
Message.FIND_IN_SEARCH_INDEX, async (message) => {
if (!(await isLssEnabled)) {
return {results: null};
}
const dataFromApp =
/** @type {{query: string, maxResults:(number|undefined)}} */
(message);
const response = await indexRemote.find(
toString16(dataFromApp.query), dataFromApp.maxResults || 50);
// Record the search status in the trusted frame.
chrome.metricsPrivate.recordEnumerationValue(
'Discover.Search.SearchStatus', response.status,
chromeos.localSearchService.mojom.ResponseStatus.MAX_VALUE);
if (response.status !==
chromeos.localSearchService.mojom.ResponseStatus.kSuccess ||
!response.results) {
return {results: null};
}
const search_results =
/** @type {!Array<!chromeos.localSearchService.mojom.Result>} */ (
response.results);
// Sort results by decreasing score.
search_results.sort((a, b) => b.score - a.score);
/** @type {!Array<!helpApp.SearchResult>} */
const results = search_results.map(result => {
/** @type {!Array<!helpApp.Position>} */
const titlePositions = [];
/** @type {!Array<!helpApp.Position>} */
const bodyPositions = [];
// Id of the best subheading that appears in positions. We consider
// the subheading containing the most match positions to be the best.
// "" means no subheading positions found.
let bestSubheadingId = "";
/**
* Counts how many positions there are for each subheading id.
* @type {!Object<string, number>}
*/
const subheadingPosCounts = {};
// Note: result.positions is not sorted.
for (const position of result.positions) {
if (position.contentId === TITLE_ID) {
titlePositions.push(
{length: position.length, start: position.start});
} else if (position.contentId === BODY_ID) {
bodyPositions.push(
{length: position.length, start: position.start});
} else if (position.contentId.startsWith(SUBHEADING_ID)) {
// Update the subheadings's position count and check if it's the new
// best subheading.
const newCount = (subheadingPosCounts[position.contentId] || 0) + 1;
subheadingPosCounts[position.contentId] = newCount;
if (!bestSubheadingId
|| newCount > subheadingPosCounts[bestSubheadingId]) {
bestSubheadingId = position.contentId;
}
}
}
// Use only the positions of the best subheading.
/** @type {!Array<!helpApp.Position>} */
const subheadingPositions = [];
if (bestSubheadingId) {
for (const position of result.positions) {
if (position.contentId === bestSubheadingId) {
subheadingPositions.push({
start: position.start,
length: position.length,
});
}
}
subheadingPositions.sort(compareByStart);
}
// Sort positions by start index.
titlePositions.sort(compareByStart);
bodyPositions.sort(compareByStart);
return {
id: result.id,
titlePositions,
bodyPositions,
subheadingIndex: bestSubheadingId
? Number(bestSubheadingId.substring(SUBHEADING_ID.length))
: null,
subheadingPositions: bestSubheadingId
? subheadingPositions
: null,
};
});
return {results};
});
guestMessagePipe.registerHandler(Message.CLOSE_BACKGROUND_PAGE, async () => {
// TODO(b/186180962): Add background page and test that it closes when done.
if (window.location.pathname !== '/background') {
return;
}
window.close();
return;
});
guestMessagePipe.registerHandler(
Message.UPDATE_LAUNCHER_SEARCH_INDEX, async (message) => {
if (!(await isLauncherSearchEnabled)) return;
const dataFromApp =
/** @type {!Array<!helpApp.LauncherSearchableItem>} */ (message);
/** @type {!Array<!chromeos.helpApp.mojom.SearchConcept>} */
const dataToSend = dataFromApp.map(
searchableItem => ({
id: truncate(searchableItem.id),
title: toString16(searchableItem.title),
mainCategory: toString16(searchableItem.mainCategoryName),
tags: searchableItem.tags.map(tag => toString16(tag))
.filter(tag => tag.data.length > 0),
tagLocale: searchableItem.tagLocale || '',
urlPathWithParameters:
truncate(searchableItem.urlPathWithParameters),
locale: truncate(searchableItem.locale),
}));
// Filter out invalid items. No field can be empty except locale.
const dataFiltered = dataToSend.filter(item => {
const valid = item.id && item.title && item.mainCategory
&& item.tags.length > 0 && item.urlPathWithParameters;
// This is a google-internal histogram. If changing this, also change
// the corresponding histograms file.
if (!valid) {
chrome.metricsPrivate.recordSparseHashable(
'Discover.LauncherSearch.InvalidConceptInUpdate', item.id);
}
return valid;
});
return searchHandlerRemote.update(dataFiltered);
});
guestMessagePipe.registerHandler(
Message.MAYBE_SHOW_DISCOVER_NOTIFICATION, () => {
help_app.handler.maybeShowDiscoverNotification();
});
guestMessagePipe.registerHandler(
Message.MAYBE_SHOW_RELEASE_NOTES_NOTIFICATION, () => {
help_app.handler.maybeShowReleaseNotesNotification();
});
/**
* Compare two positions by their start index. Use for sorting.
*
* @param {!helpApp.Position} a
* @param {!helpApp.Position} b
*/
function compareByStart(a, b) {
return a.start - b.start;
}
/**
* Limits the maximum length of the input string. Converts non-strings into
* empty string.
*
* @param {*} s Probably a string, but might not be.
* @return {string}
*/
function truncate(s) {
if (typeof s !== 'string') return '';
if (s.length <= MAX_STRING_LEN) return s;
return s.substring(0, MAX_STRING_LEN);
}