mirror of
https://github.com/sunnypilot/sunnypilot.git
synced 2026-09-29 12:53:42 +08:00
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:
@@ -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) {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user