Commit e084e7b0 authored by Erik Jensen's avatar Erik Jensen Committed by Commit Bot

remoting: Implement session chooser for Linux.

Chrome Remote Desktop on Linux sets up a dedicated virtual X server
which runs its own desktop environment separate from any that the user
may be logged into locally. Previously, we would attempt to launch a
default session on boot. This had a few issues:

  1. A full desktop environment would be running at all times, taking up
     resources even if the user only used CRD occasionally.
  2. There was no guarantee that session we launched was the session the
     user wanted in CRD, especially when the system's default session
     might using a resource-hungry desktop environment.
  3. Some desktop environments have trouble running multiple instances
     at the same time under the same user. This could result in
     detrimental effects when logging in locally, in some cases
     including not being able to log in locally at all after installing
     Chrome Remote Desktop.

While all of these issues could be worked around by creating a custom
~/.chrome-remote-desktop-session file and customizing it appropriately,
this was not very discoverable or user friendly.

This patchset aims to address these issues by introducing a new session
chooser that allows the user to pick from among the various session
types installed on their machine. This chooser will appear by default
when no custom session has been configured, but can still be overridden
via /etc/chrome-remote-desktop-session or
~/.chrome-remote-desktop-session. By offering the user a choice of what
session to launch, this addresses (2). It also addresses (1) and (3)
because only the chooser dialog is launched at boot, and a desktop
environment is only started when the user connects and selects a session
type. Additionally, if the user logs out of their session, the CRD
environment will return to the chooser, rather than immediately
relaunching the full session.

