diff --git a/assets/settings.html b/assets/settings.html
index a3cb8cc..42728b2 100644
--- a/assets/settings.html
+++ b/assets/settings.html
@@ -444,6 +444,96 @@
cursor: default;
}
+ /* Location Spoofing styles */
+ .location-row {
+ margin: 16px 0;
+ padding: 20px;
+ border-radius: 12px;
+ background: var(--bg-secondary);
+ border: 1px solid var(--border-color);
+ }
+
+ .location-label {
+ font-size: 14px;
+ font-weight: 600;
+ margin-bottom: 12px;
+ color: var(--text-primary);
+ }
+
+ .location-inputs {
+ display: flex;
+ gap: 16px;
+ margin-bottom: 12px;
+ }
+
+ .location-field {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+ }
+
+ .location-field label {
+ font-size: 11px;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+ color: var(--text-secondary);
+ }
+
+ .location-input {
+ width: 100%;
+ padding: 10px 12px;
+ border-radius: 8px;
+ border: 1px solid var(--border-color);
+ background: var(--bg-tertiary);
+ color: var(--text-primary);
+ font-size: 13px;
+ font-family: 'Consolas', 'Courier New', monospace;
+ }
+
+ .location-input:focus {
+ outline: none;
+ border-color: var(--accent-color);
+ }
+
+ .location-input:disabled {
+ opacity: 0.5;
+ cursor: default;
+ }
+
+ .location-help {
+ font-size: 12px;
+ color: var(--text-tertiary);
+ }
+
+ .location-presets {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-top: 12px;
+ }
+
+ .location-preset {
+ padding: 6px 12px;
+ border-radius: 6px;
+ background: var(--bg-tertiary);
+ border: 1px solid var(--border-color);
+ color: var(--text-secondary);
+ font-size: 11px;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ }
+
+ .location-preset:hover {
+ background: var(--bg-hover);
+ color: var(--text-primary);
+ }
+
+ .location-preset:disabled {
+ opacity: 0.5;
+ cursor: default;
+ }
+
/* Dark Theme Excluded Sites styles */
.dark-sites-row {
margin: 32px 0 24px;
@@ -560,6 +650,29 @@
Enter URL patterns for sites where dark theme should be disabled (one per line). Supports wildcards (*) and comments starting with #. Browser internal pages (settings, history, etc.) are automatically excluded.
+
+
Target User Agent String
@@ -611,7 +724,8 @@
DRM WebView
{ key: 'enable_javascript', name: 'Enable JavaScript', description: 'Allow JavaScript execution' },
{ key: 'enable_web_security', name: 'Web Security', description: 'Enforce same-origin policy' },
{ key: 'block_third_party_cookies', name: 'Block Third-Party Cookies', description: 'Prevent cross-site tracking' },
- { key: 'do_not_track', name: 'Do Not Track', description: 'Send DNT header' }
+ { key: 'do_not_track', name: 'Do Not Track', description: 'Send DNT header' },
+ { key: 'enable_location_spoofing', name: 'Location Spoofing', description: 'Override GPS coordinates sent to websites' }
]
},
drm: {
@@ -667,6 +781,8 @@
DRM WebView
let currentSettings = {}, originalSettings = {}, isDirty = false;
let targetUserAgent = '', originalTargetUserAgent = '';
let darkThemeExcludedSites = '', originalDarkThemeExcludedSites = '';
+ let spoofedLatitude = 0, originalSpoofedLatitude = 0;
+ let spoofedLongitude = 0, originalSpoofedLongitude = 0;
const drmElements = {};
let drmStatusSnapshot = null;
@@ -732,6 +848,21 @@
DRM WebView
darkThemeExcludedSites = '';
originalDarkThemeExcludedSites = '';
}
+ // Hydrate location spoofing coordinates from payload
+ if (typeof data.spoofed_latitude === 'number') {
+ spoofedLatitude = data.spoofed_latitude;
+ originalSpoofedLatitude = data.spoofed_latitude;
+ } else {
+ spoofedLatitude = 0;
+ originalSpoofedLatitude = 0;
+ }
+ if (typeof data.spoofed_longitude === 'number') {
+ spoofedLongitude = data.spoofed_longitude;
+ originalSpoofedLongitude = data.spoofed_longitude;
+ } else {
+ spoofedLongitude = 0;
+ originalSpoofedLongitude = 0;
+ }
if (data.meta && data.meta.storage_path) {
document.getElementById('storage-path').textContent = 'Storage: ' + data.meta.storage_path;
}
@@ -746,6 +877,7 @@
DRM WebView
renderSettings();
hydrateUserAgentEditor();
hydrateDarkThemeExcludedSitesEditor();
+ hydrateLocationEditor();
updateDirtyState();
loadDrmStatus(true);
}
@@ -842,6 +974,12 @@
DRM WebView
if (!isDirty && darkThemeExcludedSites !== originalDarkThemeExcludedSites) {
isDirty = true;
}
+ if (!isDirty && spoofedLatitude !== originalSpoofedLatitude) {
+ isDirty = true;
+ }
+ if (!isDirty && spoofedLongitude !== originalSpoofedLongitude) {
+ isDirty = true;
+ }
document.getElementById('btn-save').disabled = !isDirty;
}
@@ -852,6 +990,8 @@
DRM WebView
originalSettings = { ...currentSettings };
originalTargetUserAgent = targetUserAgent;
originalDarkThemeExcludedSites = darkThemeExcludedSites;
+ originalSpoofedLatitude = spoofedLatitude;
+ originalSpoofedLongitude = spoofedLongitude;
isDirty = false;
document.getElementById('btn-save').disabled = true;
log('Saved');
@@ -881,9 +1021,25 @@
DRM WebView
darkThemeExcludedSites = '';
originalDarkThemeExcludedSites = '';
}
+ // Reset location spoofing coordinates to defaults
+ if (typeof data.spoofed_latitude === 'number') {
+ spoofedLatitude = data.spoofed_latitude;
+ originalSpoofedLatitude = data.spoofed_latitude;
+ } else {
+ spoofedLatitude = 0;
+ originalSpoofedLatitude = 0;
+ }
+ if (typeof data.spoofed_longitude === 'number') {
+ spoofedLongitude = data.spoofed_longitude;
+ originalSpoofedLongitude = data.spoofed_longitude;
+ } else {
+ spoofedLongitude = 0;
+ originalSpoofedLongitude = 0;
+ }
renderSettings();
hydrateUserAgentEditor();
hydrateDarkThemeExcludedSitesEditor();
+ hydrateLocationEditor();
updateDirtyState();
loadDrmStatus(true);
log('Restored');
@@ -934,6 +1090,76 @@
DRM WebView
};
}
+ function hydrateLocationEditor() {
+ const latInput = document.getElementById('location-lat');
+ const lngInput = document.getElementById('location-lng');
+ const presetButtons = document.querySelectorAll('.location-preset');
+
+ if (latInput) {
+ latInput.value = spoofedLatitude || 0;
+ const locationEnabled = !!currentSettings.enable_location_spoofing;
+ latInput.disabled = !locationEnabled;
+ latInput.oninput = null;
+ latInput.oninput = () => {
+ let val = parseFloat(latInput.value) || 0;
+ val = Math.max(-90, Math.min(90, val));
+ spoofedLatitude = val;
+ if (typeof window.OnUpdateSetting === 'function') {
+ window.OnUpdateSetting('spoofed_latitude', val);
+ }
+ updateDirtyState();
+ const autoSave = !!currentSettings.auto_save_settings;
+ if (autoSave && typeof window.OnSaveSettings === 'function') {
+ window.OnSaveSettings();
+ }
+ };
+ }
+
+ if (lngInput) {
+ lngInput.value = spoofedLongitude || 0;
+ const locationEnabled = !!currentSettings.enable_location_spoofing;
+ lngInput.disabled = !locationEnabled;
+ lngInput.oninput = null;
+ lngInput.oninput = () => {
+ let val = parseFloat(lngInput.value) || 0;
+ val = Math.max(-180, Math.min(180, val));
+ spoofedLongitude = val;
+ if (typeof window.OnUpdateSetting === 'function') {
+ window.OnUpdateSetting('spoofed_longitude', val);
+ }
+ updateDirtyState();
+ const autoSave = !!currentSettings.auto_save_settings;
+ if (autoSave && typeof window.OnSaveSettings === 'function') {
+ window.OnSaveSettings();
+ }
+ };
+ }
+
+ // Handle preset buttons
+ presetButtons.forEach(btn => {
+ const locationEnabled = !!currentSettings.enable_location_spoofing;
+ btn.disabled = !locationEnabled;
+ btn.onclick = null;
+ btn.onclick = () => {
+ const lat = parseFloat(btn.dataset.lat) || 0;
+ const lng = parseFloat(btn.dataset.lng) || 0;
+ spoofedLatitude = lat;
+ spoofedLongitude = lng;
+ if (latInput) latInput.value = lat;
+ if (lngInput) lngInput.value = lng;
+ if (typeof window.OnUpdateSetting === 'function') {
+ window.OnUpdateSetting('spoofed_latitude', lat);
+ window.OnUpdateSetting('spoofed_longitude', lng);
+ }
+ updateDirtyState();
+ const autoSave = !!currentSettings.auto_save_settings;
+ if (autoSave && typeof window.OnSaveSettings === 'function') {
+ window.OnSaveSettings();
+ }
+ };
+ });
+ }
+
// Search helpers
function applySettingsSearch(term) {
window.__settingsSearchTerm = (term || '').toString();
diff --git a/src/Settings.cpp b/src/Settings.cpp
index eeb9a82..f64d22f 100644
--- a/src/Settings.cpp
+++ b/src/Settings.cpp
@@ -78,6 +78,39 @@ namespace
}
return result;
}
+
+ double ParseDoubleLenient(const std::string &buffer, const std::string &key, double fallback)
+ {
+ if (key.empty())
+ return fallback;
+ std::string needle = std::string("\"") + key + "\"";
+ auto pos = buffer.find(needle);
+ if (pos == std::string::npos)
+ return fallback;
+ pos = buffer.find(':', pos + needle.size());
+ if (pos == std::string::npos)
+ return fallback;
+ ++pos;
+ while (pos < buffer.size() && std::isspace(static_cast
(buffer[pos])))
+ ++pos;
+ if (pos >= buffer.size())
+ return fallback;
+ // Parse number (may be negative, may have decimal point)
+ std::string numStr;
+ while (pos < buffer.size() && (buffer[pos] == '-' || buffer[pos] == '+' || buffer[pos] == '.' ||
+ std::isdigit(static_cast(buffer[pos]))))
+ {
+ numStr += buffer[pos];
+ ++pos;
+ }
+ if (numStr.empty())
+ return fallback;
+ try {
+ return std::stod(numStr);
+ } catch (...) {
+ return fallback;
+ }
+ }
}
void SettingsManager::EnsureDataDirectoryExists()
@@ -147,6 +180,9 @@ bool SettingsManager::LoadSettingsFromDisk(UI &ui)
// Parse string settings
ui.settings_.custom_user_agent = ParseStringLenient(content, "custom_user_agent", "");
ui.settings_.dark_theme_excluded_sites = ParseStringLenient(content, "dark_theme_excluded_sites", "");
+ // Parse location spoofing coordinates
+ ui.settings_.spoofed_latitude = ParseDoubleLenient(content, "spoofed_latitude", 0.0);
+ ui.settings_.spoofed_longitude = ParseDoubleLenient(content, "spoofed_longitude", 0.0);
ui.settings_storage_path_ = (migrated ? legacy_path.string() : primary_path.string());
}
else
@@ -182,6 +218,8 @@ bool SettingsManager::SaveSettingsToDisk(UI &ui)
doc << " \"values\": " << ui.BuildSettingsJSON() << ",\n";
doc << " \"custom_user_agent\": \"" << util::EscapeJsonString(ui.settings_.custom_user_agent) << "\",\n";
doc << " \"dark_theme_excluded_sites\": \"" << util::EscapeJsonString(ui.settings_.dark_theme_excluded_sites) << "\",\n";
+ doc << " \"spoofed_latitude\": " << ui.settings_.spoofed_latitude << ",\n";
+ doc << " \"spoofed_longitude\": " << ui.settings_.spoofed_longitude << ",\n";
doc << " \"meta\": {\n";
doc << " \"updated_at\": \"" << util::ToIso8601UTC(std::chrono::system_clock::now()) << "\",\n";
doc << " \"dirty\": false,\n";
diff --git a/src/Tab.cpp b/src/Tab.cpp
index 5463dbb..62b1830 100644
--- a/src/Tab.cpp
+++ b/src/Tab.cpp
@@ -460,6 +460,70 @@ void Tab::OnWindowObjectReady(View *caller, uint64_t frame_id, bool is_main_fram
})();
)JS";
caller->EvaluateScript(String(cryptoPolyfill), nullptr);
+
+ // Inject location spoofing if enabled in settings
+ // This overrides navigator.geolocation to report custom coordinates
+ if (ui_->location_spoofing_enabled())
+ {
+ double lat = ui_->spoofed_latitude();
+ double lng = ui_->spoofed_longitude();
+ std::ostringstream geoScript;
+ geoScript << R"JS(
+(function() {
+ 'use strict';
+ var spoofedLat = )JS" << lat << R"JS(;
+ var spoofedLng = )JS" << lng << R"JS(;
+
+ // Create a fake GeolocationPosition object
+ function createPosition() {
+ return {
+ coords: {
+ latitude: spoofedLat,
+ longitude: spoofedLng,
+ accuracy: 10,
+ altitude: null,
+ altitudeAccuracy: null,
+ heading: null,
+ speed: null
+ },
+ timestamp: Date.now()
+ };
+ }
+
+ // Override getCurrentPosition
+ var originalGetCurrentPosition = navigator.geolocation.getCurrentPosition;
+ navigator.geolocation.getCurrentPosition = function(success, error, options) {
+ console.log('[Ultralight] Geolocation spoofed to:', spoofedLat, spoofedLng);
+ setTimeout(function() {
+ success(createPosition());
+ }, 100);
+ };
+
+ // Override watchPosition
+ var watchId = 0;
+ var watches = {};
+ navigator.geolocation.watchPosition = function(success, error, options) {
+ var id = ++watchId;
+ console.log('[Ultralight] Geolocation watch spoofed to:', spoofedLat, spoofedLng);
+ watches[id] = setInterval(function() {
+ success(createPosition());
+ }, 1000);
+ return id;
+ };
+
+ // Override clearWatch
+ navigator.geolocation.clearWatch = function(id) {
+ if (watches[id]) {
+ clearInterval(watches[id]);
+ delete watches[id];
+ }
+ };
+
+ console.log('[Ultralight] Location spoofing enabled:', spoofedLat, spoofedLng);
+})();
+)JS";
+ caller->EvaluateScript(String(geoScript.str().c_str()), nullptr);
+ }
// Inject Do Not Track (DNT) header simulation if enabled in settings
// This overrides navigator.doNotTrack to report the user's preference
diff --git a/src/UI.cpp b/src/UI.cpp
index 7f974ce..9c208b9 100644
--- a/src/UI.cpp
+++ b/src/UI.cpp
@@ -58,7 +58,7 @@ namespace
bool default_value;
};
- constexpr std::array kFallbackSettingsCatalog = {
+ constexpr std::array kFallbackSettingsCatalog = {
// Appearance
SettingDescriptor{"launch_dark_theme", "Launch in dark theme",
"Start Ultralight with dark chrome, toolbars, and tabs by default.",
@@ -169,7 +169,12 @@ namespace
// Networking / User Agent
SettingDescriptor{"use_custom_user_agent", "Use custom user agent",
"When enabled, send a user agent string that you specify instead of the automatic Chromium-like default.",
- "privacy", nullptr, false, &UI::BrowserSettings::use_custom_user_agent, false}};
+ "privacy", nullptr, false, &UI::BrowserSettings::use_custom_user_agent, false},
+
+ // Location Spoofing
+ SettingDescriptor{"enable_location_spoofing", "Location Spoofing",
+ "Override navigator.geolocation to report custom GPS coordinates instead of your real location.",
+ "privacy", nullptr, false, &UI::BrowserSettings::enable_location_spoofing, false}};
struct ParsedCatalogEntry
{
@@ -3135,6 +3140,55 @@ void UI::OnUpdateSetting(const JSObject &, const JSArgs &args)
UpdateSettingsDirtyFlag();
return;
}
+
+ // Special-case: spoofed_latitude is a numeric value for location spoofing
+ if (key == "spoofed_latitude")
+ {
+ double val = 0.0;
+ if (args[1].IsNumber())
+ {
+ val = args[1].ToNumber();
+ }
+ else if (args[1].IsString())
+ {
+ ultralight::String str_ul = args[1].ToString();
+ auto str_data = str_ul.utf8();
+ std::string str = str_data.data() ? str_data.data() : "";
+ try { val = std::stod(str); } catch (...) { val = 0.0; }
+ }
+ // Clamp to valid latitude range
+ val = (std::max)(-90.0, (std::min)(90.0, val));
+ settings_.spoofed_latitude = val;
+ UpdateSettingsDirtyFlag();
+ ApplySettings(false, false);
+ UpdateSettingsDirtyFlag();
+ return;
+ }
+
+ // Special-case: spoofed_longitude is a numeric value for location spoofing
+ if (key == "spoofed_longitude")
+ {
+ double val = 0.0;
+ if (args[1].IsNumber())
+ {
+ val = args[1].ToNumber();
+ }
+ else if (args[1].IsString())
+ {
+ ultralight::String str_ul = args[1].ToString();
+ auto str_data = str_ul.utf8();
+ std::string str = str_data.data() ? str_data.data() : "";
+ try { val = std::stod(str); } catch (...) { val = 0.0; }
+ }
+ // Clamp to valid longitude range
+ val = (std::max)(-180.0, (std::min)(180.0, val));
+ settings_.spoofed_longitude = val;
+ UpdateSettingsDirtyFlag();
+ ApplySettings(false, false);
+ UpdateSettingsDirtyFlag();
+ return;
+ }
+
bool value = false;
if (args[1].IsBoolean())
{
@@ -3591,6 +3645,9 @@ std::string UI::BuildSettingsPayload(bool snapshot_is_baseline) const
ss << "\"target_user_agent\": \"" << util::EscapeJsonString(settings_.custom_user_agent.empty() ? active_user_agent_ : settings_.custom_user_agent) << "\",";
// Expose dark_theme_excluded_sites as a separate field for the text input in settings UI
ss << "\"dark_theme_excluded_sites\": \"" << util::EscapeJsonString(settings_.dark_theme_excluded_sites) << "\",";
+ // Expose location spoofing coordinates
+ ss << "\"spoofed_latitude\": " << settings_.spoofed_latitude << ",";
+ ss << "\"spoofed_longitude\": " << settings_.spoofed_longitude << ",";
ss << "\"meta\": {";
ss << "\"updated_at\": \"" << util::ToIso8601UTC(std::chrono::system_clock::now()) << "\",";
ss << "\"dirty\": " << (settings_dirty_ ? "true" : "false") << ",";
diff --git a/src/UI.h b/src/UI.h
index 38e918d..4dd28fa 100644
--- a/src/UI.h
+++ b/src/UI.h
@@ -106,6 +106,11 @@ class UI : public WindowListener,
bool restore_session_on_startup = true; // Restore previous session tabs on startup
bool save_session_continuously = true; // Save session state continuously (crash recovery)
+ // Location Spoofing
+ bool enable_location_spoofing = false; // When true, override navigator.geolocation
+ double spoofed_latitude = 0.0; // Latitude to report (-90 to 90)
+ double spoofed_longitude = 0.0; // Longitude to report (-180 to 180)
+
bool operator==(const BrowserSettings &other) const;
bool operator!=(const BrowserSettings &other) const { return !(*this == other); }
};
@@ -223,6 +228,11 @@ class UI : public WindowListener,
bool do_not_track_enabled() const { return settings_.do_not_track; }
bool block_third_party_cookies_enabled() const { return settings_.block_third_party_cookies; }
bool web_security_enabled() const { return settings_.enable_web_security; }
+
+ // Location spoofing accessors for Tab's JavaScript injection
+ bool location_spoofing_enabled() const { return settings_.enable_location_spoofing; }
+ double spoofed_latitude() const { return settings_.spoofed_latitude; }
+ double spoofed_longitude() const { return settings_.spoofed_longitude; }
protected:
static std::filesystem::path SettingsDirectory();