Encrypted Storage

This commit is contained in:
koalasat
2025-07-25 12:36:15 +02:00
parent 639dff8481
commit ab05579fc1
23 changed files with 317 additions and 261 deletions

View File

@ -66,7 +66,6 @@ android {
}
dependencies {
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.appcompat)
implementation(libs.material)
@ -74,6 +73,7 @@ dependencies {
implementation(libs.kmp.tor)
implementation(libs.quartz)
implementation(libs.ammolite)
implementation(libs.security.crypto.ktx)
// Add the KMP Tor binary dependency (contains the native .so files)
implementation(libs.kmp.tor.binary)
implementation(libs.androidx.activity)

View File

@ -28,6 +28,7 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.constraintlayout.widget.ConstraintLayout
import androidx.core.app.ActivityCompat
import androidx.core.content.ContextCompat
import com.robosats.models.EncryptedStorage
import com.robosats.services.NotificationsService
import com.robosats.tor.TorKmp
import com.robosats.tor.TorKmpManager
@ -42,6 +43,8 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
EncryptedStorage.init(this)
// Lock the screen orientation to portrait mode
requestedOrientation = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT
@ -73,6 +76,9 @@ class MainActivity : AppCompatActivity() {
}
}
/**
* Initialize Notifications service
*/
private fun initializeNotifications() {
startForegroundService(
Intent(
@ -82,8 +88,10 @@ class MainActivity : AppCompatActivity() {
)
}
/**
* Initialize TorKmp if it's not already initialized
*/
private fun initializeTor() {
// Initialize TorKmp if it's not already initialized
try {
try {
torKmp = TorKmpManager.getTorKmpObject()
@ -170,6 +178,9 @@ class MainActivity : AppCompatActivity() {
}
}
/**
* Configures initial WebView settings with external blocked
*/
private fun setupWebView() {
// Double-check Tor is connected before proceeding
if (!torKmp.isConnected()) {
@ -367,8 +378,6 @@ class MainActivity : AppCompatActivity() {
webSettings.textZoom = 100
}
// SSL error description method removed as we're not using SSL
/**
* Clear all WebView data when activity is destroyed
*/

View File

@ -6,6 +6,7 @@ import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.widget.Toast
import com.robosats.models.EncryptedStorage
import com.robosats.tor.TorKmpManager.getTorKmpObject
import okhttp3.Call
import okhttp3.Callback
@ -318,6 +319,64 @@ class WebAppInterface(private val context: Context, private val webView: WebView
}
}
@JavascriptInterface
fun getEncryptedStorage(uuid: String, key: String) {
// Validate inputs before processing
if (!isValidUuid(uuid) || !isValidInput(key)) {
Log.e(TAG, "Invalid input for getEncryptedStorage: uuid=$uuid, key=$key")
rejectPromise(uuid, "Invalid input parameters")
return
}
try {
// Sanitize the input before passing to native code
val sanitizedKey = key.trim()
val value = EncryptedStorage.getEncryptedStorage(sanitizedKey)
// Safely encode and return the result
resolvePromise(uuid, value)
} catch (e: Exception) {
Log.e(TAG, "Error in getEncryptedStorage", e)
rejectPromise(uuid, "Error obtaining encrypted storage: $key")
}
}
@JavascriptInterface
fun setEncryptedStorage(uuid: String, key: String, value: String) {
// Validate inputs before processing
if (!isValidUuid(uuid) || !isValidInput(key)) {
Log.e(TAG, "Invalid input for setEncryptedStorage: uuid=$uuid, key=$key")
rejectPromise(uuid, "Invalid input parameters")
return
}
// Sanitize the input before passing to native code
val sanitizedKey = key.trim()
val sanitizedValue = value.trim()
EncryptedStorage.setEncryptedStorage(sanitizedKey, sanitizedValue)
// Safely encode and return the result
resolvePromise(uuid, key)
}
@JavascriptInterface
fun deleteEncryptedStorage(uuid: String, key: String) {
// Validate inputs before processing
if (!isValidUuid(uuid) || !isValidInput(key)) {
Log.e(TAG, "Invalid input for deleteEncryptedStorage: uuid=$uuid, key=$key")
rejectPromise(uuid, "Invalid input parameters")
return
}
// Sanitize the input before passing to native code
val sanitizedKey = key.trim()
EncryptedStorage.deleteEncryptedStorage(sanitizedKey)
// Safely encode and return the result
resolvePromise(uuid, key)
}
private fun onWsMessage(path: String?, message: String?) {
val encodedMessage = encodeForJavaScript(message)
safeEvaluateJavascript("javascript:window.AndroidRobosats.onWSMessage('$path', '$encodedMessage')")

View File

@ -0,0 +1,42 @@
package com.robosats.models
import android.content.Context
import android.content.SharedPreferences
import androidx.core.content.edit
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
object EncryptedStorage {
private const val PREFERENCES_NAME = "secret_keeper"
private lateinit var sharedPreferences: SharedPreferences
fun init(context: Context) {
val masterKey: MasterKey =
MasterKey.Builder(context, MasterKey.DEFAULT_MASTER_KEY_ALIAS)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build()
sharedPreferences = EncryptedSharedPreferences.create(
context,
PREFERENCES_NAME,
masterKey,
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
) as EncryptedSharedPreferences
}
fun setEncryptedStorage(key: String, value: String) {
sharedPreferences.edit { putString(key, value) }
}
fun getEncryptedStorage(key: String): String {
return sharedPreferences.getString(key, "") ?: ""
}
fun deleteEncryptedStorage(key: String) {
sharedPreferences.edit { remove(key) }
}
}

View File

@ -7,6 +7,7 @@ import com.vitorpamplona.ammolite.relays.Relay
import com.vitorpamplona.ammolite.relays.RelayPool
import com.vitorpamplona.ammolite.relays.TypedFilter
import com.vitorpamplona.ammolite.relays.filters.SincePerRelayFilter
import org.json.JSONObject
object NostrClient {
private var subscriptionNotificationId = "robosatsNotificationId"
@ -41,6 +42,10 @@ object NostrClient {
}
private fun connectRelays() {
val garageString = EncryptedStorage.getEncryptedStorage("garage_slots")
val garage = JSONObject(garageString)
val relays = emptyList<String>()
relays.forEach {

View File

@ -2,6 +2,7 @@
agp = "8.11.1"
kotlin = "2.0.21"
coreKtx = "1.16.0"
securityCryptoKtx = "1.1.0-beta01"
junit = "4.13.2"
junitVersion = "1.2.1"
espressoCore = "3.6.1"
@ -28,6 +29,7 @@ kmp-tor-binary = { group = "io.matthewnelson.kotlin-components", name = "kmp-tor
okhttp = { group = "com.squareup.okhttp3", name = "okhttp", version.ref = "okhttp" }
quartz = { module = "com.github.vitorpamplona.amethyst:quartz", version.ref = "quartz" }
ammolite = { module = "com.github.vitorpamplona.amethyst:ammolite", version.ref = "quartz" }
security-crypto-ktx = { module = "androidx.security:security-crypto-ktx", version.ref = "securityCryptoKtx" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }

View File

@ -59,14 +59,13 @@ const NotificationsDrawer = ({
const [openSnak, setOpenSnak] = React.useState<boolean>(false);
const [snakEvent, setSnakevent] = React.useState<Event>();
const [subscribedTokens, setSubscribedTokens] = React.useState<string[]>([]);
const [_, setLastNotification] = React.useState<number>(
parseInt(
systemClient.getItem('last_notification') === ''
? '0'
: (systemClient.getItem('last_notification') ?? '0'),
10,
),
);
const [_, setLastNotification] = React.useState<number>(0);
useEffect(() => {
systemClient.getItem('last_notification').then((result) => {
setLastNotification(!result || result === '' ? 0 : parseInt(result, 10));
});
}, []);
useEffect(() => {
setShow(false);

View File

@ -8,9 +8,9 @@ const SelfhostedAlert = (): React.JSX.Element => {
const [show, setShow] = useState<boolean>(false);
useEffect(() => {
if (!systemClient.getItem('selfhosted-alert')) {
setShow(true);
}
systemClient.getItem('selfhosted-alert').then((result) => {
if (result) setShow(true);
});
}, []);
// If alert is hidden return null

View File

@ -41,9 +41,9 @@ const UnsafeAlert = (): React.JSX.Element => {
const [unsafeClient, setUnsafeClient] = useState<boolean>(false);
useEffect(() => {
if (!systemClient.getItem('unsafe-alert')) {
setShow(true);
}
systemClient.getItem('unsafe-alert').then((result) => {
if (result) setShow(true);
});
}, []);
const checkClient = (): void => {

View File

@ -8,7 +8,6 @@ import {
defaultExchange,
} from '.';
import defaultFederation from '../../static/federation.json';
import { systemClient } from '../services/System';
import { federationLottery, getHost } from '../utils';
import { coordinatorDefaultValues } from './Coordinator.model';
import { updateExchangeInfo } from './Exchange.model';
@ -98,9 +97,6 @@ export class Federation {
} else {
void this.loadBook();
}
const federationUrls = Object.values(this.coordinators).map((c) => c.url);
systemClient.setCookie('federation', JSON.stringify(federationUrls));
};
refreshBookHosts: (robosatsOnly: boolean) => void = (robosatsOnly) => {

View File

@ -51,9 +51,9 @@ class Garage {
this.triggerHook('onSlotUpdate');
};
loadSlots = (): void => {
loadSlots = async (): Promise<void> => {
this.slots = {};
const slotsDump: string = systemClient.getItem('garage_slots') ?? '';
const slotsDump: string = (await systemClient.getItem('garage_slots')) ?? '';
if (slotsDump !== '') {
const rawSlots: Record<string, object> = JSON.parse(slotsDump);

View File

@ -1,11 +1,8 @@
import { systemClient } from '../services/System';
import BaseSettings from './Settings.model';
class SettingsSelfhosted extends BaseSettings {
constructor() {
super();
const fontSizeCookie = systemClient.getItem('settings_fontsize_basic');
this.fontSize = fontSizeCookie !== '' ? Number(fontSizeCookie) : 14;
}
public frontend: 'basic' | 'pro' = 'basic';

View File

@ -1,11 +1,8 @@
import { systemClient } from '../services/System';
import BaseSettings from './Settings.model';
class Settings extends BaseSettings {
constructor() {
super();
const fontSizeCookie = systemClient.getItem('settings_fontsize_basic');
this.fontSize = fontSizeCookie !== '' ? Number(fontSizeCookie) : 14;
}
public frontend: 'basic' | 'pro' = 'basic';

View File

@ -1,11 +1,8 @@
import { systemClient } from '../services/System';
import BaseSettings from './Settings.model';
class SettingsSelfhostedPro extends BaseSettings {
constructor() {
super();
const fontSizeCookie = systemClient.getItem('settings_fontsize_pro');
this.fontSize = fontSizeCookie !== '' ? Number(fontSizeCookie) : 12;
}
public frontend: 'basic' | 'pro' = 'pro';

View File

@ -1,11 +1,8 @@
import { systemClient } from '../services/System';
import BaseSettings from './Settings.model';
class SettingsPro extends BaseSettings {
constructor() {
super();
const fontSizeCookie = systemClient.getItem('settings_fontsize_pro');
this.fontSize = fontSizeCookie !== '' ? Number(fontSizeCookie) : 12;
}
public frontend: 'basic' | 'pro' = 'pro';

View File

@ -25,45 +25,57 @@ export type Language =
class BaseSettings {
constructor() {
const modeCookie: 'light' | 'dark' | '' = systemClient.getItem('settings_mode');
this.mode =
modeCookie !== ''
? modeCookie
: window?.matchMedia('(prefers-color-scheme: dark)')?.matches
? 'dark'
: 'light';
const [client] = window.RobosatsSettings.split('-');
this.client = client as 'web' | 'mobile';
this.lightQRs = systemClient.getItem('settings_light_qr') === 'true';
const languageCookie = systemClient.getItem('settings_language');
this.language =
languageCookie !== ''
? languageCookie
: i18n.resolvedLanguage == null
? 'en'
: i18n.resolvedLanguage.substring(0, 2);
const connection = systemClient.getItem('settings_connection');
this.connection = connection && connection !== '' ? connection : this.connection;
const networkCookie = systemClient.getItem('settings_network');
this.network = networkCookie && networkCookie !== '' ? networkCookie : this.network;
this.host = getHost();
const [client] = window.RobosatsSettings.split('-');
this.client = client;
systemClient.getItem('settings_mode').then((mode) => {
this.mode = mode !== '' ? (mode as 'light' | 'dark') : this.getMode();
});
const stopNotifications = systemClient.getItem('settings_stop_notifications');
this.stopNotifications = client === 'mobile' && stopNotifications === 'true';
systemClient.getItem('settings_fontsize_basic').then((fontSizeCookie) => {
this.fontSize = fontSizeCookie !== '' ? Number(fontSizeCookie) : 14;
});
const useProxy = systemClient.getItem('settings_use_proxy');
this.useProxy = client === 'mobile' && useProxy !== 'false';
apiClient.useProxy = this.useProxy;
websocketClient.useProxy = this.useProxy;
systemClient.getItem('settings_light_qr').then((result) => {
this.lightQRs = result === 'true';
});
systemClient.getItem('settings_language').then((result) => {
this.language =
result !== ''
? (result as Language)
: i18n.resolvedLanguage == null
? 'en'
: (i18n.resolvedLanguage.substring(0, 2) as Language);
});
systemClient.getItem('settings_connection').then((result) => {
this.connection = result && result !== '' ? (result as 'api' | 'nostr') : this.connection;
});
systemClient.getItem('settings_network').then((result) => {
this.network = result && result !== '' ? (result as 'mainnet' | 'testnet') : this.network;
});
systemClient.getItem('settings_stop_notifications').then((result) => {
this.stopNotifications = client === 'mobile' && result === 'true';
});
systemClient.getItem('settings_use_proxy').then((result) => {
this.useProxy = client === 'mobile' && result !== 'false';
apiClient.useProxy = this.useProxy;
websocketClient.useProxy = this.useProxy;
});
}
getMode = (): 'light' | 'dark' => {
return window?.matchMedia('(prefers-color-scheme: dark)')?.matches ? 'dark' : 'light';
};
public frontend: 'basic' | 'pro' = 'basic';
public mode: 'light' | 'dark' = 'light';
public mode: 'light' | 'dark' = this.getMode();
public client: 'web' | 'mobile' = 'web';
public fontSize: number = 14;
public lightQRs: boolean = false;
@ -74,8 +86,8 @@ class BaseSettings {
public host?: string;
public unsafeClient: boolean = false;
public selfhostedClient: boolean = false;
public useProxy: boolean;
public stopNotifications: boolean;
public useProxy: boolean = false;
public stopNotifications: boolean = false;
}
export default BaseSettings;

View File

@ -7,6 +7,9 @@ declare global {
}
interface AndroidAppRobosats {
getEncryptedStorage: (uuid: string, key: string) => void;
setEncryptedStorage: (uuid: string, key: string, value: string) => void;
deleteEncryptedStorage: (uuid: string, key: string) => void;
generateRoboname: (uuid: string, initialString: string) => void;
generateRobohash: (uuid: string, initialString: string) => void;
copyToClipboard: (value: string) => void;

View File

@ -1,5 +1,6 @@
import { type SystemClient } from '..';
import AndroidRobosats from '../../Android';
import { v4 as uuidv4 } from 'uuid';
class SystemAndroidClient implements SystemClient {
constructor() {
@ -13,44 +14,30 @@ class SystemAndroidClient implements SystemClient {
window.AndroidAppRobosats?.copyToClipboard(value ?? '');
};
// Cookies
public getCookie: (key: string) => string = (key) => {
let cookieValue = null;
if (document?.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
// Does this cookie string begin with the key we want?
if (cookie.substring(0, key.length + 1) === key + '=') {
cookieValue = decodeURIComponent(cookie.substring(key.length + 1));
break;
}
}
}
return cookieValue ?? '';
};
public setCookie: (key: string, value: string) => void = (key, value) => {
document.cookie = `${key}=${value};path=/;SameSite=None;Secure`;
};
public deleteCookie: (key: string) => void = (key) => {
document.cookie = `${key}= ;path=/; expires = Thu, 01 Jan 1970 00:00:00 GMT`;
};
// Local storage
public getItem: (key: string) => string = (key) => {
const value = window.localStorage.getItem(key);
return value ?? '';
public getItem: (key: string) => Promise<string | undefined> = async (key) => {
try {
const result = await new Promise<string>((resolve, reject) => {
const uuid: string = uuidv4();
window.AndroidAppRobosats?.getEncryptedStorage(uuid, key);
window.AndroidRobosats?.storePromise(uuid, resolve, reject);
});
return result;
} catch (error) {
console.error('Error generating roboname:', error);
return;
}
};
public setItem: (key: string, value: string) => void = (key, value) => {
window.localStorage.setItem(key, value);
const uuid: string = uuidv4();
window.AndroidAppRobosats?.setEncryptedStorage(uuid, key, value);
};
public deleteItem: (key: string) => void = (key) => {
window.localStorage.removeItem(key);
const uuid: string = uuidv4();
window.AndroidAppRobosats?.deleteEncryptedStorage(uuid, key);
};
}

View File

@ -26,34 +26,8 @@ class SystemDesktopClient implements SystemClient {
}
};
// Cookies
public getCookie: (key: string) => string = (key) => {
let cookieValue = null;
if (document?.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
// Does this cookie string begin with the key we want?
if (cookie.substring(0, key.length + 1) === key + '=') {
cookieValue = decodeURIComponent(cookie.substring(key.length + 1));
break;
}
}
}
return cookieValue ?? '';
};
public setCookie: (key: string, value: string) => void = (key, value) => {
document.cookie = `${key}=${value};path=/;SameSite=None;Secure`;
};
public deleteCookie: (key: string) => void = (key) => {
document.cookie = `${key}= ;path=/; expires = Thu, 01 Jan 1970 00:00:00 GMT`;
};
// Local storage
public getItem: (key: string) => string = (key) => {
public getItem: (key: string) => Promise<string | undefined> = async (key) => {
const value = window.sessionStorage.getItem(key);
return value ?? '';
};

View File

@ -27,34 +27,8 @@ class SystemWebClient implements SystemClient {
}
};
// Cookies
public getCookie: (key: string) => string = (key) => {
let cookieValue = null;
if (document?.cookie !== '') {
const cookies = document.cookie.split(';');
for (let i = 0; i < cookies.length; i++) {
const cookie = cookies[i].trim();
// Does this cookie string begin with the key we want?
if (cookie.substring(0, key.length + 1) === key + '=') {
cookieValue = decodeURIComponent(cookie.substring(key.length + 1));
break;
}
}
}
return cookieValue ?? '';
};
public setCookie: (key: string, value: string) => void = (key, value) => {
document.cookie = `${key}=${value};path=/;SameSite=None;Secure`;
};
public deleteCookie: (key: string) => void = (key) => {
document.cookie = `${key}= ;path=/; expires = Thu, 01 Jan 1970 00:00:00 GMT`;
};
// Local storage
public getItem: (key: string) => string = (key) => {
public getItem: (key: string) => Promise<string | undefined> = async (key) => {
const value = window.localStorage.getItem(key);
return value ?? '';
};

View File

@ -5,10 +5,7 @@ import SystemAndroidClient from './SystemAndroidClient';
export interface SystemClient {
loading: boolean;
copyToClipboard: (value: string) => void;
getCookie: (key: string) => string | undefined;
setCookie: (key: string, value: string) => void;
deleteCookie: (key: string) => void;
getItem: (key: string) => string | undefined;
getItem: (key: string) => Promise<string | undefined>;
setItem: (key: string, value: string) => void;
deleteItem: (key: string) => void;
}

View File

@ -1,5 +1,4 @@
import { type ApiClient, type Auth } from '..';
import { systemClient } from '../../System';
import ApiWebClient from '../ApiWebClient';
import { v4 as uuidv4 } from 'uuid';
@ -32,14 +31,8 @@ class ApiAndroidClient implements ApiClient {
return headers;
};
private readonly parseResponse = (response: Record<string, object>): object => {
if (response.headers['set-cookie'] != null) {
response.headers['set-cookie'].forEach((cookie: string) => {
const keySplit: string[] = cookie.split('=');
systemClient.setCookie(keySplit[0], keySplit[1].split(';')[0]);
});
}
return response.json;
private readonly parseResponse = (response: string): object => {
return JSON.parse(response).json;
};
public put: (baseUrl: string, path: string, body: object) => Promise<object | undefined> = async (
@ -64,7 +57,7 @@ class ApiAndroidClient implements ApiClient {
window.AndroidRobosats?.storePromise(uuid, resolve, reject);
});
return this.parseResponse(JSON.parse(result));
return this.parseResponse(result);
};
public post: (
@ -84,7 +77,7 @@ class ApiAndroidClient implements ApiClient {
window.AndroidRobosats?.storePromise(uuid, resolve, reject);
});
return this.parseResponse(JSON.parse(result));
return this.parseResponse(result);
};
public get: (baseUrl: string, path: string, auth?: Auth) => Promise<object | undefined> = async (
@ -102,7 +95,7 @@ class ApiAndroidClient implements ApiClient {
window.AndroidRobosats?.storePromise(uuid, resolve, reject);
});
return this.parseResponse(JSON.parse(result));
return this.parseResponse(result);
};
}

View File

@ -1,103 +1,119 @@
<!DOCTYPE html>
<html>
<head>
<meta http-equiv="onion-location" content="{{ ONION_LOCATION }}" />
<% if (!mobile) { %>
<link rel="shortcut icon" href="<%= htmlWebpackPlugin.options.basePath %>static/assets/images/favicon-96x96.png" />
<link rel="icon" type="image/png" href="<%= htmlWebpackPlugin.options.basePath %>static/assets/images/favicon-32x32.png" sizes="32x32">
<link rel="icon" type="image/png" href="<%= htmlWebpackPlugin.options.basePath %>static/assets/images/favicon-96x96.png" sizes="96x96">
<link rel="icon" type="image/png" href="<%= htmlWebpackPlugin.options.basePath %>static/assets/images/favicon-192x192.png" sizes="192x192">
<head>
<meta http-equiv="onion-location" content="{{ ONION_LOCATION }}" />
<% if (!mobile) { %>
<link rel="shortcut icon" href="<%= htmlWebpackPlugin.options.basePath %>static/assets/images/favicon-96x96.png" />
<link rel="icon" type="image/png"
href="<%= htmlWebpackPlugin.options.basePath %>static/assets/images/favicon-32x32.png" sizes="32x32">
<link rel="icon" type="image/png"
href="<%= htmlWebpackPlugin.options.basePath %>static/assets/images/favicon-96x96.png" sizes="96x96">
<link rel="icon" type="image/png"
href="<%= htmlWebpackPlugin.options.basePath %>static/assets/images/favicon-192x192.png" sizes="192x192">
<% } %>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description" content="A simple and private way to exchange bitcoin for national currencies. Robosats simplifies the peer-to-peer user experience and uses lightning hold invoices to minimize custody and trust requirements. No user registration required.">
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="description"
content="A simple and private way to exchange bitcoin for national currencies. Robosats simplifies the peer-to-peer user experience and uses lightning hold invoices to minimize custody and trust requirements. No user registration required.">
<% if (pro) { %>
<title>RoboSats PRO - Simple and Private Bitcoin Exchange</title>
<link rel="stylesheet" href="<%= htmlWebpackPlugin.options.basePath %>static/css_pro/fonts.css"/>
<link rel="stylesheet" type="text/css" href="<%= htmlWebpackPlugin.options.basePath %>static/css_pro/react-grid-layout.css"/>
<link rel="stylesheet" type="text/css" href="<%= htmlWebpackPlugin.options.basePath %>static/css_pro/react-resizable.css"/>
<% } else { %>
<title>RoboSats - Simple and Private Bitcoin Exchange</title>
<link rel="stylesheet" href="<%= htmlWebpackPlugin.options.basePath %>static/css/fonts.css"/>
<% } %>
<% if (pro) { %>
<title>RoboSats PRO - Simple and Private Bitcoin Exchange</title>
<link rel="stylesheet" href="<%= htmlWebpackPlugin.options.basePath %>static/css_pro/fonts.css" />
<link rel="stylesheet" type="text/css"
href="<%= htmlWebpackPlugin.options.basePath %>static/css_pro/react-grid-layout.css" />
<link rel="stylesheet" type="text/css"
href="<%= htmlWebpackPlugin.options.basePath %>static/css_pro/react-resizable.css" />
<% } else { %>
<title>RoboSats - Simple and Private Bitcoin Exchange</title>
<link rel="stylesheet" href="<%= htmlWebpackPlugin.options.basePath %>static/css/fonts.css" />
<% } %>
<link rel="stylesheet" type="text/css" href="<%= htmlWebpackPlugin.options.basePath %>static/css/loader.css"/>
<link rel="stylesheet" type="text/css" href="<%= htmlWebpackPlugin.options.basePath %>static/css/index.css"/>
<link rel="stylesheet" type="text/css" href="<%= htmlWebpackPlugin.options.basePath %>static/css/leaflet.css"/>
<link rel="stylesheet" type="text/css"
href="<%= htmlWebpackPlugin.options.basePath %>static/css/loader.css" />
<link rel="stylesheet" type="text/css"
href="<%= htmlWebpackPlugin.options.basePath %>static/css/index.css" />
<link rel="stylesheet" type="text/css"
href="<%= htmlWebpackPlugin.options.basePath %>static/css/leaflet.css" />
<style>
/* Styles for the JavaScript-disabled disclaimer */
.noscript-container {
max-width: 600px;
margin: 40px auto;
padding: 20px;
border: 2px solid #e74c3c;
background-color: #fdecea;
color: #c0392b;
text-align: center;
font-family: Arial, sans-serif;
}
.noscript-container h2 {
margin-top: 0;
font-size: 1.8em;
}
.noscript-container p {
font-size: 1.1em;
line-height: 1.4;
}
.noscript-container a {
color: #c0392b;
text-decoration: underline;
}
</style>
</head>
<body>
<!-- This block is only rendered if JavaScript is disabled -->
<noscript>
<div class="noscript-container">
<h2>JavaScript is Disabled</h2>
<p>This website requires JavaScript for full functionality. Please enable JavaScript in your browser settings to proceed.</p>
<p>If youre using Tor Browser, ensure your "Security Level" is set to "Standard". If the issue persists, try clearing your cache and storage.</p>
<p>Need more help? Visit our support channel on Telegram: <a href="https://t.me/robosats" target="_blank" rel="noopener">t.me/robosats</a>.</p>
</div>
</noscript>
<style>
/* Styles for the JavaScript-disabled disclaimer */
.noscript-container {
max-width: 600px;
margin: 40px auto;
padding: 20px;
border: 2px solid #e74c3c;
background-color: #fdecea;
color: #c0392b;
text-align: center;
font-family: Arial, sans-serif;
}
<div id="main">
<div id="app">
<div class="loaderCenter">
<% if (!mobile) { %>
<div class="loaderSpinner"></div>
<div class="content-slider">
<div class="slider">
<div class="mask">
<ul>
<li class="anim1">
<div class="quote">Looking for robot parts ...</div>
</li>
<li class="anim2">
<div class="quote">Adding layers to the onion ...</div>
</li>
<li class="anim3">
<div class="quote">Winning at game theory ...</div>
</li>
<li class="anim4">
<div class="quote">Moving Sats at light speed ...</div>
</li>
<li class="anim5">
<div class="quote">Hiding in 2^256 bits of entropy...</div>
</li>
</ul>
</div>
</div>
<% } %>
.noscript-container h2 {
margin-top: 0;
font-size: 1.8em;
}
.noscript-container p {
font-size: 1.1em;
line-height: 1.4;
}
.noscript-container a {
color: #c0392b;
text-decoration: underline;
}
</style>
</head>
<body>
<!-- This block is only rendered if JavaScript is disabled -->
<noscript>
<div class="noscript-container">
<h2>JavaScript is Disabled</h2>
<p>This website requires JavaScript for full functionality. Please enable JavaScript in your browser settings to
proceed.</p>
<p>If youre using Tor Browser, ensure your "Security Level" is set to "Standard". If the issue persists, try
clearing your cache and storage.</p>
<p>Need more help? Visit our support channel on Telegram: <a href="https://t.me/robosats" target="_blank"
rel="noopener">t.me/robosats</a>.</p>
</div>
</noscript>
<div id="main">
<div id="app">
<div class="loaderCenter">
<div class="loaderSpinner"></div>
<div class="content-slider">
<div class="slider">
<div class="mask">
<ul>
<li class="anim1">
<div class="quote">Looking for robot parts ...</div>
</li>
<li class="anim2">
<div class="quote">Adding layers to the onion ...</div>
</li>
<li class="anim3">
<div class="quote">Winning at game theory ...</div>
</li>
<li class="anim4">
<div class="quote">Moving Sats at light speed ...</div>
</li>
<li class="anim5">
<div class="quote">Hiding in 2^256 bits of entropy...</div>
</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
<script>
window.RobosatsSettings = '<%= htmlWebpackPlugin.options.robosatsSettings %>';
</script>
</body>
</html>
<script>
window.RobosatsSettings = '<%= htmlWebpackPlugin.options.robosatsSettings %>';
</script>
</body>
</html>