cabana: add browser sign-in for remote routes (#38894)

* cabana: add browser sign-in for remote routes

* cabana: use shared dialog styling for browser sign-in

* cabana: support spacious centered action buttons

* cabana: vertically center sign-in states

* cabana: keep sign-in layout stable between states

* cabana: center sign-in error text

* cabana: avoid hint flash when retrying sign-in
This commit is contained in:
Trey Moen
2026-09-13 15:23:22 -07:00
committed by GitHub
parent b6918cb31b
commit 94f71fce00
5 changed files with 195 additions and 15 deletions
@@ -1,6 +1,13 @@
#include "tools/cabana/ui/dialogs/routesdialog.h"
#include <algorithm>
#include <cmath>
#include <utility>
#include <thread>
#include "json11/json11.hpp"
#include "tools/replay/py_downloader.h"
#include "tools/cabana/ui/theme.h"
#include "imgui.h"
#include "imgui_internal.h"
@@ -14,12 +21,17 @@ const int PERIOD_DAYS[] = {7, 14, 30, 180, -1};
} // namespace
void RoutesDialog::open(std::function<void(bool, const std::string &)> on_done) {
if (alive_) return;
on_done_ = std::move(on_done);
open_ = true;
open_ = false;
popup_.reset();
s_ = State{};
alive_ = std::make_shared<bool>(true);
fetchDevices();
}
void RoutesDialog::fetchDevices() {
routes::fetchDevices([this, alive = std::weak_ptr<bool>(alive_)](std::vector<routes::DeviceInfo> devices, bool success, int error_code) {
utils::runOnMainThread(utils::guarded(alive.lock(), [this, devices = std::move(devices), success, error_code]() {
setDeviceList(devices, success, error_code);
@@ -29,14 +41,19 @@ void RoutesDialog::open(std::function<void(bool, const std::string &)> on_done)
void RoutesDialog::setDeviceList(const std::vector<routes::DeviceInfo> &devices, bool success, int error_code) {
if (success) {
s_.login = false;
open_ = true;
s_.devices.clear();
for (const auto &device : devices) s_.devices.push_back(device.dongle_id);
s_.devices_loaded = true;
s_.device_index = 0;
fetchRoutes();
} else if (error_code == 401) {
s_.login = true;
open_ = true;
} else {
// the box shows on top of the dialog, which is rejected once the box is dismissed
MessageBox::warning("Error", error_code == 401 ? "Unauthorized. Authenticate with openpilot/tools/lib/auth.py" : "Network error", "",
// Initial failures are shown on the calling window without opening the route browser.
MessageBox::warning("Error", "Network error", "",
utils::guarded(alive_, [this]() { finish(false); }));
}
}
@@ -72,6 +89,8 @@ void RoutesDialog::setRouteList(const std::vector<routes::RouteInfo> &list, bool
}
void RoutesDialog::finish(bool accepted) {
if (auth_abort_) *auth_abort_ = true;
auth_abort_.reset();
alive_.reset();
open_ = false;
auto on_done = std::move(on_done_);
@@ -82,6 +101,16 @@ void RoutesDialog::draw() {
if (!open_) return;
if (!beginDialog("Remote Routes", &popup_, ImVec2(480.0f, 420.0f))) return;
if (s_.login) {
drawLogin();
if (open_) {
MessageBox::draw();
if (!open_) ImGui::CloseCurrentPopup();
}
ImGui::EndPopup();
return;
}
ImGui::AlignTextToFramePadding();
ImGui::TextUnformatted("Device");
ImGui::SameLine();
@@ -122,3 +151,124 @@ void RoutesDialog::draw() {
ImGui::EndPopup();
if (accepted || rejected) finish(accepted);
}
void RoutesDialog::signIn(const std::string &provider) {
s_.provider = provider == "google" ? "Google" : provider == "apple" ? "Apple" : "GitHub";
s_.auth_error.clear();
auth_abort_ = std::make_shared<std::atomic<bool>>(false);
std::thread([this, alive = std::weak_ptr<bool>(alive_), abort = auth_abort_, provider]() {
const std::string result = PyDownloader::authenticate(provider, abort.get());
utils::runOnMainThread(utils::guarded(alive.lock(), [this, abort, result]() {
if (*abort) return;
auth_abort_.reset();
std::string error;
auto status = json11::Json::parse(result, error);
if (status["success"].bool_value()) {
s_.login = false;
fetchDevices();
} else {
s_.auth_error = status["error"].string_value();
if (s_.auth_error.empty()) s_.auth_error = "Could not start sign-in. Please try again.";
}
}));
}).detach();
}
void RoutesDialog::drawLogin() {
const auto &p = palette();
const char *providers[] = {"Google", "Apple", "GitHub"};
const char *methods[] = {"google", "apple", "github"};
const char *icons[] = {"\xef\x8f\xb0", "\xef\x99\x9b", "\xef\x8f\xad"};
IconTextButtonOptions button_options{.height = 44.0f, .rounding = 8.0f, .icon_gap = 16.0f, .center_content = true};
float button_width = iconTextButtonWidth("", "Choose another method", button_options);
for (int i = 0; i < 3; ++i) {
const std::string label = std::string("Sign in with ") + providers[i];
button_width = std::max(button_width, iconTextButtonWidth(icons[i], label, button_options));
button_options.label_width = std::max(button_options.label_width, ImGui::CalcTextSize(label.c_str()).x);
}
button_width += ImGui::GetStyle().FramePadding.x * 4;
// Keep the footer anchored while longer errors scroll inside the content area.
const float footer = ImGui::GetFrameHeightWithSpacing() + ImGui::GetStyle().ItemSpacing.y;
ImGui::BeginChild("login_content", ImVec2(0, -footer));
ImGui::Indent(16);
const float gap = ImGui::GetStyle().ItemSpacing.y;
const float wrap_x = ImGui::GetWindowWidth() - 28;
const float text_width = wrap_x - ImGui::GetCursorPosX();
const char *chooser_intro = "Use your comma account to browse recorded drives and open a route for analysis.";
const auto waiting_intro = [](const std::string &provider) {
return "Sign in with " + provider + " in your browser, then return to Cabana to choose a device and route.";
};
const std::string intro = auth_abort_ ? waiting_intro(s_.provider) : chooser_intro;
const char *hint = "Use the comma account paired with your device.";
// Reserve the same space in every state so the heading and controls never jump.
float intro_height = ImGui::CalcTextSize(chooser_intro, nullptr, false, text_width).y;
for (const char *provider : providers) {
intro_height = std::max(intro_height, ImGui::CalcTextSize(waiting_intro(provider).c_str(), nullptr, false, text_width).y);
}
const float controls_height = 3 * (button_options.height + 2 * gap) +
ImGui::CalcTextSize(hint, nullptr, false, text_width).y;
const float content_height = 28 + 2 * gap + intro_height + 2 * gap + 12 + controls_height;
ImGui::SetCursorPosY(ImGui::GetCursorPosY() + std::max(0.0f, (ImGui::GetContentRegionAvail().y - content_height) * 0.5f));
ImGui::PushFont(boldFont(), 28.0f);
ImGui::TextUnformatted("Open your routes in Cabana");
ImGui::PopFont();
ImGui::Spacing();
ImGui::PushTextWrapPos(wrap_x);
const float controls_y = ImGui::GetCursorPosY() + intro_height + 2 * gap + 12;
const float buttons_x = ImGui::GetCursorPosX() + (ImGui::GetContentRegionAvail().x - 16 - button_width) * 0.5f;
if (auth_abort_) {
ImGui::TextWrapped("%s", intro.c_str());
ImGui::SetCursorPosY(controls_y);
ImGui::BeginChild("auth_status", ImVec2(-16, 76), ImGuiChildFlags_None,
ImGuiWindowFlags_NoScrollbar | ImGuiWindowFlags_NoBackground);
const char *status = "Waiting for browser sign-in";
const char *timeout = "This request expires after 3 minutes.";
const float spinner_size = ImGui::GetFontSize();
const float status_width = spinner_size + 8 + ImGui::CalcTextSize(status).x;
ImGui::SetCursorPos(ImVec2((ImGui::GetWindowWidth() - status_width) * 0.5f, 16));
const ImVec2 pos = ImGui::GetCursorScreenPos();
const float angle = std::fmod(ImGui::GetTime() * 4.0, 2.0 * IM_PI);
auto *draw_list = ImGui::GetWindowDrawList();
draw_list->PathArcTo(ImVec2(pos.x + spinner_size * 0.5f, pos.y + spinner_size * 0.5f),
spinner_size * 0.35f, angle, angle + IM_PI * 1.5f, 24);
draw_list->PathStroke(ImGui::GetColorU32(p.accent), 0, 2.0f);
ImGui::Dummy(ImVec2(spinner_size, spinner_size));
ImGui::SameLine(0, 8);
ImGui::TextUnformatted(status);
ImGui::SetCursorPos(ImVec2((ImGui::GetWindowWidth() - ImGui::CalcTextSize(timeout).x) * 0.5f, 40));
ImGui::TextUnformatted(timeout);
ImGui::EndChild();
ImGui::SetCursorPosY(controls_y + 2 * (button_options.height + 2 * gap));
ImGui::SetCursorPosX(buttons_x);
if (iconTextButton("auth_retry", "", "Choose another method", button_width, button_options)) {
*auth_abort_ = true;
auth_abort_.reset();
}
} else {
ImGui::TextWrapped("%s", intro.c_str());
ImGui::SetCursorPosY(controls_y);
const char *selected_method = nullptr;
for (int i = 0; i < 3; ++i) {
ImGui::SetCursorPosX(buttons_x);
if (iconTextButton(methods[i], icons[i], std::string("Sign in with ") + providers[i],
button_width, button_options)) selected_method = methods[i];
ImGui::Spacing();
}
const char *message = s_.auth_error.empty() ? hint : s_.auth_error.c_str();
const float width = ImGui::GetContentRegionAvail().x - 16;
ImGui::SetCursorPosX(ImGui::GetCursorPosX() + std::max(0.0f, (width - ImGui::CalcTextSize(message).x) * 0.5f));
ImGui::TextWrapped("%s", message);
// Finish drawing this state before signIn clears its error text.
if (selected_method) signIn(selected_method);
}
ImGui::PopTextWrapPos();
ImGui::Unindent(16);
ImGui::EndChild();
ImGui::Separator();
bool rejected = false;
dialogButtons("Cancel", &rejected, nullptr, true, nullptr);
if (rejected || dialogEscapePressed()) {
ImGui::CloseCurrentPopup();
finish(false);
}
}
@@ -1,5 +1,6 @@
#pragma once
#include <atomic>
#include <functional>
#include <memory>
#include <string>
@@ -11,10 +12,15 @@
// "Remote routes" browser. on_done gets accepted=true with the selected route name ("" if none), accepted=false on cancel.
class RoutesDialog {
public:
~RoutesDialog() { if (auth_abort_) *auth_abort_ = true; }
void open(std::function<void(bool accepted, const std::string &route)> on_done);
void draw();
bool isActive() const { return alive_ != nullptr; }
private:
void fetchDevices();
void signIn(const std::string &provider);
void drawLogin();
void setDeviceList(const std::vector<routes::DeviceInfo> &devices, bool success, int error_code);
void setRouteList(const std::vector<routes::RouteInfo> &list, bool success);
void fetchRoutes();
@@ -26,6 +32,9 @@ private:
};
struct State {
bool login = false;
std::string provider;
std::string auth_error;
bool devices_loaded = false;
std::vector<std::string> devices;
int device_index = 0;
@@ -36,6 +45,7 @@ private:
int fetch_id = 0; // the reply of an older request is dropped
};
std::shared_ptr<std::atomic<bool>> auth_abort_;
bool open_ = false;
PopupOwner popup_;
State s_;
@@ -22,11 +22,13 @@ void OpenReplayWidget::draw() {
ImGui::GetStyle().ItemSpacing.x * 2));
inputText("##route", &route_, "Enter a route name or browse for a local or remote route");
ImGui::SameLine();
ImGui::BeginDisabled(routes_dialog_.isActive());
if (ImGui::Button("Remote Route...")) {
routes_dialog_.open(utils::guarded(alive_, [this](bool accepted, const std::string &route) {
if (accepted) route_ = route;
}));
}
ImGui::EndDisabled();
ImGui::SameLine();
if (ImGui::Button("Local Route...")) {
FileDialog::getExistingDirectory("Open Local Route", settings.last_route_dir, utils::guarded(alive_, [this](const std::string &dir) {
+20 -10
View File
@@ -224,23 +224,33 @@ bool iconButton(const char *id, const char *icon, const char *tooltip) {
return clicked;
}
float iconTextButtonWidth(const char *icon, const std::string &text) {
float iconTextButtonWidth(const char *icon, const std::string &text, const IconTextButtonOptions &options) {
const ImGuiStyle &style = ImGui::GetStyle();
return ImGui::CalcTextSize(icon).x + style.ItemInnerSpacing.x + ImGui::CalcTextSize(text.c_str(), nullptr, true).x + style.FramePadding.x * 2;
const float gap = *icon ? (options.icon_gap >= 0.0f ? options.icon_gap : style.ItemInnerSpacing.x) : 0.0f;
return ImGui::CalcTextSize(icon).x + gap + std::max(ImGui::CalcTextSize(text.c_str(), nullptr, true).x, options.label_width) +
style.FramePadding.x * 2;
}
bool iconTextButton(const char *id, const char *icon, const std::string &text, float width) {
bool iconTextButton(const char *id, const char *icon, const std::string &text, float width, const IconTextButtonOptions &options) {
const ImGuiStyle &style = ImGui::GetStyle();
if (width <= 0.0f) width = iconTextButtonWidth(icon, text);
const bool clicked = ImGui::Button((std::string("###") + id).c_str(), ImVec2(width, 0.0f));
const float icon_width = ImGui::CalcTextSize(icon).x;
const float gap = *icon ? (options.icon_gap >= 0.0f ? options.icon_gap : style.ItemInnerSpacing.x) : 0.0f;
const float text_width = ImGui::CalcTextSize(text.c_str(), nullptr, true).x;
const float label_width = std::max(text_width, options.label_width);
if (width <= 0.0f) width = icon_width + gap + label_width + style.FramePadding.x * 2;
if (options.rounding >= 0.0f) ImGui::PushStyleVar(ImGuiStyleVar_FrameRounding, options.rounding);
const bool clicked = ImGui::Button((std::string("###") + id).c_str(), ImVec2(width, options.height));
if (options.rounding >= 0.0f) ImGui::PopStyleVar();
const ImVec2 min = ImGui::GetItemRectMin(), max = ImGui::GetItemRectMax();
const float top = min.y + (max.y - min.y - ImGui::GetFontSize()) * 0.5f;
const float left = min.x + (options.center_content ? std::max(style.FramePadding.x, (max.x - min.x - icon_width - gap - label_width) * 0.5f)
: style.FramePadding.x);
const float text_left = left + icon_width + gap;
const float slack = options.center_content ? 0.0f : std::max(0.0f, (max.x - style.FramePadding.x - text_left - text_width) * 0.5f);
const ImU32 color = ImGui::GetColorU32(ImGuiCol_Text);
auto *draw_list = ImGui::GetWindowDrawList();
draw_list->AddText(ImVec2(min.x + style.FramePadding.x, min.y + style.FramePadding.y), color, icon);
// the text is centered between the icon and the right padding
const float left = min.x + style.FramePadding.x + ImGui::CalcTextSize(icon).x + style.ItemInnerSpacing.x;
const float slack = max.x - style.FramePadding.x - left - ImGui::CalcTextSize(text.c_str(), nullptr, true).x;
draw_list->AddText(ImVec2(left + std::max(0.0f, slack * 0.5f), min.y + style.FramePadding.y), color, text.c_str());
draw_list->AddText(ImVec2(left, top), color, icon);
draw_list->AddText(ImVec2(text_left + slack, top), color, text.c_str());
return clicked;
}
+10 -2
View File
@@ -84,8 +84,16 @@ inline std::string shortcut(const char *keys) { return std::string(MOD_KEY) + "+
bool iconButton(const char *id, const char *icon, const char *tooltip = nullptr);
float iconButtonWidth();
bool stepButton(const char *id, bool increment, const char *tooltip = nullptr);
bool iconTextButton(const char *id, const char *icon, const std::string &text, float width = 0.0f);
float iconTextButtonWidth(const char *icon, const std::string &text);
struct IconTextButtonOptions {
float height = 0.0f;
float rounding = -1.0f; // Negative uses the theme default.
float icon_gap = -1.0f; // Negative uses the theme default.
bool center_content = false;
float label_width = 0.0f; // Shared width aligns labels in a group of centered buttons.
};
bool iconTextButton(const char *id, const char *icon, const std::string &text, float width = 0.0f,
const IconTextButtonOptions &options = {});
float iconTextButtonWidth(const char *icon, const std::string &text, const IconTextButtonOptions &options = {});
// tooltip for the last item that also shows while the item is disabled
void disabledItemTooltip(const char *text);