diff --git a/CMakeLists.txt b/CMakeLists.txt index 06a2520..8e02a87 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -127,6 +127,7 @@ string(TIMESTAMP COMPUTER_CPP_BUILD_TIMESTAMP "%Y-%m-%dT%H:%M:%SZ" UTC) add_library(computer_cpp_core src/core/AppPaths.cpp src/core/AppConfig.cpp + src/core/Browser.cpp src/core/ConfiguredServerController.cpp src/core/GobiiApiClient.cpp src/core/GobiiArtifactUploader.cpp diff --git a/README.md b/README.md index 8baec03..60f84b4 100644 --- a/README.md +++ b/README.md @@ -1040,6 +1040,48 @@ computer.cpp clipboard write "hello" computer.cpp clipboard paste ``` +## Managed Browser Configuration + +Browser-based Lua apps use a persistent ComputerCpp-managed Chromium profile. +Google Chrome is the default and recommended browser. When a task needs browser +inspection, ComputerCpp launches the configured browser automatically with CDP +on an ephemeral loopback port and reuses it for later tasks. + +```bash +computer.cpp config set-browser --browser chrome --profile default \ + --user-data-dir /absolute/path/to/dedicated-browser-data \ + --proxy https://proxy.example:8001 +``` + +The equivalent configuration is: + +```toml +[browser] +default = "chrome" +profile = "default" +# Optional. Omit this to use ComputerCpp's managed directory. +user_data_dir = "/absolute/path/to/dedicated-browser-data" +# Optional Chromium proxy endpoint or rule. Do not include credentials. +proxy = "https://proxy.example:8001" +``` + +Supported managed browser ids are `chrome`, `edge`, `brave`, and `chromium`. +Each browser/profile pair has isolated persistent sign-in data. If the selected +browser is not installed, ordinary URL opens fall back to the operating system +default browser, while inspection-dependent apps return setup guidance. +The advanced Browser settings page can select or clear a custom user-data +directory. Use a dedicated automation directory; Chrome may reject remote +debugging when its normal everyday user-data directory is selected. Clear the +field, or run `config set-browser --no-user-data-dir`, to restore the managed +directory. +The optional proxy is passed to Chromium as `--proxy-server`. Leave it empty to +use the system default. Proxy credentials belong in the managed browser's native +sign-in prompt, where ComputerCpp can submit an already-filled proxy prompt; +they are not stored in `config.toml`. Use `config set-browser --no-proxy` to +clear the proxy. +Because Chromium fixes proxy settings at launch, a changed proxy takes effect +after the managed browser is closed and ComputerCpp starts it again. + ## LLM Configuration LLM calls use one canonical user config file. The tray settings window and the @@ -1057,8 +1099,8 @@ computer.cpp config test ``` Use `computer.cpp config open` to open the editable TOML file. The config stores -providers, profiles, model ids, timeouts, sampling defaults, OpenRouter routing -preferences, and provider API keys. `config show` redacts keys, and the file is +providers, profiles, managed-browser settings, model ids, timeouts, sampling +defaults, OpenRouter routing preferences, and provider API keys. `config show` redacts keys, and the file is created in the platform user config directory. On macOS/Linux it is written owner-read/write only. @@ -1122,6 +1164,9 @@ window edits the same `config.toml` file: id, optional temperature, top-p, max token, timeout, extra request params, and optional OpenRouter routing JSON. Use `Set Active` to make a profile the default and `Test Inference` to verify it. +- `Browser` chooses the managed Chromium browser, persistent profile, optional + custom user-data directory, and proxy used by browser-based Lua apps. Chrome + is recommended and selected by default. - `Config` shows the config file path. `Open Config` opens the TOML file in the default editor, `Reload` discards unsaved UI changes, and `Save Changes` writes the TOML file. diff --git a/include/computer_cpp/AppConfig.h b/include/computer_cpp/AppConfig.h index 7c9e90c..515be36 100644 --- a/include/computer_cpp/AppConfig.h +++ b/include/computer_cpp/AppConfig.h @@ -50,6 +50,17 @@ struct RecordingConfig { int retentionDays = 14; }; +struct BrowserConfig { + std::string defaultBrowser = "chrome"; + std::string profile = "default"; + std::string userDataDir; + std::string proxyServer; +}; + +bool IsSupportedBrowserId(const std::string& browser); +bool IsValidBrowserProfileName(const std::string& profile); +bool IsValidBrowserProxyServer(const std::string& proxyServer); + struct GobiiConfig { std::string baseUrl = "https://gobii.ai"; std::string machineId; @@ -69,6 +80,7 @@ struct AppConfig { std::string defaultProfile = "main"; std::map providers; std::map profiles; + BrowserConfig browser; ServerConfig server; RecordingConfig recording; GobiiConfig gobii; diff --git a/include/computer_cpp/Browser.h b/include/computer_cpp/Browser.h new file mode 100644 index 0000000..8d8f0c1 --- /dev/null +++ b/include/computer_cpp/Browser.h @@ -0,0 +1,27 @@ +#pragma once + +#include +#include +#include + +namespace ComputerCpp { + +struct BrowserDescriptor { + std::string id; + std::string displayName; + std::string applicationName; + std::string windowQuery; + std::string executable; + bool installed = false; + bool recommended = false; +}; + +std::string NormalizeBrowserId(const std::string& value); +BrowserDescriptor DescribeBrowser(const std::string& browserId); +std::vector BrowserCatalog(); +std::filesystem::path ManagedBrowserDataDir( + const std::string& browserId, + const std::string& profile); +void PrepareManagedBrowserDataDir(const std::filesystem::path& path); + +} // namespace ComputerCpp diff --git a/src/app/TrayIcon.cpp b/src/app/TrayIcon.cpp index 9f3ea55..6822cde 100644 --- a/src/app/TrayIcon.cpp +++ b/src/app/TrayIcon.cpp @@ -3,6 +3,7 @@ #include "computer_cpp/AppConfig.h" #include "computer_cpp/AppPaths.h" +#include "computer_cpp/Browser.h" #include "computer_cpp/CommandRecording.h" #include "computer_cpp/Daemon.h" #include "computer_cpp/ConfiguredServerController.h" @@ -40,6 +41,7 @@ #include #include #include +#include #include #include #include @@ -887,6 +889,7 @@ class LlmSettingsDialog : public wxDialog { enum class SettingsPage : size_t { Profiles, Providers, + Browser, Server, Recording, Advanced, @@ -905,6 +908,7 @@ class LlmSettingsDialog : public wxDialog { kSettingsPageLabels = { "Model Profiles", "AI Providers", + "Browser", "Local Server", "Recording", "Advanced" @@ -999,6 +1003,7 @@ class LlmSettingsDialog : public wxDialog { pages_ = new wxSimplebook(this, wxID_ANY); BuildProfilesPage(); BuildProvidersPage(); + BuildBrowserPage(); BuildServerPage(); BuildRecordingPage(); BuildConfigPage(); @@ -1651,6 +1656,109 @@ class LlmSettingsDialog : public wxDialog { BindDirty(serverAppPath_); } + void BuildBrowserPage() { + auto* page = AddSettingsPage(); + auto* root = new wxBoxSizer(wxVERTICAL); + AddPageHeader( + page, + root, + "Managed Browser", + "Choose the browser ComputerCpp opens for browser-based apps."); + auto* content = new wxBoxSizer(wxVERTICAL); + auto* browserBox = AddSectionCard( + page, + content, + "Automation browser", + "ComputerCpp starts this browser automatically with local inspection enabled."); + wxWindow* browserParent = browserBox->GetStaticBox(); + auto* grid = new wxFlexGridSizer(2, 8, 10); + grid->AddGrowableCol(1, 1); + grid->Add(new wxStaticText(browserParent, wxID_ANY, "Browser"), + 0, wxALIGN_CENTER_VERTICAL | wxRIGHT | wxBOTTOM, 8); + browserChoice_ = new wxChoice(browserParent, wxID_ANY); + grid->Add(browserChoice_, 1, wxEXPAND | wxBOTTOM, 8); + browserBox->Add(grid, 0, wxALL | wxEXPAND, 12); + browserStatus_ = AddHelperText(browserParent, browserBox, "", 12); + + auto* advanced = new wxCollapsiblePane( + page, + wxID_ANY, + "Advanced browser settings", + wxDefaultPosition, + wxDefaultSize, + wxCP_NO_TLW_RESIZE); + auto* advancedRoot = new wxBoxSizer(wxVERTICAL); + auto* advancedGrid = new wxFlexGridSizer(2, 8, 10); + advancedGrid->AddGrowableCol(1, 1); + browserProfile_ = AddTextField( + advanced->GetPane(), advancedGrid, "Managed profile"); + browserProfile_->SetHint("default"); + advancedGrid->Add( + new wxStaticText( + advanced->GetPane(), wxID_ANY, "User data directory"), + 0, + wxALIGN_CENTER_VERTICAL | wxRIGHT | wxBOTTOM, + 8); + auto* userDataRow = new wxBoxSizer(wxHORIZONTAL); + browserUserDataDir_ = new wxTextCtrl(advanced->GetPane(), wxID_ANY); + browserUserDataDir_->SetHint("Managed automatically"); + auto* chooseUserDataDir = new wxButton( + advanced->GetPane(), wxID_ANY, "Choose..."); + auto* clearUserDataDir = new wxButton( + advanced->GetPane(), wxID_ANY, "Use Default"); + userDataRow->Add(browserUserDataDir_, 1, wxRIGHT | wxEXPAND, 8); + userDataRow->Add(chooseUserDataDir, 0, wxRIGHT, 8); + userDataRow->Add(clearUserDataDir, 0); + advancedGrid->Add(userDataRow, 1, wxEXPAND | wxBOTTOM, 8); + browserProxy_ = AddTextField( + advanced->GetPane(), advancedGrid, "Proxy server"); + browserProxy_->SetHint("https://proxy.example:8080"); + advancedRoot->Add(advancedGrid, 0, wxALL | wxEXPAND, 10); + auto* browserProfileHelp = AddHelperText( + advanced->GetPane(), + advancedRoot, + "Each browser/profile pair has separate persistent sign-in data. " + "Leave the directory empty to let ComputerCpp manage it. If you " + "choose one, use a dedicated automation directory rather than your " + "everyday browser data directory. Leave the proxy empty to use the " + "system default. Store proxy credentials in the managed browser's " + "native sign-in prompt, not in this field. Proxy changes apply the " + "next time the managed browser starts.", + 10); + browserProfileHelp->Wrap(FromDIP(720)); + advanced->GetPane()->SetSizer(advancedRoot); + content->Add(advanced, 0, wxTOP | wxEXPAND, 18); + + root->Add(content, 0, wxALL | wxEXPAND, 22); + root->AddStretchSpacer(); + page->SetSizer(root); + + browserChoice_->Bind(wxEVT_CHOICE, [this](wxCommandEvent& event) { + RefreshBrowserStatus(); + OnControlChanged(event); + }); + chooseUserDataDir->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { + const std::string current = FieldValue(browserUserDataDir_); + const wxString initial = current.empty() + ? wxString::FromUTF8(ConfigDir().string()) + : wxString::FromUTF8(current); + wxDirDialog dialog( + this, + "Choose a dedicated browser user data directory", + initial, + wxDD_DEFAULT_STYLE | wxDD_NEW_DIR_BUTTON); + if (dialog.ShowModal() == wxID_OK) { + browserUserDataDir_->SetValue(dialog.GetPath()); + } + }); + clearUserDataDir->Bind(wxEVT_BUTTON, [this](wxCommandEvent&) { + browserUserDataDir_->SetValue(""); + }); + BindDirty(browserProfile_); + BindDirty(browserUserDataDir_); + BindDirty(browserProxy_); + } + void BuildConfigPage() { auto* page = AddSettingsPage(); auto* root = new wxBoxSizer(wxVERTICAL); @@ -1669,7 +1777,7 @@ class LlmSettingsDialog : public wxDialog { AddHelperText( cardParent, box, - "Providers, profiles, server settings, and recording preferences are stored here.", + "Providers, profiles, browser, server, and recording preferences are stored here.", 12); auto* grid = new wxFlexGridSizer(2, 8, 10); grid->AddGrowableCol(1, 1); @@ -1929,6 +2037,7 @@ class LlmSettingsDialog : public wxDialog { config_.recording.retentionDays)); } RefreshRecordingPermissionStatus(); + LoadBrowserFields(); PopulateProviderLists(FirstProviderName()); PopulateProviderChoices(); PopulateProfileList(config_.defaultProfile.empty() ? FirstProfileName() : config_.defaultProfile); @@ -2087,6 +2196,66 @@ class LlmSettingsDialog : public wxDialog { loading_ = false; } + void LoadBrowserFields() { + if (!browserChoice_ || !browserProfile_ || !browserUserDataDir_ || + !browserProxy_) return; + loading_ = true; + browserChoice_->Clear(); + browserIds_.clear(); + for (const auto& browser : BrowserCatalog()) { + browserIds_.push_back(browser.id); + wxString label = wxString::FromUTF8(browser.displayName); + if (browser.recommended) label += " (Recommended)"; + if (!browser.installed) label += " (Not installed)"; + browserChoice_->Append(label); + } + auto found = std::find( + browserIds_.begin(), browserIds_.end(), + config_.browser.defaultBrowser); + browserChoice_->SetSelection(found == browserIds_.end() + ? 0 + : static_cast(std::distance(browserIds_.begin(), found))); + browserProfile_->ChangeValue(config_.browser.profile); + browserUserDataDir_->ChangeValue(config_.browser.userDataDir); + browserProxy_->ChangeValue(config_.browser.proxyServer); + loading_ = false; + RefreshBrowserStatus(); + } + + void RefreshBrowserStatus() { + if (!browserStatus_ || !browserChoice_) return; + const int selection = browserChoice_->GetSelection(); + if (selection == wxNOT_FOUND || + static_cast(selection) >= browserIds_.size()) { + browserStatus_->SetLabel("Choose a managed browser."); + return; + } + const BrowserDescriptor browser = + DescribeBrowser(browserIds_[static_cast(selection)]); + if (browser.installed) { + const auto windows = Platform::ListWindows(browser.windowQuery); + const bool running = std::any_of( + windows.begin(), windows.end(), + [](const Platform::WindowInfo& window) { + return window.available; + }); + browserStatus_->SetLabel( + browser.displayName + + (running + ? " is running. ComputerCpp will reuse its managed profile when available." + : " is installed and will open automatically for browser tasks.")); + browserStatus_->SetForegroundColour( + wxSystemSettings::GetColour(wxSYS_COLOUR_GRAYTEXT)); + } else { + browserStatus_->SetLabel( + browser.displayName + + " is not installed. Ordinary links use the system browser; automation apps will ask you to choose an installed browser."); + browserStatus_->SetForegroundColour(wxColour(190, 110, 30)); + } + browserStatus_->Wrap(FromDIP(720)); + Layout(); + } + void PopulateServerAppList(const std::string& selected) { loading_ = true; serverAppList_->Clear(); @@ -2396,6 +2565,45 @@ class LlmSettingsDialog : public wxDialog { return true; } + bool FlushBrowserFields() { + if (!browserChoice_ || !browserProfile_ || !browserUserDataDir_ || + !browserProxy_) return true; + const int selection = browserChoice_->GetSelection(); + if (selection == wxNOT_FOUND || + static_cast(selection) >= browserIds_.size()) { + SetStatus("Choose a managed browser.", StatusKind::Error); + return false; + } + const std::string profile = FieldValue(browserProfile_); + if (!IsValidBrowserProfileName(profile)) { + SetStatus( + "Managed browser profile must match [A-Za-z0-9][A-Za-z0-9._-]*.", + StatusKind::Error); + return false; + } + const std::string userDataDir = FieldValue(browserUserDataDir_); + if (!userDataDir.empty() && + !std::filesystem::path(userDataDir).is_absolute()) { + SetStatus( + "Browser user data directory must be an absolute path.", + StatusKind::Error); + return false; + } + const std::string proxyServer = FieldValue(browserProxy_); + if (!IsValidBrowserProxyServer(proxyServer)) { + SetStatus( + "Browser proxy must be a Chromium proxy endpoint or rule without whitespace.", + StatusKind::Error); + return false; + } + config_.browser.defaultBrowser = + browserIds_[static_cast(selection)]; + config_.browser.profile = profile; + config_.browser.userDataDir = userDataDir; + config_.browser.proxyServer = proxyServer; + return true; + } + bool FlushServerAppFields() { if (activeServerApp_.empty()) { return true; @@ -2468,13 +2676,15 @@ class LlmSettingsDialog : public wxDialog { if (recordingEnabled_) { config_.recording.enabled = recordingEnabled_->GetValue(); } - return FlushProviderFields() && FlushProfileFields() && FlushServerFields(); + return FlushBrowserFields() && FlushProviderFields() && + FlushProfileFields() && FlushServerFields(); } void RefreshAfterMutation(const std::string& profile, const std::string& provider) { PopulateProviderLists(provider.empty() ? activeProvider_ : provider); PopulateProviderChoices(); PopulateProfileList(profile.empty() ? activeProfile_ : profile); + LoadBrowserFields(); LoadServerFields(); PopulateServerAppList(activeServerApp_); } @@ -2993,6 +3203,13 @@ class LlmSettingsDialog : public wxDialog { std::map providerApiKeyDrafts_; std::map providerUseApiKeyModes_; + wxChoice* browserChoice_ = nullptr; + wxTextCtrl* browserProfile_ = nullptr; + wxTextCtrl* browserUserDataDir_ = nullptr; + wxTextCtrl* browserProxy_ = nullptr; + wxStaticText* browserStatus_ = nullptr; + std::vector browserIds_; + wxListBox* serverAppList_ = nullptr; wxTextCtrl* serverHost_ = nullptr; wxTextCtrl* serverPort_ = nullptr; diff --git a/src/cli/CliCommands.cpp b/src/cli/CliCommands.cpp index 0297a45..99381ef 100644 --- a/src/cli/CliCommands.cpp +++ b/src/cli/CliCommands.cpp @@ -81,6 +81,14 @@ CommandRequest BuildOpenCommand(const std::vector& args) { return Error("open url --browser requires a non-empty value"); } params["browser"] = args[++i]; + } else if (args[i] == "--profile") { + if (i + 1 >= args.size()) { + return Error("open url --profile requires a value"); + } + if (IsBlank(args[i + 1])) { + return Error("open url --profile requires a non-empty value"); + } + params["profile"] = args[++i]; } else if (args[i] == "--new-window") { params["newWindow"] = true; } else if (args[i] == "--no-new-window") { diff --git a/src/cli/CliConfig.cpp b/src/cli/CliConfig.cpp index fd8c3f7..2db4ae9 100644 --- a/src/cli/CliConfig.cpp +++ b/src/cli/CliConfig.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include #include @@ -222,6 +223,72 @@ int HandleSetProvider(const CliOptions& options, const std::vector& return SaveAndReport(options, config, extra); } +int HandleSetBrowser(const CliOptions& options, const std::vector& args) { + AppConfig config; + std::string error; + if (!LoadConfig(config, error)) { + return ErrorExit(error, 1); + } + bool changed = false; + for (size_t i = 2; i < args.size(); ++i) { + if (args[i] == "--browser") { + if (i + 1 >= args.size()) return ErrorExit("config set-browser --browser requires a value"); + const std::string value = Lowercase(Trim(args[++i])); + if (!IsSupportedBrowserId(value)) { + return ErrorExit("browser must be chrome, edge, brave, or chromium"); + } + config.browser.defaultBrowser = value; + changed = true; + } else if (args[i] == "--profile") { + if (i + 1 >= args.size()) return ErrorExit("config set-browser --profile requires a value"); + const std::string value = Trim(args[++i]); + if (!IsValidBrowserProfileName(value)) { + return ErrorExit("browser profile must match [A-Za-z0-9][A-Za-z0-9._-]*"); + } + config.browser.profile = value; + changed = true; + } else if (args[i] == "--user-data-dir") { + if (i + 1 >= args.size()) return ErrorExit("config set-browser --user-data-dir requires a value"); + const std::string value = Trim(args[++i]); + if (value.empty() || !std::filesystem::path(value).is_absolute()) { + return ErrorExit("browser user data directory must be an absolute path"); + } + config.browser.userDataDir = value; + changed = true; + } else if (args[i] == "--no-user-data-dir") { + config.browser.userDataDir.clear(); + changed = true; + } else if (args[i] == "--proxy" || args[i] == "--proxy-server") { + if (i + 1 >= args.size()) { + return ErrorExit("config set-browser --proxy requires a value"); + } + const std::string value = Trim(args[++i]); + if (value.empty() || !IsValidBrowserProxyServer(value)) { + return ErrorExit( + "browser proxy must be a Chromium proxy endpoint or rule without whitespace"); + } + config.browser.proxyServer = value; + changed = true; + } else if (args[i] == "--no-proxy" || args[i] == "--no-proxy-server") { + config.browser.proxyServer.clear(); + changed = true; + } else { + return ErrorExit("unknown config set-browser option: " + args[i]); + } + } + if (!changed) { + return ErrorExit( + "config set-browser requires --browser, --profile, " + "--user-data-dir, --proxy, or a corresponding --no-* option"); + } + return SaveAndReport(options, config, { + {"browser", config.browser.defaultBrowser}, + {"profile", config.browser.profile}, + {"userDataDir", config.browser.userDataDir}, + {"proxy", config.browser.proxyServer}, + }); +} + int HandleSetProfile(const CliOptions& options, const std::vector& args) { if (args.size() < 3 || IsBlank(args[2])) { return ErrorExit("config set-profile requires a profile name"); @@ -381,6 +448,9 @@ int HandleConfigCommand(const CliOptions& options, const std::vector [--type openrouter|openai-compatible] [--base-url url] [--api-key key|--api-key-stdin|--no-api-key] @@ -406,6 +476,7 @@ int HandleConfigCommand(const CliOptions& options, const std::vector ... Configure an LLM endpoint and API key config set-profile ... Configure model and sampling defaults + config set-browser ... Configure the managed automation browser config set-gobii --base-url url Configure the Gobii platform endpoint config import-env One-time import from legacy LLM env vars config test [profile] [--live] Validate or test configured inference @@ -66,7 +67,7 @@ Control Session: app activate-pid Activate app by process id desktop session-state Inspect desktop lock, screensaver, and display state desktop wake [--force] Wake a screensaver or display; force sends activity despite a false-ready state - open url [--browser app] [--new-window|--no-new-window] [--new-instance] + open url [--browser id] [--profile name] [--new-window|--no-new-window] [--new-instance] Open a URL in a browser observe events [limit] List recent observed input events observe frames [@evN|last] [limit] List frames captured for an event diff --git a/src/cli/LuaPrelude.cpp b/src/cli/LuaPrelude.cpp index 731e05c..6277ab5 100644 --- a/src/cli/LuaPrelude.cpp +++ b/src/cli/LuaPrelude.cpp @@ -1736,14 +1736,15 @@ end ac.browser = {} function ac.browser.open(url, opts) - return ac.request("open_url", merge({ url = url, browser = "firefox", newWindow = true }, opts or {})) + return ac.request("open_url", merge({ url = url, newWindow = true }, opts or {})) end function ac.browser.eval(script, opts) return ac.request("browser_eval", merge({ script = script, readOnly = true }, opts or {}), { allow_error = true }) end --- Browser-backed semantic apps share one native Chrome window containing one --- managed tab. The target id is persisted between Lua processes, while every +-- Browser-backed semantic apps share one native managed-browser surface. A cold +-- launch uses Chromium's initial window; reuse creates a dedicated window when +-- no saved surface remains. The target id is persisted between Lua processes, while every -- reuse is verified against both the native window and document.hasFocus(). -- Navigation and window creation remain native keyboard actions; CDP is used -- only to inspect and bind the exact page that received those actions. @@ -1775,13 +1776,17 @@ end local function managed_browser_options(opts, extra) opts = opts or {} - return merge({ - browser = opts.browser or "Google Chrome", - host = opts.host or os.getenv("COMPUTER_CPP_CHROME_CDP_HOST") or "127.0.0.1", - port = tonumber(opts.port or os.getenv("COMPUTER_CPP_CHROME_CDP_PORT") or "9222") or 9222, + local options = { launch = false, readOnly = true, - }, extra or {}) + } + if opts.browser ~= nil then options.browser = opts.browser end + if opts.profile ~= nil then options.profile = opts.profile end + local host = opts.host or os.getenv("COMPUTER_CPP_CHROME_CDP_HOST") + local port = opts.port or os.getenv("COMPUTER_CPP_CHROME_CDP_PORT") + if host ~= nil and tostring(host) ~= "" then options.host = tostring(host) end + if port ~= nil and tostring(port) ~= "" then options.port = tonumber(port) end + return merge(options, extra or {}) end local function managed_window_active() @@ -1803,6 +1808,84 @@ local function managed_wait(predicate, timeout_ms, interval_ms) return nil end +local function managed_bounds_contains(outer, inner) + if type(outer) ~= "table" or type(inner) ~= "table" or + outer.available ~= true or inner.available ~= true then return false end + local ox, oy = tonumber(outer.x), tonumber(outer.y) + local ow, oh = tonumber(outer.width), tonumber(outer.height) + local ix, iy = tonumber(inner.x), tonumber(inner.y) + local iw, ih = tonumber(inner.width), tonumber(inner.height) + if not ox or not oy or not ow or not oh or not ix or not iy or not iw or not ih then return false end + return ix >= ox and iy >= oy and ix + iw <= ox + ow and iy + ih <= oy + oh +end + +local function managed_submit_filled_proxy_auth(browser_pid, proxy_configured) + -- Proxy configuration is the user's consent to this narrow browser-chrome + -- helper. Never infer a credential prompt from arbitrary page content alone. + if proxy_configured ~= true then return false end + local snapshot = ac.request("snapshot", { + interactive = false, + bounds = true, + actions = true, + maxDepth = 10, + maxNodes = 200, + }, { allow_error = true }) + if not snapshot or not snapshot.ok or not snapshot.data then return false end + local frontmost = snapshot.data.frontmostApp or {} + if tonumber(frontmost.pid) ~= tonumber(browser_pid) then return false end + + local dialog_bounds = {} + for _, ref in ipairs(snapshot.data.refs or {}) do + local role = tostring(ref.role or ""):lower() + if role:find("dialog", 1, true) or role:find("sheet", 1, true) then + if ref.bounds and ref.bounds.available == true then + table.insert(dialog_bounds, ref.bounds) + end + end + end + if #dialog_bounds == 0 then return false end + + local proxy_prompt = false + local cancel_button = false + local sign_in_target = "" + local filled_fields = 0 + for _, ref in ipairs(snapshot.data.refs or {}) do + local inside_dialog = false + for _, bounds in ipairs(dialog_bounds) do + if managed_bounds_contains(bounds, ref.bounds) then + inside_dialog = true + break + end + end + if inside_dialog then + local role = tostring(ref.role or ""):lower() + local name = tostring(ref.name or "") + local value = tostring(ref.value or "") + local text = (name .. " " .. value):lower() + if text:find("the proxy ", 1, true) and + text:find("requires a username and password", 1, true) then + proxy_prompt = true + elseif role:find("textfield", 1, true) and value ~= "" then + filled_fields = filled_fields + 1 + elseif role:find("button", 1, true) and name:lower() == "cancel" then + cancel_button = true + elseif role:find("button", 1, true) and name:lower() == "sign in" then + sign_in_target = tostring(ref.displayRef or ref.ref or "") + end + end + end + if not proxy_prompt or not cancel_button or filled_fields < 2 or sign_in_target == "" then + return false + end + if sign_in_target:sub(1, 1) ~= "@" then sign_in_target = "@" .. sign_in_target end + local clicked = ac.request("click", { + target = sign_in_target, + button = "left", + clickCount = 1, + }, { allow_error = true }) + return clicked and clicked.ok == true +end + local function managed_target_is_focused(record, opts) if type(record) ~= "table" or tostring(record.targetId or "") == "" then return false end local result = ac.browser.eval("document.hasFocus()", managed_browser_options(opts, { @@ -1819,6 +1902,10 @@ local function reuse_managed_browser_surface(record, opts) launch = false, })) if not exists or not exists.ok then return nil end + local resolved_browser = tostring(exists.data and exists.data.browser or "") + local resolved_profile = tostring(exists.data and exists.data.profile or "") + if tostring(record.browser or "") ~= "" and tostring(record.browser) ~= resolved_browser then return nil end + if tostring(record.profile or "") ~= "" and tostring(record.profile) ~= resolved_profile then return nil end local activated = ac.request("window_activate", { id = tostring(record.windowId) }, { allow_error = true }) if not activated or not activated.ok then return nil end @@ -1841,6 +1928,8 @@ local function reuse_managed_browser_surface(record, opts) targetId = tostring(record.targetId), windowId = tostring(record.windowId), browserPid = tonumber(record.browserPid), + browser = resolved_browser, + profile = resolved_profile, currentUrl = tostring(focused.data and focused.data.targetUrl or exists.data and exists.data.value or ""), reused = true, } @@ -1865,6 +1954,14 @@ function ac.browser.managed.ensure(opts) local reused = reuse_managed_browser_surface(state[name], opts) if reused then reused.name = name + state[name] = { + targetId = reused.targetId, + windowId = reused.windowId, + browserPid = reused.browserPid, + browser = reused.browser, + profile = reused.profile, + } + write_managed_browser_state(state) return { ok = true, data = reused } end @@ -1875,35 +1972,46 @@ function ac.browser.managed.ensure(opts) launch = opts.launch ~= false, })) if not bootstrap or not bootstrap.ok or not bootstrap.data then - return bootstrap or { ok = false, code = "browser_debug_unavailable", error = "Chrome DevTools is unavailable" } + return bootstrap or { ok = false, code = "browser_debug_unavailable", error = "managed browser inspection is unavailable" } end local browser_pid = tonumber(bootstrap.data.browserPid) if not browser_pid or browser_pid <= 0 then - return { ok = false, code = "browser_pid_unavailable", error = "Chrome DevTools did not report its browser process id" } + return { ok = false, code = "browser_pid_unavailable", error = "the managed browser did not report its process id" } end local activated = ac.request("app_activate_pid", { pid = browser_pid }, { allow_error = true }) if not activated or not activated.ok then - return { ok = false, code = "browser_focus_failed", error = "could not activate the debuggable Chrome process" } + return { ok = false, code = "browser_focus_failed", error = "could not activate the managed browser process" } end - managed_wait(function() + local window = managed_wait(function() local window = managed_window_active() return window and tonumber(window.pid) == browser_pid and window or false end, opts.focusTimeoutMs or 7000, 150) - local before = managed_window_active() - local before_id = before and tostring(before.id or "") or "" + if not window or tostring(window.id or "") == "" then + return { ok = false, code = "browser_window_unavailable", error = "the managed browser did not expose an active window" } + end - local opened = ac.request("press", { keys = { "primary", "n" }, holdMs = 40 }, { allow_error = true }) - if not opened or not opened.ok then - return { ok = false, code = "browser_window_create_failed", error = "could not create the managed Chrome window" } + local auto_proxy_auth = opts.autoSubmitProxyAuth == true or + (opts.autoSubmitProxyAuth ~= false and bootstrap.data.proxyConfigured == true) + local proxy_auth_submitted = managed_submit_filled_proxy_auth(browser_pid, auto_proxy_auth) + if proxy_auth_submitted then + managed_delay(200) end - local window = managed_wait(function() - local current = managed_window_active() - local current_id = current and tostring(current.id or "") or "" - return current and tonumber(current.pid) == browser_pid and current_id ~= "" and current_id ~= before_id and current or false - end, opts.createTimeoutMs or 7000, 150) - if not window then - return { ok = false, code = "browser_window_create_failed", error = "Chrome did not expose the new managed window" } + + if bootstrap.data.launched ~= true then + local before_id = tostring(window.id or "") + local created = ac.request("press", { keys = { "primary", "n" }, holdMs = 40 }, { allow_error = true }) + if not created or not created.ok then + return { ok = false, code = "browser_window_create_failed", error = "could not create a managed browser window" } + end + window = managed_wait(function() + local candidate = managed_window_active() + return candidate and tonumber(candidate.pid) == browser_pid and + tostring(candidate.id or "") ~= "" and tostring(candidate.id) ~= before_id and candidate or false + end, opts.createTimeoutMs or 7000, 150) + if not window then + return { ok = false, code = "browser_window_create_failed", error = "the managed browser did not create a new window" } + end end ac.request("press", { keys = { "primary", "l" }, holdMs = 40 }, { allow_error = true }) @@ -1924,22 +2032,29 @@ function ac.browser.managed.ensure(opts) targetFocused = true, launch = false, })) - if not result or not result.ok or not result.data then return false end + if not result or not result.ok or not result.data then + if auto_proxy_auth and not proxy_auth_submitted then + proxy_auth_submitted = managed_submit_filled_proxy_auth(browser_pid, true) + end + return false + end local current_url = tostring(result.data.value or result.data.targetUrl or "") if expected_prefix ~= "" and current_url:sub(1, #expected_prefix) ~= expected_prefix then return false end return result end, opts.navigationTimeoutMs or 15000, 200) if not selected then - return { ok = false, code = "browser_target_not_found", error = "could not bind the focused tab in the new managed Chrome window" } + return { ok = false, code = "browser_target_not_found", error = "could not bind the focused tab in the managed browser window" } end local record = { targetId = tostring(selected.data.targetId or ""), windowId = tostring(window.id or ""), browserPid = browser_pid, + browser = tostring(selected.data.browser or bootstrap.data.browser or ""), + profile = tostring(selected.data.profile or bootstrap.data.profile or ""), } if record.targetId == "" or record.windowId == "" then - return { ok = false, code = "browser_target_not_found", error = "managed Chrome surface did not expose stable target and window ids" } + return { ok = false, code = "browser_target_not_found", error = "managed browser surface did not expose stable target and window ids" } end state[name] = record write_managed_browser_state(state) @@ -1948,6 +2063,8 @@ function ac.browser.managed.ensure(opts) targetId = record.targetId, windowId = record.windowId, browserPid = record.browserPid, + browser = record.browser, + profile = record.profile, currentUrl = tostring(selected.data.value or selected.data.targetUrl or ""), reused = false, } } diff --git a/src/core/AppConfig.cpp b/src/core/AppConfig.cpp index 9230ee7..1b7d7eb 100644 --- a/src/core/AppConfig.cpp +++ b/src/core/AppConfig.cpp @@ -207,6 +207,15 @@ bool IsAllowedProviderType(const std::string& type) { return type == "openrouter" || type == "openai-compatible"; } +bool IsStableName(const std::string& value) { + if (value.empty() || !std::isalnum(static_cast(value.front()))) { + return false; + } + return std::all_of(value.begin(), value.end(), [](unsigned char ch) { + return std::isalnum(ch) || ch == '.' || ch == '_' || ch == '-'; + }); +} + void AddProfileScalarParams(LlmProfileConfig& profile) { if (profile.temperature.has_value()) { profile.params["temperature"] = *profile.temperature; @@ -285,6 +294,22 @@ std::optional ParseDouble(const std::string& raw) { } // namespace +bool IsSupportedBrowserId(const std::string& browser) { + return browser == "chrome" || browser == "edge" || + browser == "brave" || browser == "chromium"; +} + +bool IsValidBrowserProfileName(const std::string& profile) { + return IsStableName(profile); +} + +bool IsValidBrowserProxyServer(const std::string& proxyServer) { + if (proxyServer.empty()) return true; + return std::none_of(proxyServer.begin(), proxyServer.end(), [](unsigned char ch) { + return std::isspace(ch) != 0 || std::iscntrl(ch) != 0; + }); +} + std::string GenerateServerAuthToken() { std::array bytes {}; bool filled = false; @@ -432,10 +457,22 @@ AppConfig LoadAppConfig( config.profiles.clear(); config.server = ServerConfig{}; config.recording = RecordingConfig{}; + config.browser = BrowserConfig{}; config.gobii = GobiiConfig{}; config.version = static_cast(parsed["version"].value_or(1)); config.defaultProfile = parsed["default_profile"].value_or("main"); + if (auto browser = parsed["browser"].as_table()) { + config.browser.defaultBrowser = + TomlString(*browser, "default", config.browser.defaultBrowser); + config.browser.profile = + TomlString(*browser, "profile", config.browser.profile); + config.browser.userDataDir = + TomlString(*browser, "user_data_dir"); + config.browser.proxyServer = + TomlString(*browser, "proxy"); + } + if (auto providers = parsed["providers"].as_table()) { for (const auto& [key, node] : *providers) { auto* table = node.as_table(); @@ -600,6 +637,31 @@ AppConfig LoadAppConfig( return {}; } } + if (!IsSupportedBrowserId(config.browser.defaultBrowser)) { + if (error) { + *error = "browser.default must be chrome, edge, brave, or chromium"; + } + return {}; + } + if (!IsValidBrowserProfileName(config.browser.profile)) { + if (error) { + *error = "browser.profile must match [A-Za-z0-9][A-Za-z0-9._-]*"; + } + return {}; + } + if (!config.browser.userDataDir.empty() && + !fs::path(config.browser.userDataDir).is_absolute()) { + if (error) { + *error = "browser.user_data_dir must be an absolute path"; + } + return {}; + } + if (!IsValidBrowserProxyServer(config.browser.proxyServer)) { + if (error) { + *error = "browser.proxy must be a Chromium proxy endpoint or rule without whitespace"; + } + return {}; + } return config; } @@ -657,6 +719,12 @@ json AppConfigToJson(const AppConfig& config, bool redactSecrets) { {"includesCursor", true}, {"includesAudio", false}, }; + out["browser"] = { + {"default", config.browser.defaultBrowser}, + {"profile", config.browser.profile}, + {"userDataDir", config.browser.userDataDir}, + {"proxy", config.browser.proxyServer}, + }; out["gobii"] = { {"baseUrl", config.gobii.baseUrl}, {"machineId", config.gobii.machineId}, @@ -689,6 +757,17 @@ std::string AppConfigToToml(const AppConfig& config) { out << "version = " << config.version << "\n"; out << "default_profile = " << TomlStringLiteral(config.defaultProfile) << "\n\n"; + out << TomlTablePath({"browser"}) << "\n"; + out << "default = " << TomlStringLiteral(config.browser.defaultBrowser) << "\n"; + out << "profile = " << TomlStringLiteral(config.browser.profile) << "\n"; + if (!config.browser.userDataDir.empty()) { + out << "user_data_dir = " << TomlStringLiteral(config.browser.userDataDir) << "\n"; + } + if (!config.browser.proxyServer.empty()) { + out << "proxy = " << TomlStringLiteral(config.browser.proxyServer) << "\n"; + } + out << "\n"; + for (const auto& [name, provider] : config.providers) { out << TomlTablePath({"providers", name}) << "\n"; out << "type = " << TomlStringLiteral(provider.type) << "\n"; diff --git a/src/core/Browser.cpp b/src/core/Browser.cpp new file mode 100644 index 0000000..4ed3ed2 --- /dev/null +++ b/src/core/Browser.cpp @@ -0,0 +1,215 @@ +#include "computer_cpp/Browser.h" + +#include "computer_cpp/AppConfig.h" +#include "computer_cpp/AppPaths.h" +#include "computer_cpp/StringUtils.h" + +#include +#include +#include +#include +#include + +#if !defined(_WIN32) +#include +#endif + +namespace fs = std::filesystem; + +namespace ComputerCpp { +namespace { + +fs::path HomeDir() { + if (const char* home = std::getenv("HOME")) { + if (*home) return fs::path(home); + } + return fs::temp_directory_path(); +} + +bool IsExecutableFile(const fs::path& path) { + std::error_code ec; + if (!fs::is_regular_file(path, ec) || ec) return false; +#if defined(_WIN32) + return true; +#else + return ::access(path.c_str(), X_OK) == 0; +#endif +} + +[[maybe_unused]] std::string FindOnPath(const std::vector& names) { + const char* raw = std::getenv("PATH"); + if (!raw) return {}; +#if defined(_WIN32) + constexpr char separator = ';'; +#else + constexpr char separator = ':'; +#endif + std::string pathValue(raw); + size_t start = 0; + while (start <= pathValue.size()) { + size_t end = pathValue.find(separator, start); + std::string directory = pathValue.substr( + start, end == std::string::npos ? std::string::npos : end - start); + for (const auto& name : names) { + fs::path candidate = fs::path(directory) / name; + if (IsExecutableFile(candidate)) return candidate.string(); + } + if (end == std::string::npos) break; + start = end + 1; + } + return {}; +} + +fs::path BrowserStorageRoot() { + if (const char* overrideDir = std::getenv("COMPUTER_CPP_HOME")) { + if (*overrideDir) return AppDataDir(); + } +#if defined(_WIN32) + if (const char* local = std::getenv("LOCALAPPDATA")) { + return fs::path(local) / "computer.cpp"; + } + return AppDataDir(); +#elif defined(__APPLE__) + return HomeDir() / "Library" / "Application Support" / "computer.cpp"; +#else + if (const char* state = std::getenv("XDG_STATE_HOME")) { + if (*state) return fs::path(state) / "computer.cpp"; + } + return HomeDir() / ".local" / "state" / "computer.cpp"; +#endif +} + +void MakePrivate(const fs::path& path) { + EnsureDirectory(path); +#if defined(__unix__) || defined(__APPLE__) + std::error_code ec; + fs::permissions(path, fs::perms::owner_all, fs::perm_options::replace, ec); +#endif +} + +BrowserDescriptor BuildDescriptor(const std::string& id) { + BrowserDescriptor out; + out.id = id; + out.recommended = id == "chrome"; +#if defined(__APPLE__) + if (id == "chrome") { + out.displayName = "Google Chrome"; + out.applicationName = "Google Chrome"; + out.windowQuery = "Google Chrome"; + } else if (id == "edge") { + out.displayName = "Microsoft Edge"; + out.applicationName = "Microsoft Edge"; + out.windowQuery = "Microsoft Edge"; + } else if (id == "brave") { + out.displayName = "Brave Browser"; + out.applicationName = "Brave Browser"; + out.windowQuery = "Brave Browser"; + } else if (id == "chromium") { + out.displayName = "Chromium"; + out.applicationName = "Chromium"; + out.windowQuery = "Chromium"; + } + for (const auto& root : {fs::path("/Applications"), HomeDir() / "Applications"}) { + fs::path bundle = root / (out.applicationName + ".app"); + std::error_code ec; + if (fs::is_directory(bundle, ec) && !ec) { + out.executable = bundle.string(); + out.installed = true; + break; + } + } +#elif defined(_WIN32) + std::vector relative; + std::vector names; + if (id == "chrome") { + out.displayName = "Google Chrome"; out.applicationName = "Google Chrome"; out.windowQuery = "Google Chrome"; + relative = {"Google/Chrome/Application/chrome.exe"}; names = {"chrome.exe"}; + } else if (id == "edge") { + out.displayName = "Microsoft Edge"; out.applicationName = "Microsoft Edge"; out.windowQuery = "Microsoft Edge"; + relative = {"Microsoft/Edge/Application/msedge.exe"}; names = {"msedge.exe"}; + } else if (id == "brave") { + out.displayName = "Brave Browser"; out.applicationName = "Brave Browser"; out.windowQuery = "Brave Browser"; + relative = {"BraveSoftware/Brave-Browser/Application/brave.exe"}; names = {"brave.exe"}; + } else if (id == "chromium") { + out.displayName = "Chromium"; out.applicationName = "Chromium"; out.windowQuery = "Chromium"; + relative = {"Chromium/Application/chrome.exe"}; names = {"chrome.exe"}; + } + for (const char* variable : {"PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"}) { + if (const char* root = std::getenv(variable)) { + for (const auto& suffix : relative) { + fs::path candidate = fs::path(root) / suffix; + if (IsExecutableFile(candidate)) out.executable = candidate.string(); + } + } + if (!out.executable.empty()) break; + } + if (out.executable.empty()) out.executable = FindOnPath(names); + out.installed = !out.executable.empty(); +#else + std::vector names; + if (id == "chrome") { + out.displayName = "Google Chrome"; out.applicationName = "Google Chrome"; out.windowQuery = "google-chrome"; + names = {"google-chrome", "google-chrome-stable"}; + } else if (id == "edge") { + out.displayName = "Microsoft Edge"; out.applicationName = "Microsoft Edge"; out.windowQuery = "microsoft-edge"; + names = {"microsoft-edge", "microsoft-edge-stable"}; + } else if (id == "brave") { + out.displayName = "Brave Browser"; out.applicationName = "Brave Browser"; out.windowQuery = "brave"; + names = {"brave-browser", "brave"}; + } else if (id == "chromium") { + out.displayName = "Chromium"; out.applicationName = "Chromium"; out.windowQuery = "chromium"; + names = {"chromium", "chromium-browser"}; + } + out.executable = FindOnPath(names); + out.installed = !out.executable.empty(); +#endif + return out; +} + +} // namespace + +std::string NormalizeBrowserId(const std::string& value) { + const std::string lower = Lowercase(Trim(value)); + if (lower == "chrome" || lower.find("google chrome") != std::string::npos) return "chrome"; + if (lower == "edge" || lower.find("microsoft edge") != std::string::npos || lower.find("msedge") != std::string::npos) return "edge"; + if (lower == "brave" || lower.find("brave browser") != std::string::npos) return "brave"; + if (lower == "chromium" || lower.find("chromium") != std::string::npos) return "chromium"; + return {}; +} + +BrowserDescriptor DescribeBrowser(const std::string& browserId) { + return BuildDescriptor(NormalizeBrowserId(browserId)); +} + +std::vector BrowserCatalog() { + std::vector out; + for (const char* id : {"chrome", "edge", "brave", "chromium"}) { + out.push_back(BuildDescriptor(id)); + } + return out; +} + +fs::path ManagedBrowserDataDir(const std::string& browserId, const std::string& profile) { + const std::string id = NormalizeBrowserId(browserId); + if (!IsSupportedBrowserId(id) || !IsValidBrowserProfileName(profile)) { + return {}; + } + if (id == "chrome" && profile == "default") { + if (const char* configured = std::getenv("COMPUTER_CPP_CHROME_USER_DATA_DIR")) { + if (*configured) { + fs::path path(configured); + return path; + } + } + fs::path legacy = BrowserStorageRoot() / "chrome-cdp"; + return legacy; + } + fs::path path = BrowserStorageRoot() / "browser-profiles" / id / profile; + return path; +} + +void PrepareManagedBrowserDataDir(const fs::path& path) { + MakePrivate(path); +} + +} // namespace ComputerCpp diff --git a/src/daemon/DaemonBrowser.cpp b/src/daemon/DaemonBrowser.cpp index 707c79a..87898fb 100644 --- a/src/daemon/DaemonBrowser.cpp +++ b/src/daemon/DaemonBrowser.cpp @@ -1,5 +1,8 @@ #include "DaemonBrowser.h" +#include "computer_cpp/AppConfig.h" +#include "computer_cpp/AppPaths.h" +#include "computer_cpp/Browser.h" #include "computer_cpp/StringUtils.h" #include "DaemonParsing.h" @@ -13,9 +16,12 @@ #include #include #include +#include #include +#include #include #include +#include #include #include #include @@ -34,9 +40,12 @@ #include #else #include +#include #include #include #include +#include +#include #include #include extern char** environ; @@ -155,74 +164,21 @@ bool ProbeCdp(const std::string& host, int port) { return !HttpGet(CdpBaseUrl(host, port) + "/json/version", 500).empty(); } -std::string LowerBrowserName(std::string value) { - for (char& ch : value) { - ch = static_cast(std::tolower(static_cast(ch))); - } - return value; -} - -std::string CdpUserDataDir() { - if (const char* configured = std::getenv("COMPUTER_CPP_CHROME_USER_DATA_DIR")) { - if (*configured != '\0') { - return configured; - } - } -#if defined(_WIN32) - if (const char* localAppData = std::getenv("LOCALAPPDATA")) { - return (std::filesystem::path(localAppData) / "computer.cpp" / "chrome-cdp").string(); - } -#elif defined(__APPLE__) - if (const char* home = std::getenv("HOME")) { - return (std::filesystem::path(home) / "Library" / "Application Support" / "computer.cpp" / "chrome-cdp").string(); - } -#else - if (const char* stateHome = std::getenv("XDG_STATE_HOME")) { - return (std::filesystem::path(stateHome) / "computer.cpp" / "chrome-cdp").string(); - } - if (const char* home = std::getenv("HOME")) { - return (std::filesystem::path(home) / ".local" / "state" / "computer.cpp" / "chrome-cdp").string(); - } -#endif - return {}; -} - -#if defined(_WIN32) -std::vector WindowsBrowserCandidates(const std::string& browser) { - std::vector candidates; - if (!browser.empty()) { - candidates.push_back(browser); - } - const std::string lower = LowerBrowserName(browser); - const bool chrome = lower.empty() || lower.find("chrome") != std::string::npos; - const bool edge = lower.find("edge") != std::string::npos; - const std::string executable = edge ? "msedge.exe" : "chrome.exe"; - if (chrome || edge) { - for (const char* variable : {"PROGRAMFILES", "PROGRAMFILES(X86)", "LOCALAPPDATA"}) { - if (const char* root = std::getenv(variable)) { - std::filesystem::path path(root); - path /= edge ? "Microsoft/Edge/Application/msedge.exe" : "Google/Chrome/Application/chrome.exe"; - std::error_code ec; - if (std::filesystem::is_regular_file(path, ec) && !ec) { - candidates.push_back(path.string()); - } - } - } - candidates.push_back(executable); - } - return candidates; -} -#endif - -bool LaunchBrowserForCdp(const std::string& browser, int port) { +bool LaunchBrowserForCdp( + const BrowserDescriptor& browser, + const std::filesystem::path& userDataDir, + const std::string& proxyServer, + int port +) { const std::string flag = "--remote-debugging-port=" + std::to_string(port); - const std::string userDataDir = CdpUserDataDir(); - const std::string userDataFlag = userDataDir.empty() ? "" : "--user-data-dir=" + userDataDir; + const std::string userDataFlag = "--user-data-dir=" + userDataDir.string(); + const std::string proxyFlag = "--proxy-server=" + proxyServer; #if defined(__APPLE__) pid_t pid = 0; - std::string app = browser.empty() ? "Google Chrome" : browser; - std::vector args = {"/usr/bin/open", "-n", "-a", app, "--args", flag}; - if (!userDataFlag.empty()) args.push_back(userDataFlag); + std::vector args = { + "/usr/bin/open", "-n", "-a", browser.applicationName, + "--args", flag, userDataFlag, "--no-first-run", "--no-default-browser-check"}; + if (!proxyServer.empty()) args.push_back(proxyFlag); std::vector argv; for (auto& arg : args) argv.push_back(arg.data()); argv.push_back(nullptr); @@ -233,53 +189,146 @@ bool LaunchBrowserForCdp(const std::string& browser, int port) { int status = 0; return waitpid(pid, &status, 0) >= 0 && WIFEXITED(status) && WEXITSTATUS(status) == 0; #elif defined(_WIN32) - for (const auto& chosen : WindowsBrowserCandidates(browser)) { - std::vector args = {chosen, flag}; - if (!userDataFlag.empty()) args.push_back(userDataFlag); - if (Windows::LaunchDetached(args)) return true; - } - return false; + if (browser.executable.empty()) return false; + std::vector args = {browser.executable, flag, userDataFlag, + "--no-first-run", "--no-default-browser-check"}; + if (!proxyServer.empty()) args.push_back(proxyFlag); + return Windows::LaunchDetached(args); #else - std::vector candidates; - const std::string lower = LowerBrowserName(browser); - if (!browser.empty()) candidates.push_back(browser); - if (lower.empty() || lower.find("chrome") != std::string::npos) { - candidates.push_back("google-chrome"); - candidates.push_back("chromium"); - candidates.push_back("chromium-browser"); - } - for (auto& chosen : candidates) { - std::vector args = {chosen, flag}; - if (!userDataFlag.empty()) args.push_back(userDataFlag); - std::vector argv; - for (auto& arg : args) argv.push_back(arg.data()); - argv.push_back(nullptr); - pid_t pid = 0; - if (posix_spawnp(&pid, chosen.c_str(), nullptr, nullptr, argv.data(), environ) == 0) return true; - } - return false; + if (browser.executable.empty()) return false; + std::vector args = {browser.executable, flag, userDataFlag, + "--no-first-run", "--no-default-browser-check"}; + if (!proxyServer.empty()) args.push_back(proxyFlag); + std::vector argv; + for (auto& arg : args) argv.push_back(arg.data()); + argv.push_back(nullptr); + posix_spawn_file_actions_t actions; + if (posix_spawn_file_actions_init(&actions) != 0) return false; + const int stdoutRc = posix_spawn_file_actions_addopen( + &actions, STDOUT_FILENO, "/dev/null", O_WRONLY, 0); + const int stderrRc = posix_spawn_file_actions_addopen( + &actions, STDERR_FILENO, "/dev/null", O_WRONLY, 0); + pid_t pid = 0; + const int rc = stdoutRc == 0 && stderrRc == 0 + ? posix_spawn(&pid, browser.executable.c_str(), &actions, nullptr, + argv.data(), environ) + : stdoutRc != 0 ? stdoutRc : stderrRc; + posix_spawn_file_actions_destroy(&actions); + if (rc != 0) return false; + std::thread([pid]() { + int status = 0; + while (::waitpid(pid, &status, 0) < 0 && errno == EINTR) { + } + }).detach(); + return true; #endif } -bool EnsureCdp(const std::string& browser, const std::string& host, int port, bool launch) { - if (ProbeCdp(host, port)) { +std::optional ReadActivePort(const std::filesystem::path& userDataDir) { + std::ifstream input(userDataDir / "DevToolsActivePort"); + int port = 0; + if (input >> port && port > 0 && port <= 65535) return port; + return std::nullopt; +} + +bool BrowserProfileHasLiveSingleton(const std::filesystem::path& userDataDir) { + for (const char* name : {"SingletonLock", "lockfile"}) { + const std::filesystem::path lockPath = userDataDir / name; + std::error_code ec; + const auto status = std::filesystem::symlink_status(lockPath, ec); + if (ec || !std::filesystem::exists(status)) continue; +#if defined(_WIN32) return true; - } - if (!launch) { - return false; - } - if (!LaunchBrowserForCdp(browser, port)) { - return false; - } - for (int i = 0; i < 40; ++i) { - std::this_thread::sleep_for(std::chrono::milliseconds(250)); - if (ProbeCdp(host, port)) { +#else + if (!std::filesystem::is_symlink(status)) return true; + const std::string target = std::filesystem::read_symlink(lockPath, ec).string(); + if (ec) return true; + const size_t dash = target.rfind('-'); + if (dash == std::string::npos || dash + 1 >= target.size()) return true; + try { + const long pid = std::stol(target.substr(dash + 1)); + if (pid > 0 && (::kill(static_cast(pid), 0) == 0 || errno == EPERM)) { + return true; + } + } catch (...) { return true; } +#endif } return false; } +std::optional EnvPort() { + const char* raw = std::getenv("COMPUTER_CPP_CHROME_CDP_PORT"); + if (!raw || !*raw) return std::nullopt; + try { + int value = std::stoi(raw); + if (value > 0 && value <= 65535) return value; + } catch (...) { + } + return std::nullopt; +} + +class BrowserLaunchLock { +public: + explicit BrowserLaunchLock(const std::filesystem::path& path) { +#if defined(_WIN32) + for (int attempt = 0; attempt < 100; ++attempt) { + handle_ = CreateFileW( + path.wstring().c_str(), GENERIC_READ | GENERIC_WRITE, 0, + nullptr, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + if (handle_ != INVALID_HANDLE_VALUE) break; + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } +#else + fd_ = ::open(path.c_str(), O_CREAT | O_RDWR, 0600); + if (fd_ >= 0) { + bool acquired = false; + for (int attempt = 0; attempt < 100; ++attempt) { + if (::flock(fd_, LOCK_EX | LOCK_NB) == 0) { + acquired = true; + break; + } + if (errno != EINTR && errno != EWOULDBLOCK && errno != EAGAIN) { + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + if (!acquired) { + ::close(fd_); + fd_ = -1; + } + } +#endif + } + + ~BrowserLaunchLock() { +#if defined(_WIN32) + if (handle_ != INVALID_HANDLE_VALUE) CloseHandle(handle_); +#else + if (fd_ >= 0) { + ::flock(fd_, LOCK_UN); + ::close(fd_); + } +#endif + } + + bool valid() const { +#if defined(_WIN32) + return handle_ != INVALID_HANDLE_VALUE; +#else + return fd_ >= 0; +#endif + } + +private: +#if defined(_WIN32) + HANDLE handle_ = INVALID_HANDLE_VALUE; +#else + int fd_ = -1; +#endif +}; + std::optional ParseWebSocketUrl(const std::string& url) { std::string_view view(url); constexpr std::string_view wsPrefix = "ws://"; @@ -809,6 +858,232 @@ json CdpEvaluate( } // namespace +AppConfig LoadDaemonAppConfig(std::string* error) { + struct Cache { + std::mutex mutex; + bool initialized = false; + bool exists = false; + std::filesystem::path path; + std::filesystem::file_time_type modified {}; + uintmax_t size = 0; + AppConfig config; + std::string error; + }; + static Cache cache; + + const std::filesystem::path path = ConfigPath(); + std::error_code ec; + const bool exists = std::filesystem::exists(path, ec) && !ec; + const auto modified = exists + ? std::filesystem::last_write_time(path, ec) + : std::filesystem::file_time_type{}; + ec.clear(); + const uintmax_t size = exists ? std::filesystem::file_size(path, ec) : 0; + + std::lock_guard lock(cache.mutex); + if (!cache.initialized || cache.path != path || cache.exists != exists || + cache.modified != modified || cache.size != size) { + cache.config = LoadAppConfig(&cache.error); + cache.initialized = true; + cache.path = path; + cache.exists = exists; + cache.modified = modified; + cache.size = size; + } + if (error) *error = cache.error; + return cache.config; +} + +ManagedBrowserSession ResolveManagedBrowserSession( + const json& params, + bool launch, + bool includePid +) { + ManagedBrowserSession session; + std::string configError; + const AppConfig config = LoadDaemonAppConfig(&configError); + if (!configError.empty()) { + session.code = "browser_config_invalid"; + session.error = configError; + return session; + } + + const bool explicitBrowser = params.contains("browser"); + const bool explicitProfile = params.contains("profile"); + const std::string requestedBrowser = explicitBrowser + ? params.value("browser", "") + : config.browser.defaultBrowser; + session.browser = NormalizeBrowserId(requestedBrowser); + session.profile = explicitProfile + ? params.value("profile", "") + : config.browser.profile; + if (!IsSupportedBrowserId(session.browser)) { + session.code = "browser_automation_unavailable"; + session.error = "browser '" + requestedBrowser + + "' does not support managed Chromium automation"; + return session; + } + if (!IsValidBrowserProfileName(session.profile)) { + session.code = "invalid_browser_profile"; + session.error = "browser profile must match [A-Za-z0-9][A-Za-z0-9._-]*"; + return session; + } + + const BrowserDescriptor descriptor = DescribeBrowser(session.browser); + session.applicationName = descriptor.applicationName; + session.windowQuery = descriptor.windowQuery; + const char* envHost = std::getenv("COMPUTER_CPP_CHROME_CDP_HOST"); + const auto envPort = EnvPort(); + const bool explicitEndpoint = params.contains("host") || params.contains("port") || + (envHost && *envHost) || envPort.has_value(); + std::filesystem::path userDataDir = + ManagedBrowserDataDir(session.browser, session.profile); + const char* legacyUserDataDir = + std::getenv("COMPUTER_CPP_CHROME_USER_DATA_DIR"); + const bool legacyUserDataOverride = + session.browser == "chrome" && session.profile == "default" && + legacyUserDataDir && *legacyUserDataDir; + if (!legacyUserDataOverride && + session.browser == config.browser.defaultBrowser && + session.profile == config.browser.profile && + !config.browser.userDataDir.empty()) { + userDataDir = config.browser.userDataDir; + } + // A custom data directory names one configured browser/profile identity. + // Proxy routing is an egress policy and applies to every managed identity. + const std::string proxyServer = config.browser.proxyServer; + session.proxyConfigured = !proxyServer.empty(); + + if (explicitEndpoint) { + session.managed = false; + session.host = params.contains("host") + ? params.value("host", "127.0.0.1") + : envHost && *envHost ? std::string(envHost) : "127.0.0.1"; + session.port = params.contains("port") + ? params.value("port", 9222) + : envPort.value_or(9222); + if (session.host != "127.0.0.1" && session.host != "localhost" && + session.host != "::1") { + session.code = "invalid_browser_eval"; + session.error = "browser inspection only supports loopback Chrome DevTools hosts"; + return session; + } + if (ProbeCdp(session.host, session.port)) { + session.ok = true; + } else if (!launch) { + session.code = "browser_debug_unavailable"; + session.error = "Chrome DevTools endpoint is not available on " + + session.host + ":" + std::to_string(session.port); + return session; + } else if (!descriptor.installed) { + session.code = "browser_automation_unavailable"; + session.error = descriptor.displayName + + " is not installed; choose an installed browser in ComputerCpp settings"; + return session; + } else { + PrepareManagedBrowserDataDir(userDataDir); + BrowserLaunchLock lock(userDataDir / ".computer-cpp-launch.lock"); + if (!lock.valid()) { + session.code = "browser_launch_failed"; + session.error = "could not lock managed browser profile '" + session.profile + "'"; + return session; + } + if (ProbeCdp(session.host, session.port)) { + session.ok = true; + } else { + if (BrowserProfileHasLiveSingleton(userDataDir)) { + session.code = "browser_profile_in_use"; + session.error = descriptor.displayName + " profile '" + + session.profile + "' is already open without the requested debugging endpoint; close it or choose a separate managed profile"; + return session; + } + if (!LaunchBrowserForCdp( + descriptor, userDataDir, proxyServer, session.port)) { + session.code = "browser_launch_failed"; + session.error = "could not launch " + descriptor.displayName; + return session; + } + session.launched = true; + for (int attempt = 0; attempt < 100; ++attempt) { + if (ProbeCdp(session.host, session.port)) { + session.ok = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + } + } + } else { + session.managed = true; + session.host = "127.0.0.1"; + if (auto activePort = ReadActivePort(userDataDir); + activePort && ProbeCdp(session.host, *activePort)) { + session.port = *activePort; + session.ok = true; + } else if (!launch) { + session.code = "browser_debug_unavailable"; + session.error = "managed " + descriptor.displayName + + " profile '" + session.profile + "' is not running"; + return session; + } else if (!descriptor.installed) { + session.code = "browser_automation_unavailable"; + session.error = descriptor.displayName + + " is not installed; choose an installed browser in ComputerCpp settings"; + return session; + } else { + PrepareManagedBrowserDataDir(userDataDir); + BrowserLaunchLock lock(userDataDir / ".computer-cpp-launch.lock"); + if (!lock.valid()) { + session.code = "browser_launch_failed"; + session.error = "could not lock managed browser profile '" + session.profile + "'"; + return session; + } + if (auto activePort = ReadActivePort(userDataDir); + activePort && ProbeCdp(session.host, *activePort)) { + session.port = *activePort; + session.ok = true; + } else { + if (BrowserProfileHasLiveSingleton(userDataDir)) { + session.code = "browser_profile_in_use"; + session.error = descriptor.displayName + " profile '" + + session.profile + "' is already open without local inspection; close it or choose a separate managed profile"; + return session; + } + std::error_code ec; + std::filesystem::remove(userDataDir / "DevToolsActivePort", ec); + if (!LaunchBrowserForCdp(descriptor, userDataDir, proxyServer, 0)) { + session.code = "browser_launch_failed"; + session.error = "could not launch " + descriptor.displayName; + return session; + } + session.launched = true; + for (int attempt = 0; attempt < 100; ++attempt) { + auto activePort = ReadActivePort(userDataDir); + if (activePort && ProbeCdp(session.host, *activePort)) { + session.port = *activePort; + session.ok = true; + break; + } + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + } + } + } + + if (!session.ok) { + session.code = "browser_debug_unavailable"; + session.error = "Chrome DevTools did not become available for " + + descriptor.displayName + " profile '" + session.profile + "'"; + return session; + } + if (includePid) { + if (auto pid = CdpBrowserProcessId(session.host, session.port, 2000)) { + session.pid = static_cast(*pid); + } + } + return session; +} + json RunBrowserEvalCommand(const json& params) { if (auto unknown = UnknownParam(params, { "script", @@ -818,6 +1093,7 @@ json RunBrowserEvalCommand(const json& params) { "targetFocused", "browserContextId", "browser", + "profile", "host", "port", "launch", @@ -835,22 +1111,29 @@ json RunBrowserEvalCommand(const json& params) { auto targetTitle = StringParam(params, "targetTitle", ""); auto targetFocused = BoolParam(params, "targetFocused", false); auto browserContextId = StringParam(params, "browserContextId", ""); - auto browser = StringParam(params, "browser", "Google Chrome"); - auto host = StringParam(params, "host", "127.0.0.1"); - auto port = IntParam(params, "port", 9222); + auto browser = StringParam(params, "browser", ""); + auto profile = StringParam(params, "profile", ""); + auto host = StringParam(params, "host", ""); + auto port = IntParam(params, "port", 0); auto launch = BoolParam(params, "launch", true); auto timeoutMs = IntParam(params, "timeoutMs", 5000); auto readOnly = BoolParam(params, "readOnly", true); - if (!script || !targetId || !targetUrlPrefix || !targetTitle || !targetFocused || !browserContextId || !browser || !host || !port || !launch || !timeoutMs || !readOnly) { - return Error("browser_eval requires string script/targetId/targetUrlPrefix/targetTitle/browserContextId/browser/host, integer port/timeoutMs, and boolean targetFocused/launch/readOnly", "invalid_browser_eval"); + if (!script || !targetId || !targetUrlPrefix || !targetTitle || !targetFocused || !browserContextId || !browser || !profile || !host || !port || !launch || !timeoutMs || !readOnly) { + return Error("browser_eval requires string script/targetId/targetUrlPrefix/targetTitle/browserContextId/browser/profile/host, integer port/timeoutMs, and boolean targetFocused/launch/readOnly", "invalid_browser_eval"); } if (IsBlank(*script)) { return Error("browser_eval script must be non-empty", "invalid_browser_eval"); } - if (*host != "127.0.0.1" && *host != "localhost" && *host != "::1") { + if (params.contains("browser") && IsBlank(*browser)) { + return Error("browser_eval browser must be non-empty when provided", "invalid_browser_eval"); + } + if (params.contains("profile") && !IsValidBrowserProfileName(*profile)) { + return Error("browser_eval profile must match [A-Za-z0-9][A-Za-z0-9._-]*", "invalid_browser_eval"); + } + if (params.contains("host") && *host != "127.0.0.1" && *host != "localhost" && *host != "::1") { return Error("browser_eval only supports loopback Chrome DevTools hosts", "invalid_browser_eval"); } - if (*port <= 0 || *port > 65535) { + if (params.contains("port") && (*port <= 0 || *port > 65535)) { return Error("browser_eval port must be 1..65535", "invalid_browser_eval"); } if (*timeoutMs < 500 || *timeoutMs > 30000) { @@ -862,12 +1145,22 @@ json RunBrowserEvalCommand(const json& params) { if (LooksMutatingScript(*script)) { return Error("browser_eval rejected a script that appears to mutate browser/UI state", "invalid_browser_eval"); } - if (!EnsureCdp(*browser, *host, *port, *launch)) { - return Error( - "Chrome DevTools is not available; restart Chrome with --remote-debugging-port=" + std::to_string(*port), - "browser_debug_unavailable"); - } - return CdpEvaluate(*host, *port, *targetId, *targetUrlPrefix, *targetTitle, *targetFocused, *browserContextId, *script, *timeoutMs); + const ManagedBrowserSession session = ResolveManagedBrowserSession(params, *launch); + if (!session.ok) { + return Error(session.error, session.code); + } + json response = CdpEvaluate(session.host, session.port, *targetId, + *targetUrlPrefix, *targetTitle, *targetFocused, *browserContextId, + *script, *timeoutMs); + if (response.value("ok", false) && response.contains("data")) { + response["data"]["browser"] = session.browser; + response["data"]["profile"] = session.profile; + response["data"]["managed"] = session.managed; + response["data"]["launched"] = session.launched; + response["data"]["proxyConfigured"] = session.proxyConfigured; + if (session.pid > 0) response["data"]["browserPid"] = session.pid; + } + return response; } } // namespace ComputerCpp diff --git a/src/daemon/DaemonBrowser.h b/src/daemon/DaemonBrowser.h index 7aae04d..1059145 100644 --- a/src/daemon/DaemonBrowser.h +++ b/src/daemon/DaemonBrowser.h @@ -1,9 +1,36 @@ #pragma once +#include "computer_cpp/AppConfig.h" + #include +#include + namespace ComputerCpp { +struct ManagedBrowserSession { + bool ok = false; + bool managed = true; + bool launched = false; + bool proxyConfigured = false; + std::string browser; + std::string profile; + std::string applicationName; + std::string windowQuery; + std::string host = "127.0.0.1"; + int port = 0; + int pid = -1; + std::string code; + std::string error; +}; + +AppConfig LoadDaemonAppConfig(std::string* error); + +ManagedBrowserSession ResolveManagedBrowserSession( + const nlohmann::json& params, + bool launch, + bool includePid = false); + nlohmann::json RunBrowserEvalCommand(const nlohmann::json& params); } // namespace ComputerCpp diff --git a/src/daemon/DaemonDesktop.cpp b/src/daemon/DaemonDesktop.cpp index b0c6d39..d5dc6d6 100644 --- a/src/daemon/DaemonDesktop.cpp +++ b/src/daemon/DaemonDesktop.cpp @@ -1,5 +1,9 @@ #include "DaemonDesktop.h" +#include "DaemonBrowser.h" + +#include "computer_cpp/AppConfig.h" +#include "computer_cpp/Browser.h" #include "computer_cpp/ControlSession.h" #include "computer_cpp/Platform.h" #include "computer_cpp/StringUtils.h" @@ -9,6 +13,8 @@ #include "DaemonParsing.h" #include "DaemonProtocol.h" +#include +#include #include #include #include @@ -61,6 +67,44 @@ Platform::WindowInfo WaitForOpenedWindow(const std::string& appQuery, const std: return Platform::GetActiveWindow(); } +std::vector BrowserWindowsForPid(int pid) { + std::vector matches; + for (auto& window : Platform::ListWindows("")) { + if (window.pid == pid) matches.push_back(std::move(window)); + } + return matches; +} + +Platform::WindowInfo WaitForBrowserWindow( + int pid, + const std::set& beforeIds, + bool requireNew +) { + Platform::WindowInfo fallback; + for (int attempt = 0; attempt < 40; ++attempt) { + for (const auto& window : BrowserWindowsForPid(pid)) { + if (!window.available || window.id.empty()) continue; + if (beforeIds.count(window.id) == 0) { + if (window.active) return window; + if (fallback.id.empty()) fallback = window; + } else if (!requireNew && window.active) { + return window; + } + } + std::this_thread::sleep_for(std::chrono::milliseconds(125)); + } + return requireNew ? Platform::WindowInfo{} : fallback; +} + +bool WaitForActivePid(int pid) { + if (pid <= 0) return false; + for (int attempt = 0; attempt < 70; ++attempt) { + if (Platform::GetFrontmostAppPid() == pid) return true; + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } + return false; +} + bool IsHttpUrl(const std::string& url) { return url.rfind("http://", 0) == 0 || url.rfind("https://", 0) == 0; } @@ -426,16 +470,17 @@ json RunAppActiveCommand() { json RunOpenUrlCommand(const json& params, const std::string& activeControlToken) { if (auto unknown = UnknownParam(params, { - "url", "browser", "newWindow", "newInstance", "controlSession", "controlSessionToken", "controlScope" + "url", "browser", "profile", "newWindow", "newInstance", "controlSession", "controlSessionToken", "controlScope" })) { return Error("unknown open_url parameter: " + *unknown, "invalid_url"); } auto urlParam = StringParam(params, "url", ""); - auto browserParam = StringParam(params, "browser", "firefox"); + auto browserParam = StringParam(params, "browser", ""); + auto profileParam = StringParam(params, "profile", ""); auto newWindow = BoolParam(params, "newWindow", true); auto newInstance = BoolParam(params, "newInstance", false); - if (!urlParam || !browserParam || !newWindow || !newInstance) { - return Error("open_url requires string url/browser and boolean newWindow/newInstance", "invalid_url"); + if (!urlParam || !browserParam || !profileParam || !newWindow || !newInstance) { + return Error("open_url requires string url/browser/profile and boolean newWindow/newInstance", "invalid_url"); } std::string url = *urlParam; if (IsBlank(url)) { @@ -444,10 +489,99 @@ json RunOpenUrlCommand(const json& params, const std::string& activeControlToken if (!IsHttpUrl(url)) { return Error("open_url requires http or https URL", "invalid_url"); } + if (std::any_of(url.begin(), url.end(), [](unsigned char ch) { + return std::iscntrl(ch) != 0; + })) { + return Error("open_url url must not contain control characters", "invalid_url"); + } std::string browser = *browserParam; if (params.contains("browser") && IsBlank(browser)) { return Error("open_url browser must be non-empty when provided", "invalid_url"); } + if (params.contains("profile") && !IsValidBrowserProfileName(*profileParam)) { + return Error("open_url profile must match [A-Za-z0-9][A-Za-z0-9._-]*", "invalid_url"); + } + + const std::string normalizedBrowser = NormalizeBrowserId(browser); + const bool explicitUnsupported = params.contains("browser") && + !IsSupportedBrowserId(normalizedBrowser); + std::string fallbackWarning; + if (!*newInstance && !explicitUnsupported) { + const ManagedBrowserSession session = ResolveManagedBrowserSession(params, true, true); + if (session.ok) { + if (session.pid <= 0) { + return Error("managed browser did not report its process id", "browser_pid_unavailable"); + } + auto beforeIds = VisibleWindowIds(BrowserWindowsForPid(session.pid)); + if (!Platform::ActivateAppByPid(session.pid) || + !WaitForActivePid(session.pid)) { + return Error("could not activate managed browser", "browser_focus_failed"); + } + Platform::WindowInfo activeWindow; + const bool createWindow = *newWindow && !session.launched; + if (createWindow) { + if (!Platform::SendHotkey({"primary", "n"}, 40)) { + return Error("could not create managed browser window", "browser_window_create_failed"); + } + activeWindow = WaitForBrowserWindow(session.pid, beforeIds, true); + if (!activeWindow.available) { + return Error("managed browser did not create a new window", "browser_window_create_failed"); + } + } else { + activeWindow = WaitForBrowserWindow(session.pid, {}, false); + if (!activeWindow.available) { + return Error("managed browser did not expose a window", "browser_window_unavailable"); + } + } + if (!Platform::SendHotkey({"primary", "l"}, 40) || + !Platform::TypeText(url, 1) || + !Platform::SendHotkey({"enter"}, 40)) { + return Error("could not navigate managed browser", "browser_navigation_failed"); + } + if (activeWindow.available && !activeWindow.id.empty()) { + RegisterControlSessionResource(activeControlToken, "window", + activeWindow.id, session.windowQuery, + WindowToJson(activeWindow)); + } + return Ok({ + {"url", url}, + {"browser", session.browser}, + {"profile", session.profile}, + {"managed", true}, + {"newWindow", *newWindow}, + {"newInstance", false}, + {"window", WindowToJson(activeWindow)} + }); + } + if (session.code == "invalid_browser_profile") { + return Error(session.error, session.code); + } + fallbackWarning = session.error; + browser.clear(); + } + + if (!explicitUnsupported) { + std::string configError; + const AppConfig config = LoadDaemonAppConfig(&configError); + if (!configError.empty()) { + if (fallbackWarning.empty()) fallbackWarning = configError; + browser.clear(); + } else { + const std::string browserId = params.contains("browser") + ? normalizedBrowser + : config.browser.defaultBrowser; + const BrowserDescriptor descriptor = DescribeBrowser(browserId); + if (descriptor.installed) { +#if defined(__APPLE__) + browser = descriptor.applicationName; +#else + browser = descriptor.executable; +#endif + } else { + browser.clear(); + } + } + } auto beforeIds = VisibleWindowIds(Platform::ListWindows(browser)); bool opened = Platform::OpenUrl(url, browser, *newWindow, *newInstance); if (!opened) { @@ -457,13 +591,17 @@ json RunOpenUrlCommand(const json& params, const std::string& activeControlToken if (activeWindow.available && !activeWindow.id.empty()) { RegisterControlSessionResource(activeControlToken, "window", activeWindow.id, browser, WindowToJson(activeWindow)); } - return Ok({ + json response = Ok({ {"url", url}, {"browser", browser}, + {"profile", ""}, + {"managed", false}, {"newWindow", *newWindow}, {"newInstance", *newInstance}, {"window", WindowToJson(activeWindow)} }); + if (!fallbackWarning.empty()) response["data"]["warning"] = fallbackWarning; + return response; } } // namespace ComputerCpp diff --git a/src/daemon/DaemonMetadata.cpp b/src/daemon/DaemonMetadata.cpp index 5a9e3dc..b59c845 100644 --- a/src/daemon/DaemonMetadata.cpp +++ b/src/daemon/DaemonMetadata.cpp @@ -175,10 +175,11 @@ json SchemaJson() { }}, {"openUrl", { {"url", "required non-empty http or https URL"}, - {"browser", "optional non-empty app name or bundle id; default firefox"}, + {"browser", "optional browser id or app name; defaults to config.toml browser.default"}, + {"profile", "optional managed browser profile; defaults to config.toml browser.profile"}, {"newWindow", "boolean; default true"}, {"newInstance", "boolean; default false"}, - {"response", "url, browser, newWindow, newInstance, and opened window metadata when available"} + {"response", "url, resolved browser/profile, managed, newWindow, newInstance, and opened window metadata when available"} }}, {"browserEval", { {"method", "browser_eval"}, @@ -188,10 +189,11 @@ json SchemaJson() { {"targetTitle", "optional exact Chrome DevTools page title that the target must match"}, {"targetFocused", "optional boolean requiring the selected page target to report document.hasFocus(); useful for binding native input to the same tab"}, {"browserContextId", "optional Chrome DevTools browser context id filter"}, - {"browser", "optional browser app name for launch attempt; default Google Chrome"}, + {"browser", "optional Chromium browser id; defaults to config.toml browser.default"}, + {"profile", "optional managed browser profile; defaults to config.toml browser.profile"}, {"host", "optional loopback Chrome DevTools host; default 127.0.0.1"}, - {"port", "optional Chrome DevTools port; default 9222"}, - {"launch", "boolean default true; attempts to start Chrome with --remote-debugging-port when endpoint is unavailable"}, + {"port", "optional explicit Chrome DevTools port; managed sessions otherwise use an ephemeral port"}, + {"launch", "boolean default true; starts the configured managed browser when unavailable"}, {"readOnly", "must be true; obvious DOM/input mutation snippets are rejected"}, {"response", "backend cdp, value, JavaScript result type, host, port, exact targetId/targetUrl/browserContextId, targetUrlPrefix, and browserPid when querying without a prefix"}, {"inputBoundary", "browser_eval is for inspection only; user-like input must use native click/type/press/mouse commands"} @@ -268,8 +270,12 @@ json SchemaJson() { "invalid_llm_request", "invalid_mouse_down", "invalid_mouse_drag", "invalid_mouse_move", "invalid_mouse_up", "invalid_permissions", "invalid_screenshot", "invalid_screenshot_region", "invalid_scroll", "invalid_snapshot", "invalid_target", "invalid_type", "invalid_url", - "invalid_wait", "invalid_window", "browser_debug_invalid_response", "browser_debug_unavailable", - "browser_eval_failed", "browser_eval_timeout", "browser_target_not_found", "missing_api_key", "open_url_failed", + "invalid_wait", "invalid_window", "invalid_browser_profile", "browser_config_invalid", + "browser_automation_unavailable", "browser_debug_invalid_response", "browser_debug_unavailable", + "browser_eval_failed", "browser_eval_timeout", "browser_focus_failed", "browser_launch_failed", + "browser_navigation_failed", "browser_pid_unavailable", "browser_profile_in_use", "browser_target_not_found", + "browser_window_create_failed", "browser_window_unavailable", + "missing_api_key", "open_url_failed", "permission_or_capture_failed", "target_not_found", "unknown_method", "unsupported_visual_target", "wait_timeout", "window_close_failed" }} diff --git a/src/platform/linux/PlatformLinux.cpp b/src/platform/linux/PlatformLinux.cpp index 9b5dff0..1d99c6c 100644 --- a/src/platform/linux/PlatformLinux.cpp +++ b/src/platform/linux/PlatformLinux.cpp @@ -1227,7 +1227,10 @@ bool OpenUrl(const std::string& url, const std::string& browser, bool newWindow, if (url.empty()) { return false; } - std::string chosen = browser.empty() ? "firefox" : browser; + if (browser.empty()) { + return SpawnDetached({"xdg-open", url}); + } + std::string chosen = browser; if (chosen == "firefox") { chosen = "firefox-esr"; } diff --git a/src/platform/macos/PlatformMac.mm b/src/platform/macos/PlatformMac.mm index cb4bfa6..728fe6d 100644 --- a/src/platform/macos/PlatformMac.mm +++ b/src/platform/macos/PlatformMac.mm @@ -574,7 +574,9 @@ bool IsContentRole(const std::string& role) { return role == "AXStaticText" || role == "AXHeading" || role == "AXCell" || - role == "AXRow"; + role == "AXRow" || + role == "AXDialog" || + role == "AXSheet"; } std::string RoleLabel(const std::string& role) { diff --git a/tests/CliTests.cpp b/tests/CliTests.cpp index 83489c6..57c594d 100644 --- a/tests/CliTests.cpp +++ b/tests/CliTests.cpp @@ -442,6 +442,8 @@ void TestCliCommandBuilders() { "https://example.com", "--browser", "Safari", + "--profile", + "work", "--no-new-window", "--new-instance" }); @@ -449,6 +451,7 @@ void TestCliCommandBuilders() { assert(openUrl.method == "open_url"); assert(openUrl.params["url"] == "https://example.com"); assert(openUrl.params["browser"] == "Safari"); + assert(openUrl.params["profile"] == "work"); assert(openUrl.params["newWindow"] == false); assert(openUrl.params["newInstance"] == true); auto routedOpenUrl = ComputerCpp::Cli::BuildDaemonCommand({"open", "url", "https://example.com"}); @@ -2992,10 +2995,30 @@ void TestConfigCliCanonicalFile() { }); assert(profile.exitCode == 0); + const std::string browserUserDataDir = + (ComputerCpp::AppDataDir() / "cli-browser-data").string(); + auto browser = RunConfigCommand({ + "config", + "set-browser", + "--browser", + "brave", + "--profile", + "recruiting-1", + "--user-data-dir", + browserUserDataDir, + "--proxy", + "https://proxy.example:8001" + }); + assert(browser.exitCode == 0); + std::string error; auto config = ComputerCpp::LoadAppConfig(&error); assert(error.empty()); assert(config.defaultProfile == "vision"); + assert(config.browser.defaultBrowser == "brave"); + assert(config.browser.profile == "recruiting-1"); + assert(config.browser.userDataDir == browserUserDataDir); + assert(config.browser.proxyServer == "https://proxy.example:8001"); assert(config.providers["router"].type == "openrouter"); assert(config.providers["router"].baseUrl == "https://openrouter.ai/api/v1"); assert(config.profiles["vision"].provider == "router"); @@ -3007,6 +3030,31 @@ void TestConfigCliCanonicalFile() { assert(config.profiles["vision"].params["parallel_tool_calls"] == true); assert(config.profiles["vision"].openRouterProvider["allow_fallbacks"] == false); + auto invalidBrowser = RunConfigCommand({ + "config", "set-browser", "--browser", "firefox"}); + assert(invalidBrowser.exitCode == 2); + auto invalidBrowserProfile = RunConfigCommand({ + "config", "set-browser", "--profile", "../personal"}); + assert(invalidBrowserProfile.exitCode == 2); + auto invalidBrowserUserDataDir = RunConfigCommand({ + "config", "set-browser", "--user-data-dir", "relative/path"}); + assert(invalidBrowserUserDataDir.exitCode == 2); + auto invalidBrowserProxy = RunConfigCommand({ + "config", "set-browser", "--proxy", "https://proxy invalid"}); + assert(invalidBrowserProxy.exitCode == 2); + auto clearBrowserUserDataDir = RunConfigCommand({ + "config", "set-browser", "--no-user-data-dir"}); + assert(clearBrowserUserDataDir.exitCode == 0); + config = ComputerCpp::LoadAppConfig(&error); + assert(error.empty()); + assert(config.browser.userDataDir.empty()); + auto clearBrowserProxy = RunConfigCommand({ + "config", "set-browser", "--no-proxy"}); + assert(clearBrowserProxy.exitCode == 0); + config = ComputerCpp::LoadAppConfig(&error); + assert(error.empty()); + assert(config.browser.proxyServer.empty()); + auto gobii = RunConfigCommand({ "config", "set-gobii", @@ -3377,6 +3425,24 @@ void TestMicroAgentLuaDryRun() { assert(data["trace_count"] >= 3); } +void TestBrowserOpenLuaDryRun() { + if (SkipLuaTestIfUnavailable("TestBrowserOpenLuaDryRun")) return; + ComputerCpp::LuaRunOptions options; + options.scriptPath = RepoRoot() / "tests/lua/browser-open-dry-run.lua"; + options.dryRun = true; + options.jsonOutput = true; + const auto result = ComputerCpp::RunLuaScriptCapture(options); + AssertLuaRunSucceeded(result); + const auto payload = nlohmann::json::parse(result.stdoutText); + const auto& data = payload["data"]["result"]; + assert(data["browser"] == "Safari"); + assert(data["new_instance"] == true); + assert(data["default_url"] == "https://example.net"); + assert(!data.contains("default_browser")); + assert(!data.contains("default_profile")); + assert(data["default_new_window"] == true); +} + void TestMicroAgentStrictToolCallsLuaDryRun() { if (SkipLuaTestIfUnavailable("TestMicroAgentStrictToolCallsLuaDryRun")) { return; @@ -4036,6 +4102,7 @@ void RunCliTests() { TestRecordingSurfaceMetadata(); TestCliCommandRecordingMetadata(); TestMicroAgentLuaDryRun(); + TestBrowserOpenLuaDryRun(); TestMicroAgentStrictToolCallsLuaDryRun(); TestMicroAgentRuntimeLuaDryRun(); TestLuaApprovalContextDryRun(); diff --git a/tests/CoreTests.cpp b/tests/CoreTests.cpp index 643a659..06a5698 100644 --- a/tests/CoreTests.cpp +++ b/tests/CoreTests.cpp @@ -1,5 +1,6 @@ #include "computer_cpp/AppConfig.h" #include "computer_cpp/AppPaths.h" +#include "computer_cpp/Browser.h" #include "computer_cpp/CommandRecording.h" #include "computer_cpp/HumanInput.h" #include "computer_cpp/Image.h" @@ -65,6 +66,40 @@ void TestStringUtils() { assert(ComputerCpp::Join(keys, ",") == "Cmd,Shift,G"); } +void TestBrowserRegistry() { + assert(ComputerCpp::NormalizeBrowserId("Google Chrome") == "chrome"); + assert(ComputerCpp::NormalizeBrowserId("msedge.exe") == "edge"); + assert(ComputerCpp::NormalizeBrowserId("Brave Browser") == "brave"); + assert(ComputerCpp::NormalizeBrowserId("../../not-a-browser").empty()); + const auto catalog = ComputerCpp::BrowserCatalog(); + assert(catalog.size() == 4); + assert(catalog.front().id == "chrome"); + assert(catalog.front().displayName == "Google Chrome"); + assert(catalog.front().recommended); + assert(!catalog[1].recommended); +#if defined(__linux__) + assert(catalog.front().windowQuery == "google-chrome"); +#else + assert(!catalog.front().windowQuery.empty()); +#endif + + assert(ComputerCpp::ManagedBrowserDataDir("chrome", "default") == + ComputerCpp::AppDataDir() / "chrome-cdp"); + assert(ComputerCpp::ManagedBrowserDataDir("brave", "work") == + ComputerCpp::AppDataDir() / "browser-profiles" / "brave" / "work"); + assert(ComputerCpp::ManagedBrowserDataDir("../../bad", "work").empty()); + assert(ComputerCpp::ManagedBrowserDataDir("chrome", "../bad").empty()); + + const fs::path privateDir = ComputerCpp::AppDataDir() / "browser-mode-test"; + ComputerCpp::PrepareManagedBrowserDataDir(privateDir); + assert(fs::is_directory(privateDir)); +#if !defined(_WIN32) + const auto permissions = fs::status(privateDir).permissions(); + assert((permissions & fs::perms::owner_all) == fs::perms::owner_all); + assert((permissions & (fs::perms::group_all | fs::perms::others_all)) == fs::perms::none); +#endif +} + void TestAppConfigServerRoundTrip() { std::string missingError; ComputerCpp::AppConfig missing = ComputerCpp::LoadAppConfig(&missingError); @@ -74,6 +109,10 @@ void TestAppConfigServerRoundTrip() { assert(missing.server.apps.empty()); assert(!missing.recording.enabled); assert(missing.recording.retentionDays == 14); + assert(missing.browser.defaultBrowser == "chrome"); + assert(missing.browser.profile == "default"); + assert(missing.browser.userDataDir.empty()); + assert(missing.browser.proxyServer.empty()); ComputerCpp::AppConfig defaults = ComputerCpp::DefaultAppConfig(); assert(defaults.server.host == "127.0.0.1"); @@ -86,6 +125,11 @@ void TestAppConfigServerRoundTrip() { config.server.authToken = "test-token"; config.server.allowedOrigins = {"https://mcp.example.com", "http://127.0.0.1:3000"}; config.recording.enabled = true; + config.browser.defaultBrowser = "edge"; + config.browser.profile = "work_1"; + config.browser.userDataDir = + (ComputerCpp::AppDataDir() / "custom-browser-data").string(); + config.browser.proxyServer = "https://proxy.example:8001"; ComputerCpp::ServerAppConfig linkedin; linkedin.name = "linkedin"; @@ -102,6 +146,12 @@ void TestAppConfigServerRoundTrip() { assert(toml.find("[recording]") != std::string::npos); assert(toml.find("enabled = true") != std::string::npos); assert(toml.find("retention_days = 14") != std::string::npos); + assert(toml.find("[browser]") != std::string::npos); + assert(toml.find("default = \"edge\"") != std::string::npos); + assert(toml.find("profile = \"work_1\"") != std::string::npos); + assert(toml.find("user_data_dir = ") != std::string::npos); + assert(toml.find("proxy = \"https://proxy.example:8001\"") != + std::string::npos); std::string error; assert(ComputerCpp::SaveAppConfig(config, &error)); @@ -116,6 +166,10 @@ void TestAppConfigServerRoundTrip() { assert(loaded.server.apps["linkedin"].path == "/tmp/linkedin-recruiter.lua"); assert(loaded.recording.enabled); assert(loaded.recording.retentionDays == 14); + assert(loaded.browser.defaultBrowser == "edge"); + assert(loaded.browser.profile == "work_1"); + assert(loaded.browser.userDataDir == config.browser.userDataDir); + assert(loaded.browser.proxyServer == config.browser.proxyServer); auto redacted = ComputerCpp::AppConfigToJson(loaded); assert(redacted["server"]["authToken"] == ""); @@ -123,6 +177,15 @@ void TestAppConfigServerRoundTrip() { assert(visible["server"]["authToken"] == "test-token"); assert(visible["server"]["port"] == 8790); assert(!visible["server"].contains("basePort")); + assert(visible["browser"]["default"] == "edge"); + assert(visible["browser"]["profile"] == "work_1"); + assert(visible["browser"]["userDataDir"] == config.browser.userDataDir); + assert(visible["browser"]["proxy"] == config.browser.proxyServer); + assert(ComputerCpp::IsSupportedBrowserId("chrome")); + assert(ComputerCpp::IsSupportedBrowserId("brave")); + assert(!ComputerCpp::IsSupportedBrowserId("firefox")); + assert(ComputerCpp::IsValidBrowserProfileName("qa.profile-1")); + assert(!ComputerCpp::IsValidBrowserProfileName("../profile")); assert(!visible["server"]["apps"]["linkedin"].contains("port")); assert(visible["recording"]["enabled"] == true); assert(visible["recording"]["retentionDays"] == 14); @@ -199,6 +262,61 @@ void TestServerPortConfigMigration() { assert(ComputerCpp::SaveAppConfig(original, &error)); } +void TestBrowserConfigValidation() { + std::string error; + const ComputerCpp::AppConfig original = ComputerCpp::LoadAppConfig(&error); + assert(error.empty()); + { + std::ofstream config(ComputerCpp::ConfigPath(), std::ios::trunc); + config << "version = 1\n\n" + << "[browser]\n" + << "default = \"firefox\"\n" + << "profile = \"default\"\n"; + } + ComputerCpp::LoadAppConfig(&error); + assert(error.find("browser.default") != std::string::npos); + { + std::ofstream config(ComputerCpp::ConfigPath(), std::ios::trunc); + config << "version = 1\n\n" + << "[browser]\n" + << "default = \"chrome\"\n" + << "profile = \"../personal\"\n"; + } + ComputerCpp::LoadAppConfig(&error); + assert(error.find("browser.profile") != std::string::npos); + { + std::ofstream config(ComputerCpp::ConfigPath(), std::ios::trunc); + config << "version = 1\n\n" + << "[browser]\n" + << "default = \"chrome\"\n" + << "profile = \"default\"\n" + << "user_data_dir = \"relative/path\"\n"; + } + ComputerCpp::LoadAppConfig(&error); + assert(error.find("browser.user_data_dir") != std::string::npos); + { + std::ofstream config(ComputerCpp::ConfigPath(), std::ios::trunc); + config << "version = 1\n\n" + << "[browser]\n" + << "default = \"chrome\"\n" + << "profile = \"default\"\n" + << "proxy = \"https://proxy.example:80 invalid\"\n"; + } + ComputerCpp::LoadAppConfig(&error); + assert(error.find("browser.proxy") != std::string::npos); + { + std::ofstream config(ComputerCpp::ConfigPath(), std::ios::trunc); + config << "version = 1\n"; + } + const auto legacy = ComputerCpp::LoadAppConfig(&error); + assert(error.empty()); + assert(legacy.browser.defaultBrowser == "chrome"); + assert(legacy.browser.profile == "default"); + assert(legacy.browser.userDataDir.empty()); + assert(legacy.browser.proxyServer.empty()); + assert(ComputerCpp::SaveAppConfig(original, &error)); +} + class FakeScreenRecordingSession final : public ComputerCpp::Platform::ScreenRecordingSession { public: explicit FakeScreenRecordingSession(bool stopSucceeds) @@ -1071,9 +1189,11 @@ int main() { SetEnvValue("COMPUTER_CPP_HOME", tempHome.string()); RunTest("StringUtils", TestStringUtils); + RunTest("BrowserRegistry", TestBrowserRegistry); RunTest("AppConfigServerRoundTrip", TestAppConfigServerRoundTrip); RunTest("ServerAppNameValidation", TestServerAppNameValidation); RunTest("ServerPortConfigMigration", TestServerPortConfigMigration); + RunTest("BrowserConfigValidation", TestBrowserConfigValidation); RunTest("CommandRecordingLifecycle", TestCommandRecordingLifecycle); RunTest("NativeCommandRecordingSmoke", TestNativeCommandRecordingSmoke); RunTest("TrayServerState", TestTrayServerState); diff --git a/tests/DaemonDispatchTests.cpp b/tests/DaemonDispatchTests.cpp index d5d201e..578e2ca 100644 --- a/tests/DaemonDispatchTests.cpp +++ b/tests/DaemonDispatchTests.cpp @@ -222,6 +222,7 @@ void TestDaemonDispatch() { assert(browserEvalSchema.find("exact Chrome DevTools page target id") != std::string::npos); assert(browserEvalSchema.find("document.hasFocus") != std::string::npos); assert(browserEvalSchema.find("native click/type/press/mouse") != std::string::npos); + assert(browserEvalSchema.find("ephemeral port") != std::string::npos); auto batchSchema = schema["data"]["batch"].dump(); assert(batchSchema.find("CLI reads the array from stdin") != std::string::npos); assert(batchSchema.find("--continue-on-error") != std::string::npos); @@ -267,6 +268,26 @@ void TestDaemonDispatch() { }); assert(invalidFocusedBrowserEval["ok"] == false); assert(invalidFocusedBrowserEval["code"] == "invalid_browser_eval"); + auto unsupportedBrowserEval = ComputerCpp::HandleDaemonRequest("unit", { + {"method", "browser_eval"}, + {"params", { + {"script", "document.title"}, + {"browser", "Safari"}, + {"launch", false} + }} + }); + assert(unsupportedBrowserEval["ok"] == false); + assert(unsupportedBrowserEval["code"] == "browser_automation_unavailable"); + auto invalidBrowserProfile = ComputerCpp::HandleDaemonRequest("unit", { + {"method", "browser_eval"}, + {"params", { + {"script", "document.title"}, + {"profile", "../personal"}, + {"launch", false} + }} + }); + assert(invalidBrowserProfile["ok"] == false); + assert(invalidBrowserProfile["code"] == "invalid_browser_eval"); assert(batchSchema.find("requested, executed, failed") != std::string::npos); auto targetSchema = schema["data"]["target"].dump(); assert(targetSchema.find("rect:left,top,right,bottom") != std::string::npos); @@ -340,7 +361,9 @@ void TestDaemonDispatch() { assert(llmSchema.find("raw provider JSON") != std::string::npos); auto openUrlSchema = schema["data"]["openUrl"].dump(); assert(openUrlSchema.find("http or https URL") != std::string::npos); - assert(openUrlSchema.find("default firefox") != std::string::npos); + assert(openUrlSchema.find("browser.default") != std::string::npos); + assert(openUrlSchema.find("managed") != std::string::npos); + assert(openUrlSchema.find("profile") != std::string::npos); assert(openUrlSchema.find("newWindow") != std::string::npos); assert(openUrlSchema.find("newInstance") != std::string::npos); assert(openUrlSchema.find("opened window metadata") != std::string::npos); diff --git a/tests/DaemonTests.cpp b/tests/DaemonTests.cpp index a4e59d9..603d54a 100644 --- a/tests/DaemonTests.cpp +++ b/tests/DaemonTests.cpp @@ -228,6 +228,13 @@ void TestDaemonDesktopVisibleWindowIds() { assert(ids.count("window-1") == 1); } +void TestOpenUrlRejectsControlCharacters() { + const auto response = ComputerCpp::RunOpenUrlCommand( + {{"url", "https://example.test/path\nsecond-command"}}, ""); + assert(response["ok"] == false); + assert(response["code"] == "invalid_url"); +} + void TestDaemonTargetTextScoring() { assert(ComputerCpp::HasRemovedVisualTargetPrefix("text:\"Open Settings\"")); assert(ComputerCpp::HasRemovedVisualTargetPrefix("field: Search ")); @@ -312,6 +319,7 @@ void RunDaemonTests() { TestDaemonTargetResolveCoordinates(); TestDaemonInputKeyChordParsing(); TestDaemonDesktopVisibleWindowIds(); + TestOpenUrlRejectsControlCharacters(); TestDaemonTargetTextScoring(); TestDaemonTargetRefCandidates(); TestDaemonRemovedVisualTargets(); diff --git a/tests/lua/browser-open-dry-run.lua b/tests/lua/browser-open-dry-run.lua index 8b2d543..9c29851 100644 --- a/tests/lua/browser-open-dry-run.lua +++ b/tests/lua/browser-open-dry-run.lua @@ -12,8 +12,11 @@ ac.browser.open("https://example.org", { no_new_instance = true, }) +ac.browser.open("https://example.net") + local params = ac.trace[1].steps[1].params local no_params = ac.trace[2].steps[1].params +local default_params = ac.trace[3].steps[1].params return { method = ac.trace[1].steps[1].method, url = params.url, @@ -25,4 +28,8 @@ return { no_browser = no_params.browser, no_new_window = no_params.newWindow, no_new_instance = no_params.newInstance, + default_url = default_params.url, + default_browser = default_params.browser, + default_profile = default_params.profile, + default_new_window = default_params.newWindow, }