// Copyright 2020 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
#include "chrome/browser/ash/guest_os/guest_os_shelf_utils.h"
#include <string_view>
#include "base/logging.h"
#include "base/no_destructor.h"
#include "base/strings/string_util.h"
#include "chrome/browser/ash/borealis/borealis_window_manager.h"
#include "chrome/browser/ash/guest_os/guest_id.h"
#include "chrome/browser/ash/guest_os/guest_os_pref_names.h"
#include "chrome/browser/ash/guest_os/guest_os_session_tracker.h"
#include "chrome/browser/ash/guest_os/public/types.h"
#include "chrome/browser/profiles/profile.h"
#include "components/prefs/pref_service.h"
#include "components/services/app_service/public/cpp/app_types.h"
namespace guest_os {
namespace {
// This prefix is used as a prefix when generating shelf ids for windows we
// couldn't match to an app. It is also used for crostini web dialogs (e.g.
// crostini installer/upgrader) which need to appear in the shelf.
//
// Note: if the value is changed, you will also need to manually update
// kCrostiniInstallerShelfId and kCrostiniUpgraderShelfId.
constexpr char kCrostiniShelfIdPrefix[] = "crostini:";
// Prefix of the WindowAppId set on exo windows for GuestOS X apps.
constexpr char kGuestOsWindowAppIdPrefix[] = "org.chromium.guest_os.";
// This comes after kGuestOsWindowAppIdPrefix+token for GuestOS Wayland apps.
constexpr char kWaylandPrefix[] = "wayland.";
// This comes after kGuestOsWindowAppIdPrefix+token.
constexpr char kWmClassPrefix[] = "wmclass.";
// TODO(b/267377562): Borealis windows have a hardcoded "borealis" token.
constexpr char kBorealisToken[] = "borealis";
const std::string* GetAppNameForWMClass(std::string_view wmclass) {
// A hard-coded mapping from WMClass to app names.
// This is used to deal with the Linux apps that don't specify the correct
// WMClass in their desktop files so that their aura windows can be identified
// with their respective app IDs.
static const base::NoDestructor<std::map<std::string, std::string>>
kWMClassToNname({{"Octave-gui", "GNU Octave"},
{"MuseScore2", "MuseScore 2"},
{"XnViewMP", "XnView Multi Platform"}});
const auto it = kWMClassToNname->find(std::string(wmclass));
if (it == kWMClassToNname->end())
return nullptr;
return &it->second;
}
bool MatchingString(const std::string& search_string,
const std::string& value_string,
bool ignore_space) {
std::string search = search_string;
std::string value = value_string;
if (ignore_space) {
base::RemoveChars(search, " ", &search);
base::RemoveChars(value, " ", &value);
}
return base::EqualsCaseInsensitiveASCII(search, value);
}
enum class FindAppIdResult { NoMatch, UniqueMatch, NonUniqueMatch };
// Looks for an app where prefs_key is set to search_value. Returns the apps id
// if there was only one app matching, otherwise returns an empty string.
FindAppIdResult FindAppId(const base::Value::Dict& prefs,
std::string_view prefs_key,
std::string_view search_value,
const std::optional<GuestId>& guest_id,
std::string* result,
bool require_startup_notify = false,
bool need_display = false,
bool ignore_space = false) {
result->clear();
for (const auto item : prefs) {
if (require_startup_notify && !*item.second.GetDict().FindBool(
guest_os::prefs::kAppStartupNotifyKey)) {
continue;
}
if (need_display) {
const std::optional<bool> no_display =
item.second.GetDict().FindBool(guest_os::prefs::kAppNoDisplayKey);
if (no_display && *no_display) {
continue;
}
}
// If guest_id is provided, also check that it matches. The guest_id is
// considered matched if its vm_name and container_name matches
// corresponding entries in the dictionary.
if (guest_id && !MatchContainerDict(item.second, *guest_id)) {
continue;
}
const base::Value* value = item.second.GetDict().Find(prefs_key);
if (!value)
continue;
if (value->is_string()) {
if (!MatchingString(std::string(search_value), value->GetString(),
ignore_space)) {
continue;
}
} else if (value->is_dict()) {
// Look at the unlocalized name to see if that matches.
const std::string* str_value = value->GetDict().FindString("");
if (!str_value || !MatchingString(std::string(search_value), *str_value,
ignore_space)) {
continue;
}
} else {
continue;
}
if (!result->empty())
return FindAppIdResult::NonUniqueMatch;
*result = item.first;
}
if (!result->empty())
return FindAppIdResult::UniqueMatch;
return FindAppIdResult::NoMatch;
}
// For GuestOS |window_app_id|s which match the prefix of
// org.chromium.guest_os.<token>.*, return the guest token.
// The token should be one of the following:
// - For Crostini app windows: it is the container_token
// - For Bruschetta app windows: it is the container_token
// - For Borealis app windows: "borealis"
// - For all other guest app windows: "termina"
// Note that PluginVM does not match this prefix since it has a
// hard-coded window_app_id.
std::string GetGuestTokenForWindowId(const std::string* window_app_id) {
if (!window_app_id ||
!base::StartsWith(*window_app_id, kGuestOsWindowAppIdPrefix,
base::CompareCase::SENSITIVE)) {
return std::string();
}
const auto token_start = strlen(kGuestOsWindowAppIdPrefix);
// Find the first "." after the kGuestOsWindowAppIdPrefix
const auto token_end = window_app_id->find(".", token_start);
auto token = window_app_id->substr(token_start, token_end - token_start);
return token;
}
std::string GetUnregisteredAppIdPrefix(const std::optional<std::string> token) {
if (token == kBorealisToken) {
return borealis::kBorealisAnonymousPrefix;
}
// TODO(b/244651040): We should support other VMs, e.g. bruschetta.
// For all other unregistered apps, default to "crostini:".
return kCrostiniShelfIdPrefix;
}
} // namespace
// The code follows these steps to identify apps and returns the first match:
// 1) If the |window_startup_id| is set, look for a matching desktop file id.
// 2) Ignore windows if the |window_app_id| is not set.
// 3) The |window_app_id| is prefixed by org.chromium.guest_os.<token>., so we
// should be able to obtain a guest token from it. This will be used to find
// a guest_id to which the app window belongs to. In the following steps, the
// container_name and vm_name from the guest_id will be used to find a unique
// match if available.
// 4) Remove the org.chromium.guest_os.<token>. prefix and use the remaining
// string (the suffix) for the next steps.
// 5) If the suffix is prefixed by wayland., it's a native Wayland app. Look for
// a matching desktop file id.
// 6) If the suffix from step 4 is prefixed by wmclass.:
// 6.1) Look for an app where StartupWMClass matches the remaining string.
// 6.2) Look for an app where the desktop file id matches the remaining string.
// 6.3) Look for an app where the unlocalized name matches the remaining
// string. This handles the xterm & uxterm examples.
// 7) If we couldn't find a match, prefix the |window_app_id| with a generic
// prefix of 'crostini:' or 'borealis:"', so we can easily identify
// shelf entries as GuestOs apps. If we could not identify the VM, default
// to using "crostini:".
std::string GetGuestOsShelfAppId(Profile* profile,
const std::string* window_app_id,
const std::string* window_startup_id) {
if (!profile || !profile->GetPrefs())
return std::string();
const base::Value::Dict& apps =
profile->GetPrefs()->GetDict(guest_os::prefs::kGuestOsRegistry);
// TODO(b/244651040): Consider moving the borealis GetBorealisAppId logic
// here.
std::string app_id;
std::string token = GetGuestTokenForWindowId(window_app_id);
std::optional<GuestId> guest_id =
GuestOsSessionTracker::GetForProfile(profile)->GetGuestIdForToken(token);
if (window_startup_id) {
if (FindAppId(apps, guest_os::prefs::kAppDesktopFileIdKey,
*window_startup_id, guest_id, &app_id,
true) == FindAppIdResult::UniqueMatch) {
return app_id;
}
LOG(WARNING) << "Startup ID was set to '" << *window_startup_id
<< "' but not matched. Will attempt to match with window ID.";
}
if (!window_app_id) {
return std::string();
}
// If the window_id does not follow the expected format, return a generic id.
if (!base::StartsWith(*window_app_id, kGuestOsWindowAppIdPrefix,
base::CompareCase::SENSITIVE)) {
LOG(ERROR) << "window_app_id:" << *window_app_id
<< " provided is not prefixed with "
<< kGuestOsWindowAppIdPrefix;
return GetUnregisteredAppIdPrefix(token) + *window_app_id;
}
// Get the suffix by stripping "org.chromium.guest_os.<token>.".
// token.length() + 1 is used since the '.' separator was not included in the
// token.
std::string_view suffix = base::MakeStringPiece(
window_app_id->begin() + strlen(kGuestOsWindowAppIdPrefix) +
token.length() + 1,
window_app_id->end());
// Wayland apps will have a "wayland." identifier.
if (base::StartsWith(suffix, kWaylandPrefix, base::CompareCase::SENSITIVE)) {
const std::string_view wayland_app = suffix.substr(strlen(kWaylandPrefix));
if (FindAppId(apps, guest_os::prefs::kAppDesktopFileIdKey, wayland_app,
guest_id, &app_id) == FindAppIdResult::UniqueMatch) {
return app_id;
}
return GetUnregisteredAppIdPrefix(token) + *window_app_id;
}
// If we don't have an id to match to a desktop file, use the window app id.
if (!base::StartsWith(suffix, kWmClassPrefix, base::CompareCase::SENSITIVE)) {
return GetUnregisteredAppIdPrefix(token) + *window_app_id;
}
// If an app had StartupWMClass set to the given WM class, use that,
// otherwise look for a desktop file id matching the WM class.
std::string_view key = suffix.substr(strlen(kWmClassPrefix));
FindAppIdResult result = FindAppId(
apps, guest_os::prefs::kAppStartupWMClassKey, key, guest_id, &app_id,
false /* require_startup_notification */, true /* need_display */);
if (result == FindAppIdResult::UniqueMatch)
return app_id;
if (result == FindAppIdResult::NonUniqueMatch)
return GetUnregisteredAppIdPrefix(token) + *window_app_id;
if (FindAppId(apps, guest_os::prefs::kAppDesktopFileIdKey, key, guest_id,
&app_id) == FindAppIdResult::UniqueMatch) {
return app_id;
}
if (FindAppId(apps, guest_os::prefs::kAppNameKey, key, guest_id, &app_id,
false /* require_startup_notification */,
true /* need_display */,
true /* ignore_space */) == FindAppIdResult::UniqueMatch) {
return app_id;
}
const std::string* app_name = GetAppNameForWMClass(key);
if (app_name &&
FindAppId(apps, guest_os::prefs::kAppNameKey, *app_name, guest_id,
&app_id, false /* require_startup_notification */,
true /* need_display */) == FindAppIdResult::UniqueMatch) {
return app_id;
}
return GetUnregisteredAppIdPrefix(token) + *window_app_id;
}
bool IsUnregisteredCrostiniShelfAppId(std::string_view shelf_app_id) {
return base::StartsWith(shelf_app_id, kCrostiniShelfIdPrefix,
base::CompareCase::SENSITIVE);
}
bool IsUnregisteredGuestOsShelfAppId(std::string_view shelf_app_id) {
return IsUnregisteredCrostiniShelfAppId(shelf_app_id) ||
base::StartsWith(shelf_app_id, borealis::kBorealisAnonymousPrefix,
base::CompareCase::SENSITIVE);
}
bool IsCrostiniShelfAppId(const Profile* profile,
std::string_view shelf_app_id) {
if (IsUnregisteredCrostiniShelfAppId(shelf_app_id)) {
return true;
}
if (!profile || !profile->GetPrefs()) {
return false;
}
// TODO(timloh): We need to handle desktop files that have been removed.
// For example, running windows with a no-longer-valid app id will try to
// use the ExtensionContextMenuModel.
const auto& apps =
profile->GetPrefs()->GetDict(guest_os::prefs::kGuestOsRegistry);
return apps.contains(shelf_app_id);
}
apps::AppType GetAppType(Profile* profile, std::string_view shelf_app_id) {
if (shelf_app_id.starts_with(kCrostiniShelfIdPrefix)) {
shelf_app_id.remove_prefix(strlen(kCrostiniShelfIdPrefix));
}
const std::string id(shelf_app_id);
const std::string token = GetGuestTokenForWindowId(&id);
std::optional<GuestId> guest_id =
GuestOsSessionTracker::GetForProfile(profile)->GetGuestIdForToken(token);
if (guest_id.has_value()) {
return ToAppType(guest_id->vm_type);
}
return ToAppType(vm_tools::apps::UNKNOWN);
}
} // namespace guest_os