Change-Id: Iead83fdad0691384fcde66192ecbba832d03e2d1
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/1775932
Commit-Queue: Erik Jensen <rkjnsn@chromium.org>
Reviewed-by: default avatarLambros Lambrou <lambroslambrou@chromium.org>
Reviewed-by: default avatarJamie Walch <jamiewalch@chromium.org>
Cr-Commit-Position: refs/heads/master@{#692302}
parent fd9078ab
...@@ -292,6 +292,8 @@ static_library("common") { ...@@ -292,6 +292,8 @@ static_library("common") {
"username.h", "username.h",
"xmpp_register_support_host_request.cc", "xmpp_register_support_host_request.cc",
"xmpp_register_support_host_request.h", "xmpp_register_support_host_request.h",
"xsession_chooser_linux.cc",
"xsession_chooser_ui.inc",
] ]
libs = [] libs = []
...@@ -384,6 +386,7 @@ static_library("common") { ...@@ -384,6 +386,7 @@ static_library("common") {
"disconnect_window_linux.cc", "disconnect_window_linux.cc",
"me2me_desktop_environment.cc", "me2me_desktop_environment.cc",
"me2me_desktop_environment.h", "me2me_desktop_environment.h",
"xsession_chooser_linux.cc",
] ]
deps += [ deps += [
"//ash", "//ash",
......
...@@ -49,6 +49,9 @@ int DesktopProcessMain(); ...@@ -49,6 +49,9 @@ int DesktopProcessMain();
int FileChooserMain(); int FileChooserMain();
int RdpDesktopSessionMain(); int RdpDesktopSessionMain();
#endif // defined(OS_WIN) #endif // defined(OS_WIN)
#if defined(OS_LINUX)
int XSessionChooserMain();
#endif // defined(OS_LINUX)
namespace { namespace {
...@@ -143,6 +146,10 @@ MainRoutineFn SelectMainRoutine(const std::string& process_type) { ...@@ -143,6 +146,10 @@ MainRoutineFn SelectMainRoutine(const std::string& process_type) {
} else if (process_type == kProcessTypeRdpDesktopSession) { } else if (process_type == kProcessTypeRdpDesktopSession) {
main_routine = &RdpDesktopSessionMain; main_routine = &RdpDesktopSessionMain;
#endif // defined(OS_WIN) #endif // defined(OS_WIN)
#if defined(OS_LINUX)
} else if (process_type == kProcessTypeXSessionChooser) {
main_routine = &XSessionChooserMain;
#endif // defined(OS_LINUX)
} }
return main_routine; return main_routine;
......
...@@ -909,29 +909,8 @@ def choose_x_session(): ...@@ -909,29 +909,8 @@ def choose_x_session():
# current user. # current user.
return ["/bin/sh", startup_file] return ["/bin/sh", startup_file]
# Choose a session wrapper script to run the session. On some systems, # If there's no configuration, show the user a session chooser.
# /etc/X11/Xsession fails to load the user's .profile, so look for an return [HOST_BINARY_PATH, "--type=xsession_chooser"]
# alternative wrapper that is more likely to match the script that the
# system actually uses for console desktop sessions.
SESSION_WRAPPERS = [
"/usr/sbin/lightdm-session",
"/etc/gdm/Xsession",
"/etc/X11/Xsession" ]
for session_wrapper in SESSION_WRAPPERS:
if os.path.exists(session_wrapper):
if os.path.exists("/usr/bin/unity-2d-panel"):
# On Ubuntu 12.04, the default session relies on 3D-accelerated
# hardware. Trying to run this with a virtual X display produces
# weird results on some systems (for example, upside-down and
# corrupt displays). So if the ubuntu-2d session is available,
# choose it explicitly.
return [session_wrapper, "/usr/bin/gnome-session --session=ubuntu-2d"]
else:
# Use the session wrapper by itself, and let the system choose a
# session.
return [session_wrapper]
return None
class ParentProcessLogger(object): class ParentProcessLogger(object):
"""Redirects logs to the parent process, until the host is ready or quits. """Redirects logs to the parent process, until the host is ready or quits.
......
...@@ -21,6 +21,9 @@ const char kProcessTypeHost[] = "host"; ...@@ -21,6 +21,9 @@ const char kProcessTypeHost[] = "host";
const char kProcessTypeRdpDesktopSession[] = "rdp_desktop_session"; const char kProcessTypeRdpDesktopSession[] = "rdp_desktop_session";
const char kProcessTypeEvaluateCapability[] = "evaluate_capability"; const char kProcessTypeEvaluateCapability[] = "evaluate_capability";
const char kProcessTypeFileChooser[] = "file_chooser"; const char kProcessTypeFileChooser[] = "file_chooser";
#if defined(OS_LINUX)
const char kProcessTypeXSessionChooser[] = "xsession_chooser";
#endif // defined(OS_LINUX)
const char kEvaluateCapabilitySwitchName[] = "evaluate-type"; const char kEvaluateCapabilitySwitchName[] = "evaluate-type";
......
...@@ -34,6 +34,9 @@ extern const char kProcessTypeHost[]; ...@@ -34,6 +34,9 @@ extern const char kProcessTypeHost[];
extern const char kProcessTypeRdpDesktopSession[]; extern const char kProcessTypeRdpDesktopSession[];
extern const char kProcessTypeEvaluateCapability[]; extern const char kProcessTypeEvaluateCapability[];
extern const char kProcessTypeFileChooser[]; extern const char kProcessTypeFileChooser[];
#if defined(OS_LINUX)
extern const char kProcessTypeXSessionChooser[];
#endif // defined(OS_LINUX)
extern const char kEvaluateCapabilitySwitchName[]; extern const char kEvaluateCapabilitySwitchName[];
......
// Copyright 2019 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.
// This file implements a dialog allowing the user to pick between installed
// X session types. It finds sessions by looking for .desktop files in
// /etc/X11/sessions and in the xsessions folder (if any) in each XDG system
// data directory. (By default, this will be /usr/local/share/xsessions and
// /usr/share/xsessions.) Once the user selects a session, it will be launched
// via /etc/X11/Xsession. There will additionally be an "Xsession" will will
// invoke Xsession without arguments to launch a "default" session based on the
// system's configuration. If no session .desktop files are found, this will be
// the only option present.
#include <gtk/gtk.h>
#include <unistd.h>
#include <map>
#include <memory>
#include <string>
#include <vector>
#include "base/bind.h"
#include "base/callback.h"
#include "base/environment.h"
#include "base/files/file_enumerator.h"
#include "base/files/file_path.h"
#include "base/files/file_util.h"
#include "base/i18n/icu_util.h"
#include "base/logging.h"
#include "base/message_loop/message_pump_type.h"
#include "base/optional.h"
#include "base/run_loop.h"
#include "base/strings/string_util.h"
#include "base/task/single_thread_task_executor.h"
#include "remoting/base/string_resources.h"
#include "remoting/host/logging.h"
#include "third_party/icu/source/common/unicode/unistr.h"
#include "third_party/icu/source/i18n/unicode/coll.h"
#include "ui/base/glib/glib_signal.h"
#include "ui/base/glib/scoped_gobject.h"
#include "ui/base/l10n/l10n_util.h"
#include "remoting/host/xsession_chooser_ui.inc"
namespace remoting {
namespace {
const char XSESSION_SCRIPT[] = "/etc/X11/Xsession";
struct XSession {
std::string name;
std::string comment;
std::vector<std::string> desktop_names;
std::string exec;
};
class SessionDialog {
public:
SessionDialog(std::vector<XSession> choices,
base::OnceCallback<void(XSession)> callback,
base::OnceClosure cancel_callback)
: choices_(std::move(choices)),
callback_(std::move(callback)),
cancel_callback_(std::move(cancel_callback)),
ui_(gtk_builder_new_from_string(UI, -1)) {
gtk_label_set_text(
GTK_LABEL(gtk_builder_get_object(ui_, "message")),
l10n_util::GetStringUTF8(IDS_SESSION_DIALOG_MESSAGE).c_str());
gtk_tree_view_column_set_title(
GTK_TREE_VIEW_COLUMN(gtk_builder_get_object(ui_, "name_column")),
l10n_util::GetStringUTF8(IDS_SESSION_DIALOG_NAME_COLUMN).c_str());
gtk_tree_view_column_set_title(
GTK_TREE_VIEW_COLUMN(gtk_builder_get_object(ui_, "comment_column")),
l10n_util::GetStringUTF8(IDS_SESSION_DIALOG_COMMENT_COLUMN).c_str());
GtkListStore* session_store =
GTK_LIST_STORE(gtk_builder_get_object(ui_, "session_store"));
for (std::size_t i = 0; i < choices_.size(); ++i) {
GtkTreeIter iter;
gtk_list_store_append(session_store, &iter);
// gtk_list_store_set makes its own internal copy of the strings.
gtk_list_store_set(session_store, &iter,
INDEX_COLUMN, static_cast<guint>(i),
NAME_COLUMN, choices_[i].name.c_str(),
COMMENT_COLUMN, choices_[i].comment.c_str(),
-1);
}
g_signal_connect(gtk_builder_get_object(ui_, "session_list"),
"row-activated", G_CALLBACK(OnRowActivatedThunk), this);
g_signal_connect(gtk_builder_get_object(ui_, "ok_button"), "clicked",
G_CALLBACK(OnOkClickedThunk), this);
g_signal_connect(gtk_builder_get_object(ui_, "dialog"), "delete-event",
G_CALLBACK(OnCloseThunk), this);
}
void Show() {
gtk_widget_show(GTK_WIDGET(gtk_builder_get_object(ui_, "dialog")));
}
private:
void ActivateChoice(std::size_t index) {
gtk_widget_hide(GTK_WIDGET(gtk_builder_get_object(ui_, "dialog")));
if (callback_) {
std::move(callback_).Run(std::move(choices_.at(index)));
}
}
CHROMEG_CALLBACK_2(SessionDialog,
void,
OnRowActivated,
GtkTreeView*,
GtkTreePath*,
GtkTreeViewColumn*);
CHROMEG_CALLBACK_0(SessionDialog, void, OnOkClicked, GtkButton*);
CHROMEG_CALLBACK_1(SessionDialog, gboolean, OnClose, GtkWidget*, GdkEvent*);
enum Columns { INDEX_COLUMN, NAME_COLUMN, COMMENT_COLUMN, NUM_COLUMNS };
std::vector<XSession> choices_;
base::OnceCallback<void(XSession)> callback_;
base::OnceClosure cancel_callback_;
ScopedGObject<GtkBuilder> ui_;
SessionDialog(const SessionDialog&) = delete;
SessionDialog& operator=(const SessionDialog&) = delete;
};
void SessionDialog::OnRowActivated(GtkTreeView* session_list,
GtkTreePath* path,
GtkTreeViewColumn*) {
GtkTreeModel* model = gtk_tree_view_get_model(session_list);
GtkTreeIter iter;
guint index;
if (!gtk_tree_model_get_iter(model, &iter, path)) {
// Strange, but the user should still be able to click OK to progress.
return;
}
gtk_tree_model_get(model, &iter, INDEX_COLUMN, &index, -1);
ActivateChoice(index);
}
void SessionDialog::OnOkClicked(GtkButton*) {
GtkTreeSelection* selection = gtk_tree_view_get_selection(
GTK_TREE_VIEW(gtk_builder_get_object(ui_, "session_list")));
GtkTreeModel* model;
GtkTreeIter iter;
guint index;
if (!gtk_tree_selection_get_selected(selection, &model, &iter)) {
// Nothing selected, so do nothing. Note that the selection mode is set to
// "browse", which should, under most circumstances, ensure that exactly one
// item is selected, preventing this from being reached. However, it does
// not completely guarantee that it can never happen.
return;
}
gtk_tree_model_get(model, &iter, INDEX_COLUMN, &index, -1);
ActivateChoice(index);
}
gboolean SessionDialog::OnClose(GtkWidget* dialog, GdkEvent*) {
gtk_widget_hide(dialog);
if (cancel_callback_) {
std::move(cancel_callback_).Run();
}
return true;
}
base::Optional<XSession> TryLoadSession(base::FilePath path) {
std::unique_ptr<GKeyFile, void (*)(GKeyFile*)> key_file(g_key_file_new(),
&g_key_file_free);
GError* error;
if (!g_key_file_load_from_file(key_file.get(), path.value().c_str(),
G_KEY_FILE_NONE, &error)) {
LOG(WARNING) << "Failed to load " << path << ": " << error->message;
g_error_free(error);
return base::nullopt;
}
// Files without a "Desktop Entry" group can be ignored. (An empty file can be
// put in a higher-priority directory to hide entries from a lower-priority
// directory.)
if (!g_key_file_has_group(key_file.get(), G_KEY_FILE_DESKTOP_GROUP)) {
return base::nullopt;
}
// Files with "NoDisplay" or "Hidden" set should be ignored.
for (const char* key :
{G_KEY_FILE_DESKTOP_KEY_NO_DISPLAY, G_KEY_FILE_DESKTOP_KEY_HIDDEN}) {
if (g_key_file_get_boolean(key_file.get(), G_KEY_FILE_DESKTOP_GROUP, key,
nullptr)) {
return base::nullopt;
}
}
// If there's a "TryExec" key, we need to check if the specified path is
// executable and ignore the entry if not. (However, we should not try to
// actually execute the specified path.)
if (gchar* try_exec =
g_key_file_get_string(key_file.get(), G_KEY_FILE_DESKTOP_GROUP,
G_KEY_FILE_DESKTOP_KEY_TRY_EXEC, nullptr)) {
base::FilePath try_exec_path(
base::TrimWhitespaceASCII(try_exec, base::TRIM_ALL));
g_free(try_exec);
if (try_exec_path.IsAbsolute()
? access(try_exec_path.value().c_str(), X_OK) != 0
: !base::ExecutableExistsInPath(base::Environment::Create().get(),
try_exec_path.value())) {
LOG(INFO) << "Rejecting " << path << " due to TryExec=" << try_exec_path;
return base::nullopt;
}
}
XSession session;
// Required fields.
if (gchar* localized_name = g_key_file_get_locale_string(
key_file.get(), G_KEY_FILE_DESKTOP_GROUP, G_KEY_FILE_DESKTOP_KEY_NAME,
nullptr, nullptr)) {
session.name = localized_name;
g_free(localized_name);
} else {
LOG(WARNING) << "Failed to load value of " << G_KEY_FILE_DESKTOP_KEY_NAME
<< " from " << path;
return base::nullopt;
}
if (gchar* exec =
g_key_file_get_string(key_file.get(), G_KEY_FILE_DESKTOP_GROUP,
G_KEY_FILE_DESKTOP_KEY_EXEC, nullptr)) {
session.exec = exec;
g_free(exec);
} else {
LOG(WARNING) << "Failed to load value of " << G_KEY_FILE_DESKTOP_KEY_EXEC
<< " from " << path;
return base::nullopt;
}
// Optional fields.
if (gchar* localized_comment = g_key_file_get_locale_string(
key_file.get(), G_KEY_FILE_DESKTOP_GROUP,
G_KEY_FILE_DESKTOP_KEY_COMMENT, nullptr, nullptr)) {
session.comment = localized_comment;
g_free(localized_comment);
}
// DesktopNames does not yet have a constant in glib.
if (gchar** desktop_names =
g_key_file_get_string_list(key_file.get(), G_KEY_FILE_DESKTOP_GROUP,
"DesktopNames", nullptr, nullptr)) {
for (std::size_t i = 0; desktop_names[i]; ++i) {
session.desktop_names.push_back(desktop_names[i]);
}
g_strfreev(desktop_names);
}
return session;
}
std::vector<XSession> CollectXSessions() {
std::vector<base::FilePath> session_search_dirs;
session_search_dirs.emplace_back("/etc/X11/sessions");
// Returned list is owned by GLib and should not be modified or freed.
const gchar* const* system_data_dirs = g_get_system_data_dirs();
// List is null-terminated.
for (std::size_t i = 0; system_data_dirs[i]; ++i) {
session_search_dirs.push_back(
base::FilePath(system_data_dirs[i]).Append("xsessions"));
}
std::map<base::FilePath, base::FilePath> session_files;
for (const base::FilePath& search_dir : session_search_dirs) {
base::FileEnumerator file_enumerator(search_dir, false /* recursive */,
base::FileEnumerator::FILES,
"*.desktop");
base::FilePath session_path;
while (!(session_path = file_enumerator.Next()).empty()) {
base::FilePath basename = session_path.BaseName().RemoveFinalExtension();
// Files in higher-priority directory should shadow those from lower-
// priority directories. Emplace will only insert if an entry with the
// same basename wasn't found in a previous directory.
session_files.emplace(basename, session_path);
}
}
std::vector<XSession> sessions;
// Ensure there's always at least one session.
sessions.push_back(
{l10n_util::GetStringUTF8(IDS_SESSION_DIALOG_DEFAULT_SESSION_NAME),
l10n_util::GetStringUTF8(IDS_SESSION_DIALOG_DEFAULT_SESSION_COMMENT),
{},
"default"});
for (const auto& session : session_files) {
base::Optional<XSession> loaded_session = TryLoadSession(session.second);
if (loaded_session) {
sessions.push_back(std::move(*loaded_session));
}
}
UErrorCode err = U_ZERO_ERROR;
std::unique_ptr<icu::Collator> collator(icu::Collator::createInstance(err));
if (U_SUCCESS(err)) {
std::sort(sessions.begin() + 1, sessions.end(),
[&](const XSession& first, const XSession& second) {
UErrorCode err = U_ZERO_ERROR;
UCollationResult result = collator->compare(
icu::UnicodeString::fromUTF8(first.name),
icu::UnicodeString::fromUTF8(second.name), err);
// The icu documentation isn't clear under what circumstances
// this can fail. base::i18n::CompareString16WithCollator just
// does a DCHECK of the result, so do the same here for now.
DCHECK(U_SUCCESS(err));
return result == UCOL_LESS;
});
} else {
LOG(WARNING) << "Error creating collator. Not sorting list. ("
<< u_errorName(err) << ")";
}
return sessions;
}
void ExecXSession(base::OnceClosure quit_closure, XSession session) {
LOG(INFO) << "Running " << XSESSION_SCRIPT << " " << session.exec;
if (!session.desktop_names.empty()) {
std::unique_ptr<base::Environment> environment =
base::Environment::Create();
environment->SetVar("XDG_CURRENT_DESKTOP",
base::JoinString(session.desktop_names, ":"));
}
execl(XSESSION_SCRIPT, XSESSION_SCRIPT, session.exec.c_str(), nullptr);
PLOG(ERROR) << "Failed to exec XSession";
std::move(quit_closure).Run();
}
} // namespace
int XSessionChooserMain() {
#if GTK_CHECK_VERSION(3, 90, 0)
gtk_init();
#else
gtk_init(nullptr, nullptr);
#endif
base::SingleThreadTaskExecutor task_executor(base::MessagePumpType::UI);
base::RunLoop run_loop;
SessionDialog dialog(CollectXSessions(),
base::BindOnce(&ExecXSession, run_loop.QuitClosure()),
run_loop.QuitClosure());
dialog.Show();
run_loop.Run();
// Control only gets to here if something went wrong.
return 1;
}
} // namespace remoting
// Copyright 2019 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.
// The user interface definition used for the session chooser.
namespace remoting {
namespace {
const char UI[] = R"UI_Definition(
<interface>
<requires lib="gtk+" version="3.2"/>
<object class="GtkListStore" id="session_store">
<columns>
<column type="guint"/>
<column type="gchararray"/>
<column type="gchararray"/>
</columns>
</object>
<object class="GtkDialog" id="dialog">
<property name="can_focus">False</property>
<property name="default_width">600</property>
<property name="default_height">400</property>
<property name="type_hint">dialog</property>
<child>
<placeholder/>
</child>
<child internal-child="vbox">
<object class="GtkBox">
<property name="can_focus">False</property>
<property name="orientation">vertical</property>
<property name="spacing">2</property>
<child internal-child="action_area">
<object class="GtkButtonBox">
<property name="can_focus">False</property>
<property name="layout_style">end</property>
<child>
<object class="GtkButton" id="ok_button">
<property name="label">gtk-ok</property>
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="receives_default">True</property>
<property name="use_stock">True</property>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">0</property>
</packing>
</child>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">False</property>
<property name="position">0</property>
</packing>
</child>
<child>
<object class="GtkLabel" id="message">
<property name="visible">True</property>
<property name="can_focus">False</property>
<property name="wrap">True</property>
</object>
<packing>
<property name="expand">False</property>
<property name="fill">True</property>
<property name="position">1</property>
</packing>
</child>
<child>
<object class="GtkScrolledWindow">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="shadow_type">in</property>
<child>
<object class="GtkTreeView" id="session_list">
<property name="visible">True</property>
<property name="can_focus">True</property>
<property name="model">session_store</property>
<child internal-child="selection">
<object class="GtkTreeSelection">
<property name="mode">browse</property>
</object>
</child>
<child>
<object class="GtkTreeViewColumn" id="name_column">
<child>
<object class="GtkCellRendererText"/>
<attributes>
<attribute name="text">1</attribute>
</attributes>
</child>
</object>
</child>
<child>
<object class="GtkTreeViewColumn" id="comment_column">
<child>
<object class="GtkCellRendererText"/>
<attributes>
<attribute name="text">2</attribute>
</attributes>
</child>
</object>
</child>
</object>
</child>
</object>
<packing>
<property name="expand">True</property>
<property name="fill">True</property>
<property name="position">2</property>
</packing>
</child>
</object>
</child>
</object>
</interface>
)UI_Definition";
} // namespace
} // namespace remoting
...@@ -1332,6 +1332,23 @@ If '<ph name="SERVICE_SCRIPT_NAME">$3<ex>org.chromium.chromoting.me2me.sh</ex></ ...@@ -1332,6 +1332,23 @@ If '<ph name="SERVICE_SCRIPT_NAME">$3<ex>org.chromium.chromoting.me2me.sh</ex></
Not Now Not Now
</message> </message>
</if> </if>
<if expr="is_linux">
<message name="IDS_SESSION_DIALOG_MESSAGE" desc="The message to show at the top of the session-selection dialog.">
Select a session to launch within your Chrome Remote Desktop environment. (Note that some session types may not support running within Chrome Remote Desktop and on the local console simultaneously.)
</message>
<message name="IDS_SESSION_DIALOG_NAME_COLUMN" desc="The title of the column containing the names of potential sessions.">
Name
</message>
<message name="IDS_SESSION_DIALOG_COMMENT_COLUMN" desc="The title of the column containing the comment/description of potential sessions.">
Comment
</message>
<message name="IDS_SESSION_DIALOG_DEFAULT_SESSION_NAME" desc="The name of the entry to launch the default session.">
(default)
</message>
<message name="IDS_SESSION_DIALOG_DEFAULT_SESSION_COMMENT" desc="The comment for the entry to launch the default session.">
Launch the default XSession
</message>
</if> <!-- is_linux -->
</messages> </messages>
</release> </release>
</grit> </grit>
bbb2a1814aec1f4862f443ff4c7b5f899ecbf58d
\ No newline at end of file
bbb2a1814aec1f4862f443ff4c7b5f899ecbf58d
\ No newline at end of file
bbb2a1814aec1f4862f443ff4c7b5f899ecbf58d
\ No newline at end of file
bbb2a1814aec1f4862f443ff4c7b5f899ecbf58d
\ No newline at end of file
bbb2a1814aec1f4862f443ff4c7b5f899ecbf58d
\ No newline at end of file
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment