diff --git a/.gitignore b/.gitignore old mode 100755 new mode 100644 index 77d7b33..caf9462 --- a/.gitignore +++ b/.gitignore @@ -1,47 +1,49 @@ -# Miscellaneous -*.class -*.log -*.pyc -*.swp -.DS_Store -.atom/ -.buildlog/ -.history -.svn/ -migrate_working_dir/ - -# IntelliJ related -*.iml -*.ipr -*.iws -.idea/ - -# The .vscode folder contains launch configuration and tasks you configure in -# VS Code which you may wish to be included in version control, so this line -# is commented out by default. -#.vscode/ - -# Flutter/Dart/Pub related -**/doc/api/ -**/ios/Flutter/.last_build_id -.dart_tool/ -.flutter-plugins -.flutter-plugins-dependencies -.pub-cache/ -.pub/ -/build/ - -# Symbolication related -app.*.symbols - -# Obfuscation related -app.*.map.json - -# Android Studio will place build artifacts here -/android/app/debug -/android/app/profile -/android/app/release -/android/app/build -/android/.gradle - -local +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +# Android +local.properties + +# MacOSX +.DS_Store + +# Android Studio +.idea +.gradle +build +local diff --git a/.metadata b/.metadata deleted file mode 100755 index 417ad2f..0000000 --- a/.metadata +++ /dev/null @@ -1,45 +0,0 @@ -# This file tracks properties of this Flutter project. -# Used by Flutter tool to assess capabilities and perform upgrades etc. -# -# This file should be version controlled and should not be manually edited. - -version: - revision: "ef1af02aead6fe2414f3aafa5a61087b610e1332" - channel: "stable" - -project_type: app - -# Tracks metadata for the flutter migrate command -migration: - platforms: - - platform: root - create_revision: ef1af02aead6fe2414f3aafa5a61087b610e1332 - base_revision: ef1af02aead6fe2414f3aafa5a61087b610e1332 - - platform: android - create_revision: ef1af02aead6fe2414f3aafa5a61087b610e1332 - base_revision: ef1af02aead6fe2414f3aafa5a61087b610e1332 - - platform: ios - create_revision: ef1af02aead6fe2414f3aafa5a61087b610e1332 - base_revision: ef1af02aead6fe2414f3aafa5a61087b610e1332 - - platform: linux - create_revision: ef1af02aead6fe2414f3aafa5a61087b610e1332 - base_revision: ef1af02aead6fe2414f3aafa5a61087b610e1332 - - platform: macos - create_revision: ef1af02aead6fe2414f3aafa5a61087b610e1332 - base_revision: ef1af02aead6fe2414f3aafa5a61087b610e1332 - - platform: web - create_revision: ef1af02aead6fe2414f3aafa5a61087b610e1332 - base_revision: ef1af02aead6fe2414f3aafa5a61087b610e1332 - - platform: windows - create_revision: ef1af02aead6fe2414f3aafa5a61087b610e1332 - base_revision: ef1af02aead6fe2414f3aafa5a61087b610e1332 - - # User provided section - - # List of Local paths (relative to this file) that should be - # ignored by the migrate tool. - # - # Files that are not part of the templates will be ignored by default. - unmanaged_files: - - 'lib/main.dart' - - 'ios/Runner.xcodeproj/project.pbxproj' diff --git a/LICENSE b/LICENSE index f2a6a10..5483294 100644 --- a/LICENSE +++ b/LICENSE @@ -186,7 +186,7 @@ same "printed page" as the copyright notice for easier identification within third-party archives. - Copyright NodeBase with all contributors + Copyright 2017 Seven Lju and contributors Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. diff --git a/README.md b/README.md old mode 100755 new mode 100644 index 312f37f..9526c5a --- a/README.md +++ b/README.md @@ -1,18 +1,57 @@ # NodeBase -Platform to Build Sharable Application for Android - -Running Websocket application over Wifi and share with your friends. - -> WSTun: it is a base server on http/websocket with netty; and project name is "WSTun" - -The design is changed, we never use a pre-built binary of NodeJS to run server anymore. - -We run a native http/websocket with netty. - -Any JS client can register itself as a service so that it can be a control center of data. - -Others can connect as clients of the service and do more flexible things like file sharing, board gaming! - -> It is also a project enjoy vibe coding! (save lots of time) +Android NodeJS Platform to Build Sharable Application (Android as a Server) + +Share application with your friends in the same Wi-Fi! + +Build IoT edge on your phone; smart your home! + +# How to use + +- build to generate apk +- install the apk on Android phone +- click "Reset" in right-top menu will donwload NodeJS binary and copy to app scope target +- (notice that in this repo, there is no NodeJS binary provided, [download latest](https://github.com/dna2github/dna2oslab/releases)) put compiled NodeJS binary to `/sdcard/.nodebase/.bin/node` +- click "Node Upgrade" to update NodeJS binary for NodeBase +- do `npm install` in `modules` folder + - to make node-gyp work, download GCC4droid from for example Google Play Store and then unzip the apk to get android `gcc` +- adb push entire `modules` as `/sdcard/.nodebase` + +# How to share apps + +- `Service Share` +
+  Write nodeJS server program and listen on 0.0.0.0
+  (set `app_manager` as an example; if listen on 127.0.0.1, local use only)
+  "Start" app and share IP and port to near device
+
+ +- `Copy Share` +
+  read the label on top of another Nodebase "Network (xxx.xxx.xxx.xxx)"
+  click "Install App Manager" in right-top menu
+  "Refresh" application list
+  "Start" app manager and "Open" in browser
+  "Start" app manager in another Android
+  type xxx.xxx.xxx.xxx:20180 under "Shared Application" and click "Enter"
+  select an app and click to enter
+  click "Import" to get app
+  then NodeBase Application Manager will make a copy of the app on local
+
+ +# FAQ + +- "Network" shows 0.0.0.0? + - A: probably not connect to Wi-Fi; not support start service on Internet. + +# Modules + +#### Screenshots + +
+ + + + +
diff --git a/android/README.md b/android/README.md deleted file mode 100644 index 8f633da..0000000 --- a/android/README.md +++ /dev/null @@ -1,166 +0,0 @@ -# WSTun - WebSocket Tunnel Server - -An Android app that runs an HTTP/WebSocket server for service sharing in local networks. - -## Features - -### Server (Android App) -- HTTP server with WebSocket support using Netty -- Optional HTTPS with self-signed certificate -- Service registration via WebSocket -- HTTP request relay to connected services -- Foreground service for background operation -- Service management UI (view, kick) - -### Services -- Connect via WebSocket and register endpoints -- Define HTTP routes that relay to the service -- Provide static resources (HTML/JS/CSS) -- No server-side storage - everything relayed - -## Architecture - -``` -┌─────────────┐ WebSocket ┌─────────────────┐ -│ Service │◄───────────────────►│ WSTun App │ -│ Client │ │ (HTTP Server) │ -└─────────────┘ └────────┬────────┘ - │ HTTP - ▼ - ┌─────────────────┐ - │ Web Browser │ - │ (User) │ - └─────────────────┘ -``` - -1. Service connects via WebSocket and registers -2. User accesses `http://server/[service]/main` -3. Server relays request to service via WebSocket -4. Service sends response via WebSocket -5. Server sends HTTP response to user - -## Building - -### Android App - -```bash -cd wstun -./gradlew assembleDebug -``` - -### Service Clients - -```bash -cd services/fileshare -npm install - -cd services/chat -npm install -``` - -## Usage - -### Start the Server - -1. Install the APK on an Android device -2. Configure port and HTTPS option -3. Tap "Start Server" -4. Note the displayed IP address - -### Connect Services - -```bash -# File sharing -cd services/fileshare -node client.js ws://192.168.1.100:8080/ws - -# Chat -cd services/chat -node client.js ws://192.168.1.100:8080/ws -``` - -### Access Services - -- Server info: `http://192.168.1.100:8080/` -- FileShare: `http://192.168.1.100:8080/fileshare/main` -- Chat: `http://192.168.1.100:8080/chat/main` - -## Protocol - -### Service Registration - -```json -{ - "type": "register", - "id": "unique-id", - "payload": { - "name": "servicename", - "type": "service-type", - "description": "Service description", - "endpoints": [ - { "path": "/main", "method": "GET", "relay": true } - ], - "static_resources": { - "/main": "..." - } - } -} -``` - -### HTTP Request Relay - -Server sends to service: -```json -{ - "type": "http_request", - "payload": { - "request_id": "req-123", - "method": "GET", - "path": "/servicename/main", - "headers": { "Content-Type": "text/html" }, - "body": "..." - } -} -``` - -Service responds: -```json -{ - "type": "http_response", - "payload": { - "request_id": "req-123", - "status": 200, - "headers": { "Content-Type": "text/html" }, - "body": "..." - } -} -``` - -## Included Services - -### FileShare - -Share files without server storage: -- Virtual folder tree -- File upload/download via browser -- Relay mode for temporary sharing -- All data flows through the client - -### Chat - -Real-time chat with rich messages: -- Text, Card, and Poll message types -- Broadcast to all or private messages -- Abstract JSON format for custom rendering -- Self-contained HTML/JS/CSS - -## Security Notes - -- HTTPS uses self-signed certificate (browser warning expected) -- No authentication built-in (add as needed) -- For local network use only -- Services should validate inputs - -## License - -MIT diff --git a/android/app/build.gradle b/android/app/build.gradle deleted file mode 100644 index 334f5b2..0000000 --- a/android/app/build.gradle +++ /dev/null @@ -1,48 +0,0 @@ -plugins { - id 'com.android.application' -} - -android { - namespace 'seven.lab.wstun' - compileSdk 34 - - defaultConfig { - applicationId "seven.lab.wstun" - minSdk 24 - targetSdk 34 - versionCode 1 - versionName "1.0" - } - - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } - - packagingOptions { - exclude 'META-INF/INDEX.LIST' - exclude 'META-INF/io.netty.versions.properties' - } -} - -dependencies { - implementation 'androidx.appcompat:appcompat:1.6.1' - implementation 'com.google.android.material:material:1.11.0' - implementation 'androidx.constraintlayout:constraintlayout:2.1.4' - implementation 'androidx.recyclerview:recyclerview:1.3.2' - implementation 'com.google.code.gson:gson:2.10.1' - - // Netty - implementation 'io.netty:netty-all:4.1.100.Final' - - // Bouncy Castle for SSL - implementation 'org.bouncycastle:bcprov-jdk15on:1.70' - implementation 'org.bouncycastle:bcpkix-jdk15on:1.70' -} diff --git a/android/app/proguard-rules.pro b/android/app/proguard-rules.pro deleted file mode 100644 index d52bf27..0000000 --- a/android/app/proguard-rules.pro +++ /dev/null @@ -1,20 +0,0 @@ -# Netty --keepclassmembers class io.netty.** { *; } --keep class io.netty.** { *; } --dontwarn io.netty.** - -# Bouncy Castle --keep class org.bouncycastle.** { *; } --dontwarn org.bouncycastle.** - -# Gson --keepattributes Signature --keepattributes *Annotation* --keep class com.google.gson.** { *; } --keep class * implements java.io.Serializable { *; } - -# Protocol classes --keep class seven.lab.wstun.protocol.** { *; } - -# Server classes --keep class seven.lab.wstun.server.** { *; } diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml deleted file mode 100644 index 9d0275f..0000000 --- a/android/app/src/main/AndroidManifest.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/android/app/src/main/assets/libwstun.js b/android/app/src/main/assets/libwstun.js deleted file mode 100644 index a014927..0000000 --- a/android/app/src/main/assets/libwstun.js +++ /dev/null @@ -1,274 +0,0 @@ -/** - * WSTun Client Library v2.0 - * Supports: Server auth, Service instances, User management - */ -(function(global) { -'use strict'; - -const WSTun = { - version: '1.0.0', - - /** Create instance host (creates and manages an instance) */ - createInstanceHost: function(options) { return new InstanceHost(options); }, - - /** Create instance client (joins an existing instance) */ - createInstanceClient: function(options) { return new InstanceClient(options); }, - - /** Generate unique ID */ - generateId: function() { return Date.now().toString(36) + Math.random().toString(36).substr(2, 6); }, - - /** Build WebSocket URL */ - buildWsUrl: function(serverToken) { - const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; - let url = protocol + '//' + location.host + '/ws'; - if (serverToken) url += '?token=' + encodeURIComponent(serverToken); - return url; - } -}; - -/** Base WebSocket client */ -class BaseClient { - constructor(options) { - this.service = options.service; - this.serverToken = options.serverToken || null; - this.onOpen = options.onOpen || (() => {}); - this.onMessage = options.onMessage || (() => {}); - this.onClose = options.onClose || (() => {}); - this.onError = options.onError || (() => {}); - this.ws = null; - this.connected = false; - } - - connect() { - try { this.ws = new WebSocket(WSTun.buildWsUrl(this.serverToken)); } - catch (err) { this.onError(err); return; } - this.ws.onopen = () => { this.connected = true; this.onOpen(); this._onConnected(); }; - this.ws.onmessage = (e) => { try { this._handleMessage(JSON.parse(e.data)); } catch(err) { console.error(err); } }; - this.ws.onclose = () => { this.connected = false; this.onClose(); }; - this.ws.onerror = (err) => { this.onError(err); }; - } - - disconnect() { if (this.ws) { this.ws.close(); this.ws = null; } } - - send(type, payload) { - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify({ type, service: this.service, payload })); - } - } - - _onConnected() {} - _handleMessage(msg) { this.onMessage(msg); } -} - -/** - * Instance Host - Creates and manages a service instance (room) - * @param {Object} options - * @param {string} options.service - Service type (e.g., 'fileshare', 'chat') - * @param {string} options.name - Instance name (room name) - * @param {string} [options.serverToken] - Server auth token - * @param {string} [options.instanceToken] - Token users need to join - * @param {string} [options.reclaimUuid] - UUID of instance to reclaim (for reconnection) - * @param {function} [options.onInstanceCreated] - Called when instance is created - * @param {function} [options.onInstanceReclaimed] - Called when instance is reclaimed - */ -class InstanceHost extends BaseClient { - constructor(options) { - super(options); - this.instanceName = options.name || 'Room'; - this.instanceToken = options.instanceToken || null; - this.reclaimUuid = options.reclaimUuid || null; - this.onInstanceCreated = options.onInstanceCreated || (() => {}); - this.onInstanceReclaimed = options.onInstanceReclaimed || options.onInstanceCreated || (() => {}); - this.instanceUuid = null; - } - - _onConnected() { - if (this.reclaimUuid) { - // Try to reclaim an existing instance - this.send('reclaim_instance', { - uuid: this.reclaimUuid, - token: this.instanceToken, - server_token: this.serverToken - }); - } else { - // Create new instance - this.send('create_instance', { - name: this.instanceName, - token: this.instanceToken, - server_token: this.serverToken - }); - } - } - - _handleMessage(msg) { - if (msg.type === 'instance_created' && msg.payload?.success) { - this.instanceUuid = msg.payload.uuid; - this.onInstanceCreated(msg.payload); - } else if (msg.type === 'instance_reclaimed' && msg.payload?.success) { - this.instanceUuid = msg.payload.uuid; - this.onInstanceReclaimed(msg.payload); - } else if (msg.type === 'instance_reclaimed' && !msg.payload?.success) { - // Reclaim failed, create new instance instead - this.reclaimUuid = null; - this.send('create_instance', { - name: this.instanceName, - token: this.instanceToken, - server_token: this.serverToken - }); - } else if (msg.type === 'error') { - this.onError(new Error(msg.payload?.message || 'Unknown error')); - } else { - this.onMessage(msg); - } - } - - /** Broadcast message to all users in instance */ - broadcast(type, payload) { - if (this.instanceUuid) { - payload = payload || {}; - payload.instanceUuid = this.instanceUuid; - this.send(type, payload); - } - } - - /** Kick a user from instance */ - kickUser(userId) { - this.send('kick_client', { userId, instanceUuid: this.instanceUuid }); - } -} - -/** - * Instance Client - Joins an existing instance - * @param {Object} options - * @param {string} options.service - Service type - * @param {string} options.instanceUuid - Instance UUID to join - * @param {string} [options.serverToken] - Server auth token - * @param {string} [options.instanceToken] - Instance access token - * @param {string} [options.userId] - User ID (auto-generated if not provided) - * @param {function} [options.onJoined] - Called when joined instance - * @param {function} [options.onKicked] - Called when kicked from instance - */ -class InstanceClient extends BaseClient { - constructor(options) { - super(options); - this.instanceUuid = options.instanceUuid; - this.instanceToken = options.instanceToken || null; - this.userId = options.userId || WSTun.generateId(); - this.onJoined = options.onJoined || (() => {}); - this.onKicked = options.onKicked || (() => {}); - this.instanceName = null; - } - - _onConnected() { - this.send('join_instance', { - uuid: this.instanceUuid, - userId: this.userId, - token: this.instanceToken - }); - } - - _handleMessage(msg) { - if (msg.type === 'ack' && msg.payload?.success && msg.payload?.instanceUuid) { - this.instanceName = msg.payload.instanceName; - this.onJoined(msg.payload); - } else if (msg.type === 'kick') { - this.onKicked(msg.payload); - this.disconnect(); - } else if (msg.type === 'error') { - this.onError(new Error(msg.payload?.message || 'Unknown error')); - } else { - this.onMessage(msg); - } - } -} - -/** - * List available instances for a service - * @param {string} service - Service type - * @param {string} [serverToken] - Server auth token - * @returns {Promise} List of instances - */ -WSTun.listInstances = function(service, serverToken) { - return new Promise((resolve, reject) => { - let resolved = false; - let ws; - - try { - ws = new WebSocket(WSTun.buildWsUrl(serverToken)); - } catch(err) { - reject(err); - return; - } - - const cleanup = () => { - resolved = true; - if (ws && ws.readyState !== WebSocket.CLOSED) { - try { ws.close(); } catch(e) {} - } - }; - - ws.onopen = () => { - if (resolved) return; - ws.send(JSON.stringify({ type: 'list_instances', service, payload: {} })); - }; - ws.onmessage = (e) => { - if (resolved) return; - try { - const msg = JSON.parse(e.data); - if (msg.type === 'instance_list') { - cleanup(); - resolve(msg.payload?.instances || []); - } else if (msg.type === 'error') { - cleanup(); - reject(new Error(msg.payload?.message || msg.payload?.error || 'Failed to list instances')); - } - } catch(err) { - cleanup(); - reject(err); - } - }; - ws.onerror = (err) => { - if (resolved) return; - cleanup(); - reject(new Error('WebSocket connection failed')); - }; - ws.onclose = () => { - if (resolved) return; - cleanup(); - resolve([]); // Return empty list if connection closes without response - }; - - // Shorter timeout (3 seconds) to avoid long waits - setTimeout(() => { - if (resolved) return; - cleanup(); - resolve([]); // Return empty list on timeout instead of rejecting - }, 3000); - }); -}; - -// Legacy support - createService and createClient still work for simple cases -WSTun.createService = function(options) { return new InstanceHost(options); }; -WSTun.createClient = function(options) { - // If instanceUuid provided, use InstanceClient, otherwise fallback - if (options.instanceUuid) return new InstanceClient(options); - // Simple client without instance support (legacy) - const client = new BaseClient(options); - client.userId = options.userId || WSTun.generateId(); - client.onRegistered = options.onRegistered || (() => {}); - client.onKicked = options.onKicked || (() => {}); - client._onConnected = function() { - this.send('client_register', { clientType: this.service, userId: this.userId, auth_token: options.serviceToken }); - }; - client._handleMessage = function(msg) { - if (msg.type === 'ack' && msg.payload?.success) { this.onRegistered(msg.payload); } - else if (msg.type === 'kick') { this.onKicked(msg.payload); this.disconnect(); } - else if (msg.type === 'error') { this.onError(new Error(msg.payload?.message || 'Error')); } - else { this.onMessage(msg); } - }; - return client; -}; -WSTun.generateUserId = WSTun.generateId; - -global.WSTun = WSTun; -})(typeof window !== 'undefined' ? window : this); diff --git a/android/app/src/main/assets/services/chat/index.html b/android/app/src/main/assets/services/chat/index.html deleted file mode 100644 index 86aa902..0000000 --- a/android/app/src/main/assets/services/chat/index.html +++ /dev/null @@ -1,274 +0,0 @@ - - - - - - Chat Instance Manager - WSTun - - - -
-
-

Chat

-

Create a chat room

- -
- - -
-
- - -
-
- - -
- - - - -
- -
-

Existing Rooms

-

Click to open the chat page

-
-

Loading...

-
- -
-
- - - - - diff --git a/android/app/src/main/assets/services/chat/main.html b/android/app/src/main/assets/services/chat/main.html deleted file mode 100644 index 7d2fac6..0000000 --- a/android/app/src/main/assets/services/chat/main.html +++ /dev/null @@ -1,375 +0,0 @@ - - - - - - Chat - WSTun - - - -
-

Join Chat Room

-
Loading rooms...
- -
- -
-
Or enter room details manually
- - - - -
-
- -
-
-
-

Chat Room

- 0 users -
- -
-
-
- - -
-
- - - - - diff --git a/android/app/src/main/assets/services/fileshare/index.html b/android/app/src/main/assets/services/fileshare/index.html deleted file mode 100644 index a350c59..0000000 --- a/android/app/src/main/assets/services/fileshare/index.html +++ /dev/null @@ -1,276 +0,0 @@ - - - - - - FileShare Instance Manager - WSTun - - - -
-
-

FileShare

-

Create a file sharing room

- -
- - -
-
- - -
-
- - -
- - - - -
- -
-

Existing Rooms

-

Click to open the file sharing page

-
-

Loading...

-
- -
-
- - - - - diff --git a/android/app/src/main/assets/services/fileshare/main.html b/android/app/src/main/assets/services/fileshare/main.html deleted file mode 100644 index 2ddb8d8..0000000 --- a/android/app/src/main/assets/services/fileshare/main.html +++ /dev/null @@ -1,463 +0,0 @@ - - - - - - FileShare - WSTun - - - -
-
-

FileShare

-

Share files with others

-
Loading...
-
- - -
-

Join a Room

-
-

Loading available rooms...

-
-
-

Or Enter Room Details

-
- - -
-
- - -
- -
-
- - - -
- - - - - diff --git a/android/app/src/main/java/seven/lab/wstun/config/ServerConfig.java b/android/app/src/main/java/seven/lab/wstun/config/ServerConfig.java deleted file mode 100644 index 349a3c2..0000000 --- a/android/app/src/main/java/seven/lab/wstun/config/ServerConfig.java +++ /dev/null @@ -1,157 +0,0 @@ -package seven.lab.wstun.config; - -import android.content.Context; -import android.content.SharedPreferences; - -/** - * Server configuration stored in SharedPreferences. - */ -public class ServerConfig { - - private static final String PREFS_NAME = "wstun_config"; - private static final String KEY_PORT = "port"; - private static final String KEY_HTTPS_ENABLED = "https_enabled"; - private static final String KEY_CERT_GENERATED = "cert_generated"; - private static final String KEY_CORS_ORIGINS = "cors_origins"; - private static final String KEY_FILESHARE_ENABLED = "fileshare_enabled"; - private static final String KEY_CHAT_ENABLED = "chat_enabled"; - private static final String KEY_SERVER_AUTH_TOKEN = "server_auth_token"; - private static final String KEY_AUTH_ENABLED = "auth_enabled"; - private static final String KEY_DEBUG_LOGS_ENABLED = "debug_logs_enabled"; - - private final SharedPreferences prefs; - - public ServerConfig(Context context) { - this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); - } - - public int getPort() { - return prefs.getInt(KEY_PORT, 8080); - } - - public void setPort(int port) { - prefs.edit().putInt(KEY_PORT, port).apply(); - } - - public boolean isHttpsEnabled() { - return prefs.getBoolean(KEY_HTTPS_ENABLED, false); - } - - public void setHttpsEnabled(boolean enabled) { - prefs.edit().putBoolean(KEY_HTTPS_ENABLED, enabled).apply(); - } - - public boolean isCertGenerated() { - return prefs.getBoolean(KEY_CERT_GENERATED, false); - } - - public void setCertGenerated(boolean generated) { - prefs.edit().putBoolean(KEY_CERT_GENERATED, generated).apply(); - } - - /** - * Get CORS allowed origins. Default is "*" (all origins). - * Can be comma-separated list of origins or "*". - */ - public String getCorsOrigins() { - return prefs.getString(KEY_CORS_ORIGINS, "*"); - } - - /** - * Set CORS allowed origins. - * Use "*" to allow all origins, or comma-separated list like: - * "http://localhost:3000,http://192.168.1.100:8080" - */ - public void setCorsOrigins(String origins) { - prefs.edit().putString(KEY_CORS_ORIGINS, origins).apply(); - } - - /** - * Check if FileShare service is enabled. - */ - public boolean isFileshareEnabled() { - return prefs.getBoolean(KEY_FILESHARE_ENABLED, false); - } - - /** - * Enable or disable FileShare service. - */ - public void setFileshareEnabled(boolean enabled) { - prefs.edit().putBoolean(KEY_FILESHARE_ENABLED, enabled).apply(); - } - - /** - * Check if Chat service is enabled. - */ - public boolean isChatEnabled() { - return prefs.getBoolean(KEY_CHAT_ENABLED, false); - } - - /** - * Enable or disable Chat service. - */ - public void setChatEnabled(boolean enabled) { - prefs.edit().putBoolean(KEY_CHAT_ENABLED, enabled).apply(); - } - - /** - * Check if server authentication is enabled. - */ - public boolean isAuthEnabled() { - return prefs.getBoolean(KEY_AUTH_ENABLED, false); - } - - /** - * Enable or disable server authentication. - */ - public void setAuthEnabled(boolean enabled) { - prefs.edit().putBoolean(KEY_AUTH_ENABLED, enabled).apply(); - } - - /** - * Get the server auth token. Returns null if not set. - */ - public String getServerAuthToken() { - return prefs.getString(KEY_SERVER_AUTH_TOKEN, null); - } - - /** - * Set the server auth token. Set to null or empty to disable. - */ - public void setServerAuthToken(String token) { - if (token == null || token.isEmpty()) { - prefs.edit().remove(KEY_SERVER_AUTH_TOKEN).apply(); - } else { - prefs.edit().putString(KEY_SERVER_AUTH_TOKEN, token).apply(); - } - } - - /** - * Validate server auth token. - * Returns true if auth is disabled or token matches. - */ - public boolean validateServerAuth(String token) { - if (!isAuthEnabled()) { - return true; - } - String serverToken = getServerAuthToken(); - if (serverToken == null || serverToken.isEmpty()) { - return true; - } - return serverToken.equals(token); - } - - /** - * Check if debug logs endpoint is enabled. - */ - public boolean isDebugLogsEnabled() { - return prefs.getBoolean(KEY_DEBUG_LOGS_ENABLED, false); - } - - /** - * Enable or disable debug logs endpoint. - */ - public void setDebugLogsEnabled(boolean enabled) { - prefs.edit().putBoolean(KEY_DEBUG_LOGS_ENABLED, enabled).apply(); - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/marketplace/InstalledService.java b/android/app/src/main/java/seven/lab/wstun/marketplace/InstalledService.java deleted file mode 100644 index e711b5e..0000000 --- a/android/app/src/main/java/seven/lab/wstun/marketplace/InstalledService.java +++ /dev/null @@ -1,122 +0,0 @@ -package seven.lab.wstun.marketplace; - -import com.google.gson.Gson; -import com.google.gson.JsonObject; -import com.google.gson.annotations.SerializedName; - -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -/** - * Represents an installed service with its manifest and files. - */ -public class InstalledService { - - private static final Gson gson = new Gson(); - - @SerializedName("manifest") - private ServiceManifest manifest; - - @SerializedName("enabled") - private boolean enabled; - - @SerializedName("installedAt") - private long installedAt; - - @SerializedName("source") - private String source; // marketplace URL or "builtin" - - // Runtime state (not persisted) - private transient Map files = new ConcurrentHashMap<>(); // path -> content - private transient boolean running; - - public InstalledService() { - this.enabled = false; - this.running = false; - this.installedAt = System.currentTimeMillis(); - } - - public InstalledService(ServiceManifest manifest, String source) { - this(); - this.manifest = manifest; - this.source = source; - } - - // Getters and setters - public ServiceManifest getManifest() { return manifest; } - public void setManifest(ServiceManifest manifest) { this.manifest = manifest; } - - public boolean isEnabled() { return enabled; } - public void setEnabled(boolean enabled) { this.enabled = enabled; } - - public long getInstalledAt() { return installedAt; } - public void setInstalledAt(long installedAt) { this.installedAt = installedAt; } - - public String getSource() { return source; } - public void setSource(String source) { this.source = source; } - - public Map getFiles() { return files; } - public void setFiles(Map files) { this.files = files; } - - public boolean isRunning() { return running; } - public void setRunning(boolean running) { this.running = running; } - - /** - * Get file content for an endpoint path. - */ - public String getFileContent(String path) { - if (manifest == null || manifest.getEndpoints() == null) { - return null; - } - - for (ServiceManifest.Endpoint ep : manifest.getEndpoints()) { - if (path.equals(ep.getPath())) { - return files.get(ep.getFile()); - } - } - return null; - } - - /** - * Get service name. - */ - public String getName() { - return manifest != null ? manifest.getName() : null; - } - - /** - * Get display name. - */ - public String getDisplayName() { - return manifest != null ? manifest.getDisplayName() : null; - } - - /** - * Convert to JSON for API. - */ - public JsonObject toJson() { - JsonObject obj = new JsonObject(); - if (manifest != null) { - obj.add("manifest", manifest.toJson()); - } - obj.addProperty("enabled", enabled); - obj.addProperty("running", running); - obj.addProperty("installedAt", installedAt); - obj.addProperty("source", source); - return obj; - } - - /** - * Convert to JSON string for persistence. - */ - public String toJsonString() { - return gson.toJson(this); - } - - /** - * Parse from JSON string. - */ - public static InstalledService fromJson(String json) { - return gson.fromJson(json, InstalledService.class); - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/marketplace/MarketplaceService.java b/android/app/src/main/java/seven/lab/wstun/marketplace/MarketplaceService.java deleted file mode 100644 index cd17294..0000000 --- a/android/app/src/main/java/seven/lab/wstun/marketplace/MarketplaceService.java +++ /dev/null @@ -1,503 +0,0 @@ -package seven.lab.wstun.marketplace; - -import android.content.Context; -import android.content.SharedPreferences; -import android.util.Log; - -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonElement; -import com.google.gson.JsonObject; -import com.google.gson.reflect.TypeToken; - -import java.io.BufferedReader; -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.io.OutputStreamWriter; -import java.lang.reflect.Type; -import java.net.HttpURLConnection; -import java.net.URL; -import java.nio.charset.StandardCharsets; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; - -/** - * Manages marketplace operations: browsing, downloading, installing services. - * - * Marketplace API: - * - GET {baseUrl}/list - Returns JSON array of available services with name, description - * - GET {baseUrl}/service/{name}.json - Returns service manifest - * - GET {baseUrl}/service/{name}/{file} - Downloads service file - */ -public class MarketplaceService { - - private static final String TAG = "MarketplaceService"; - private static final String PREFS_NAME = "wstun_marketplace"; - private static final String KEY_INSTALLED_SERVICES = "installed_services"; - private static final String KEY_MARKETPLACE_URL = "marketplace_url"; - private static final Gson gson = new Gson(); - - private final Context context; - private final File servicesDir; - private final SharedPreferences prefs; - private final ExecutorService executor; - - // Installed services cache - private final Map installedServices = new ConcurrentHashMap<>(); - - // Callback interfaces - public interface MarketplaceCallback { - void onSuccess(T result); - void onError(String error); - } - - public MarketplaceService(Context context) { - this.context = context; - this.prefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE); - this.executor = Executors.newCachedThreadPool(); - - // Create services directory - this.servicesDir = new File(context.getFilesDir(), "services"); - if (!servicesDir.exists()) { - servicesDir.mkdirs(); - } - - // Load installed services - loadInstalledServices(); - - // Load built-in services - loadBuiltinServices(); - } - - /** - * Load installed services from storage. - */ - private void loadInstalledServices() { - String json = prefs.getString(KEY_INSTALLED_SERVICES, "{}"); - try { - Type type = new TypeToken>(){}.getType(); - Map loaded = gson.fromJson(json, type); - if (loaded != null) { - installedServices.putAll(loaded); - } - - // Load file contents from disk - for (InstalledService service : installedServices.values()) { - loadServiceFiles(service); - } - - Log.i(TAG, "Loaded " + installedServices.size() + " installed services"); - } catch (Exception e) { - Log.e(TAG, "Failed to load installed services", e); - } - } - - /** - * Load built-in services (fileshare, chat) as installed services. - */ - private void loadBuiltinServices() { - // Load fileshare - if (!installedServices.containsKey("fileshare")) { - InstalledService fileshare = createBuiltinService("fileshare", "FileShare", - "Share files with others in real-time"); - installedServices.put("fileshare", fileshare); - } - - // Load chat - if (!installedServices.containsKey("chat")) { - InstalledService chat = createBuiltinService("chat", "Chat", - "Real-time chat rooms"); - installedServices.put("chat", chat); - } - - // Load built-in HTML from assets - loadBuiltinAssets(); - } - - /** - * Create a built-in service entry. - */ - private InstalledService createBuiltinService(String name, String displayName, String description) { - ServiceManifest manifest = new ServiceManifest(); - manifest.setName(name); - manifest.setDisplayName(displayName); - manifest.setDescription(description); - manifest.setVersion("1.0.0"); - manifest.setAuthor("Built-in"); - - List endpoints = new ArrayList<>(); - - ServiceManifest.Endpoint serviceEp = new ServiceManifest.Endpoint(); - serviceEp.setPath("/service"); - serviceEp.setFile("index.html"); - serviceEp.setType("service"); - endpoints.add(serviceEp); - - ServiceManifest.Endpoint mainEp = new ServiceManifest.Endpoint(); - mainEp.setPath("/main"); - mainEp.setFile("main.html"); - mainEp.setType("client"); - endpoints.add(mainEp); - - manifest.setEndpoints(endpoints); - - InstalledService service = new InstalledService(manifest, "builtin"); - service.setInstalledAt(0); // Built-in - service.setEnabled(false); // Disabled by default - user enables on the fly - return service; - } - - /** - * Load built-in HTML assets. - */ - private void loadBuiltinAssets() { - try { - InstalledService fileshare = installedServices.get("fileshare"); - if (fileshare != null && fileshare.getFiles().isEmpty()) { - fileshare.getFiles().put("index.html", loadAsset("services/fileshare/index.html")); - fileshare.getFiles().put("main.html", loadAsset("services/fileshare/main.html")); - } - - InstalledService chat = installedServices.get("chat"); - if (chat != null && chat.getFiles().isEmpty()) { - chat.getFiles().put("index.html", loadAsset("services/chat/index.html")); - chat.getFiles().put("main.html", loadAsset("services/chat/main.html")); - } - } catch (IOException e) { - Log.e(TAG, "Failed to load built-in assets", e); - } - } - - private String loadAsset(String path) throws IOException { - InputStream is = context.getAssets().open(path); - BufferedReader reader = new BufferedReader(new InputStreamReader(is, StandardCharsets.UTF_8)); - StringBuilder sb = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - sb.append(line).append("\n"); - } - reader.close(); - return sb.toString(); - } - - /** - * Save installed services to storage. - */ - private void saveInstalledServices() { - try { - // Create a copy without file contents for storage - Map toSave = new ConcurrentHashMap<>(); - for (Map.Entry entry : installedServices.entrySet()) { - if (!"builtin".equals(entry.getValue().getSource())) { - toSave.put(entry.getKey(), entry.getValue()); - } - } - String json = gson.toJson(toSave); - prefs.edit().putString(KEY_INSTALLED_SERVICES, json).apply(); - } catch (Exception e) { - Log.e(TAG, "Failed to save installed services", e); - } - } - - /** - * Load service files from disk. - */ - private void loadServiceFiles(InstalledService service) { - if (service == null || service.getManifest() == null) return; - if ("builtin".equals(service.getSource())) return; // Built-ins loaded from assets - - File serviceDir = new File(servicesDir, service.getName()); - if (!serviceDir.exists()) return; - - List endpoints = service.getManifest().getEndpoints(); - if (endpoints == null) return; - - for (ServiceManifest.Endpoint ep : endpoints) { - File file = new File(serviceDir, ep.getFile()); - if (file.exists()) { - try { - String content = readFile(file); - service.getFiles().put(ep.getFile(), content); - } catch (IOException e) { - Log.e(TAG, "Failed to load file: " + file.getPath(), e); - } - } - } - } - - private String readFile(File file) throws IOException { - FileInputStream fis = new FileInputStream(file); - BufferedReader reader = new BufferedReader(new InputStreamReader(fis, StandardCharsets.UTF_8)); - StringBuilder sb = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - sb.append(line).append("\n"); - } - reader.close(); - return sb.toString(); - } - - /** - * Get or set marketplace URL. - */ - public String getMarketplaceUrl() { - return prefs.getString(KEY_MARKETPLACE_URL, ""); - } - - public void setMarketplaceUrl(String url) { - prefs.edit().putString(KEY_MARKETPLACE_URL, url).apply(); - } - - /** - * List services from marketplace. - */ - public void listMarketplace(String baseUrl, MarketplaceCallback> callback) { - executor.execute(() -> { - try { - String listUrl = baseUrl.endsWith("/") ? baseUrl + "list" : baseUrl + "/list"; - String json = httpGet(listUrl); - JsonArray arr = gson.fromJson(json, JsonArray.class); - - List services = new ArrayList<>(); - for (JsonElement el : arr) { - services.add(el.getAsJsonObject()); - } - - callback.onSuccess(services); - } catch (Exception e) { - Log.e(TAG, "Failed to list marketplace", e); - callback.onError("Failed to fetch marketplace: " + e.getMessage()); - } - }); - } - - /** - * Get service manifest from marketplace. - */ - public void getServiceManifest(String baseUrl, String serviceName, - MarketplaceCallback callback) { - executor.execute(() -> { - try { - String manifestUrl = baseUrl.endsWith("/") - ? baseUrl + "service/" + serviceName + ".json" - : baseUrl + "/service/" + serviceName + ".json"; - String json = httpGet(manifestUrl); - ServiceManifest manifest = ServiceManifest.fromJson(json); - callback.onSuccess(manifest); - } catch (Exception e) { - Log.e(TAG, "Failed to get manifest for: " + serviceName, e); - callback.onError("Failed to fetch manifest: " + e.getMessage()); - } - }); - } - - /** - * Install a service from marketplace. - */ - public void installService(String baseUrl, String serviceName, - MarketplaceCallback callback) { - executor.execute(() -> { - try { - // Get manifest first - String manifestUrl = baseUrl.endsWith("/") - ? baseUrl + "service/" + serviceName + ".json" - : baseUrl + "/service/" + serviceName + ".json"; - String manifestJson = httpGet(manifestUrl); - ServiceManifest manifest = ServiceManifest.fromJson(manifestJson); - - if (manifest == null || manifest.getName() == null) { - callback.onError("Invalid manifest"); - return; - } - - // Create service directory - File serviceDir = new File(servicesDir, manifest.getName()); - if (!serviceDir.exists()) { - serviceDir.mkdirs(); - } - - // Download all endpoint files - InstalledService service = new InstalledService(manifest, baseUrl); - List endpoints = manifest.getEndpoints(); - - if (endpoints != null) { - for (ServiceManifest.Endpoint ep : endpoints) { - String fileUrl = baseUrl.endsWith("/") - ? baseUrl + "service/" + serviceName + "/" + ep.getFile() - : baseUrl + "/service/" + serviceName + "/" + ep.getFile(); - String content = httpGet(fileUrl); - - // Save to disk - File file = new File(serviceDir, ep.getFile()); - writeFile(file, content); - - // Cache in memory - service.getFiles().put(ep.getFile(), content); - } - } - - // Save manifest to disk - File manifestFile = new File(serviceDir, "manifest.json"); - writeFile(manifestFile, manifestJson); - - // Add to installed services - installedServices.put(manifest.getName(), service); - saveInstalledServices(); - - Log.i(TAG, "Installed service: " + manifest.getName()); - callback.onSuccess(service); - - } catch (Exception e) { - Log.e(TAG, "Failed to install service: " + serviceName, e); - callback.onError("Failed to install: " + e.getMessage()); - } - }); - } - - private void writeFile(File file, String content) throws IOException { - FileOutputStream fos = new FileOutputStream(file); - OutputStreamWriter writer = new OutputStreamWriter(fos, StandardCharsets.UTF_8); - writer.write(content); - writer.close(); - } - - /** - * Uninstall a service. - */ - public boolean uninstallService(String serviceName) { - InstalledService service = installedServices.get(serviceName); - if (service == null) { - return false; - } - - // Don't allow uninstalling built-in services - if ("builtin".equals(service.getSource())) { - Log.w(TAG, "Cannot uninstall built-in service: " + serviceName); - return false; - } - - // Delete service directory - File serviceDir = new File(servicesDir, serviceName); - if (serviceDir.exists()) { - deleteDirectory(serviceDir); - } - - // Remove from cache - installedServices.remove(serviceName); - saveInstalledServices(); - - Log.i(TAG, "Uninstalled service: " + serviceName); - return true; - } - - private void deleteDirectory(File dir) { - File[] files = dir.listFiles(); - if (files != null) { - for (File file : files) { - if (file.isDirectory()) { - deleteDirectory(file); - } else { - file.delete(); - } - } - } - dir.delete(); - } - - /** - * Enable a service (attach endpoints). - */ - public boolean enableService(String serviceName) { - InstalledService service = installedServices.get(serviceName); - if (service == null) { - return false; - } - - service.setEnabled(true); - saveInstalledServices(); - Log.i(TAG, "Enabled service: " + serviceName); - return true; - } - - /** - * Disable a service (detach endpoints, close instances). - */ - public boolean disableService(String serviceName) { - InstalledService service = installedServices.get(serviceName); - if (service == null) { - return false; - } - - service.setEnabled(false); - service.setRunning(false); - saveInstalledServices(); - Log.i(TAG, "Disabled service: " + serviceName); - return true; - } - - /** - * Get all installed services. - */ - public Map getInstalledServices() { - return installedServices; - } - - /** - * Get installed service by name. - */ - public InstalledService getInstalledService(String name) { - return installedServices.get(name); - } - - /** - * Check if a service is installed. - */ - public boolean isInstalled(String name) { - return installedServices.containsKey(name); - } - - /** - * HTTP GET request. - */ - private String httpGet(String urlStr) throws IOException { - URL url = new URL(urlStr); - HttpURLConnection conn = (HttpURLConnection) url.openConnection(); - conn.setRequestMethod("GET"); - conn.setConnectTimeout(10000); - conn.setReadTimeout(10000); - - try { - int responseCode = conn.getResponseCode(); - if (responseCode != HttpURLConnection.HTTP_OK) { - throw new IOException("HTTP " + responseCode); - } - - BufferedReader reader = new BufferedReader( - new InputStreamReader(conn.getInputStream(), StandardCharsets.UTF_8)); - StringBuilder sb = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - sb.append(line).append("\n"); - } - reader.close(); - return sb.toString(); - } finally { - conn.disconnect(); - } - } - - /** - * Shutdown executor. - */ - public void shutdown() { - executor.shutdown(); - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/marketplace/ServiceManifest.java b/android/app/src/main/java/seven/lab/wstun/marketplace/ServiceManifest.java deleted file mode 100644 index d0b365b..0000000 --- a/android/app/src/main/java/seven/lab/wstun/marketplace/ServiceManifest.java +++ /dev/null @@ -1,150 +0,0 @@ -package seven.lab.wstun.marketplace; - -import com.google.gson.Gson; -import com.google.gson.JsonObject; -import com.google.gson.annotations.SerializedName; - -import java.util.List; -import java.util.Map; - -/** - * Represents a service manifest downloaded from marketplace. - * The manifest defines service metadata and files to be downloaded. - * - * Example manifest JSON: - * { - * "name": "test", - * "displayName": "Test Service", - * "description": "A test service for demonstration", - * "version": "1.0.0", - * "author": "Example Author", - * "endpoints": [ - * {"path": "/service", "file": "index.html", "type": "service"}, - * {"path": "/main", "file": "main.html", "type": "client"} - * ] - * } - */ -public class ServiceManifest { - - private static final Gson gson = new Gson(); - - @SerializedName("name") - private String name; - - @SerializedName("displayName") - private String displayName; - - @SerializedName("description") - private String description; - - @SerializedName("version") - private String version; - - @SerializedName("author") - private String author; - - @SerializedName("icon") - private String icon; - - @SerializedName("endpoints") - private List endpoints; - - /** - * Represents an endpoint defined by the service. - */ - public static class Endpoint { - @SerializedName("path") - private String path; // e.g., "/service", "/main" - - @SerializedName("file") - private String file; // e.g., "index.html", "main.html" - - @SerializedName("type") - private String type; // "service" or "client" - - @SerializedName("contentType") - private String contentType; // optional, defaults based on file extension - - public String getPath() { return path; } - public void setPath(String path) { this.path = path; } - - public String getFile() { return file; } - public void setFile(String file) { this.file = file; } - - public String getType() { return type; } - public void setType(String type) { this.type = type; } - - public String getContentType() { return contentType; } - public void setContentType(String contentType) { this.contentType = contentType; } - - public JsonObject toJson() { - JsonObject obj = new JsonObject(); - obj.addProperty("path", path); - obj.addProperty("file", file); - obj.addProperty("type", type); - if (contentType != null) { - obj.addProperty("contentType", contentType); - } - return obj; - } - } - - // Getters and setters - public String getName() { return name; } - public void setName(String name) { this.name = name; } - - public String getDisplayName() { return displayName != null ? displayName : name; } - public void setDisplayName(String displayName) { this.displayName = displayName; } - - public String getDescription() { return description; } - public void setDescription(String description) { this.description = description; } - - public String getVersion() { return version; } - public void setVersion(String version) { this.version = version; } - - public String getAuthor() { return author; } - public void setAuthor(String author) { this.author = author; } - - public String getIcon() { return icon; } - public void setIcon(String icon) { this.icon = icon; } - - public List getEndpoints() { return endpoints; } - public void setEndpoints(List endpoints) { this.endpoints = endpoints; } - - /** - * Parse manifest from JSON string. - */ - public static ServiceManifest fromJson(String json) { - return gson.fromJson(json, ServiceManifest.class); - } - - /** - * Convert to JSON string. - */ - public String toJsonString() { - return gson.toJson(this); - } - - /** - * Convert to JsonObject for API responses. - */ - public JsonObject toJson() { - JsonObject obj = new JsonObject(); - obj.addProperty("name", name); - obj.addProperty("displayName", getDisplayName()); - obj.addProperty("description", description); - obj.addProperty("version", version); - obj.addProperty("author", author); - if (icon != null) { - obj.addProperty("icon", icon); - } - if (endpoints != null) { - com.google.gson.JsonArray arr = new com.google.gson.JsonArray(); - for (Endpoint ep : endpoints) { - arr.add(ep.toJson()); - } - obj.add("endpoints", arr); - } - return obj; - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/protocol/HttpRelayRequest.java b/android/app/src/main/java/seven/lab/wstun/protocol/HttpRelayRequest.java deleted file mode 100644 index 7197f2d..0000000 --- a/android/app/src/main/java/seven/lab/wstun/protocol/HttpRelayRequest.java +++ /dev/null @@ -1,88 +0,0 @@ -package seven.lab.wstun.protocol; - -import com.google.gson.annotations.SerializedName; - -import java.util.Map; - -/** - * HTTP request relayed to a service client. - */ -public class HttpRelayRequest { - - @SerializedName("request_id") - private String requestId; - - @SerializedName("method") - private String method; - - @SerializedName("path") - private String path; - - @SerializedName("query") - private String query; - - @SerializedName("headers") - private Map headers; - - @SerializedName("body") - private String body; - - @SerializedName("body_base64") - private String bodyBase64; // For binary data - - public String getRequestId() { - return requestId; - } - - public void setRequestId(String requestId) { - this.requestId = requestId; - } - - public String getMethod() { - return method; - } - - public void setMethod(String method) { - this.method = method; - } - - public String getPath() { - return path; - } - - public void setPath(String path) { - this.path = path; - } - - public String getQuery() { - return query; - } - - public void setQuery(String query) { - this.query = query; - } - - public Map getHeaders() { - return headers; - } - - public void setHeaders(Map headers) { - this.headers = headers; - } - - public String getBody() { - return body; - } - - public void setBody(String body) { - this.body = body; - } - - public String getBodyBase64() { - return bodyBase64; - } - - public void setBodyBase64(String bodyBase64) { - this.bodyBase64 = bodyBase64; - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/protocol/HttpRelayResponse.java b/android/app/src/main/java/seven/lab/wstun/protocol/HttpRelayResponse.java deleted file mode 100644 index 8b18582..0000000 --- a/android/app/src/main/java/seven/lab/wstun/protocol/HttpRelayResponse.java +++ /dev/null @@ -1,99 +0,0 @@ -package seven.lab.wstun.protocol; - -import com.google.gson.annotations.SerializedName; - -import java.util.Map; - -/** - * HTTP response from a service client. - */ -public class HttpRelayResponse { - - @SerializedName("request_id") - private String requestId; - - @SerializedName("status") - private int status; - - @SerializedName("headers") - private Map headers; - - @SerializedName("body") - private String body; - - @SerializedName("body_base64") - private String bodyBase64; // For binary data - - @SerializedName("streaming") - private boolean streaming; - - @SerializedName("chunk_index") - private int chunkIndex; - - @SerializedName("is_final") - private boolean isFinal; - - public String getRequestId() { - return requestId; - } - - public void setRequestId(String requestId) { - this.requestId = requestId; - } - - public int getStatus() { - return status; - } - - public void setStatus(int status) { - this.status = status; - } - - public Map getHeaders() { - return headers; - } - - public void setHeaders(Map headers) { - this.headers = headers; - } - - public String getBody() { - return body; - } - - public void setBody(String body) { - this.body = body; - } - - public String getBodyBase64() { - return bodyBase64; - } - - public void setBodyBase64(String bodyBase64) { - this.bodyBase64 = bodyBase64; - } - - public boolean isStreaming() { - return streaming; - } - - public void setStreaming(boolean streaming) { - this.streaming = streaming; - } - - public int getChunkIndex() { - return chunkIndex; - } - - public void setChunkIndex(int chunkIndex) { - this.chunkIndex = chunkIndex; - } - - public boolean isFinal() { - return isFinal; - } - - public void setFinal(boolean aFinal) { - isFinal = aFinal; - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/protocol/Message.java b/android/app/src/main/java/seven/lab/wstun/protocol/Message.java deleted file mode 100644 index dcb5726..0000000 --- a/android/app/src/main/java/seven/lab/wstun/protocol/Message.java +++ /dev/null @@ -1,155 +0,0 @@ -package seven.lab.wstun.protocol; - -import com.google.gson.Gson; -import com.google.gson.JsonObject; -import com.google.gson.annotations.SerializedName; - -/** - * Base message class for WebSocket communication. - * Supports both text (JSON) and binary modes. - */ -public class Message { - - private static final Gson gson = new Gson(); - - // Message types - public static final String TYPE_REGISTER = "register"; - public static final String TYPE_UNREGISTER = "unregister"; - public static final String TYPE_DATA = "data"; - public static final String TYPE_FILE_REQUEST = "file_request"; - public static final String TYPE_FILE_DATA = "file_data"; - public static final String TYPE_FILE_END = "file_end"; - public static final String TYPE_CHAT_MESSAGE = "chat_message"; - public static final String TYPE_CHAT_JOIN = "chat_join"; - public static final String TYPE_CHAT_LEAVE = "chat_leave"; - public static final String TYPE_ERROR = "error"; - public static final String TYPE_ACK = "ack"; - public static final String TYPE_RELAY_REGISTER = "relay_register"; - public static final String TYPE_RELAY_REQUEST = "relay_request"; - public static final String TYPE_HTTP_REQUEST = "http_request"; - public static final String TYPE_HTTP_RESPONSE = "http_response"; - - // Streaming response types for large file transfers - public static final String TYPE_HTTP_RESPONSE_START = "http_response_start"; - public static final String TYPE_HTTP_RESPONSE_CHUNK = "http_response_chunk"; - - // File registry types for relay file sharing - public static final String TYPE_FILE_REGISTER = "file_register"; - public static final String TYPE_FILE_UNREGISTER = "file_unregister"; - public static final String TYPE_FILE_LIST = "file_list"; - - // Client registration (for user clients, not services) - public static final String TYPE_CLIENT_REGISTER = "client_register"; - - // Chat broadcast types - public static final String TYPE_CHAT_USER_LIST = "chat_user_list"; - - // Service management - public static final String TYPE_KICK_CLIENT = "kick_client"; - - // Instance management - public static final String TYPE_CREATE_INSTANCE = "create_instance"; - public static final String TYPE_JOIN_INSTANCE = "join_instance"; - public static final String TYPE_LEAVE_INSTANCE = "leave_instance"; - public static final String TYPE_LIST_INSTANCES = "list_instances"; - public static final String TYPE_INSTANCE_LIST = "instance_list"; - public static final String TYPE_INSTANCE_CREATED = "instance_created"; - public static final String TYPE_RECLAIM_INSTANCE = "reclaim_instance"; - public static final String TYPE_INSTANCE_RECLAIMED = "instance_reclaimed"; - - // Instance-scoped messages (for fileshare/chat within a room) - public static final String TYPE_USER_JOIN = "user_join"; - public static final String TYPE_USER_LEAVE = "user_leave"; - public static final String TYPE_FILE_ADD = "file_add"; - public static final String TYPE_FILE_REMOVE = "file_remove"; - public static final String TYPE_REQUEST_STATE = "request_state"; - public static final String TYPE_STATE_RESPONSE = "state_response"; - - @SerializedName("type") - private String type; - - @SerializedName("id") - private String id; - - @SerializedName("service") - private String service; - - @SerializedName("payload") - private JsonObject payload; - - @SerializedName("binary") - private boolean binary; - - public Message() { - } - - public Message(String type) { - this.type = type; - this.id = generateId(); - } - - public Message(String type, String service) { - this(type); - this.service = service; - } - - private String generateId() { - return String.valueOf(System.currentTimeMillis()) + "-" + (int)(Math.random() * 10000); - } - - public String getType() { - return type; - } - - public void setType(String type) { - this.type = type; - } - - public String getId() { - return id; - } - - public void setId(String id) { - this.id = id; - } - - public String getService() { - return service; - } - - public void setService(String service) { - this.service = service; - } - - public JsonObject getPayload() { - return payload; - } - - public void setPayload(JsonObject payload) { - this.payload = payload; - } - - public boolean isBinary() { - return binary; - } - - public void setBinary(boolean binary) { - this.binary = binary; - } - - public String toJson() { - return gson.toJson(this); - } - - public static Message fromJson(String json) { - return gson.fromJson(json, Message.class); - } - - public static T payloadAs(JsonObject payload, Class clazz) { - return gson.fromJson(payload, clazz); - } - - public static JsonObject toPayload(Object obj) { - return gson.toJsonTree(obj).getAsJsonObject(); - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/protocol/ServiceRegistration.java b/android/app/src/main/java/seven/lab/wstun/protocol/ServiceRegistration.java deleted file mode 100644 index 8d78202..0000000 --- a/android/app/src/main/java/seven/lab/wstun/protocol/ServiceRegistration.java +++ /dev/null @@ -1,124 +0,0 @@ -package seven.lab.wstun.protocol; - -import com.google.gson.annotations.SerializedName; - -import java.util.List; -import java.util.Map; - -/** - * Service registration data sent by clients. - */ -public class ServiceRegistration { - - @SerializedName("name") - private String name; - - @SerializedName("type") - private String type; - - @SerializedName("description") - private String description; - - @SerializedName("endpoints") - private List endpoints; - - @SerializedName("static_resources") - private Map staticResources; // path -> content - - @SerializedName("auth_token") - private String authToken; // Optional auth token for clients to access this service - - public static class Endpoint { - @SerializedName("path") - private String path; - - @SerializedName("method") - private String method; // GET, POST, etc. - - @SerializedName("description") - private String description; - - @SerializedName("relay") - private boolean relay; // Whether to relay requests to the client - - public String getPath() { - return path; - } - - public void setPath(String path) { - this.path = path; - } - - public String getMethod() { - return method; - } - - public void setMethod(String method) { - this.method = method; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public boolean isRelay() { - return relay; - } - - public void setRelay(boolean relay) { - this.relay = relay; - } - } - - public String getName() { - return name; - } - - public void setName(String name) { - this.name = name; - } - - public String getType() { - return type; - } - - public void setType(String type) { - this.type = type; - } - - public String getDescription() { - return description; - } - - public void setDescription(String description) { - this.description = description; - } - - public List getEndpoints() { - return endpoints; - } - - public void setEndpoints(List endpoints) { - this.endpoints = endpoints; - } - - public Map getStaticResources() { - return staticResources; - } - - public void setStaticResources(Map staticResources) { - this.staticResources = staticResources; - } - - public String getAuthToken() { - return authToken; - } - - public void setAuthToken(String authToken) { - this.authToken = authToken; - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/server/HttpHandler.java b/android/app/src/main/java/seven/lab/wstun/server/HttpHandler.java deleted file mode 100644 index 4d08699..0000000 --- a/android/app/src/main/java/seven/lab/wstun/server/HttpHandler.java +++ /dev/null @@ -1,1448 +0,0 @@ -package seven.lab.wstun.server; - -import android.util.Base64; -import android.util.Log; - -import com.google.gson.Gson; -import com.google.gson.JsonObject; - -import java.io.BufferedReader; -import java.io.InputStreamReader; -import java.nio.charset.StandardCharsets; -import java.util.HashMap; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -import io.netty.handler.codec.http.DefaultHttpContent; - -import io.netty.buffer.ByteBuf; -import io.netty.buffer.Unpooled; -import io.netty.channel.Channel; -import io.netty.channel.ChannelFutureListener; -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.SimpleChannelInboundHandler; -import io.netty.handler.codec.http.DefaultFullHttpResponse; -import io.netty.handler.codec.http.DefaultHttpResponse; -import io.netty.handler.codec.http.FullHttpRequest; -import io.netty.handler.codec.http.FullHttpResponse; -import io.netty.handler.codec.http.HttpHeaderNames; -import io.netty.handler.codec.http.HttpHeaderValues; -import io.netty.handler.codec.http.HttpMethod; -import io.netty.handler.codec.http.HttpResponse; -import io.netty.handler.codec.http.HttpResponseStatus; -import io.netty.handler.codec.http.HttpUtil; -import io.netty.handler.codec.http.HttpVersion; -import io.netty.handler.codec.http.LastHttpContent; -import io.netty.handler.codec.http.QueryStringDecoder; -import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; -import io.netty.handler.codec.http.websocketx.WebSocketServerHandshaker; -import io.netty.handler.codec.http.websocketx.WebSocketServerHandshakerFactory; -import seven.lab.wstun.config.ServerConfig; -import seven.lab.wstun.protocol.HttpRelayRequest; -import seven.lab.wstun.protocol.HttpRelayResponse; -import seven.lab.wstun.protocol.Message; - -/** - * HTTP request handler that routes requests to registered services. - */ -public class HttpHandler extends SimpleChannelInboundHandler { - - private static final String TAG = "HttpHandler"; - private static final Gson gson = new Gson(); - - private final ServiceManager serviceManager; - private final RequestManager requestManager; - private final LocalServiceManager localServiceManager; - private final boolean ssl; - private final String corsOrigins; - private final int port; - private final ServerConfig serverConfig; - - private WebSocketServerHandshaker handshaker; - - // Static reference for relay responses - private static String staticCorsOrigins = "*"; - - // Static reference for server config (for WebSocket handler) - private static ServerConfig staticServerConfig; - - // Track channels with active streaming responses - private static final Map streamingResponses = new ConcurrentHashMap<>(); - - public HttpHandler(ServiceManager serviceManager, RequestManager requestManager, - LocalServiceManager localServiceManager, boolean ssl, String corsOrigins, int port, - ServerConfig serverConfig) { - this.serviceManager = serviceManager; - this.requestManager = requestManager; - this.localServiceManager = localServiceManager; - this.ssl = ssl; - this.corsOrigins = corsOrigins != null ? corsOrigins : "*"; - this.port = port; - this.serverConfig = serverConfig; - staticCorsOrigins = this.corsOrigins; - staticServerConfig = serverConfig; - } - - public HttpHandler(ServiceManager serviceManager, RequestManager requestManager, boolean ssl) { - this(serviceManager, requestManager, null, ssl, "*", 8080, null); - } - - public static ServerConfig getServerConfig() { - return staticServerConfig; - } - - @Override - protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest request) throws Exception { - Log.d(TAG, "HTTP Request: " + request.method() + " " + request.uri()); - try { - // Check for WebSocket upgrade - if (isWebSocketUpgrade(request)) { - handleWebSocketUpgrade(ctx, request); - return; - } - - // Handle HTTP request - handleHttpRequest(ctx, request); - } catch (Exception e) { - Log.e(TAG, "Error handling HTTP request", e); - try { - sendInternalServerError(ctx, request, e.getMessage()); - } catch (Exception e2) { - Log.e(TAG, "Error sending error response", e2); - ctx.close(); - } - } - } - - private boolean isWebSocketUpgrade(FullHttpRequest request) { - String upgrade = request.headers().get(HttpHeaderNames.UPGRADE); - return "websocket".equalsIgnoreCase(upgrade); - } - - private void handleWebSocketUpgrade(ChannelHandlerContext ctx, FullHttpRequest request) { - String wsUrl = (ssl ? "wss" : "ws") + "://" + request.headers().get(HttpHeaderNames.HOST) + "/ws"; - - WebSocketServerHandshakerFactory wsFactory = new WebSocketServerHandshakerFactory( - wsUrl, null, true, 65536 - ); - - handshaker = wsFactory.newHandshaker(request); - if (handshaker == null) { - WebSocketServerHandshakerFactory.sendUnsupportedVersionResponse(ctx.channel()); - } else { - handshaker.handshake(ctx.channel(), request).addListener(future -> { - if (future.isSuccess()) { - // Replace this handler with WebSocket handler - ctx.pipeline().replace(this, "websocket", - new WebSocketHandler(serviceManager, requestManager)); - Log.i(TAG, "WebSocket connection established"); - } - }); - } - } - - private void handleHttpRequest(ChannelHandlerContext ctx, FullHttpRequest request) { - String uri = request.uri(); - QueryStringDecoder decoder = new QueryStringDecoder(uri); - String path = decoder.path(); - - // Only log at INFO level to reduce overhead - // Log.d(TAG, "HTTP " + request.method() + " " + path); - - // Handle CORS preflight - if (request.method() == HttpMethod.OPTIONS) { - sendCorsPreflightResponse(ctx, request); - return; - } - - // Check server auth (skip for libwstun.js which is public) - if (!"/libwstun.js".equals(path) && !validateServerAuth(request)) { - sendUnauthorized(ctx, request, "Invalid or missing server auth token"); - return; - } - - // Root path - show server info - if ("/".equals(path)) { - sendServerInfo(ctx, request); - return; - } - - // Admin/management page - if ("/admin".equals(path) || "/admin/".equals(path)) { - sendAdminPage(ctx, request); - return; - } - - // Debug logs endpoint - streams logcat (only if enabled in config) - if ("/debug/logs".equals(path)) { - if (serverConfig != null && serverConfig.isDebugLogsEnabled()) { - handleDebugLogs(ctx, request); - } else { - sendNotFound(ctx, request); - } - return; - } - - // Serve libwstun.js library (public, no auth) - if ("/libwstun.js".equals(path)) { - sendLibWstun(ctx, request); - return; - } - - // Parse service name from path - String[] pathParts = path.split("/"); - if (pathParts.length < 2) { - sendNotFound(ctx, request); - return; - } - - String serviceName = pathParts[1]; - - // Handle marketplace and service management API - if ("_api".equals(serviceName)) { - handleApiRequest(ctx, request, pathParts); - return; - } - - // Check for local/installed service endpoints - if (localServiceManager != null) { - seven.lab.wstun.marketplace.InstalledService installedSvc = localServiceManager.getInstalledService(serviceName); - if (installedSvc != null) { - if (handleLocalService(ctx, request, serviceName, pathParts)) { - return; - } - } - } - - // Check for file download requests (/fileshare/download/{fileId}) - if ("fileshare".equals(serviceName) && pathParts.length >= 4 && "download".equals(pathParts[2])) { - handleFileDownload(ctx, request, pathParts); - return; - } - - ServiceManager.ServiceEntry service = serviceManager.getService(serviceName); - - if (service == null) { - sendNotFound(ctx, request); - return; - } - - // Check for static resources - if (service.getRegistration().getStaticResources() != null) { - String resourcePath = path.substring(serviceName.length() + 1); - String content = service.getRegistration().getStaticResources().get(resourcePath); - if (content != null) { - sendStaticResource(ctx, request, content, resourcePath); - return; - } - } - - // Relay request to service client - relayRequest(ctx, request, service, path); - } - - private void relayRequest(ChannelHandlerContext ctx, FullHttpRequest request, - ServiceManager.ServiceEntry service, String path) { - // Create relay request - String requestId = String.valueOf(System.currentTimeMillis()) + "-" + (int)(Math.random() * 10000); - - HttpRelayRequest relayRequest = new HttpRelayRequest(); - relayRequest.setRequestId(requestId); - relayRequest.setMethod(request.method().name()); - relayRequest.setPath(path); - - QueryStringDecoder decoder = new QueryStringDecoder(request.uri()); - relayRequest.setQuery(request.uri().contains("?") ? - request.uri().substring(request.uri().indexOf("?") + 1) : ""); - - // Copy headers - Map headers = new HashMap<>(); - for (Map.Entry entry : request.headers()) { - headers.put(entry.getKey(), entry.getValue()); - } - relayRequest.setHeaders(headers); - - // Copy body - ByteBuf content = request.content(); - if (content.readableBytes() > 0) { - byte[] bodyBytes = new byte[content.readableBytes()]; - content.readBytes(bodyBytes); - - // Check if binary - String contentType = request.headers().get(HttpHeaderNames.CONTENT_TYPE); - if (contentType != null && isBinaryContentType(contentType)) { - relayRequest.setBodyBase64(Base64.encodeToString(bodyBytes, Base64.NO_WRAP)); - } else { - relayRequest.setBody(new String(bodyBytes, StandardCharsets.UTF_8)); - } - } - - // Store pending request - PendingRequest pending = new PendingRequest(requestId, ctx, request, service.getName()); - requestManager.addPendingRequest(pending); - - // Send to service via WebSocket - Message message = new Message(Message.TYPE_HTTP_REQUEST, service.getName()); - message.setPayload(Message.toPayload(relayRequest)); - - if (service.getChannel() != null && service.getChannel().isActive()) { - service.getChannel().writeAndFlush(new TextWebSocketFrame(message.toJson())); - } else { - requestManager.removePendingRequest(requestId); - sendServiceUnavailable(ctx, request); - } - } - - private boolean isBinaryContentType(String contentType) { - return contentType.startsWith("application/octet-stream") || - contentType.startsWith("image/") || - contentType.startsWith("audio/") || - contentType.startsWith("video/"); - } - - /** - * Send relay response to HTTP client. - */ - public static void sendRelayResponse(ChannelHandlerContext ctx, HttpRelayResponse response) { - HttpResponseStatus status = HttpResponseStatus.valueOf(response.getStatus()); - - byte[] body; - if (response.getBodyBase64() != null) { - body = Base64.decode(response.getBodyBase64(), Base64.NO_WRAP); - } else if (response.getBody() != null) { - body = response.getBody().getBytes(StandardCharsets.UTF_8); - } else { - body = new byte[0]; - } - - FullHttpResponse httpResponse = new DefaultFullHttpResponse( - HttpVersion.HTTP_1_1, - status, - Unpooled.wrappedBuffer(body) - ); - - // Add CORS headers using static method - addStaticCorsHeaders(httpResponse); - - // Copy headers - if (response.getHeaders() != null) { - for (Map.Entry entry : response.getHeaders().entrySet()) { - httpResponse.headers().set(entry.getKey(), entry.getValue()); - } - } - - httpResponse.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, body.length); - httpResponse.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE); - - ctx.writeAndFlush(httpResponse).addListener(ChannelFutureListener.CLOSE); - } - - /** - * Handle local service endpoints (/fileshare/service, /chat/service, etc.) - * Returns true if the request was handled. - */ - private boolean handleLocalService(ChannelHandlerContext ctx, FullHttpRequest request, - String serviceName, String[] pathParts) { - String subPath = pathParts.length > 2 ? pathParts[2] : ""; - - // Build server URL for generating links - String host = request.headers().get(HttpHeaderNames.HOST); - if (host == null) { - host = "localhost:" + port; - } - String protocol = ssl ? "https" : "http"; - String serverUrl = protocol + "://" + host; - - // /service - Service management page - if ("service".equals(subPath)) { - // Check for API endpoints under /service/api/* - if (pathParts.length > 3 && "api".equals(pathParts[3])) { - return handleLocalServiceApi(ctx, request, serviceName, pathParts); - } - - // Service management page - String html = localServiceManager.getServicePageHtml(serviceName, serverUrl); - if (html == null) { - sendNotFound(ctx, request); - } else { - sendHtmlResponse(ctx, request, html); - } - return true; - } - - // /main - Main service UI (serve directly when service is registered) - if ("main".equals(subPath)) { - // Serve the user client HTML directly from assets (regardless of service running state) - String html = localServiceManager.getServiceMainHtml(serviceName); - if (html != null) { - sendHtmlResponse(ctx, request, html); - return true; - } - - // Fallback: send not found - sendNotFound(ctx, request); - return true; - } - - return false; - } - - /** - * Handle local service API endpoints. - */ - private boolean handleLocalServiceApi(ChannelHandlerContext ctx, FullHttpRequest request, - String serviceName, String[] pathParts) { - if (pathParts.length < 5) { - return false; - } - - String apiAction = pathParts[4]; - - // GET /service/api/status - Get service status - if ("status".equals(apiAction) && request.method() == HttpMethod.GET) { - LocalServiceManager.ServiceStatus status = localServiceManager.getServiceStatus(serviceName); - if (status != null) { - sendJsonResponse(ctx, request, status.toJson().toString()); - } else { - sendJsonResponse(ctx, request, "{\"error\": \"Service not found\"}"); - } - return true; - } - - // POST /service/api/start - Start service - if ("start".equals(apiAction) && request.method() == HttpMethod.POST) { - boolean success = localServiceManager.startService(serviceName); - LocalServiceManager.ServiceStatus status = localServiceManager.getServiceStatus(serviceName); - JsonObject response = new JsonObject(); - response.addProperty("success", success); - if (success && status != null) { - response.addProperty("uuid", status.getUuid()); - } else if (!success) { - response.addProperty("error", "Failed to start service"); - } - sendJsonResponse(ctx, request, response.toString()); - return true; - } - - // POST /service/api/stop - Stop service - if ("stop".equals(apiAction) && request.method() == HttpMethod.POST) { - // Parse request body for UUID - String uuid = null; - ByteBuf content = request.content(); - if (content.readableBytes() > 0) { - byte[] bodyBytes = new byte[content.readableBytes()]; - content.readBytes(bodyBytes); - try { - JsonObject body = gson.fromJson(new String(bodyBytes, StandardCharsets.UTF_8), JsonObject.class); - if (body.has("uuid")) { - uuid = body.get("uuid").getAsString(); - } - } catch (Exception e) { - // Ignore parse errors - } - } - - boolean success; - if (uuid != null) { - success = localServiceManager.stopServiceByUuid(serviceName, uuid); - } else { - success = localServiceManager.stopService(serviceName); - } - - JsonObject response = new JsonObject(); - response.addProperty("success", success); - if (!success) { - response.addProperty("error", "Failed to stop service (UUID mismatch or service not running)"); - } - sendJsonResponse(ctx, request, response.toString()); - return true; - } - - // GET /service/api/clients - List connected clients - if ("clients".equals(apiAction) && request.method() == HttpMethod.GET) { - java.util.List clients = serviceManager.getClientsByType(serviceName); - com.google.gson.JsonArray clientsArray = new com.google.gson.JsonArray(); - for (ServiceManager.ClientInfo client : clients) { - JsonObject clientObj = new JsonObject(); - clientObj.addProperty("userId", client.getUserId()); - clientObj.addProperty("clientType", client.getClientType()); - clientObj.addProperty("connectedAt", client.getConnectedAt()); - clientObj.addProperty("connected", client.getChannel() != null && client.getChannel().isActive()); - clientsArray.add(clientObj); - } - JsonObject response = new JsonObject(); - response.add("clients", clientsArray); - response.addProperty("count", clients.size()); - sendJsonResponse(ctx, request, response.toString()); - return true; - } - - // POST /service/api/kick - Kick a client - if ("kick".equals(apiAction) && request.method() == HttpMethod.POST) { - String userId = null; - ByteBuf content = request.content(); - if (content.readableBytes() > 0) { - byte[] bodyBytes = new byte[content.readableBytes()]; - content.readBytes(bodyBytes); - try { - JsonObject body = gson.fromJson(new String(bodyBytes, StandardCharsets.UTF_8), JsonObject.class); - if (body.has("userId")) { - userId = body.get("userId").getAsString(); - } - } catch (Exception e) { - // Ignore parse errors - } - } - - JsonObject response = new JsonObject(); - if (userId == null || userId.isEmpty()) { - response.addProperty("success", false); - response.addProperty("error", "userId is required"); - } else { - boolean success = serviceManager.kickClient(userId); - response.addProperty("success", success); - if (!success) { - response.addProperty("error", "Client not found: " + userId); - } - } - sendJsonResponse(ctx, request, response.toString()); - return true; - } - - return false; - } - - /** - * Send response when service is not running. - */ - private void sendServiceNotRunningResponse(ChannelHandlerContext ctx, FullHttpRequest request, - String serviceName, String serverUrl) { - String displayName = "fileshare".equals(serviceName) ? "FileShare" : "Chat"; - String html = "\n" + - "\n" + - "" + displayName + " - Not Running\n" + - "\n" + - "

Service Not Running

\n" + - "

The " + displayName + " service is not currently running.

\n" + - "Start Service
"; - sendHtmlResponse(ctx, request, html); - } - - /** - * Handle API requests (/_api/...). - */ - private void handleApiRequest(ChannelHandlerContext ctx, FullHttpRequest request, String[] pathParts) { - if (pathParts.length < 3) { - sendJsonResponse(ctx, request, "{\"error\": \"Invalid API path\"}"); - return; - } - - String apiCategory = pathParts[2]; - - // /_api/services - Service management - if ("services".equals(apiCategory)) { - handleServicesApi(ctx, request, pathParts); - return; - } - - // /_api/instances - Instance management - if ("instances".equals(apiCategory)) { - handleInstancesApi(ctx, request, pathParts); - return; - } - - // /_api/marketplace - Marketplace operations - if ("marketplace".equals(apiCategory)) { - handleMarketplaceApi(ctx, request, pathParts); - return; - } - - sendJsonResponse(ctx, request, "{\"error\": \"Unknown API category\"}"); - } - - /** - * Handle /_api/services endpoints. - */ - private void handleServicesApi(ChannelHandlerContext ctx, FullHttpRequest request, String[] pathParts) { - // GET /_api/services - List all installed services - if (pathParts.length == 3 && request.method() == HttpMethod.GET) { - if (localServiceManager != null) { - JsonObject response = new JsonObject(); - response.add("services", localServiceManager.getInstalledServicesJson()); - sendJsonResponse(ctx, request, response.toString()); - } else { - sendJsonResponse(ctx, request, "{\"services\": []}"); - } - return; - } - - // /_api/services/{name}/... - if (pathParts.length >= 4) { - String serviceName = pathParts[3]; - - // GET /_api/services/{name} - Get service details - if (pathParts.length == 4 && request.method() == HttpMethod.GET) { - seven.lab.wstun.marketplace.InstalledService service = - localServiceManager != null ? localServiceManager.getInstalledService(serviceName) : null; - if (service != null) { - JsonObject obj = service.toJson(); - obj.addProperty("name", serviceName); - obj.addProperty("instanceCount", serviceManager.getInstanceCountForService(serviceName)); - sendJsonResponse(ctx, request, obj.toString()); - } else { - sendJsonResponse(ctx, request, "{\"error\": \"Service not found\"}"); - } - return; - } - - // POST /_api/services/{name}/enable - if (pathParts.length == 5 && "enable".equals(pathParts[4]) && request.method() == HttpMethod.POST) { - boolean success = localServiceManager != null && localServiceManager.enableService(serviceName); - JsonObject response = new JsonObject(); - response.addProperty("success", success); - if (!success) { - response.addProperty("error", "Failed to enable service"); - } - sendJsonResponse(ctx, request, response.toString()); - return; - } - - // POST /_api/services/{name}/disable - if (pathParts.length == 5 && "disable".equals(pathParts[4]) && request.method() == HttpMethod.POST) { - boolean success = localServiceManager != null && - localServiceManager.disableService(serviceName, serviceManager); - JsonObject response = new JsonObject(); - response.addProperty("success", success); - if (!success) { - response.addProperty("error", "Failed to disable service"); - } - sendJsonResponse(ctx, request, response.toString()); - return; - } - - // DELETE /_api/services/{name} - Uninstall service - if (pathParts.length == 4 && request.method() == HttpMethod.DELETE) { - boolean success = localServiceManager != null && - localServiceManager.uninstallService(serviceName, serviceManager); - JsonObject response = new JsonObject(); - response.addProperty("success", success); - if (!success) { - response.addProperty("error", "Failed to uninstall service (may be built-in)"); - } - sendJsonResponse(ctx, request, response.toString()); - return; - } - } - - sendJsonResponse(ctx, request, "{\"error\": \"Invalid services API path\"}"); - } - - /** - * Handle /_api/instances endpoints. - */ - private void handleInstancesApi(ChannelHandlerContext ctx, FullHttpRequest request, String[] pathParts) { - // GET /_api/instances - List all running instances - if (pathParts.length == 3 && request.method() == HttpMethod.GET) { - com.google.gson.JsonArray instancesArr = new com.google.gson.JsonArray(); - for (ServiceManager.ServiceInstance instance : getAllInstances()) { - instancesArr.add(instance.toJson()); - } - JsonObject response = new JsonObject(); - response.add("instances", instancesArr); - sendJsonResponse(ctx, request, response.toString()); - return; - } - - // GET /_api/instances/{service} - List instances for a service - if (pathParts.length == 4 && request.method() == HttpMethod.GET) { - String serviceName = pathParts[3]; - com.google.gson.JsonArray instancesArr = new com.google.gson.JsonArray(); - for (ServiceManager.ServiceInstance instance : serviceManager.getInstancesForService(serviceName)) { - instancesArr.add(instance.toJson()); - } - JsonObject response = new JsonObject(); - response.add("instances", instancesArr); - sendJsonResponse(ctx, request, response.toString()); - return; - } - - sendJsonResponse(ctx, request, "{\"error\": \"Invalid instances API path\"}"); - } - - /** - * Get all instances across all services. - */ - private java.util.List getAllInstances() { - java.util.List all = new java.util.ArrayList<>(); - if (localServiceManager != null) { - for (String serviceName : localServiceManager.getInstalledServices().keySet()) { - all.addAll(serviceManager.getInstancesForService(serviceName)); - } - } - return all; - } - - /** - * Handle /_api/marketplace endpoints. - */ - private void handleMarketplaceApi(ChannelHandlerContext ctx, FullHttpRequest request, String[] pathParts) { - if (localServiceManager == null) { - sendJsonResponse(ctx, request, "{\"error\": \"Marketplace not available\"}"); - return; - } - - seven.lab.wstun.marketplace.MarketplaceService marketplace = - localServiceManager.getMarketplaceService(); - - // GET /_api/marketplace/url - Get current marketplace URL - if (pathParts.length == 4 && "url".equals(pathParts[3]) && request.method() == HttpMethod.GET) { - JsonObject response = new JsonObject(); - response.addProperty("url", marketplace.getMarketplaceUrl()); - sendJsonResponse(ctx, request, response.toString()); - return; - } - - // POST /_api/marketplace/url - Set marketplace URL - if (pathParts.length == 4 && "url".equals(pathParts[3]) && request.method() == HttpMethod.POST) { - String url = getRequestBodyString(request, "url"); - if (url != null) { - marketplace.setMarketplaceUrl(url); - } - JsonObject response = new JsonObject(); - response.addProperty("success", true); - response.addProperty("url", marketplace.getMarketplaceUrl()); - sendJsonResponse(ctx, request, response.toString()); - return; - } - - // POST /_api/marketplace/list - List services from marketplace - if (pathParts.length == 4 && "list".equals(pathParts[3]) && request.method() == HttpMethod.POST) { - String url = getRequestBodyString(request, "url"); - if (url == null || url.isEmpty()) { - url = marketplace.getMarketplaceUrl(); - } - if (url == null || url.isEmpty()) { - sendJsonResponse(ctx, request, "{\"error\": \"No marketplace URL specified\"}"); - return; - } - - final String marketplaceUrl = url; - marketplace.listMarketplace(marketplaceUrl, - new seven.lab.wstun.marketplace.MarketplaceService.MarketplaceCallback>() { - @Override - public void onSuccess(java.util.List result) { - ctx.executor().execute(() -> { - com.google.gson.JsonArray arr = new com.google.gson.JsonArray(); - for (JsonObject svc : result) { - // Add installed status - String name = svc.has("name") ? svc.get("name").getAsString() : null; - if (name != null) { - svc.addProperty("installed", marketplace.isInstalled(name)); - } - arr.add(svc); - } - JsonObject response = new JsonObject(); - response.add("services", arr); - response.addProperty("url", marketplaceUrl); - sendJsonResponse(ctx, request, response.toString()); - }); - } - - @Override - public void onError(String error) { - ctx.executor().execute(() -> { - JsonObject response = new JsonObject(); - response.addProperty("error", error); - sendJsonResponse(ctx, request, response.toString()); - }); - } - }); - return; - } - - // POST /_api/marketplace/install - Install a service - if (pathParts.length == 4 && "install".equals(pathParts[3]) && request.method() == HttpMethod.POST) { - String url = getRequestBodyString(request, "url"); - String name = getRequestBodyString(request, "name"); - - if (url == null || url.isEmpty()) { - url = marketplace.getMarketplaceUrl(); - } - if (name == null || name.isEmpty()) { - sendJsonResponse(ctx, request, "{\"error\": \"Service name required\"}"); - return; - } - - final String marketplaceUrl = url; - final String serviceName = name; - - marketplace.installService(marketplaceUrl, serviceName, - new seven.lab.wstun.marketplace.MarketplaceService.MarketplaceCallback() { - @Override - public void onSuccess(seven.lab.wstun.marketplace.InstalledService result) { - ctx.executor().execute(() -> { - JsonObject response = new JsonObject(); - response.addProperty("success", true); - response.add("service", result.toJson()); - sendJsonResponse(ctx, request, response.toString()); - }); - } - - @Override - public void onError(String error) { - ctx.executor().execute(() -> { - JsonObject response = new JsonObject(); - response.addProperty("success", false); - response.addProperty("error", error); - sendJsonResponse(ctx, request, response.toString()); - }); - } - }); - return; - } - - sendJsonResponse(ctx, request, "{\"error\": \"Invalid marketplace API path\"}"); - } - - /** - * Helper to get a string value from request JSON body. - */ - private String getRequestBodyString(FullHttpRequest request, String key) { - ByteBuf content = request.content(); - if (content.readableBytes() > 0) { - byte[] bodyBytes = new byte[content.readableBytes()]; - content.getBytes(content.readerIndex(), bodyBytes); - try { - JsonObject body = gson.fromJson(new String(bodyBytes, StandardCharsets.UTF_8), JsonObject.class); - if (body.has(key) && !body.get(key).isJsonNull()) { - return body.get(key).getAsString(); - } - } catch (Exception e) { - // Ignore parse errors - } - } - return null; - } - - /** - * Send JSON response. - */ - private void sendJsonResponse(ChannelHandlerContext ctx, FullHttpRequest request, String json) { - byte[] bytes = json.getBytes(StandardCharsets.UTF_8); - FullHttpResponse response = new DefaultFullHttpResponse( - HttpVersion.HTTP_1_1, - HttpResponseStatus.OK, - Unpooled.wrappedBuffer(bytes) - ); - - response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/json; charset=UTF-8"); - response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, bytes.length); - - sendResponse(ctx, request, response); - } - - private void sendServerInfo(ChannelHandlerContext ctx, FullHttpRequest request) { - StringBuilder html = new StringBuilder(); - html.append(""); - html.append(""); - html.append("WSTun Server"); - html.append(""); - html.append("
"); - html.append("

WSTun Server

"); - html.append("

Service Manager

"); - - // Installed services section - if (localServiceManager != null) { - html.append("

Installed Services

"); - html.append("
    "); - - for (java.util.Map.Entry entry : - localServiceManager.getInstalledServices().entrySet()) { - String name = entry.getKey(); - seven.lab.wstun.marketplace.InstalledService svc = entry.getValue(); - String displayName = svc.getDisplayName(); - boolean enabled = svc.isEnabled(); - int instanceCount = serviceManager.getInstanceCountForService(name); - - html.append("
  • ").append(displayName).append(""); - - if (enabled) { - html.append("Enabled"); - if (instanceCount > 0) { - html.append("").append(instanceCount).append(" instances"); - } - html.append(" - Manage"); - } else { - html.append("Disabled"); - } - - html.append("
  • "); - } - - if (localServiceManager.getInstalledServices().isEmpty()) { - html.append("
  • No services installed
  • "); - } - - html.append("
"); - } - - html.append("

Registered Services

"); - - if (serviceManager.getServiceCount() == 0) { - html.append("

No services registered

"); - } else { - html.append("
    "); - for (ServiceManager.ServiceEntry service : serviceManager.getAllServices()) { - html.append("
  • ").append(service.getName()).append(""); - html.append(" (").append(service.getType()).append(")"); - html.append(" - ").append("/").append(service.getName()).append("/main"); - html.append("
  • "); - } - html.append("
"); - } - - html.append("
"); - - sendHtmlResponse(ctx, request, html.toString()); - } - - private void sendStaticResource(ChannelHandlerContext ctx, FullHttpRequest request, - String content, String path) { - String contentType = "text/plain"; - if (path.endsWith(".html")) { - contentType = "text/html; charset=UTF-8"; - } else if (path.endsWith(".js")) { - contentType = "application/javascript"; - } else if (path.endsWith(".css")) { - contentType = "text/css"; - } else if (path.endsWith(".json")) { - contentType = "application/json"; - } - - byte[] bytes = content.getBytes(StandardCharsets.UTF_8); - FullHttpResponse response = new DefaultFullHttpResponse( - HttpVersion.HTTP_1_1, - HttpResponseStatus.OK, - Unpooled.wrappedBuffer(bytes) - ); - - response.headers().set(HttpHeaderNames.CONTENT_TYPE, contentType); - response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, bytes.length); - - sendResponse(ctx, request, response); - } - - private void sendHtmlResponse(ChannelHandlerContext ctx, FullHttpRequest request, String html) { - if (html == null) { - sendNotFound(ctx, request); - return; - } - byte[] bytes = html.getBytes(StandardCharsets.UTF_8); - FullHttpResponse response = new DefaultFullHttpResponse( - HttpVersion.HTTP_1_1, - HttpResponseStatus.OK, - Unpooled.wrappedBuffer(bytes) - ); - - response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/html; charset=UTF-8"); - response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, bytes.length); - - sendResponse(ctx, request, response); - } - - private void sendNotFound(ChannelHandlerContext ctx, FullHttpRequest request) { - FullHttpResponse response = new DefaultFullHttpResponse( - HttpVersion.HTTP_1_1, - HttpResponseStatus.NOT_FOUND, - Unpooled.wrappedBuffer("Not Found".getBytes()) - ); - - response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain"); - response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 9); - - sendResponse(ctx, request, response); - } - - private void sendUnauthorized(ChannelHandlerContext ctx, FullHttpRequest request, String message) { - byte[] bytes = message.getBytes(StandardCharsets.UTF_8); - FullHttpResponse response = new DefaultFullHttpResponse( - HttpVersion.HTTP_1_1, - HttpResponseStatus.UNAUTHORIZED, - Unpooled.wrappedBuffer(bytes) - ); - - response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8"); - response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, bytes.length); - response.headers().set(HttpHeaderNames.WWW_AUTHENTICATE, "Bearer realm=\"wstun\""); - - sendResponse(ctx, request, response); - } - - /** - * Validate server-level authentication. - * Checks Authorization header or ?token query parameter. - */ - private boolean validateServerAuth(FullHttpRequest request) { - if (serverConfig == null || !serverConfig.isAuthEnabled()) { - return true; - } - - String token = null; - - // Check Authorization header (Bearer token) - String authHeader = request.headers().get(HttpHeaderNames.AUTHORIZATION); - if (authHeader != null && authHeader.startsWith("Bearer ")) { - token = authHeader.substring(7); - } - - // Check query parameter as fallback - if (token == null) { - QueryStringDecoder decoder = new QueryStringDecoder(request.uri()); - if (decoder.parameters().containsKey("token")) { - token = decoder.parameters().get("token").get(0); - } - } - - return serverConfig.validateServerAuth(token); - } - - /** - * Serve the admin page. - */ - private void sendAdminPage(ChannelHandlerContext ctx, FullHttpRequest request) { - String html = localServiceManager != null ? localServiceManager.getAdminHtml() : null; - if (html == null) { - sendNotFound(ctx, request); - return; - } - sendHtmlResponse(ctx, request, html); - } - - /** - * Serve the libwstun.js library. - */ - private void sendLibWstun(ChannelHandlerContext ctx, FullHttpRequest request) { - String js = localServiceManager != null ? localServiceManager.getLibWstunJs() : null; - if (js == null) { - sendNotFound(ctx, request); - return; - } - - byte[] bytes = js.getBytes(StandardCharsets.UTF_8); - - FullHttpResponse response = new DefaultFullHttpResponse( - HttpVersion.HTTP_1_1, - HttpResponseStatus.OK, - Unpooled.wrappedBuffer(bytes) - ); - - response.headers().set(HttpHeaderNames.CONTENT_TYPE, "application/javascript; charset=UTF-8"); - response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, bytes.length); - response.headers().set(HttpHeaderNames.CACHE_CONTROL, "public, max-age=3600"); - - sendResponse(ctx, request, response); - } - - /** - * Handle debug logs endpoint - streams logcat to the browser. - */ - private void handleDebugLogs(ChannelHandlerContext ctx, FullHttpRequest request) { - // Send initial response with chunked transfer encoding - HttpResponse response = new DefaultHttpResponse( - HttpVersion.HTTP_1_1, - HttpResponseStatus.OK - ); - - response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8"); - response.headers().set(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.CHUNKED); - response.headers().set(HttpHeaderNames.CACHE_CONTROL, "no-cache"); - response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE); - response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, corsOrigins); - - ctx.writeAndFlush(response); - - // Start a thread to stream logcat - Thread logThread = new Thread(() -> { - Process process = null; - BufferedReader reader = null; - try { - // Start logcat process - filter to show Info and above, with timestamp - process = Runtime.getRuntime().exec("logcat -v time *:I"); - reader = new BufferedReader(new InputStreamReader(process.getInputStream())); - - String line; - while (ctx.channel().isActive() && (line = reader.readLine()) != null) { - // Send each log line as a chunk - String chunk = line + "\n"; - ctx.writeAndFlush(new DefaultHttpContent( - Unpooled.copiedBuffer(chunk, StandardCharsets.UTF_8))); - } - } catch (Exception e) { - Log.e(TAG, "Error streaming logcat", e); - } finally { - // Clean up - if (reader != null) { - try { reader.close(); } catch (Exception ignored) {} - } - if (process != null) { - process.destroy(); - } - - // Send end marker and close - if (ctx.channel().isActive()) { - ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT) - .addListener(ChannelFutureListener.CLOSE); - } - } - }); - logThread.setName("LogcatStreamer"); - logThread.setDaemon(true); - logThread.start(); - } - - private void sendServiceUnavailable(ChannelHandlerContext ctx, FullHttpRequest request) { - FullHttpResponse response = new DefaultFullHttpResponse( - HttpVersion.HTTP_1_1, - HttpResponseStatus.SERVICE_UNAVAILABLE, - Unpooled.wrappedBuffer("Service Unavailable".getBytes()) - ); - - response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain"); - response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 19); - - sendResponse(ctx, request, response); - } - - private void sendInternalServerError(ChannelHandlerContext ctx, FullHttpRequest request, String message) { - String body = "Internal Server Error: " + (message != null ? message : "Unknown error"); - FullHttpResponse response = new DefaultFullHttpResponse( - HttpVersion.HTTP_1_1, - HttpResponseStatus.INTERNAL_SERVER_ERROR, - Unpooled.wrappedBuffer(body.getBytes(StandardCharsets.UTF_8)) - ); - - response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain; charset=UTF-8"); - response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, body.getBytes(StandardCharsets.UTF_8).length); - - sendResponse(ctx, request, response); - } - - private void sendResponse(ChannelHandlerContext ctx, FullHttpRequest request, FullHttpResponse response) { - // Add CORS headers to all responses - addCorsHeaders(response); - - // Ensure Content-Length is set - this is critical for keep-alive to work properly - if (!response.headers().contains(HttpHeaderNames.CONTENT_LENGTH)) { - response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, response.content().readableBytes()); - } - - boolean keepAlive = HttpUtil.isKeepAlive(request); - - if (keepAlive) { - // For keep-alive, set the header and don't close - if (!response.headers().contains(HttpHeaderNames.CONNECTION)) { - response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.KEEP_ALIVE); - } - } else { - // For non-keep-alive, set Connection: close - response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE); - } - - // Write and flush the response - ctx.writeAndFlush(response).addListener(f -> { - if (!keepAlive) { - ctx.close(); - } - }); - } - - /** - * Add CORS headers to response. - */ - private void addCorsHeaders(FullHttpResponse response) { - response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, corsOrigins); - response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_METHODS, "GET, POST, PUT, DELETE, OPTIONS"); - response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_HEADERS, "Content-Type, Authorization, X-Requested-With"); - response.headers().set(HttpHeaderNames.ACCESS_CONTROL_MAX_AGE, "86400"); - } - - /** - * Add CORS headers with static origin (for relay responses). - */ - private static void addStaticCorsHeaders(FullHttpResponse response) { - response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, staticCorsOrigins); - response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_METHODS, "GET, POST, PUT, DELETE, OPTIONS"); - response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_HEADERS, "Content-Type, Authorization, X-Requested-With"); - } - - /** - * Handle CORS preflight (OPTIONS) requests. - */ - private void sendCorsPreflightResponse(ChannelHandlerContext ctx, FullHttpRequest request) { - FullHttpResponse response = new DefaultFullHttpResponse( - HttpVersion.HTTP_1_1, - HttpResponseStatus.OK, - Unpooled.EMPTY_BUFFER - ); - - addCorsHeaders(response); - response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 0); - response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE); - - ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); - } - - @Override - public void channelActive(ChannelHandlerContext ctx) throws Exception { - Log.d(TAG, "Channel active: " + ctx.channel().remoteAddress()); - super.channelActive(ctx); - } - - @Override - public void channelInactive(ChannelHandlerContext ctx) throws Exception { - Log.d(TAG, "Channel inactive: " + ctx.channel().remoteAddress()); - super.channelInactive(ctx); - } - - @Override - public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { - Log.e(TAG, "HTTP handler error", cause); - ctx.close(); - } - - @Override - public void userEventTriggered(ChannelHandlerContext ctx, Object evt) throws Exception { - if (evt instanceof io.netty.handler.timeout.IdleStateEvent) { - Log.d(TAG, "Idle timeout, closing connection"); - ctx.close(); - } else { - super.userEventTriggered(ctx, evt); - } - } - - // ==================== Streaming Response Methods ==================== - - /** - * Start a streaming HTTP response (send headers, prepare for chunks). - */ - public static void startStreamingResponse(ChannelHandlerContext ctx, String requestId, - int status, JsonObject headers) { - HttpResponse response = new DefaultHttpResponse( - HttpVersion.HTTP_1_1, - HttpResponseStatus.valueOf(status) - ); - - // Add CORS headers - response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_ORIGIN, staticCorsOrigins); - response.headers().set(HttpHeaderNames.ACCESS_CONTROL_ALLOW_METHODS, "GET, POST, PUT, DELETE, OPTIONS"); - - // Use chunked transfer encoding for streaming - response.headers().set(HttpHeaderNames.TRANSFER_ENCODING, HttpHeaderValues.CHUNKED); - - // Copy headers from payload - if (headers != null) { - for (String key : headers.keySet()) { - // Skip Content-Length as we're using chunked encoding - if (!key.equalsIgnoreCase("Content-Length")) { - response.headers().set(key, headers.get(key).getAsString()); - } - } - } - - // Flush the headers immediately so the client knows the response has started - ctx.writeAndFlush(response); - streamingResponses.put(requestId, ctx); - Log.d(TAG, "Started streaming response for: " + requestId); - } - - /** - * Send a chunk of streaming data. - */ - public static void sendStreamingChunk(ChannelHandlerContext ctx, String chunkBase64) { - if (ctx == null || !ctx.channel().isActive()) { - return; - } - - byte[] data = Base64.decode(chunkBase64, Base64.NO_WRAP); - ByteBuf buf = Unpooled.wrappedBuffer(data); - ctx.writeAndFlush(new io.netty.handler.codec.http.DefaultHttpContent(buf)); - } - - /** - * End a streaming response. - */ - public static void endStreamingResponse(ChannelHandlerContext ctx) { - if (ctx == null || !ctx.channel().isActive()) { - return; - } - - ctx.writeAndFlush(LastHttpContent.EMPTY_LAST_CONTENT) - .addListener(ChannelFutureListener.CLOSE); - Log.d(TAG, "Ended streaming response"); - } - - /** - * Send error and close streaming response. - */ - public static void sendStreamingError(ChannelHandlerContext ctx, String error) { - if (ctx == null || !ctx.channel().isActive()) { - return; - } - - // If headers not sent yet, send error response - byte[] errorBytes = error.getBytes(StandardCharsets.UTF_8); - FullHttpResponse response = new DefaultFullHttpResponse( - HttpVersion.HTTP_1_1, - HttpResponseStatus.INTERNAL_SERVER_ERROR, - Unpooled.wrappedBuffer(errorBytes) - ); - response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain"); - response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, errorBytes.length); - response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE); - addStaticCorsHeaders(response); - - ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); - } - - /** - * Request file stream from owner for relay download. - */ - public static void requestFileStream(ServiceManager serviceManager, String requestId, - String fileId, ChannelHandlerContext httpCtx) { - ServiceManager.FileInfo file = serviceManager.getFile(fileId); - if (file == null) { - sendNotFoundResponse(httpCtx); - return; - } - - Channel ownerChannel = file.getOwnerChannel(); - if (ownerChannel == null || !ownerChannel.isActive()) { - sendServiceUnavailableResponse(httpCtx); - return; - } - - // Send file request to owner - Message message = new Message(Message.TYPE_FILE_REQUEST); - JsonObject payload = new JsonObject(); - payload.addProperty("requestId", requestId); - payload.addProperty("fileId", fileId); - message.setPayload(payload); - - ownerChannel.writeAndFlush(new TextWebSocketFrame(message.toJson())); - Log.d(TAG, "Requested file stream from owner: " + fileId); - } - - private static void sendNotFoundResponse(ChannelHandlerContext ctx) { - FullHttpResponse response = new DefaultFullHttpResponse( - HttpVersion.HTTP_1_1, - HttpResponseStatus.NOT_FOUND, - Unpooled.wrappedBuffer("File not found".getBytes()) - ); - response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain"); - response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 14); - response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE); - addStaticCorsHeaders(response); - ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); - } - - private static void sendServiceUnavailableResponse(ChannelHandlerContext ctx) { - FullHttpResponse response = new DefaultFullHttpResponse( - HttpVersion.HTTP_1_1, - HttpResponseStatus.SERVICE_UNAVAILABLE, - Unpooled.wrappedBuffer("File owner not connected".getBytes()) - ); - response.headers().set(HttpHeaderNames.CONTENT_TYPE, "text/plain"); - response.headers().setInt(HttpHeaderNames.CONTENT_LENGTH, 24); - response.headers().set(HttpHeaderNames.CONNECTION, HttpHeaderValues.CLOSE); - addStaticCorsHeaders(response); - ctx.writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); - } - - /** - * Handle file download request - streams file from owner client. - * URL format: /fileshare/download/{globalFileId} - * where globalFileId = userId/localFileId - * This allows stateless routing - the server doesn't need to store file info. - */ - private void handleFileDownload(ChannelHandlerContext ctx, FullHttpRequest request, String[] pathParts) { - // Extract global file ID from path: /fileshare/download/{globalFileId} - String globalFileId; - try { - globalFileId = java.net.URLDecoder.decode(pathParts[3], "UTF-8"); - } catch (Exception e) { - globalFileId = pathParts[3]; - } - - Log.d(TAG, "File download request: " + globalFileId); - - // Parse globalFileId to extract userId - // Format: userId/localFileId - String ownerId = null; - String localFileId = globalFileId; - - if (globalFileId.contains("/")) { - int slashIdx = globalFileId.indexOf('/'); - ownerId = globalFileId.substring(0, slashIdx); - localFileId = globalFileId.substring(slashIdx + 1); - } - - if (ownerId == null || ownerId.isEmpty()) { - // Fallback: try the old registry-based lookup for backward compatibility - ServiceManager.FileInfo file = serviceManager.getFile(globalFileId); - if (file != null) { - ownerId = file.getOwnerId(); - Channel ownerChannel = file.getOwnerChannel(); - if (ownerChannel != null && ownerChannel.isActive()) { - sendFileRequest(ctx, request, ownerChannel, globalFileId); - return; - } - } - sendNotFoundResponse(ctx); - return; - } - - // Find the owner's channel by userId - ServiceManager.ClientInfo ownerClient = serviceManager.getClient(ownerId); - if (ownerClient == null || ownerClient.getChannel() == null || !ownerClient.getChannel().isActive()) { - Log.w(TAG, "File owner not connected: " + ownerId); - sendServiceUnavailableResponse(ctx); - return; - } - - sendFileRequest(ctx, request, ownerClient.getChannel(), globalFileId); - } - - /** - * Send a file request to the owner and set up pending request for streaming response. - */ - private void sendFileRequest(ChannelHandlerContext ctx, FullHttpRequest request, - Channel ownerChannel, String fileId) { - // Create pending request for streaming response - String requestId = String.valueOf(System.currentTimeMillis()) + "-" + (int)(Math.random() * 10000); - PendingRequest pending = new PendingRequest(requestId, ctx, request, "fileshare"); - requestManager.addPendingRequest(pending); - - // Send file_request to owner - Message message = new Message(Message.TYPE_FILE_REQUEST); - JsonObject payload = new JsonObject(); - payload.addProperty("requestId", requestId); - payload.addProperty("fileId", fileId); - message.setPayload(payload); - - ownerChannel.writeAndFlush(new TextWebSocketFrame(message.toJson())); - Log.d(TAG, "Sent file request to owner: " + fileId); - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/server/LocalServiceManager.java b/android/app/src/main/java/seven/lab/wstun/server/LocalServiceManager.java deleted file mode 100644 index 248a212..0000000 --- a/android/app/src/main/java/seven/lab/wstun/server/LocalServiceManager.java +++ /dev/null @@ -1,433 +0,0 @@ -package seven.lab.wstun.server; - -import android.content.Context; -import android.util.Log; - -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; - -import java.io.BufferedReader; -import java.io.IOException; -import java.io.InputStream; -import java.io.InputStreamReader; -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.UUID; -import java.util.concurrent.ConcurrentHashMap; - -import seven.lab.wstun.config.ServerConfig; -import seven.lab.wstun.marketplace.InstalledService; -import seven.lab.wstun.marketplace.MarketplaceService; -import seven.lab.wstun.marketplace.ServiceManifest; - -/** - * Manages local services including built-in and marketplace-installed services. - * Services can be enabled/disabled and have endpoints attached/detached dynamically. - */ -public class LocalServiceManager { - - private static final String TAG = "LocalServiceManager"; - private static final Gson gson = new Gson(); - - private final Context context; - private final ServerConfig config; - private final MarketplaceService marketplaceService; - - // Service status tracking (for running instances) - private final Map serviceStatuses = new ConcurrentHashMap<>(); - - // HTML/JS content cache - private String libwstunJs; - - /** - * Represents the status of a local service. - */ - public static class ServiceStatus { - private final String serviceName; - private String uuid; - private boolean running; - private long startedAt; - private long stoppedAt; - - public ServiceStatus(String serviceName) { - this.serviceName = serviceName; - this.running = false; - this.uuid = null; - } - - public void start() { - this.uuid = UUID.randomUUID().toString(); - this.running = true; - this.startedAt = System.currentTimeMillis(); - this.stoppedAt = 0; - } - - public void stop() { - this.running = false; - this.stoppedAt = System.currentTimeMillis(); - // Keep UUID so we can identify this was our service - } - - public String getServiceName() { return serviceName; } - public String getUuid() { return uuid; } - public boolean isRunning() { return running; } - public long getStartedAt() { return startedAt; } - public long getStoppedAt() { return stoppedAt; } - - public JsonObject toJson() { - JsonObject obj = new JsonObject(); - obj.addProperty("serviceName", serviceName); - obj.addProperty("uuid", uuid); - obj.addProperty("running", running); - obj.addProperty("startedAt", startedAt); - obj.addProperty("stoppedAt", stoppedAt); - return obj; - } - } - - public LocalServiceManager(Context context, ServerConfig config) { - this.context = context; - this.config = config; - this.marketplaceService = new MarketplaceService(context); - - // Initialize service statuses for all installed services - for (String name : marketplaceService.getInstalledServices().keySet()) { - serviceStatuses.put(name, new ServiceStatus(name)); - } - - // Load libwstun.js - loadLibwstunJs(); - } - - private void loadLibwstunJs() { - try { - libwstunJs = loadAsset("libwstun.js"); - Log.i(TAG, "Loaded libwstun.js from assets"); - } catch (IOException e) { - Log.e(TAG, "Failed to load assets", e); - libwstunJs = "// libwstun.js failed to load"; - } - } - - /** - * Generate the admin HTML page dynamically. - */ - public String getAdminHtml() { - StringBuilder html = new StringBuilder(); - html.append(""); - html.append(""); - html.append("WSTun Admin"); - html.append("

WSTun Service Manager

"); - html.append("

Use the WSTun Android app to manage services, or access services directly below.

"); - - html.append("

Installed Services

"); - for (Map.Entry entry : getInstalledServices().entrySet()) { - String name = entry.getKey(); - InstalledService svc = entry.getValue(); - String displayName = svc.getDisplayName() != null ? svc.getDisplayName() : name; - html.append("

").append(displayName).append(""); - html.append(""); - html.append(svc.isEnabled() ? "Enabled" : "Disabled").append(""); - if (svc.isEnabled()) { - html.append(" - Manage"); - } - html.append("

"); - } - html.append("
"); - - html.append(""); - return html.toString(); - } - - private String loadAsset(String path) throws IOException { - InputStream is = context.getAssets().open(path); - BufferedReader reader = new BufferedReader(new InputStreamReader(is)); - StringBuilder sb = new StringBuilder(); - String line; - while ((line = reader.readLine()) != null) { - sb.append(line).append("\n"); - } - reader.close(); - return sb.toString(); - } - - /** - * Check if a service is enabled. - */ - public boolean isServiceEnabled(String serviceName) { - InstalledService service = marketplaceService.getInstalledService(serviceName); - return service != null && service.isEnabled(); - } - - /** - * Get the marketplace service. - */ - public MarketplaceService getMarketplaceService() { - return marketplaceService; - } - - /** - * Get all installed services. - */ - public Map getInstalledServices() { - return marketplaceService.getInstalledServices(); - } - - /** - * Get installed service by name. - */ - public InstalledService getInstalledService(String serviceName) { - return marketplaceService.getInstalledService(serviceName); - } - - /** - * Enable a service. - */ - public boolean enableService(String serviceName) { - boolean result = marketplaceService.enableService(serviceName); - if (result && !serviceStatuses.containsKey(serviceName)) { - serviceStatuses.put(serviceName, new ServiceStatus(serviceName)); - } - return result; - } - - /** - * Disable a service. - */ - public boolean disableService(String serviceName, ServiceManager serviceManager) { - InstalledService service = marketplaceService.getInstalledService(serviceName); - if (service == null) { - return false; - } - - // Close all instances for this service - if (serviceManager != null) { - serviceManager.closeInstancesForService(serviceName); - } - - // Stop the service status - ServiceStatus status = serviceStatuses.get(serviceName); - if (status != null && status.isRunning()) { - status.stop(); - } - - return marketplaceService.disableService(serviceName); - } - - /** - * Uninstall a service. - */ - public boolean uninstallService(String serviceName, ServiceManager serviceManager) { - // Disable first - disableService(serviceName, serviceManager); - - // Remove status - serviceStatuses.remove(serviceName); - - return marketplaceService.uninstallService(serviceName); - } - - /** - * Get service status. - */ - public ServiceStatus getServiceStatus(String serviceName) { - return serviceStatuses.get(serviceName); - } - - /** - * Start a local service. - */ - public boolean startService(String serviceName) { - if (!isServiceEnabled(serviceName)) { - Log.w(TAG, "Service not enabled: " + serviceName); - return false; - } - - ServiceStatus status = serviceStatuses.get(serviceName); - if (status == null) { - Log.w(TAG, "Unknown service: " + serviceName); - return false; - } - - if (status.isRunning()) { - Log.w(TAG, "Service already running: " + serviceName); - return false; - } - - status.start(); - Log.i(TAG, "Started local service: " + serviceName + " (UUID: " + status.getUuid() + ")"); - return true; - } - - /** - * Stop a local service. - */ - public boolean stopService(String serviceName) { - ServiceStatus status = serviceStatuses.get(serviceName); - if (status == null) { - Log.w(TAG, "Unknown service: " + serviceName); - return false; - } - - if (!status.isRunning()) { - Log.w(TAG, "Service not running: " + serviceName); - return false; - } - - status.stop(); - Log.i(TAG, "Stopped local service: " + serviceName); - return true; - } - - /** - * Stop a service by UUID (ensures it's exactly our service). - */ - public boolean stopServiceByUuid(String serviceName, String uuid) { - ServiceStatus status = serviceStatuses.get(serviceName); - if (status == null) { - return false; - } - - if (!uuid.equals(status.getUuid())) { - Log.w(TAG, "UUID mismatch for service: " + serviceName); - return false; - } - - return stopService(serviceName); - } - - /** - * Get the service management HTML page for a service. - * This returns the service controller page (index.html) which: - * - Registers as a SERVICE with the server - * - Shows in Android UI - * - Manages user clients - */ - public String getServicePageHtml(String serviceName, String serverUrl) { - if (!isServiceEnabled(serviceName)) { - return getServiceDisabledHtml(serviceName); - } - - InstalledService service = marketplaceService.getInstalledService(serviceName); - if (service == null) { - return getServiceDisabledHtml(serviceName); - } - - // Get the /service endpoint file - String content = service.getFileContent("/service"); - if (content == null || content.isEmpty()) { - return getServiceDisabledHtml(serviceName); - } - return content; - } - - /** - * Get the main service HTML (the user client UI). - * This is served directly from server when service is registered. - */ - public String getServiceMainHtml(String serviceName) { - InstalledService service = marketplaceService.getInstalledService(serviceName); - if (service == null) { - return null; - } - - return service.getFileContent("/main"); - } - - /** - * Get file content for any endpoint of a service. - */ - public String getServiceFileContent(String serviceName, String path) { - InstalledService service = marketplaceService.getInstalledService(serviceName); - if (service == null || !service.isEnabled()) { - return null; - } - - return service.getFileContent(path); - } - - /** - * Get list of all endpoints for a service. - */ - public List getServiceEndpoints(String serviceName) { - InstalledService service = marketplaceService.getInstalledService(serviceName); - if (service == null || service.getManifest() == null) { - return new ArrayList<>(); - } - return service.getManifest().getEndpoints(); - } - - /** - * Get the libwstun.js library content. - */ - public String getLibWstunJs() { - return libwstunJs; - } - - private String getServiceDisabledHtml(String serviceName) { - InstalledService service = marketplaceService.getInstalledService(serviceName); - String displayName = service != null ? service.getDisplayName() : serviceName; - - return "\n" + - "\n" + - "\n" + - " \n" + - " \n" + - " " + displayName + " Service - Disabled\n" + - " \n" + - "\n" + - "\n" + - "
\n" + - "

Service Disabled

\n" + - "

The " + displayName + " service is not enabled on this server.

\n" + - "

Enable it in the WSTun app service management.

\n" + - "
\n" + - "\n" + - ""; - } - - /** - * Get all installed services as JSON array. - */ - public JsonArray getInstalledServicesJson() { - JsonArray arr = new JsonArray(); - for (Map.Entry entry : marketplaceService.getInstalledServices().entrySet()) { - JsonObject obj = entry.getValue().toJson(); - obj.addProperty("name", entry.getKey()); - - // Add running status - ServiceStatus status = serviceStatuses.get(entry.getKey()); - if (status != null) { - obj.addProperty("running", status.isRunning()); - if (status.getUuid() != null) { - obj.addProperty("uuid", status.getUuid()); - } - } - arr.add(obj); - } - return arr; - } - - /** - * Shutdown resources. - */ - public void shutdown() { - if (marketplaceService != null) { - marketplaceService.shutdown(); - } - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/server/NettyServer.java b/android/app/src/main/java/seven/lab/wstun/server/NettyServer.java deleted file mode 100644 index 0a5b8c4..0000000 --- a/android/app/src/main/java/seven/lab/wstun/server/NettyServer.java +++ /dev/null @@ -1,223 +0,0 @@ -package seven.lab.wstun.server; - -import android.content.Context; -import android.os.PowerManager; -import android.util.Log; - -import java.util.concurrent.CountDownLatch; -import java.util.concurrent.ExecutorService; -import java.util.concurrent.Executors; -import java.util.concurrent.TimeUnit; - -import io.netty.bootstrap.ServerBootstrap; -import io.netty.channel.Channel; -import io.netty.channel.ChannelInitializer; -import io.netty.channel.ChannelOption; -import io.netty.channel.ChannelPipeline; -import io.netty.channel.EventLoopGroup; -import io.netty.channel.nio.NioEventLoopGroup; -import io.netty.channel.socket.SocketChannel; -import io.netty.channel.socket.nio.NioServerSocketChannel; -import io.netty.handler.codec.http.HttpObjectAggregator; -import io.netty.handler.codec.http.HttpServerCodec; -import io.netty.handler.ssl.SslContext; -import io.netty.handler.stream.ChunkedWriteHandler; -import io.netty.handler.timeout.IdleStateHandler; -import io.netty.util.concurrent.DefaultThreadFactory; -import seven.lab.wstun.config.ServerConfig; - -/** - * Netty-based HTTP/WebSocket server with optional HTTPS support. - * Uses dedicated threads with high priority to ensure responsiveness - * even when the Android app is in the background. - */ -public class NettyServer { - - private static final String TAG = "NettyServer"; - - private final Context context; - private final ServerConfig config; - private final ServiceManager serviceManager; - private final RequestManager requestManager; - private final LocalServiceManager localServiceManager; - - private EventLoopGroup bossGroup; - private EventLoopGroup workerGroup; - private Channel serverChannel; - private boolean running = false; - private PowerManager.WakeLock wakeLock; - - public NettyServer(Context context, ServerConfig config, ServiceManager serviceManager) { - this.context = context; - this.config = config; - this.serviceManager = serviceManager; - this.requestManager = new RequestManager(); - this.localServiceManager = new LocalServiceManager(context, config); - - // Link ServiceManager to RequestManager for cleanup on disconnect - this.serviceManager.setRequestManager(this.requestManager); - } - - /** - * Start the server. - */ - public synchronized void start() throws Exception { - if (running) { - Log.w(TAG, "Server already running"); - return; - } - - // Acquire a partial wake lock to keep the CPU running for network I/O - // This is essential for the server to respond when the app is in the background - try { - PowerManager powerManager = (PowerManager) context.getSystemService(Context.POWER_SERVICE); - if (powerManager != null) { - wakeLock = powerManager.newWakeLock( - PowerManager.PARTIAL_WAKE_LOCK, - "WSTun:NettyServer" - ); - wakeLock.acquire(); - Log.i(TAG, "Acquired wake lock for background operation"); - } - } catch (Exception e) { - Log.w(TAG, "Failed to acquire wake lock: " + e.getMessage()); - } - - int port = config.getPort(); - boolean ssl = config.isHttpsEnabled(); - - // Get SSL context if needed - SslContext sslContext = null; - if (ssl) { - sslContext = SslContextFactory.getSslContext(context); - } - final SslContext finalSslContext = sslContext; - final String corsOrigins = config.getCorsOrigins(); - final int serverPort = port; - final LocalServiceManager localSvcMgr = localServiceManager; - final ServerConfig serverConfig = config; - - // Use custom thread factory to create high-priority daemon threads - // This ensures Netty threads keep running even when app is backgrounded - DefaultThreadFactory bossThreadFactory = new DefaultThreadFactory("wstun-boss", true, Thread.MAX_PRIORITY); - DefaultThreadFactory workerThreadFactory = new DefaultThreadFactory("wstun-worker", true, Thread.MAX_PRIORITY); - - bossGroup = new NioEventLoopGroup(1, bossThreadFactory); - // Use more worker threads to handle concurrent HTTP and WebSocket connections - workerGroup = new NioEventLoopGroup(Math.max(4, Runtime.getRuntime().availableProcessors() * 2), workerThreadFactory); - - ServerBootstrap bootstrap = new ServerBootstrap(); - bootstrap.group(bossGroup, workerGroup) - .channel(NioServerSocketChannel.class) - .option(ChannelOption.SO_BACKLOG, 1024) - .option(ChannelOption.SO_REUSEADDR, true) - .childOption(ChannelOption.SO_KEEPALIVE, true) - .childOption(ChannelOption.TCP_NODELAY, true) - .childHandler(new ChannelInitializer() { - @Override - protected void initChannel(SocketChannel ch) throws Exception { - Log.d(TAG, "New connection from: " + ch.remoteAddress()); - ChannelPipeline pipeline = ch.pipeline(); - - // SSL handler - if (finalSslContext != null) { - pipeline.addLast("ssl", finalSslContext.newHandler(ch.alloc())); - } - - // HTTP codec - pipeline.addLast("http-codec", new HttpServerCodec()); - - // Aggregate HTTP message parts into FullHttpRequest - pipeline.addLast("http-aggregator", new HttpObjectAggregator(65536)); - - // Idle state handler - longer timeouts for better stability - // Read idle: 120s, Write idle: 60s, All idle: 0 (disabled) - pipeline.addLast("idle", new IdleStateHandler(120, 60, 0)); - - // HTTP/WebSocket handler with CORS configuration and local service support - pipeline.addLast("http-handler", - new HttpHandler(serviceManager, requestManager, localSvcMgr, ssl, corsOrigins, serverPort, serverConfig)); - } - }); - - serverChannel = bootstrap.bind(port).sync().channel(); - running = true; - - Log.i(TAG, "Server started on port " + port + (ssl ? " (HTTPS)" : " (HTTP)")); - } - - /** - * Stop the server. - */ - public synchronized void stop() { - if (!running) { - return; - } - - running = false; - - // Close all service connections - serviceManager.clear(); - - // Shutdown request manager - requestManager.shutdown(); - - // Close server channel - if (serverChannel != null) { - try { - serverChannel.close().sync(); - } catch (InterruptedException e) { - Log.e(TAG, "Error closing server channel", e); - } - } - - // Shutdown event loops with timeout - if (workerGroup != null) { - workerGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS); - } - if (bossGroup != null) { - bossGroup.shutdownGracefully(0, 5, TimeUnit.SECONDS); - } - - // Release wake lock - if (wakeLock != null && wakeLock.isHeld()) { - try { - wakeLock.release(); - Log.i(TAG, "Released wake lock"); - } catch (Exception e) { - Log.w(TAG, "Failed to release wake lock: " + e.getMessage()); - } - wakeLock = null; - } - - Log.i(TAG, "Server stopped"); - } - - /** - * Check if server is running. - */ - public boolean isRunning() { - return running; - } - - /** - * Get the service manager. - */ - public ServiceManager getServiceManager() { - return serviceManager; - } - - /** - * Get the request manager. - */ - public RequestManager getRequestManager() { - return requestManager; - } - - /** - * Get the local service manager. - */ - public LocalServiceManager getLocalServiceManager() { - return localServiceManager; - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/server/PendingRequest.java b/android/app/src/main/java/seven/lab/wstun/server/PendingRequest.java deleted file mode 100644 index d0a7e96..0000000 --- a/android/app/src/main/java/seven/lab/wstun/server/PendingRequest.java +++ /dev/null @@ -1,48 +0,0 @@ -package seven.lab.wstun.server; - -import io.netty.channel.ChannelHandlerContext; -import io.netty.handler.codec.http.HttpRequest; - -/** - * Represents a pending HTTP request waiting for response from a service client. - */ -public class PendingRequest { - - private final String requestId; - private final ChannelHandlerContext ctx; - private final HttpRequest request; - private final String serviceName; - private final long timestamp; - - public PendingRequest(String requestId, ChannelHandlerContext ctx, HttpRequest request, String serviceName) { - this.requestId = requestId; - this.ctx = ctx; - this.request = request; - this.serviceName = serviceName; - this.timestamp = System.currentTimeMillis(); - } - - public String getRequestId() { - return requestId; - } - - public ChannelHandlerContext getCtx() { - return ctx; - } - - public HttpRequest getRequest() { - return request; - } - - public String getServiceName() { - return serviceName; - } - - public long getTimestamp() { - return timestamp; - } - - public boolean isTimedOut(long timeoutMs) { - return System.currentTimeMillis() - timestamp > timeoutMs; - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/server/RequestManager.java b/android/app/src/main/java/seven/lab/wstun/server/RequestManager.java deleted file mode 100644 index ab98f71..0000000 --- a/android/app/src/main/java/seven/lab/wstun/server/RequestManager.java +++ /dev/null @@ -1,152 +0,0 @@ -package seven.lab.wstun.server; - -import android.util.Log; - -import java.util.Iterator; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.Executors; -import java.util.concurrent.ScheduledExecutorService; -import java.util.concurrent.TimeUnit; - -import io.netty.buffer.Unpooled; -import io.netty.channel.ChannelFutureListener; -import io.netty.handler.codec.http.DefaultFullHttpResponse; -import io.netty.handler.codec.http.HttpResponseStatus; -import io.netty.handler.codec.http.HttpVersion; - -/** - * Manages pending HTTP requests that are being relayed to service clients. - */ -public class RequestManager { - - private static final String TAG = "RequestManager"; - private static final long REQUEST_TIMEOUT_MS = 10000; // 10 seconds - shorter timeout for better responsiveness - - private final Map pendingRequests = new ConcurrentHashMap<>(); - private final ScheduledExecutorService scheduler; - - public RequestManager() { - scheduler = Executors.newSingleThreadScheduledExecutor(); - scheduler.scheduleAtFixedRate(this::cleanupTimedOutRequests, 5, 5, TimeUnit.SECONDS); - } - - /** - * Register a pending request. - */ - public void addPendingRequest(PendingRequest request) { - pendingRequests.put(request.getRequestId(), request); - Log.d(TAG, "Added pending request: " + request.getRequestId()); - } - - /** - * Get and remove a pending request. - */ - public PendingRequest removePendingRequest(String requestId) { - PendingRequest request = pendingRequests.remove(requestId); - if (request != null) { - Log.d(TAG, "Removed pending request: " + requestId); - } - return request; - } - - /** - * Get a pending request without removing it. - */ - public PendingRequest getPendingRequest(String requestId) { - return pendingRequests.get(requestId); - } - - /** - * Fail all pending requests for a specific service (when service disconnects). - */ - public void failRequestsForService(String serviceName) { - Iterator> iterator = pendingRequests.entrySet().iterator(); - int count = 0; - - while (iterator.hasNext()) { - Map.Entry entry = iterator.next(); - PendingRequest request = entry.getValue(); - - if (serviceName.equals(request.getServiceName())) { - iterator.remove(); - count++; - - // Send error response on the correct event loop - if (request.getCtx().channel().isActive()) { - request.getCtx().executor().execute(() -> { - if (request.getCtx().channel().isActive()) { - DefaultFullHttpResponse response = new DefaultFullHttpResponse( - HttpVersion.HTTP_1_1, - HttpResponseStatus.SERVICE_UNAVAILABLE, - Unpooled.copiedBuffer("Service disconnected".getBytes()) - ); - response.headers().set("Content-Type", "text/plain"); - response.headers().setInt("Content-Length", 20); - request.getCtx().writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); - } - }); - } - } - } - - if (count > 0) { - Log.w(TAG, "Failed " + count + " pending requests for disconnected service: " + serviceName); - } - } - - /** - * Clean up timed out requests. - */ - private void cleanupTimedOutRequests() { - Iterator> iterator = pendingRequests.entrySet().iterator(); - - while (iterator.hasNext()) { - Map.Entry entry = iterator.next(); - PendingRequest request = entry.getValue(); - - if (request.isTimedOut(REQUEST_TIMEOUT_MS)) { - iterator.remove(); - Log.w(TAG, "Request timed out: " + request.getRequestId()); - - // Send timeout response on the correct event loop - if (request.getCtx().channel().isActive()) { - request.getCtx().executor().execute(() -> { - if (request.getCtx().channel().isActive()) { - DefaultFullHttpResponse response = new DefaultFullHttpResponse( - HttpVersion.HTTP_1_1, - HttpResponseStatus.GATEWAY_TIMEOUT, - Unpooled.copiedBuffer("Request timed out".getBytes()) - ); - request.getCtx().writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); - } - }); - } - } - } - } - - /** - * Shutdown the request manager. - */ - public void shutdown() { - scheduler.shutdown(); - - // Send error response to all pending requests on the correct event loop - for (PendingRequest request : pendingRequests.values()) { - if (request.getCtx().channel().isActive()) { - request.getCtx().executor().execute(() -> { - if (request.getCtx().channel().isActive()) { - DefaultFullHttpResponse response = new DefaultFullHttpResponse( - HttpVersion.HTTP_1_1, - HttpResponseStatus.SERVICE_UNAVAILABLE, - Unpooled.copiedBuffer("Server shutting down".getBytes()) - ); - request.getCtx().writeAndFlush(response).addListener(ChannelFutureListener.CLOSE); - } - }); - } - } - pendingRequests.clear(); - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/server/ServiceManager.java b/android/app/src/main/java/seven/lab/wstun/server/ServiceManager.java deleted file mode 100644 index bc0acdd..0000000 --- a/android/app/src/main/java/seven/lab/wstun/server/ServiceManager.java +++ /dev/null @@ -1,1106 +0,0 @@ -package seven.lab.wstun.server; - -import android.util.Log; - -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; -import java.util.concurrent.ConcurrentHashMap; - -import io.netty.channel.Channel; -import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; -import seven.lab.wstun.protocol.Message; -import seven.lab.wstun.protocol.ServiceRegistration; - -/** - * Manages registered services, instances, and their WebSocket connections. - * - * Architecture: - * - Service: A type of service (e.g., fileshare, chat) - * - ServiceInstance: A specific room/session with UUID, name, and optional token - * - User: Connected to a specific instance - */ -public class ServiceManager { - - private static final String TAG = "ServiceManager"; - - // Service name -> ServiceEntry (the service provider) - private final Map services = new ConcurrentHashMap<>(); - - // Instance UUID -> ServiceInstance - private final Map instances = new ConcurrentHashMap<>(); - - // Service name -> List of instance UUIDs - private final Map> serviceInstances = new ConcurrentHashMap<>(); - - // Channel -> Service name (for cleanup on disconnect) - private final Map channelToService = new ConcurrentHashMap<>(); - - // Channel -> Instance UUID (for instance owner cleanup) - private final Map channelToInstance = new ConcurrentHashMap<>(); - - // File registry: fileId -> FileInfo (for relay file sharing) - private final Map fileRegistry = new ConcurrentHashMap<>(); - - // Channel -> List of file IDs owned by that channel - private final Map> channelToFiles = new ConcurrentHashMap<>(); - - // Client registry: userId -> ClientInfo (for user clients) - private final Map clients = new ConcurrentHashMap<>(); - - // Channel -> userId (for client cleanup on disconnect) - private final Map channelToClient = new ConcurrentHashMap<>(); - - // Chat users: userId -> ChatUser - private final Map chatUsers = new ConcurrentHashMap<>(); - - private ServiceChangeListener listener; - private RequestManager requestManager; - - /** - * Represents a service instance (room/session). - */ - public static class ServiceInstance { - private final String uuid; - private final String serviceName; - private final String name; - private final String token; - private Channel ownerChannel; - private final long createdAt; - private final List userIds = new ArrayList<>(); - // Grace period for reconnection (10 seconds) - private static final long OWNER_GRACE_PERIOD_MS = 10000; - private long ownerDisconnectedAt = 0; - - public ServiceInstance(String uuid, String serviceName, String name, String token, Channel ownerChannel) { - this.uuid = uuid; - this.serviceName = serviceName; - this.name = name; - this.token = token; - this.ownerChannel = ownerChannel; - this.createdAt = System.currentTimeMillis(); - } - - public String getUuid() { return uuid; } - public String getServiceName() { return serviceName; } - public String getName() { return name; } - public String getToken() { return token; } - public Channel getOwnerChannel() { return ownerChannel; } - public long getCreatedAt() { return createdAt; } - public List getUserIds() { return userIds; } - - public boolean hasToken() { - return token != null && !token.isEmpty(); - } - - public boolean validateToken(String inputToken) { - if (!hasToken()) return true; - return token.equals(inputToken); - } - - public void addUser(String userId) { - if (!userIds.contains(userId)) { - userIds.add(userId); - } - } - - public void removeUser(String userId) { - userIds.remove(userId); - } - - public boolean isOwnerConnected() { - return ownerChannel != null && ownerChannel.isActive(); - } - - /** - * Check if instance is still valid (owner connected or within grace period). - */ - public boolean isValid() { - if (isOwnerConnected()) { - return true; - } - // Allow grace period for owner to reconnect - if (ownerDisconnectedAt > 0) { - return (System.currentTimeMillis() - ownerDisconnectedAt) < OWNER_GRACE_PERIOD_MS; - } - return false; - } - - /** - * Mark owner as disconnected but keep instance alive for grace period. - */ - public void markOwnerDisconnected() { - this.ownerChannel = null; - this.ownerDisconnectedAt = System.currentTimeMillis(); - } - - /** - * Reconnect owner to this instance. - */ - public void reconnectOwner(Channel newOwnerChannel) { - this.ownerChannel = newOwnerChannel; - this.ownerDisconnectedAt = 0; - } - - /** - * Check if owner can be reclaimed (within grace period). - */ - public boolean canReclaim() { - return !isOwnerConnected() && ownerDisconnectedAt > 0 && - (System.currentTimeMillis() - ownerDisconnectedAt) < OWNER_GRACE_PERIOD_MS; - } - - public int getUserCount() { - return userIds.size(); - } - - public JsonObject toJson() { - JsonObject obj = new JsonObject(); - obj.addProperty("uuid", uuid); - obj.addProperty("service", serviceName); - obj.addProperty("name", name); - obj.addProperty("hasToken", hasToken()); - obj.addProperty("createdAt", createdAt); - obj.addProperty("userCount", userIds.size()); - obj.addProperty("ownerConnected", isOwnerConnected()); - return obj; - } - } - - /** - * Represents a connected client (user, not service). - */ - public static class ClientInfo { - private final String userId; - private final String clientType; - private final String instanceUuid; - private final Channel channel; - private final long connectedAt; - - public ClientInfo(String userId, String clientType, String instanceUuid, Channel channel) { - this.userId = userId; - this.clientType = clientType; - this.instanceUuid = instanceUuid; - this.channel = channel; - this.connectedAt = System.currentTimeMillis(); - } - - public String getUserId() { return userId; } - public String getClientType() { return clientType; } - public String getInstanceUuid() { return instanceUuid; } - public Channel getChannel() { return channel; } - public long getConnectedAt() { return connectedAt; } - } - - /** - * Represents a chat user. - */ - public static class ChatUser { - private final String userId; - private String name; - private final Channel channel; - private final long joinedAt; - - public ChatUser(String userId, String name, Channel channel) { - this.userId = userId; - this.name = name; - this.channel = channel; - this.joinedAt = System.currentTimeMillis(); - } - - public String getUserId() { return userId; } - public String getName() { return name; } - public void setName(String name) { this.name = name; } - public Channel getChannel() { return channel; } - public long getJoinedAt() { return joinedAt; } - - public JsonObject toJson() { - JsonObject obj = new JsonObject(); - obj.addProperty("id", userId); - obj.addProperty("name", name); - obj.addProperty("joinedAt", joinedAt); - return obj; - } - } - - /** - * Represents a file registered for relay sharing. - */ - public static class FileInfo { - private final String fileId; - private final String filename; - private final long size; - private final String mimeType; - private final String ownerId; - private final Channel ownerChannel; - - public FileInfo(String fileId, String filename, long size, String mimeType, - String ownerId, Channel ownerChannel) { - this.fileId = fileId; - this.filename = filename; - this.size = size; - this.mimeType = mimeType; - this.ownerId = ownerId; - this.ownerChannel = ownerChannel; - } - - public String getFileId() { return fileId; } - public String getFilename() { return filename; } - public long getSize() { return size; } - public String getMimeType() { return mimeType; } - public String getOwnerId() { return ownerId; } - public Channel getOwnerChannel() { return ownerChannel; } - - public JsonObject toJson() { - JsonObject obj = new JsonObject(); - obj.addProperty("id", fileId); - obj.addProperty("filename", filename); - obj.addProperty("size", size); - obj.addProperty("mimeType", mimeType); - obj.addProperty("ownerId", ownerId); - return obj; - } - } - - public interface ServiceChangeListener { - void onServiceAdded(ServiceEntry service); - void onServiceRemoved(ServiceEntry service); - } - - /** - * Represents a registered service. - */ - public static class ServiceEntry { - private final String name; - private final String type; - private final String description; - private final Channel channel; - private final ServiceRegistration registration; - private final long registeredAt; - private final String authToken; - - public ServiceEntry(ServiceRegistration registration, Channel channel) { - this.name = registration.getName(); - this.type = registration.getType(); - this.description = registration.getDescription(); - this.channel = channel; - this.registration = registration; - this.registeredAt = System.currentTimeMillis(); - this.authToken = registration.getAuthToken(); - } - - public String getName() { - return name; - } - - public String getType() { - return type; - } - - public String getDescription() { - return description; - } - - public Channel getChannel() { - return channel; - } - - public ServiceRegistration getRegistration() { - return registration; - } - - public long getRegisteredAt() { - return registeredAt; - } - - public boolean isConnected() { - return channel != null && channel.isActive(); - } - - public String getAuthToken() { - return authToken; - } - - public boolean hasAuth() { - return authToken != null && !authToken.isEmpty(); - } - - /** - * Validate client auth token for this service. - * Returns true if no auth required or token matches. - */ - public boolean validateClientAuth(String token) { - if (!hasAuth()) { - return true; - } - return authToken.equals(token); - } - } - - public void setListener(ServiceChangeListener listener) { - this.listener = listener; - } - - public void setRequestManager(RequestManager requestManager) { - this.requestManager = requestManager; - } - - // Reserved service names that cannot be used - private static final java.util.Set RESERVED_NAMES = new java.util.HashSet<>( - java.util.Arrays.asList( - "debug", "admin", "_api", "ws", "websocket", - "api", "system", "config", "static", "assets", - "libwstun.js" - ) - ); - - /** - * Check if a service name is reserved. - */ - public static boolean isReservedName(String name) { - if (name == null) return true; - return RESERVED_NAMES.contains(name.toLowerCase()); - } - - /** - * Register a new service. - */ - public boolean registerService(ServiceRegistration registration, Channel channel) { - String name = registration.getName(); - - if (name == null || name.isEmpty()) { - Log.w(TAG, "Service registration failed: no name provided"); - return false; - } - - // Check for reserved names - if (isReservedName(name)) { - Log.w(TAG, "Service registration failed: reserved name: " + name); - return false; - } - - // Check if service with same name exists - if (services.containsKey(name)) { - Log.w(TAG, "Service already exists: " + name); - return false; - } - - ServiceEntry entry = new ServiceEntry(registration, channel); - services.put(name, entry); - channelToService.put(channel, name); - - Log.i(TAG, "Service registered: " + name + " (type: " + registration.getType() + ")"); - - if (listener != null) { - listener.onServiceAdded(entry); - } - - return true; - } - - /** - * Unregister a service by name. - */ - public void unregisterService(String name) { - ServiceEntry entry = services.remove(name); - if (entry != null) { - channelToService.remove(entry.getChannel()); - Log.i(TAG, "Service unregistered: " + name); - - if (listener != null) { - listener.onServiceRemoved(entry); - } - } - } - - /** - * Create a new service instance (room/session). - */ - public ServiceInstance createInstance(String serviceName, String name, String token, Channel ownerChannel) { - String uuid = java.util.UUID.randomUUID().toString().substring(0, 8); - - ServiceInstance instance = new ServiceInstance(uuid, serviceName, name, token, ownerChannel); - instances.put(uuid, instance); - channelToInstance.put(ownerChannel, uuid); - - // Track instances per service - serviceInstances.computeIfAbsent(serviceName, k -> new ArrayList<>()).add(uuid); - - Log.i(TAG, "Instance created: " + uuid + " (" + name + ") for service " + serviceName); - return instance; - } - - /** - * Get an instance by UUID. - */ - public ServiceInstance getInstance(String uuid) { - return instances.get(uuid); - } - - /** - * Get all instances for a service. - * Returns instances where owner is connected OR within grace period. - */ - public List getInstancesForService(String serviceName) { - List uuids = serviceInstances.get(serviceName); - if (uuids == null) return new ArrayList<>(); - - List result = new ArrayList<>(); - for (String uuid : uuids) { - ServiceInstance inst = instances.get(uuid); - if (inst != null && inst.isValid()) { - result.add(inst); - } - } - return result; - } - - /** - * Remove an instance. - */ - public void removeInstance(String uuid) { - ServiceInstance instance = instances.remove(uuid); - if (instance != null) { - channelToInstance.remove(instance.getOwnerChannel()); - List uuids = serviceInstances.get(instance.getServiceName()); - if (uuids != null) { - uuids.remove(uuid); - } - Log.i(TAG, "Instance removed: " + uuid); - } - } - - /** - * Destroy an instance (kick all users and close owner connection). - */ - public void destroyInstance(String uuid) { - ServiceInstance instance = instances.get(uuid); - if (instance == null) { - return; - } - - // Kick all users in the instance - for (String userId : new ArrayList<>(instance.getUserIds())) { - kickClient(userId); - } - - // Close owner connection - Channel ownerChannel = instance.getOwnerChannel(); - if (ownerChannel != null && ownerChannel.isActive()) { - Message msg = new Message("instance_destroyed"); - JsonObject payload = new JsonObject(); - payload.addProperty("uuid", uuid); - payload.addProperty("reason", "Instance destroyed by administrator"); - msg.setPayload(payload); - ownerChannel.writeAndFlush(new TextWebSocketFrame(msg.toJson())); - ownerChannel.close(); - } - - // Remove instance - removeInstance(uuid); - Log.i(TAG, "Instance destroyed: " + uuid); - } - - /** - * Close all instances for a service (when disabling/uninstalling). - */ - public void closeInstancesForService(String serviceName) { - List uuids = serviceInstances.get(serviceName); - if (uuids == null || uuids.isEmpty()) { - return; - } - - // Make a copy to avoid concurrent modification - List toRemove = new ArrayList<>(uuids); - - for (String uuid : toRemove) { - ServiceInstance instance = instances.get(uuid); - if (instance != null) { - // Kick all users in the instance - for (String userId : new ArrayList<>(instance.getUserIds())) { - kickClient(userId); - } - - // Close owner connection - Channel ownerChannel = instance.getOwnerChannel(); - if (ownerChannel != null && ownerChannel.isActive()) { - Message msg = new Message("service_disabled"); - JsonObject payload = new JsonObject(); - payload.addProperty("serviceName", serviceName); - payload.addProperty("reason", "Service disabled"); - msg.setPayload(payload); - ownerChannel.writeAndFlush(new TextWebSocketFrame(msg.toJson())); - ownerChannel.close(); - } - - // Remove instance - removeInstance(uuid); - } - } - - Log.i(TAG, "Closed all instances for service: " + serviceName); - } - - /** - * Get count of running instances for a service. - */ - public int getInstanceCountForService(String serviceName) { - return getInstancesForService(serviceName).size(); - } - - /** - * Clean up instance for a disconnected channel. - * Uses grace period to allow owner to reconnect. - */ - private void cleanupInstanceForChannel(Channel channel) { - String uuid = channelToInstance.remove(channel); - if (uuid != null) { - ServiceInstance instance = instances.get(uuid); - if (instance != null) { - // Mark owner as disconnected but keep instance for grace period - instance.markOwnerDisconnected(); - Log.i(TAG, "Instance owner disconnected, grace period started: " + uuid); - - // Schedule cleanup after grace period - new Thread(() -> { - try { - Thread.sleep(ServiceInstance.OWNER_GRACE_PERIOD_MS + 1000); - } catch (InterruptedException e) { - return; - } - // Check if instance still needs cleanup (owner didn't reconnect) - ServiceInstance inst = instances.get(uuid); - if (inst != null && !inst.isOwnerConnected()) { - instances.remove(uuid); - List uuids = serviceInstances.get(inst.getServiceName()); - if (uuids != null) { - uuids.remove(uuid); - } - Log.i(TAG, "Instance cleaned up after grace period: " + uuid); - } - }).start(); - } - } - } - - /** - * Reclaim an instance that lost its owner (within grace period). - * @return true if reclaim successful, false otherwise - */ - public boolean reclaimInstance(String uuid, Channel newOwnerChannel) { - ServiceInstance instance = instances.get(uuid); - if (instance == null) { - return false; - } - - if (!instance.canReclaim()) { - return false; - } - - instance.reconnectOwner(newOwnerChannel); - channelToInstance.put(newOwnerChannel, uuid); - Log.i(TAG, "Instance reclaimed by new owner: " + uuid); - return true; - } - - /** - * Handle channel disconnect - unregister associated service and clean up files. - */ - public void onChannelDisconnect(Channel channel) { - // Clean up files owned by this channel - cleanupFilesForChannel(channel); - - // Clean up client if any - cleanupClientForChannel(channel); - - // Clean up instance if any - cleanupInstanceForChannel(channel); - - String serviceName = channelToService.remove(channel); - if (serviceName != null) { - ServiceEntry entry = services.remove(serviceName); - if (entry != null) { - Log.i(TAG, "Service disconnected: " + serviceName); - - // Fail any pending HTTP requests for this service - if (requestManager != null) { - requestManager.failRequestsForService(serviceName); - } - - if (listener != null) { - listener.onServiceRemoved(entry); - } - } - } - } - - /** - * Get a service by name. - */ - public ServiceEntry getService(String name) { - return services.get(name); - } - - /** - * Get all registered services. - */ - public List getAllServices() { - return new ArrayList<>(services.values()); - } - - /** - * Check if a service exists. - */ - public boolean hasService(String name) { - return services.containsKey(name); - } - - /** - * Get the number of registered services. - */ - public int getServiceCount() { - return services.size(); - } - - /** - * Kick (disconnect) a service. - */ - public void kickService(String name) { - ServiceEntry entry = services.get(name); - if (entry != null && entry.getChannel() != null) { - entry.getChannel().close(); - // The channel close will trigger onChannelDisconnect - } - } - - /** - * Clear all services. - */ - public void clear() { - for (ServiceEntry entry : services.values()) { - if (entry.getChannel() != null) { - entry.getChannel().close(); - } - } - services.clear(); - channelToService.clear(); - fileRegistry.clear(); - channelToFiles.clear(); - } - - // ==================== File Registry Methods ==================== - - /** - * Register a file for relay sharing. - */ - public void registerFile(String fileId, String filename, long size, String mimeType, - String ownerId, Channel ownerChannel) { - FileInfo info = new FileInfo(fileId, filename, size, mimeType, ownerId, ownerChannel); - fileRegistry.put(fileId, info); - - // Track files by channel for cleanup - channelToFiles.computeIfAbsent(ownerChannel, k -> new ArrayList<>()).add(fileId); - - Log.i(TAG, "File registered: " + filename + " (" + fileId + ") by " + ownerId); - - // Broadcast updated file list to all fileshare service clients - broadcastFileList(); - } - - /** - * Unregister a file from relay sharing. - */ - public void unregisterFile(String fileId) { - FileInfo info = fileRegistry.remove(fileId); - if (info != null) { - List files = channelToFiles.get(info.getOwnerChannel()); - if (files != null) { - files.remove(fileId); - } - Log.i(TAG, "File unregistered: " + info.getFilename() + " (" + fileId + ")"); - broadcastFileList(); - } - } - - /** - * Get file info by ID. - */ - public FileInfo getFile(String fileId) { - return fileRegistry.get(fileId); - } - - /** - * Get all registered files. - */ - public List getAllFiles() { - return new ArrayList<>(fileRegistry.values()); - } - - /** - * Handle channel disconnect - clean up files owned by this channel. - */ - private void cleanupFilesForChannel(Channel channel) { - List files = channelToFiles.remove(channel); - if (files != null && !files.isEmpty()) { - for (String fileId : files) { - fileRegistry.remove(fileId); - } - Log.i(TAG, "Cleaned up " + files.size() + " files for disconnected channel"); - broadcastFileList(); - } - } - - /** - * Broadcast updated file list to all connected fileshare clients. - */ - public void broadcastFileList() { - // Build file list JSON - JsonArray filesArray = new JsonArray(); - for (FileInfo file : fileRegistry.values()) { - filesArray.add(file.toJson()); - } - - Message message = new Message(Message.TYPE_FILE_LIST); - JsonObject payload = new JsonObject(); - payload.add("files", filesArray); - message.setPayload(payload); - - String json = message.toJson(); - - // Send to all fileshare clients - for (ClientInfo client : clients.values()) { - if ("fileshare".equals(client.getClientType()) && client.getChannel().isActive()) { - client.getChannel().writeAndFlush(new TextWebSocketFrame(json)); - } - } - - // Also send to any other channels that have registered files - for (Channel channel : channelToFiles.keySet()) { - if (channel.isActive()) { - channel.writeAndFlush(new TextWebSocketFrame(json)); - } - } - } - - // ==================== Client Registry Methods ==================== - - /** - * Register a user client (not a service). - */ - public void registerClient(String userId, String clientType, Channel channel) { - registerClient(userId, clientType, null, channel); - } - - /** - * Register a user client to a specific instance. - */ - public void registerClient(String userId, String clientType, String instanceUuid, Channel channel) { - ClientInfo info = new ClientInfo(userId, clientType, instanceUuid, channel); - clients.put(userId, info); - channelToClient.put(channel, userId); - - // Add user to instance if specified - if (instanceUuid != null) { - ServiceInstance instance = instances.get(instanceUuid); - if (instance != null) { - instance.addUser(userId); - } - } - - Log.i(TAG, "Client registered: " + userId + " (type: " + clientType + - (instanceUuid != null ? ", instance: " + instanceUuid : "") + ")"); - - // Notify the instance owner that a client connected - notifyInstanceOwner(instanceUuid, clientType, "client_connected", userId); - } - - /** - * Unregister a client. - */ - public void unregisterClient(String userId) { - ClientInfo info = clients.remove(userId); - if (info != null) { - channelToClient.remove(info.getChannel()); - Log.i(TAG, "Client unregistered: " + userId); - } - } - - /** - * Get a client by userId. - */ - public ClientInfo getClient(String userId) { - return clients.get(userId); - } - - /** - * Get all clients of a specific type. - */ - public List getClientsByType(String clientType) { - List result = new ArrayList<>(); - for (ClientInfo client : clients.values()) { - if (clientType.equals(client.getClientType())) { - result.add(client); - } - } - return result; - } - - /** - * Get a client by their channel. - */ - public ClientInfo getClientByChannel(Channel channel) { - String userId = channelToClient.get(channel); - if (userId != null) { - return clients.get(userId); - } - return null; - } - - /** - * Get all clients in a specific instance. - */ - public List getClientsInInstance(String instanceUuid) { - List result = new ArrayList<>(); - ServiceInstance instance = instances.get(instanceUuid); - if (instance == null) { - return result; - } - - for (String userId : instance.getUserIds()) { - ClientInfo client = clients.get(userId); - if (client != null) { - result.add(client); - } - } - return result; - } - - /** - * Kick a client by userId. - * @return true if client was found and kicked - */ - public boolean kickClient(String userId) { - ClientInfo client = clients.get(userId); - if (client == null) { - return false; - } - - // Send kick message to client - Message kickMsg = new Message("kick"); - JsonObject payload = new JsonObject(); - payload.addProperty("reason", "Kicked by administrator"); - kickMsg.setPayload(payload); - - if (client.getChannel() != null && client.getChannel().isActive()) { - client.getChannel().writeAndFlush(new TextWebSocketFrame(kickMsg.toJson())); - client.getChannel().close(); - } - - // Clean up - clients.remove(userId); - channelToClient.remove(client.getChannel()); - - // Also remove from chat users if applicable - ChatUser chatUser = chatUsers.remove(userId); - if (chatUser != null) { - broadcastChatLeave(userId, chatUser.getName()); - broadcastChatUserList(); - } - - Log.i(TAG, "Kicked client: " + userId); - return true; - } - - /** - * Clean up clients for a disconnected channel. - */ - private void cleanupClientForChannel(Channel channel) { - String userId = channelToClient.remove(channel); - if (userId != null) { - ClientInfo client = clients.remove(userId); - Log.i(TAG, "Cleaned up client for disconnected channel: " + userId); - - // Remove from instance - if (client != null && client.getInstanceUuid() != null) { - ServiceInstance instance = instances.get(client.getInstanceUuid()); - if (instance != null) { - instance.removeUser(userId); - } - notifyInstanceOwner(client.getInstanceUuid(), client.getClientType(), "client_disconnected", userId); - } - - // Also clean up chat user if any - ChatUser chatUser = chatUsers.remove(userId); - if (chatUser != null) { - broadcastChatLeave(userId, chatUser.getName()); - broadcastChatUserList(); - } - } - } - - /** - * Notify an instance owner when a client connects or disconnects. - */ - private void notifyInstanceOwner(String instanceUuid, String serviceName, String eventType, String userId) { - ServiceInstance instance = instanceUuid != null ? instances.get(instanceUuid) : null; - Channel ownerChannel = instance != null ? instance.getOwnerChannel() : null; - - // If no instance, fall back to service channel - if (ownerChannel == null) { - ServiceEntry service = services.get(serviceName); - if (service != null && service.isConnected()) { - ownerChannel = service.getChannel(); - } - } - - if (ownerChannel != null && ownerChannel.isActive()) { - Message message = new Message(eventType); - message.setService(serviceName); - JsonObject payload = new JsonObject(); - payload.addProperty("userId", userId); - if (instanceUuid != null) { - payload.addProperty("instanceUuid", instanceUuid); - } - message.setPayload(payload); - ownerChannel.writeAndFlush(new TextWebSocketFrame(message.toJson())); - } - } - - // ==================== Chat User Methods ==================== - - /** - * Register a chat user. - */ - public void registerChatUser(String userId, String name, Channel channel) { - ChatUser user = new ChatUser(userId, name, channel); - chatUsers.put(userId, user); - Log.i(TAG, "Chat user registered: " + name + " (" + userId + ")"); - } - - /** - * Unregister a chat user. - */ - public void unregisterChatUser(String userId) { - chatUsers.remove(userId); - Log.i(TAG, "Chat user unregistered: " + userId); - } - - /** - * Get chat user name. - */ - public String getChatUserName(String userId) { - ChatUser user = chatUsers.get(userId); - return user != null ? user.getName() : null; - } - - /** - * Broadcast chat user list to all chat clients. - */ - public void broadcastChatUserList() { - JsonArray usersArray = new JsonArray(); - for (ChatUser user : chatUsers.values()) { - usersArray.add(user.toJson()); - } - - Message message = new Message(Message.TYPE_CHAT_USER_LIST); - JsonObject payload = new JsonObject(); - payload.add("users", usersArray); - message.setPayload(payload); - - String json = message.toJson(); - - // Send to all chat clients - for (ClientInfo client : clients.values()) { - if ("chat".equals(client.getClientType()) && client.getChannel().isActive()) { - client.getChannel().writeAndFlush(new TextWebSocketFrame(json)); - } - } - } - - /** - * Broadcast chat join notification. - */ - public void broadcastChatJoin(String userId, String name) { - Message message = new Message(Message.TYPE_CHAT_JOIN); - JsonObject payload = new JsonObject(); - payload.addProperty("userId", userId); - payload.addProperty("name", name); - message.setPayload(payload); - - String json = message.toJson(); - - // Send to all chat clients - for (ClientInfo client : clients.values()) { - if ("chat".equals(client.getClientType()) && client.getChannel().isActive()) { - client.getChannel().writeAndFlush(new TextWebSocketFrame(json)); - } - } - } - - /** - * Broadcast chat leave notification. - */ - public void broadcastChatLeave(String userId, String name) { - Message message = new Message(Message.TYPE_CHAT_LEAVE); - JsonObject payload = new JsonObject(); - payload.addProperty("userId", userId); - payload.addProperty("name", name); - message.setPayload(payload); - - String json = message.toJson(); - - // Send to all chat clients - for (ClientInfo client : clients.values()) { - if ("chat".equals(client.getClientType()) && client.getChannel().isActive()) { - client.getChannel().writeAndFlush(new TextWebSocketFrame(json)); - } - } - } - - /** - * Broadcast chat message to all chat clients. - */ - public void broadcastChatMessage(JsonObject msgPayload, Channel senderChannel) { - Message message = new Message(Message.TYPE_CHAT_MESSAGE); - message.setPayload(msgPayload); - - String json = message.toJson(); - - // Check if there are specific recipients - JsonArray recipients = null; - if (msgPayload.has("recipients") && !msgPayload.get("recipients").isJsonNull()) { - recipients = msgPayload.getAsJsonArray("recipients"); - } - - // Send to all chat clients (or specific recipients) - for (ClientInfo client : clients.values()) { - if (!"chat".equals(client.getClientType()) || !client.getChannel().isActive()) { - continue; - } - - // Don't send back to sender - if (client.getChannel() == senderChannel) { - continue; - } - - // If recipients specified, only send to them - if (recipients != null) { - boolean isRecipient = false; - for (int i = 0; i < recipients.size(); i++) { - if (client.getUserId().equals(recipients.get(i).getAsString())) { - isRecipient = true; - break; - } - } - if (!isRecipient) { - continue; - } - } - - client.getChannel().writeAndFlush(new TextWebSocketFrame(json)); - } - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/server/SslContextFactory.java b/android/app/src/main/java/seven/lab/wstun/server/SslContextFactory.java deleted file mode 100644 index 5b71e5b..0000000 --- a/android/app/src/main/java/seven/lab/wstun/server/SslContextFactory.java +++ /dev/null @@ -1,146 +0,0 @@ -package seven.lab.wstun.server; - -import android.content.Context; -import android.util.Log; - -import org.bouncycastle.asn1.x500.X500Name; -import org.bouncycastle.cert.X509CertificateHolder; -import org.bouncycastle.cert.X509v3CertificateBuilder; -import org.bouncycastle.cert.jcajce.JcaX509CertificateConverter; -import org.bouncycastle.cert.jcajce.JcaX509v3CertificateBuilder; -import org.bouncycastle.jce.provider.BouncyCastleProvider; -import org.bouncycastle.operator.ContentSigner; -import org.bouncycastle.operator.jcajce.JcaContentSignerBuilder; - -import java.io.File; -import java.io.FileInputStream; -import java.io.FileOutputStream; -import java.math.BigInteger; -import java.security.KeyPair; -import java.security.KeyPairGenerator; -import java.security.KeyStore; -import java.security.Security; -import java.security.cert.X509Certificate; -import java.util.Date; - -import javax.net.ssl.KeyManagerFactory; -import javax.net.ssl.SSLContext; - -import io.netty.handler.ssl.SslContext; -import io.netty.handler.ssl.SslContextBuilder; - -/** - * Factory for creating SSL context with self-signed certificates. - */ -public class SslContextFactory { - - private static final String TAG = "SslContextFactory"; - private static final String KEYSTORE_FILE = "wstun.keystore"; - private static final String KEYSTORE_PASSWORD = "wstunpass"; - private static final String KEY_ALIAS = "wstun"; - - static { - Security.addProvider(new BouncyCastleProvider()); - } - - /** - * Get or create SSL context with self-signed certificate. - */ - public static SslContext getSslContext(Context context) throws Exception { - File keystoreFile = new File(context.getFilesDir(), KEYSTORE_FILE); - - KeyStore keyStore; - if (!keystoreFile.exists()) { - Log.i(TAG, "Generating new self-signed certificate"); - keyStore = generateSelfSignedCertificate(); - saveKeyStore(keyStore, keystoreFile); - } else { - Log.i(TAG, "Loading existing certificate"); - keyStore = loadKeyStore(keystoreFile); - } - - KeyManagerFactory kmf = KeyManagerFactory.getInstance(KeyManagerFactory.getDefaultAlgorithm()); - kmf.init(keyStore, KEYSTORE_PASSWORD.toCharArray()); - - return SslContextBuilder.forServer(kmf).build(); - } - - /** - * Generate a new self-signed certificate. - */ - private static KeyStore generateSelfSignedCertificate() throws Exception { - // Generate key pair - KeyPairGenerator keyPairGen = KeyPairGenerator.getInstance("RSA"); - keyPairGen.initialize(2048); - KeyPair keyPair = keyPairGen.generateKeyPair(); - - // Certificate validity period - long now = System.currentTimeMillis(); - Date startDate = new Date(now); - Date endDate = new Date(now + 365L * 24 * 60 * 60 * 1000); // 1 year - - // Build certificate - X500Name issuer = new X500Name("CN=WSTun, O=WSTun, L=Local, C=US"); - BigInteger serial = BigInteger.valueOf(now); - - X509v3CertificateBuilder certBuilder = new JcaX509v3CertificateBuilder( - issuer, - serial, - startDate, - endDate, - issuer, - keyPair.getPublic() - ); - - ContentSigner signer = new JcaContentSignerBuilder("SHA256WithRSA") - .setProvider("BC") - .build(keyPair.getPrivate()); - - X509CertificateHolder certHolder = certBuilder.build(signer); - X509Certificate cert = new JcaX509CertificateConverter() - .setProvider("BC") - .getCertificate(certHolder); - - // Create keystore - KeyStore keyStore = KeyStore.getInstance("PKCS12"); - keyStore.load(null, null); - keyStore.setKeyEntry( - KEY_ALIAS, - keyPair.getPrivate(), - KEYSTORE_PASSWORD.toCharArray(), - new X509Certificate[]{cert} - ); - - return keyStore; - } - - /** - * Save keystore to file. - */ - private static void saveKeyStore(KeyStore keyStore, File file) throws Exception { - try (FileOutputStream fos = new FileOutputStream(file)) { - keyStore.store(fos, KEYSTORE_PASSWORD.toCharArray()); - } - } - - /** - * Load keystore from file. - */ - private static KeyStore loadKeyStore(File file) throws Exception { - KeyStore keyStore = KeyStore.getInstance("PKCS12"); - try (FileInputStream fis = new FileInputStream(file)) { - keyStore.load(fis, KEYSTORE_PASSWORD.toCharArray()); - } - return keyStore; - } - - /** - * Delete existing certificate to regenerate. - */ - public static void deleteCertificate(Context context) { - File keystoreFile = new File(context.getFilesDir(), KEYSTORE_FILE); - if (keystoreFile.exists()) { - keystoreFile.delete(); - } - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/server/WebSocketHandler.java b/android/app/src/main/java/seven/lab/wstun/server/WebSocketHandler.java deleted file mode 100644 index e83c023..0000000 --- a/android/app/src/main/java/seven/lab/wstun/server/WebSocketHandler.java +++ /dev/null @@ -1,805 +0,0 @@ -package seven.lab.wstun.server; - -import android.util.Log; - -import com.google.gson.Gson; -import com.google.gson.JsonArray; -import com.google.gson.JsonObject; - -import java.util.List; - -import seven.lab.wstun.config.ServerConfig; - -import io.netty.buffer.ByteBuf; -import io.netty.buffer.Unpooled; -import io.netty.channel.ChannelHandlerContext; -import io.netty.channel.SimpleChannelInboundHandler; -import io.netty.handler.codec.http.websocketx.BinaryWebSocketFrame; -import io.netty.handler.codec.http.websocketx.CloseWebSocketFrame; -import io.netty.handler.codec.http.websocketx.PingWebSocketFrame; -import io.netty.handler.codec.http.websocketx.PongWebSocketFrame; -import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; -import io.netty.handler.codec.http.websocketx.WebSocketFrame; -import seven.lab.wstun.protocol.HttpRelayResponse; -import seven.lab.wstun.protocol.Message; -import seven.lab.wstun.protocol.ServiceRegistration; - -/** - * Handler for WebSocket connections from service clients. - */ -public class WebSocketHandler extends SimpleChannelInboundHandler { - - private static final String TAG = "WebSocketHandler"; - private static final Gson gson = new Gson(); - - private final ServiceManager serviceManager; - private final RequestManager requestManager; - - public WebSocketHandler(ServiceManager serviceManager, RequestManager requestManager) { - this.serviceManager = serviceManager; - this.requestManager = requestManager; - } - - @Override - protected void channelRead0(ChannelHandlerContext ctx, WebSocketFrame frame) throws Exception { - if (frame instanceof TextWebSocketFrame) { - handleTextFrame(ctx, (TextWebSocketFrame) frame); - } else if (frame instanceof BinaryWebSocketFrame) { - handleBinaryFrame(ctx, (BinaryWebSocketFrame) frame); - } else if (frame instanceof PingWebSocketFrame) { - ctx.writeAndFlush(new PongWebSocketFrame(frame.content().retain())); - } else if (frame instanceof CloseWebSocketFrame) { - ctx.close(); - } - } - - private void handleTextFrame(ChannelHandlerContext ctx, TextWebSocketFrame frame) { - String text = frame.text(); - - try { - Message message = Message.fromJson(text); - handleMessage(ctx, message); - } catch (Exception e) { - Log.e(TAG, "Failed to parse message", e); - sendError(ctx, "Invalid message format"); - } - } - - private void handleBinaryFrame(ChannelHandlerContext ctx, BinaryWebSocketFrame frame) { - ByteBuf content = frame.content(); - // Binary frames are used for file data streaming - // First byte is message type, rest is data - if (content.readableBytes() < 1) { - return; - } - - byte type = content.readByte(); - byte[] data = new byte[content.readableBytes()]; - content.readBytes(data); - - // Binary data is handled silently for performance - } - - private void handleMessage(ChannelHandlerContext ctx, Message message) { - String type = message.getType(); - - switch (type) { - case Message.TYPE_REGISTER: - handleRegister(ctx, message); - break; - case Message.TYPE_UNREGISTER: - handleUnregister(ctx, message); - break; - case Message.TYPE_CLIENT_REGISTER: - handleClientRegister(ctx, message); - break; - case Message.TYPE_HTTP_RESPONSE: - handleHttpResponse(ctx, message); - break; - case Message.TYPE_HTTP_RESPONSE_START: - handleHttpResponseStart(ctx, message); - break; - case Message.TYPE_HTTP_RESPONSE_CHUNK: - handleHttpResponseChunk(ctx, message); - break; - case Message.TYPE_FILE_REGISTER: - handleFileRegister(ctx, message); - break; - case Message.TYPE_FILE_UNREGISTER: - handleFileUnregister(ctx, message); - break; - case Message.TYPE_CHAT_JOIN: - handleChatJoin(ctx, message); - break; - case Message.TYPE_CHAT_LEAVE: - handleChatLeave(ctx, message); - break; - case Message.TYPE_DATA: - handleData(ctx, message); - break; - case Message.TYPE_KICK_CLIENT: - handleKickClient(ctx, message); - break; - case Message.TYPE_FILE_LIST: - case Message.TYPE_CHAT_USER_LIST: - // These are broadcast messages from service to clients - handleBroadcast(ctx, message); - break; - case Message.TYPE_FILE_REQUEST: - handleFileRequest(ctx, message); - break; - case Message.TYPE_CREATE_INSTANCE: - handleCreateInstance(ctx, message); - break; - case Message.TYPE_JOIN_INSTANCE: - handleJoinInstance(ctx, message); - break; - case Message.TYPE_LIST_INSTANCES: - handleListInstances(ctx, message); - break; - case Message.TYPE_RECLAIM_INSTANCE: - handleReclaimInstance(ctx, message); - break; - // Instance-aware messages for fileshare/chat - case Message.TYPE_USER_JOIN: - case Message.TYPE_USER_LEAVE: - case Message.TYPE_FILE_ADD: - case Message.TYPE_FILE_REMOVE: - case Message.TYPE_REQUEST_STATE: - case Message.TYPE_STATE_RESPONSE: - case Message.TYPE_CHAT_MESSAGE: - handleInstanceMessage(ctx, message); - break; - default: - Log.w(TAG, "Unknown message type: " + type); - } - } - - private void handleRegister(ChannelHandlerContext ctx, Message message) { - try { - ServiceRegistration registration = Message.payloadAs( - message.getPayload(), - ServiceRegistration.class - ); - - boolean success = serviceManager.registerService(registration, ctx.channel()); - - // Send acknowledgment - Message ack = new Message(Message.TYPE_ACK); - ack.setId(message.getId()); - JsonObject payload = new JsonObject(); - payload.addProperty("success", success); - payload.addProperty("message", success ? "Service registered" : "Registration failed"); - ack.setPayload(payload); - - ctx.writeAndFlush(new TextWebSocketFrame(ack.toJson())); - } catch (Exception e) { - Log.e(TAG, "Failed to register service", e); - sendError(ctx, "Registration failed: " + e.getMessage()); - } - } - - private void handleUnregister(ChannelHandlerContext ctx, Message message) { - String serviceName = message.getService(); - if (serviceName != null) { - serviceManager.unregisterService(serviceName); - } - - Message ack = new Message(Message.TYPE_ACK); - ack.setId(message.getId()); - ctx.writeAndFlush(new TextWebSocketFrame(ack.toJson())); - } - - private void handleHttpResponse(ChannelHandlerContext ctx, Message message) { - try { - HttpRelayResponse response = Message.payloadAs( - message.getPayload(), - HttpRelayResponse.class - ); - - PendingRequest pending = requestManager.removePendingRequest(response.getRequestId()); - if (pending != null) { - HttpHandler.sendRelayResponse(pending.getCtx(), response); - } else { - Log.w(TAG, "No pending request for: " + response.getRequestId()); - } - } catch (Exception e) { - Log.e(TAG, "Failed to handle HTTP response", e); - } - } - - private void handleData(ChannelHandlerContext ctx, Message message) { - // Handle service-specific data messages - String service = message.getService(); - if (service != null) { - ServiceManager.ServiceEntry entry = serviceManager.getService(service); - if (entry != null) { - // Broadcast or route data as needed - Log.d(TAG, "Data message for service: " + service); - } - } - } - - /** - * Handle streaming HTTP response start (headers). - */ - private void handleHttpResponseStart(ChannelHandlerContext ctx, Message message) { - try { - JsonObject payload = message.getPayload(); - String requestId = payload.get("request_id").getAsString(); - int status = payload.get("status").getAsInt(); - JsonObject headers = payload.getAsJsonObject("headers"); - - PendingRequest pending = requestManager.getPendingRequest(requestId); - if (pending != null) { - HttpHandler.startStreamingResponse(pending.getCtx(), requestId, status, headers); - } else { - Log.w(TAG, "No pending request for streaming start: " + requestId); - } - } catch (Exception e) { - Log.e(TAG, "Failed to handle HTTP response start", e); - } - } - - /** - * Handle streaming HTTP response chunk. - */ - private void handleHttpResponseChunk(ChannelHandlerContext ctx, Message message) { - try { - JsonObject payload = message.getPayload(); - String requestId = payload.get("request_id").getAsString(); - boolean done = payload.has("done") && payload.get("done").getAsBoolean(); - - String chunkBase64 = payload.has("chunk_base64") ? - payload.get("chunk_base64").getAsString() : null; - String error = payload.has("error") ? - payload.get("error").getAsString() : null; - - PendingRequest pending = done ? - requestManager.removePendingRequest(requestId) : - requestManager.getPendingRequest(requestId); - - if (pending != null) { - if (error != null) { - HttpHandler.sendStreamingError(pending.getCtx(), error); - } else if (chunkBase64 != null) { - HttpHandler.sendStreamingChunk(pending.getCtx(), chunkBase64); - } - - if (done) { - HttpHandler.endStreamingResponse(pending.getCtx()); - } - } else { - Log.w(TAG, "No pending request for streaming chunk: " + requestId); - } - } catch (Exception e) { - Log.e(TAG, "Failed to handle HTTP response chunk", e); - } - } - - /** - * Handle file registration for relay sharing. - */ - private void handleFileRegister(ChannelHandlerContext ctx, Message message) { - try { - JsonObject payload = message.getPayload(); - String fileId = payload.get("fileId").getAsString(); - String filename = payload.get("filename").getAsString(); - long size = payload.get("size").getAsLong(); - String mimeType = payload.has("mimeType") ? - payload.get("mimeType").getAsString() : "application/octet-stream"; - String ownerId = payload.has("ownerId") ? - payload.get("ownerId").getAsString() : "unknown"; - - serviceManager.registerFile(fileId, filename, size, mimeType, ownerId, ctx.channel()); - } catch (Exception e) { - Log.e(TAG, "Failed to register file", e); - } - } - - /** - * Handle file unregistration. - */ - private void handleFileUnregister(ChannelHandlerContext ctx, Message message) { - try { - JsonObject payload = message.getPayload(); - String fileId = payload.get("fileId").getAsString(); - serviceManager.unregisterFile(fileId); - } catch (Exception e) { - Log.e(TAG, "Failed to unregister file", e); - } - } - - /** - * Handle client registration (for user clients, not services). - * Clients can connect to share files or chat without taking over the service. - */ - private void handleClientRegister(ChannelHandlerContext ctx, Message message) { - try { - JsonObject payload = message.getPayload(); - // Use service field first, then payload.clientType as fallback - String clientType = message.getService(); - if (clientType == null || clientType.isEmpty()) { - clientType = payload.has("clientType") ? payload.get("clientType").getAsString() : "unknown"; - } - String userId = payload.has("userId") ? payload.get("userId").getAsString() : "u" + System.currentTimeMillis(); - - // Validate service auth token if service requires it - ServiceManager.ServiceEntry service = serviceManager.getService(clientType); - if (service != null && service.hasAuth()) { - String clientToken = payload.has("auth_token") ? payload.get("auth_token").getAsString() : null; - if (!service.validateClientAuth(clientToken)) { - sendError(ctx, "Invalid service auth token"); - return; - } - } - - // Register the client with the service manager - serviceManager.registerClient(userId, clientType, ctx.channel()); - - // Send acknowledgment - Message ack = new Message(Message.TYPE_ACK); - ack.setId(message.getId()); - JsonObject ackPayload = new JsonObject(); - ackPayload.addProperty("success", true); - ackPayload.addProperty("message", "Client registered"); - ackPayload.addProperty("userId", userId); - ack.setPayload(ackPayload); - - ctx.writeAndFlush(new TextWebSocketFrame(ack.toJson())); - - // Send current file list if fileshare client - if ("fileshare".equals(clientType)) { - serviceManager.broadcastFileList(); - } - - // Send current user list if chat client - if ("chat".equals(clientType)) { - serviceManager.broadcastChatUserList(); - } - - Log.i(TAG, "Client registered: " + userId + " (type: " + clientType + ")"); - } catch (Exception e) { - Log.e(TAG, "Failed to register client", e); - sendError(ctx, "Client registration failed: " + e.getMessage()); - } - } - - /** - * Handle chat join message. - */ - private void handleChatJoin(ChannelHandlerContext ctx, Message message) { - try { - JsonObject payload = message.getPayload(); - String userId = payload.get("userId").getAsString(); - String name = payload.has("name") ? payload.get("name").getAsString() : "Anonymous"; - - // Register chat user - serviceManager.registerChatUser(userId, name, ctx.channel()); - - // Broadcast to all chat clients - serviceManager.broadcastChatJoin(userId, name); - serviceManager.broadcastChatUserList(); - - Log.i(TAG, "Chat user joined: " + name + " (" + userId + ")"); - } catch (Exception e) { - Log.e(TAG, "Failed to handle chat join", e); - } - } - - /** - * Handle chat leave message. - */ - private void handleChatLeave(ChannelHandlerContext ctx, Message message) { - try { - JsonObject payload = message.getPayload(); - String userId = payload.get("userId").getAsString(); - - String name = serviceManager.getChatUserName(userId); - serviceManager.unregisterChatUser(userId); - - // Broadcast to all chat clients - if (name != null) { - serviceManager.broadcastChatLeave(userId, name); - serviceManager.broadcastChatUserList(); - } - - Log.i(TAG, "Chat user left: " + userId); - } catch (Exception e) { - Log.e(TAG, "Failed to handle chat leave", e); - } - } - - /** - * Handle chat message. - */ - private void handleChatMessage(ChannelHandlerContext ctx, Message message) { - try { - JsonObject payload = message.getPayload(); - - // Broadcast to all chat clients (or specific recipients if specified) - serviceManager.broadcastChatMessage(payload, ctx.channel()); - - } catch (Exception e) { - Log.e(TAG, "Failed to handle chat message", e); - } - } - - /** - * Handle kick client request from service. - */ - private void handleKickClient(ChannelHandlerContext ctx, Message message) { - try { - JsonObject payload = message.getPayload(); - String userId = payload.get("userId").getAsString(); - serviceManager.kickClient(userId); - } catch (Exception e) { - Log.e(TAG, "Failed to kick client", e); - } - } - - /** - * Handle create instance request from service client. - */ - private void handleCreateInstance(ChannelHandlerContext ctx, Message message) { - try { - JsonObject payload = message.getPayload(); - if (payload == null) { - payload = new JsonObject(); - } - - String serviceName = message.getService(); - if (serviceName == null || serviceName.isEmpty()) { - sendError(ctx, "Service name is required"); - return; - } - - String name = getStringFromPayload(payload, "name", "Room"); - String token = getStringFromPayload(payload, "token", null); - - // Validate server auth - ServerConfig config = HttpHandler.getServerConfig(); - if (config != null && config.isAuthEnabled()) { - String authToken = getStringFromPayload(payload, "server_token", null); - if (!config.validateServerAuth(authToken)) { - sendError(ctx, "Invalid server auth token"); - return; - } - } - - ServiceManager.ServiceInstance instance = serviceManager.createInstance( - serviceName, name, token, ctx.channel() - ); - - // Send success response - Message ack = new Message(Message.TYPE_INSTANCE_CREATED); - ack.setService(serviceName); - JsonObject ackPayload = new JsonObject(); - ackPayload.addProperty("success", true); - ackPayload.addProperty("uuid", instance.getUuid()); - ackPayload.addProperty("name", instance.getName()); - ackPayload.addProperty("hasToken", instance.hasToken()); - ack.setPayload(ackPayload); - ctx.writeAndFlush(new TextWebSocketFrame(ack.toJson())); - - Log.i(TAG, "Instance created: " + instance.getUuid() + " (" + name + ")"); - } catch (Exception e) { - Log.e(TAG, "Failed to create instance", e); - sendError(ctx, "Failed to create instance: " + e.getMessage()); - } - } - - /** - * Handle join instance request from user client. - */ - private void handleJoinInstance(ChannelHandlerContext ctx, Message message) { - try { - JsonObject payload = message.getPayload(); - if (payload == null) { - sendError(ctx, "Missing payload"); - return; - } - - String serviceName = message.getService(); - String instanceUuid = getStringFromPayload(payload, "uuid", null); - if (instanceUuid == null || instanceUuid.isEmpty()) { - sendError(ctx, "Instance UUID is required"); - return; - } - String userId = getStringFromPayload(payload, "userId", "u" + System.currentTimeMillis()); - String token = getStringFromPayload(payload, "token", null); - - // Find the instance - ServiceManager.ServiceInstance instance = serviceManager.getInstance(instanceUuid); - if (instance == null || !instance.isOwnerConnected()) { - sendError(ctx, "Instance not found or not available"); - return; - } - - // Validate instance token - if (!instance.validateToken(token)) { - sendError(ctx, "Invalid instance token"); - return; - } - - // Register client to instance - serviceManager.registerClient(userId, serviceName, instanceUuid, ctx.channel()); - - // Send success response - Message ack = new Message(Message.TYPE_ACK); - ack.setService(serviceName); - JsonObject ackPayload = new JsonObject(); - ackPayload.addProperty("success", true); - ackPayload.addProperty("message", "Joined instance"); - ackPayload.addProperty("userId", userId); - ackPayload.addProperty("instanceUuid", instanceUuid); - ackPayload.addProperty("instanceName", instance.getName()); - ack.setPayload(ackPayload); - ctx.writeAndFlush(new TextWebSocketFrame(ack.toJson())); - - Log.i(TAG, "Client " + userId + " joined instance " + instanceUuid); - } catch (Exception e) { - Log.e(TAG, "Failed to join instance", e); - sendError(ctx, "Failed to join instance: " + e.getMessage()); - } - } - - /** - * Handle list instances request. - */ - private void handleListInstances(ChannelHandlerContext ctx, Message message) { - try { - String serviceName = message.getService(); - - List instances = serviceManager.getInstancesForService(serviceName); - - Message response = new Message(Message.TYPE_INSTANCE_LIST); - response.setService(serviceName); - JsonObject payload = new JsonObject(); - JsonArray arr = new JsonArray(); - for (ServiceManager.ServiceInstance inst : instances) { - arr.add(inst.toJson()); - } - payload.add("instances", arr); - response.setPayload(payload); - - ctx.writeAndFlush(new TextWebSocketFrame(response.toJson())); - } catch (Exception e) { - Log.e(TAG, "Failed to list instances", e); - sendError(ctx, "Failed to list instances: " + e.getMessage()); - } - } - - /** - * Handle reclaim instance request - allows owner to reconnect to their instance. - */ - private void handleReclaimInstance(ChannelHandlerContext ctx, Message message) { - try { - JsonObject payload = message.getPayload(); - if (payload == null) { - sendError(ctx, "Missing payload"); - return; - } - - String serviceName = message.getService(); - String uuid = getStringFromPayload(payload, "uuid", null); - String token = getStringFromPayload(payload, "token", null); - - if (uuid == null || uuid.isEmpty()) { - sendError(ctx, "Instance UUID is required"); - return; - } - - // Validate server auth - ServerConfig config = HttpHandler.getServerConfig(); - if (config != null && config.isAuthEnabled()) { - String authToken = getStringFromPayload(payload, "server_token", null); - if (!config.validateServerAuth(authToken)) { - sendError(ctx, "Invalid server auth token"); - return; - } - } - - // Find the instance and validate token - ServiceManager.ServiceInstance instance = serviceManager.getInstance(uuid); - if (instance == null) { - sendError(ctx, "Instance not found"); - return; - } - - // Validate instance token - if (!instance.validateToken(token)) { - sendError(ctx, "Invalid instance token"); - return; - } - - // Try to reclaim - boolean success = serviceManager.reclaimInstance(uuid, ctx.channel()); - - Message response = new Message(Message.TYPE_INSTANCE_RECLAIMED); - response.setService(serviceName); - JsonObject respPayload = new JsonObject(); - respPayload.addProperty("success", success); - if (success) { - respPayload.addProperty("uuid", uuid); - respPayload.addProperty("name", instance.getName()); - respPayload.addProperty("hasToken", instance.hasToken()); - respPayload.addProperty("userCount", instance.getUserCount()); - } else { - respPayload.addProperty("error", "Cannot reclaim instance (owner already connected or grace period expired)"); - } - response.setPayload(respPayload); - - ctx.writeAndFlush(new TextWebSocketFrame(response.toJson())); - - if (success) { - Log.i(TAG, "Instance reclaimed: " + uuid); - } - } catch (Exception e) { - Log.e(TAG, "Failed to reclaim instance", e); - sendError(ctx, "Failed to reclaim instance: " + e.getMessage()); - } - } - - /** - * Handle broadcast messages from service to all clients. - */ - private void handleBroadcast(ChannelHandlerContext ctx, Message message) { - try { - String service = message.getService(); - if (service == null) return; - - String json = message.toJson(); - - // Send to all clients of this service type - for (ServiceManager.ClientInfo client : serviceManager.getClientsByType(service)) { - if (client.getChannel() != null && client.getChannel().isActive() && client.getChannel() != ctx.channel()) { - client.getChannel().writeAndFlush(new TextWebSocketFrame(json)); - } - } - } catch (Exception e) { - Log.e(TAG, "Failed to broadcast message", e); - } - } - - /** - * Handle file request from service to specific client. - */ - private void handleFileRequest(ChannelHandlerContext ctx, Message message) { - try { - JsonObject payload = message.getPayload(); - String requestId = payload.get("requestId").getAsString(); - String fileId = payload.get("fileId").getAsString(); - String ownerId = payload.has("ownerId") ? payload.get("ownerId").getAsString() : null; - - // Find the file owner and send the request - if (ownerId != null) { - ServiceManager.ClientInfo owner = serviceManager.getClient(ownerId); - if (owner != null && owner.getChannel() != null && owner.getChannel().isActive()) { - // Forward the request to the owner - Message request = new Message(Message.TYPE_FILE_REQUEST); - request.setService(message.getService()); - JsonObject reqPayload = new JsonObject(); - reqPayload.addProperty("requestId", requestId); - reqPayload.addProperty("fileId", fileId); - request.setPayload(reqPayload); - owner.getChannel().writeAndFlush(new TextWebSocketFrame(request.toJson())); - return; - } - } - - // Owner not found - Log.w(TAG, "File owner not found: " + ownerId); - } catch (Exception e) { - Log.e(TAG, "Failed to handle file request", e); - } - } - - /** - * Handle instance-specific messages (user_join, file_add, etc.) - * These messages are broadcast to all other users in the same instance. - */ - private void handleInstanceMessage(ChannelHandlerContext ctx, Message message) { - try { - // Find which client/instance this message is from - ServiceManager.ClientInfo sender = serviceManager.getClientByChannel(ctx.channel()); - if (sender == null) { - Log.w(TAG, "Instance message from unknown client"); - return; - } - - String instanceUuid = sender.getInstanceUuid(); - String senderUserId = sender.getUserId(); - - // Broadcast to all other users in the same instance - String json = message.toJson(); - List instanceClients; - - if (instanceUuid != null) { - instanceClients = serviceManager.getClientsInInstance(instanceUuid); - } else { - // Fallback to service-based broadcasting for legacy clients - instanceClients = serviceManager.getClientsByType(sender.getClientType()); - } - - for (ServiceManager.ClientInfo client : instanceClients) { - // Don't send back to sender - if (client.getUserId().equals(senderUserId)) { - continue; - } - - if (client.getChannel() != null && client.getChannel().isActive()) { - client.getChannel().writeAndFlush(new TextWebSocketFrame(json)); - } - } - - // Also send to the instance owner if any - if (instanceUuid != null) { - ServiceManager.ServiceInstance instance = serviceManager.getInstance(instanceUuid); - if (instance != null && instance.getOwnerChannel() != null && - instance.getOwnerChannel().isActive() && - instance.getOwnerChannel() != ctx.channel()) { - instance.getOwnerChannel().writeAndFlush(new TextWebSocketFrame(json)); - } - } - - Log.d(TAG, "Broadcast instance message: " + message.getType() + " to " + instanceClients.size() + " clients"); - } catch (Exception e) { - Log.e(TAG, "Failed to handle instance message", e); - } - } - - /** - * Safely get a string from JsonObject, handling null and JsonNull. - */ - private String getStringFromPayload(JsonObject payload, String key, String defaultValue) { - if (payload == null || !payload.has(key)) { - return defaultValue; - } - try { - if (payload.get(key).isJsonNull()) { - return defaultValue; - } - return payload.get(key).getAsString(); - } catch (Exception e) { - return defaultValue; - } - } - - private void sendError(ChannelHandlerContext ctx, String error) { - Message errorMsg = new Message(Message.TYPE_ERROR); - JsonObject payload = new JsonObject(); - payload.addProperty("message", error); // Client expects 'message' field - payload.addProperty("error", error); // Also include 'error' for compatibility - errorMsg.setPayload(payload); - ctx.writeAndFlush(new TextWebSocketFrame(errorMsg.toJson())); - } - - @Override - public void channelInactive(ChannelHandlerContext ctx) throws Exception { - Log.i(TAG, "WebSocket channel disconnected"); - - // Clean up any pending requests for this service - cleanupPendingRequestsForChannel(ctx); - - serviceManager.onChannelDisconnect(ctx.channel()); - super.channelInactive(ctx); - } - - /** - * Clean up pending HTTP requests when a service client disconnects. - * This sends error responses to HTTP clients waiting for relay responses. - */ - private void cleanupPendingRequestsForChannel(ChannelHandlerContext ctx) { - // Find and fail all pending requests that were being handled by this channel - // The RequestManager should have a method to get requests by service - // For now, we rely on the timeout mechanism, but we can improve this - Log.d(TAG, "Cleaning up pending requests for disconnected channel"); - } - - @Override - public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) { - Log.e(TAG, "WebSocket error", cause); - ctx.close(); - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/service/WSTunService.java b/android/app/src/main/java/seven/lab/wstun/service/WSTunService.java deleted file mode 100644 index 6e0a83a..0000000 --- a/android/app/src/main/java/seven/lab/wstun/service/WSTunService.java +++ /dev/null @@ -1,247 +0,0 @@ -package seven.lab.wstun.service; - -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.PendingIntent; -import android.app.Service; -import android.content.Intent; -import android.os.Binder; -import android.os.Build; -import android.os.IBinder; -import android.util.Log; - -import androidx.core.app.NotificationCompat; - -import seven.lab.wstun.R; -import seven.lab.wstun.config.ServerConfig; -import seven.lab.wstun.server.LocalServiceManager; -import seven.lab.wstun.server.NettyServer; -import seven.lab.wstun.server.ServiceManager; -import seven.lab.wstun.ui.MainActivity; - -/** - * Foreground service that runs the HTTP/WebSocket server. - */ -public class WSTunService extends Service { - - private static final String TAG = "WSTunService"; - private static final String CHANNEL_ID = "wstun_service"; - private static final int NOTIFICATION_ID = 1; - - public static final String ACTION_START = "seven.lab.wstun.START"; - public static final String ACTION_STOP = "seven.lab.wstun.STOP"; - - private final IBinder binder = new LocalBinder(); - private NettyServer server; - private ServerConfig config; - private ServiceManager serviceManager; - private boolean isRunning = false; - - private ServiceListener listener; - - public interface ServiceListener { - void onServerStarted(); - void onServerStopped(); - void onServiceChanged(); - void onError(String message); - } - - public class LocalBinder extends Binder { - public WSTunService getService() { - return WSTunService.this; - } - } - - @Override - public void onCreate() { - super.onCreate(); - createNotificationChannel(); - config = new ServerConfig(this); - serviceManager = new ServiceManager(); - serviceManager.setListener(new ServiceManager.ServiceChangeListener() { - @Override - public void onServiceAdded(ServiceManager.ServiceEntry service) { - if (listener != null) { - listener.onServiceChanged(); - } - } - - @Override - public void onServiceRemoved(ServiceManager.ServiceEntry service) { - if (listener != null) { - listener.onServiceChanged(); - } - } - }); - } - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - if (intent != null) { - String action = intent.getAction(); - if (ACTION_START.equals(action)) { - startServer(); - } else if (ACTION_STOP.equals(action)) { - stopServer(); - stopSelf(); - } - } - return START_STICKY; - } - - @Override - public IBinder onBind(Intent intent) { - return binder; - } - - @Override - public void onDestroy() { - stopServer(); - super.onDestroy(); - } - - /** - * Start the HTTP server. - */ - public void startServer() { - if (isRunning) { - Log.w(TAG, "Server already running"); - return; - } - - try { - server = new NettyServer(this, config, serviceManager); - server.start(); - isRunning = true; - - // Start foreground - startForeground(NOTIFICATION_ID, createNotification()); - - if (listener != null) { - listener.onServerStarted(); - } - - Log.i(TAG, "Server started on port " + config.getPort()); - } catch (Exception e) { - Log.e(TAG, "Failed to start server", e); - if (listener != null) { - listener.onError("Failed to start server: " + e.getMessage()); - } - } - } - - /** - * Stop the HTTP server. - */ - public void stopServer() { - if (!isRunning) { - return; - } - - if (server != null) { - server.stop(); - server = null; - } - - isRunning = false; - stopForeground(true); - - if (listener != null) { - listener.onServerStopped(); - } - - Log.i(TAG, "Server stopped"); - } - - /** - * Check if server is running. - */ - public boolean isServerRunning() { - return isRunning; - } - - /** - * Get server port. - */ - public int getPort() { - return config.getPort(); - } - - /** - * Check if HTTPS is enabled. - */ - public boolean isHttpsEnabled() { - return config.isHttpsEnabled(); - } - - /** - * Get service manager. - */ - public ServiceManager getServiceManager() { - return serviceManager; - } - - /** - * Set service listener. - */ - public void setListener(ServiceListener listener) { - this.listener = listener; - } - - /** - * Kick a service by name. - */ - public void kickService(String serviceName) { - if (serviceManager != null) { - serviceManager.kickService(serviceName); - } - } - - /** - * Get the local service manager. - */ - public LocalServiceManager getLocalServiceManager() { - return server != null ? server.getLocalServiceManager() : null; - } - - private void createNotificationChannel() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - NotificationChannel channel = new NotificationChannel( - CHANNEL_ID, - getString(R.string.notification_channel_name), - NotificationManager.IMPORTANCE_LOW - ); - channel.setDescription(getString(R.string.notification_channel_desc)); - - NotificationManager manager = getSystemService(NotificationManager.class); - if (manager != null) { - manager.createNotificationChannel(channel); - } - } - } - - private Notification createNotification() { - Intent intent = new Intent(this, MainActivity.class); - PendingIntent pendingIntent = PendingIntent.getActivity( - this, 0, intent, PendingIntent.FLAG_IMMUTABLE - ); - - Intent stopIntent = new Intent(this, WSTunService.class); - stopIntent.setAction(ACTION_STOP); - PendingIntent stopPendingIntent = PendingIntent.getService( - this, 0, stopIntent, PendingIntent.FLAG_IMMUTABLE - ); - - String protocol = config.isHttpsEnabled() ? "HTTPS" : "HTTP"; - - return new NotificationCompat.Builder(this, CHANNEL_ID) - .setContentTitle(getString(R.string.notification_title)) - .setContentText(protocol + " server running on port " + config.getPort()) - .setSmallIcon(android.R.drawable.ic_menu_share) - .setContentIntent(pendingIntent) - .addAction(android.R.drawable.ic_menu_close_clear_cancel, "Stop", stopPendingIntent) - .setOngoing(true) - .build(); - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/ui/ConfigFragment.java b/android/app/src/main/java/seven/lab/wstun/ui/ConfigFragment.java deleted file mode 100644 index 639c741..0000000 --- a/android/app/src/main/java/seven/lab/wstun/ui/ConfigFragment.java +++ /dev/null @@ -1,341 +0,0 @@ -package seven.lab.wstun.ui; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.CheckBox; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; - -import com.google.android.material.textfield.TextInputEditText; - -import seven.lab.wstun.R; -import seven.lab.wstun.config.ServerConfig; -import seven.lab.wstun.service.WSTunService; - -/** - * Fragment for server configuration. - */ -public class ConfigFragment extends Fragment { - - private WSTunService service; - private ServerConfig config; - - private TextInputEditText portInput; - private TextInputEditText corsOriginsInput; - private CheckBox httpsCheckbox; - private TextView certInfo; - private Button saveButton; - private CheckBox debugLogsCheckbox; - private TextView debugLogsUrl; - private Button viewLogsButton; - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_config, container, false); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - config = new ServerConfig(requireContext()); - - portInput = view.findViewById(R.id.portInput); - corsOriginsInput = view.findViewById(R.id.corsOriginsInput); - httpsCheckbox = view.findViewById(R.id.httpsCheckbox); - certInfo = view.findViewById(R.id.certInfo); - saveButton = view.findViewById(R.id.saveButton); - debugLogsCheckbox = view.findViewById(R.id.debugLogsCheckbox); - debugLogsUrl = view.findViewById(R.id.debugLogsUrl); - viewLogsButton = view.findViewById(R.id.viewLogsButton); - - loadConfig(); - - httpsCheckbox.setOnCheckedChangeListener((buttonView, isChecked) -> { - certInfo.setVisibility(isChecked ? View.VISIBLE : View.GONE); - }); - - debugLogsCheckbox.setOnCheckedChangeListener((buttonView, isChecked) -> { - config.setDebugLogsEnabled(isChecked); - updateDebugLogsUrl(); - }); - - saveButton.setOnClickListener(v -> saveConfig()); - viewLogsButton.setOnClickListener(v -> showLogViewer()); - } - - public void onServiceConnected(WSTunService service) { - this.service = service; - updateUI(); - } - - private void loadConfig() { - portInput.setText(String.valueOf(config.getPort())); - httpsCheckbox.setChecked(config.isHttpsEnabled()); - certInfo.setVisibility(config.isHttpsEnabled() ? View.VISIBLE : View.GONE); - corsOriginsInput.setText(config.getCorsOrigins()); - debugLogsCheckbox.setChecked(config.isDebugLogsEnabled()); - updateDebugLogsUrl(); - } - - private void updateDebugLogsUrl() { - if (config.isDebugLogsEnabled()) { - String protocol = config.isHttpsEnabled() ? "https" : "http"; - String url = protocol + "://[device-ip]:" + config.getPort() + "/debug/logs"; - debugLogsUrl.setText("Access " + url + " in browser to view live server logs"); - } else { - debugLogsUrl.setText("Enable to access /debug/logs endpoint for live server logs"); - } - } - - private void saveConfig() { - // Validate port - String portStr = portInput.getText().toString().trim(); - int port; - try { - port = Integer.parseInt(portStr); - if (port < 1 || port > 65535) { - Toast.makeText(getContext(), "Port must be between 1 and 65535", Toast.LENGTH_SHORT).show(); - return; - } - } catch (NumberFormatException e) { - Toast.makeText(getContext(), "Invalid port number", Toast.LENGTH_SHORT).show(); - return; - } - - // Check if server is running - if (service != null && service.isServerRunning()) { - Toast.makeText(getContext(), "Stop server before changing configuration", Toast.LENGTH_SHORT).show(); - return; - } - - // Validate CORS origins - String corsOrigins = corsOriginsInput.getText().toString().trim(); - if (corsOrigins.isEmpty()) { - corsOrigins = "*"; - } - - // Save config - config.setPort(port); - config.setHttpsEnabled(httpsCheckbox.isChecked()); - config.setCorsOrigins(corsOrigins); - - Toast.makeText(getContext(), "Configuration saved", Toast.LENGTH_SHORT).show(); - } - - private void updateUI() { - if (getActivity() == null || !isAdded()) return; - - requireActivity().runOnUiThread(() -> { - boolean canEdit = service == null || !service.isServerRunning(); - portInput.setEnabled(canEdit); - httpsCheckbox.setEnabled(canEdit); - corsOriginsInput.setEnabled(canEdit); - saveButton.setEnabled(canEdit); - - updateDebugLogsUrl(); - }); - } - - // Log viewer thread and process - private Thread logViewerThread; - private Process logViewerProcess; - private volatile boolean logViewerRunning = false; - - // Track if user has scrolled manually (disable auto-scroll) - private volatile boolean userScrolling = false; - private String logFilter = ""; - - private void showLogViewer() { - // Create a dialog with a scrollable TextView for logs - android.app.AlertDialog.Builder builder = new android.app.AlertDialog.Builder(requireContext()); - builder.setTitle("Live Logs"); - - // Create main layout - android.widget.LinearLayout mainLayout = new android.widget.LinearLayout(requireContext()); - mainLayout.setOrientation(android.widget.LinearLayout.VERTICAL); - mainLayout.setPadding(16, 16, 16, 16); - - // Create filter input - android.widget.LinearLayout filterLayout = new android.widget.LinearLayout(requireContext()); - filterLayout.setOrientation(android.widget.LinearLayout.HORIZONTAL); - filterLayout.setGravity(android.view.Gravity.CENTER_VERTICAL); - - final android.widget.EditText filterInput = new android.widget.EditText(requireContext()); - filterInput.setHint("Filter keywords..."); - filterInput.setSingleLine(true); - filterInput.setLayoutParams(new android.widget.LinearLayout.LayoutParams( - 0, android.widget.LinearLayout.LayoutParams.WRAP_CONTENT, 1)); - - final android.widget.CheckBox autoScrollCheckbox = new android.widget.CheckBox(requireContext()); - autoScrollCheckbox.setText("Auto-scroll"); - autoScrollCheckbox.setChecked(true); - - filterLayout.addView(filterInput); - filterLayout.addView(autoScrollCheckbox); - mainLayout.addView(filterLayout); - - // Create a scrollable TextView - final android.widget.ScrollView scrollView = new android.widget.ScrollView(requireContext()); - scrollView.setLayoutParams(new android.widget.LinearLayout.LayoutParams( - android.widget.LinearLayout.LayoutParams.MATCH_PARENT, 600)); - - final TextView logTextView = new TextView(requireContext()); - logTextView.setTypeface(android.graphics.Typeface.MONOSPACE); - logTextView.setTextSize(10); - logTextView.setPadding(8, 8, 8, 8); - logTextView.setText("Starting log capture...\n"); - logTextView.setTextIsSelectable(true); - scrollView.addView(logTextView); - mainLayout.addView(scrollView); - - // Handle filter changes - filterInput.addTextChangedListener(new android.text.TextWatcher() { - @Override - public void beforeTextChanged(CharSequence s, int start, int count, int after) {} - @Override - public void onTextChanged(CharSequence s, int start, int before, int count) {} - @Override - public void afterTextChanged(android.text.Editable s) { - logFilter = s.toString().toLowerCase(); - } - }); - - // Handle auto-scroll toggle - autoScrollCheckbox.setOnCheckedChangeListener((buttonView, isChecked) -> { - userScrolling = !isChecked; - }); - - // Detect manual scroll - scrollView.setOnTouchListener((v, event) -> { - if (event.getAction() == android.view.MotionEvent.ACTION_DOWN || - event.getAction() == android.view.MotionEvent.ACTION_MOVE) { - userScrolling = true; - autoScrollCheckbox.setChecked(false); - } - return false; - }); - - builder.setView(mainLayout); - builder.setNeutralButton("Clear", null); // Will set listener after show() - builder.setNegativeButton("Close", (dialog, which) -> { - stopLogViewer(); - dialog.dismiss(); - }); - - android.app.AlertDialog dialog = builder.create(); - dialog.setOnDismissListener(d -> stopLogViewer()); - dialog.show(); - - // Set clear button to not dismiss dialog - dialog.getButton(android.app.AlertDialog.BUTTON_NEUTRAL).setOnClickListener(v -> { - logTextView.setText(""); - }); - - // Start log capture thread - userScrolling = false; - logFilter = ""; - logViewerRunning = true; - logViewerThread = new Thread(() -> { - java.io.BufferedReader reader = null; - try { - // Clear logcat first and then start capturing - Runtime.getRuntime().exec("logcat -c").waitFor(); - logViewerProcess = Runtime.getRuntime().exec("logcat -v time *:D"); - reader = new java.io.BufferedReader( - new java.io.InputStreamReader(logViewerProcess.getInputStream())); - - String line; - final StringBuilder buffer = new StringBuilder(); - int lineCount = 0; - - while (logViewerRunning && (line = reader.readLine()) != null) { - // Apply filter - String currentFilter = logFilter; - if (currentFilter.isEmpty() || line.toLowerCase().contains(currentFilter)) { - buffer.append(line).append("\n"); - lineCount++; - } - - // Update UI every 5 lines - if (lineCount % 5 == 0 && buffer.length() > 0) { - final String newText = buffer.toString(); - buffer.setLength(0); - - if (getActivity() != null) { - getActivity().runOnUiThread(() -> { - // Save scroll position before update if not auto-scrolling - final int scrollY = scrollView.getScrollY(); - - logTextView.append(newText); - - // Limit text length to prevent memory issues - if (logTextView.getText().length() > 50000) { - String text = logTextView.getText().toString(); - logTextView.setText(text.substring(text.length() - 40000)); - } - - // Handle scroll position - if (userScrolling) { - // Restore scroll position when user is reading - scrollView.post(() -> scrollView.scrollTo(0, scrollY)); - } else { - // Auto-scroll to bottom - scrollView.post(() -> scrollView.fullScroll(View.FOCUS_DOWN)); - } - }); - } - } - } - - // Flush remaining buffer - if (buffer.length() > 0 && getActivity() != null) { - final String remaining = buffer.toString(); - getActivity().runOnUiThread(() -> logTextView.append(remaining)); - } - - } catch (Exception e) { - if (getActivity() != null) { - final String error = "Error: " + e.getMessage() + "\n"; - getActivity().runOnUiThread(() -> logTextView.append(error)); - } - } finally { - if (reader != null) { - try { reader.close(); } catch (Exception ignored) {} - } - if (logViewerProcess != null) { - logViewerProcess.destroy(); - } - } - }); - logViewerThread.setName("LogViewer"); - logViewerThread.start(); - } - - private void stopLogViewer() { - logViewerRunning = false; - if (logViewerProcess != null) { - logViewerProcess.destroy(); - logViewerProcess = null; - } - if (logViewerThread != null) { - logViewerThread.interrupt(); - logViewerThread = null; - } - } - - @Override - public void onDestroyView() { - super.onDestroyView(); - stopLogViewer(); - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/ui/InstalledServiceAdapter.java b/android/app/src/main/java/seven/lab/wstun/ui/InstalledServiceAdapter.java deleted file mode 100644 index 9bd8ae5..0000000 --- a/android/app/src/main/java/seven/lab/wstun/ui/InstalledServiceAdapter.java +++ /dev/null @@ -1,140 +0,0 @@ -package seven.lab.wstun.ui; - -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -import java.util.ArrayList; -import java.util.List; - -import seven.lab.wstun.R; - -/** - * Adapter for displaying installed services. - */ -public class InstalledServiceAdapter extends RecyclerView.Adapter { - - private List services = new ArrayList<>(); - private final ServiceActionListener listener; - - public interface ServiceActionListener { - void onEnableDisable(String serviceName, boolean enable); - void onUninstall(String serviceName); - } - - public static class ServiceItem { - public final String name; - public final String displayName; - public final String description; - public final boolean enabled; - public final boolean builtin; - public final int instanceCount; - - public ServiceItem(String name, String displayName, String description, - boolean enabled, boolean builtin, int instanceCount) { - this.name = name; - this.displayName = displayName != null ? displayName : name; - this.description = description; - this.enabled = enabled; - this.builtin = builtin; - this.instanceCount = instanceCount; - } - } - - public InstalledServiceAdapter(ServiceActionListener listener) { - this.listener = listener; - } - - public void setServices(List services) { - this.services = services; - notifyDataSetChanged(); - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.item_installed_service, parent, false); - return new ViewHolder(view); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - ServiceItem item = services.get(position); - holder.bind(item, listener); - } - - @Override - public int getItemCount() { - return services.size(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - private final TextView nameText; - private final TextView descText; - private final TextView statusText; - private final TextView instancesText; - private final Button enableButton; - private final Button uninstallButton; - - ViewHolder(View itemView) { - super(itemView); - nameText = itemView.findViewById(R.id.serviceNameText); - descText = itemView.findViewById(R.id.serviceDescText); - statusText = itemView.findViewById(R.id.statusText); - instancesText = itemView.findViewById(R.id.instancesText); - enableButton = itemView.findViewById(R.id.enableButton); - uninstallButton = itemView.findViewById(R.id.uninstallButton); - } - - void bind(ServiceItem item, ServiceActionListener listener) { - nameText.setText(item.displayName); - - if (item.description != null && !item.description.isEmpty()) { - descText.setText(item.description); - descText.setVisibility(View.VISIBLE); - } else { - descText.setVisibility(View.GONE); - } - - if (item.enabled) { - statusText.setText("Enabled"); - statusText.setTextColor(0xFF4CAF50); - enableButton.setText("Disable"); - } else { - statusText.setText("Disabled"); - statusText.setTextColor(0xFF9E9E9E); - enableButton.setText("Enable"); - } - - if (item.instanceCount > 0) { - instancesText.setText(item.instanceCount + " instance(s)"); - instancesText.setVisibility(View.VISIBLE); - } else { - instancesText.setVisibility(View.GONE); - } - - enableButton.setOnClickListener(v -> { - if (listener != null) { - listener.onEnableDisable(item.name, !item.enabled); - } - }); - - if (item.builtin) { - uninstallButton.setVisibility(View.GONE); - } else { - uninstallButton.setVisibility(View.VISIBLE); - uninstallButton.setOnClickListener(v -> { - if (listener != null) { - listener.onUninstall(item.name); - } - }); - } - } - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/ui/InstalledServicesFragment.java b/android/app/src/main/java/seven/lab/wstun/ui/InstalledServicesFragment.java deleted file mode 100644 index 4566fce..0000000 --- a/android/app/src/main/java/seven/lab/wstun/ui/InstalledServicesFragment.java +++ /dev/null @@ -1,182 +0,0 @@ -package seven.lab.wstun.ui; - -import android.os.Bundle; -import android.text.Editable; -import android.text.TextWatcher; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.google.android.material.textfield.TextInputEditText; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import seven.lab.wstun.R; -import seven.lab.wstun.marketplace.InstalledService; -import seven.lab.wstun.server.LocalServiceManager; -import seven.lab.wstun.server.ServiceManager; -import seven.lab.wstun.service.WSTunService; - -/** - * Fragment showing installed services. - */ -public class InstalledServicesFragment extends Fragment { - - private WSTunService service; - - private RecyclerView servicesRecyclerView; - private TextView emptyText; - private TextInputEditText filterInput; - - private InstalledServiceAdapter adapter; - private List allItems = new ArrayList<>(); - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_installed_services, container, false); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - servicesRecyclerView = view.findViewById(R.id.servicesRecyclerView); - emptyText = view.findViewById(R.id.emptyText); - filterInput = view.findViewById(R.id.filterInput); - - servicesRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); - adapter = new InstalledServiceAdapter(new InstalledServiceAdapter.ServiceActionListener() { - @Override - public void onEnableDisable(String serviceName, boolean enable) { - if (service == null || service.getLocalServiceManager() == null) return; - - if (enable) { - service.getLocalServiceManager().enableService(serviceName); - } else { - service.getLocalServiceManager().disableService(serviceName, service.getServiceManager()); - } - refreshList(); - } - - @Override - public void onUninstall(String serviceName) { - if (service == null || service.getLocalServiceManager() == null) return; - - InstalledService svc = service.getLocalServiceManager().getInstalledService(serviceName); - if (svc != null && "builtin".equals(svc.getSource())) { - Toast.makeText(getContext(), "Cannot uninstall built-in service", Toast.LENGTH_SHORT).show(); - return; - } - - service.getLocalServiceManager().uninstallService(serviceName, service.getServiceManager()); - refreshList(); - Toast.makeText(getContext(), "Service uninstalled", Toast.LENGTH_SHORT).show(); - } - }); - servicesRecyclerView.setAdapter(adapter); - - filterInput.addTextChangedListener(new TextWatcher() { - @Override public void beforeTextChanged(CharSequence s, int start, int count, int after) {} - @Override public void onTextChanged(CharSequence s, int start, int before, int count) {} - @Override - public void afterTextChanged(Editable s) { - filterServices(s.toString()); - } - }); - - refreshList(); - } - - public void onServiceConnected(WSTunService service) { - this.service = service; - refreshList(); - } - - @Override - public void onResume() { - super.onResume(); - refreshList(); - } - - private void filterServices(String query) { - if (query == null || query.isEmpty()) { - adapter.setServices(allItems); - } else { - String lowerQuery = query.toLowerCase(); - List filtered = new ArrayList<>(); - for (InstalledServiceAdapter.ServiceItem item : allItems) { - if (item.displayName.toLowerCase().contains(lowerQuery) || - item.name.toLowerCase().contains(lowerQuery)) { - filtered.add(item); - } - } - adapter.setServices(filtered); - } - updateEmptyState(); - } - - private void updateEmptyState() { - if (adapter.getItemCount() == 0) { - emptyText.setVisibility(View.VISIBLE); - servicesRecyclerView.setVisibility(View.GONE); - } else { - emptyText.setVisibility(View.GONE); - servicesRecyclerView.setVisibility(View.VISIBLE); - } - } - - private void refreshList() { - if (getActivity() == null || !isAdded()) return; - - requireActivity().runOnUiThread(() -> { - if (service == null || service.getLocalServiceManager() == null) { - allItems.clear(); - adapter.setServices(allItems); - emptyText.setVisibility(View.VISIBLE); - servicesRecyclerView.setVisibility(View.GONE); - return; - } - - LocalServiceManager lsm = service.getLocalServiceManager(); - ServiceManager sm = service.getServiceManager(); - - allItems.clear(); - Map installed = lsm.getInstalledServices(); - - for (Map.Entry entry : installed.entrySet()) { - String name = entry.getKey(); - InstalledService svc = entry.getValue(); - - int instanceCount = 0; - if (sm != null) { - instanceCount = sm.getInstanceCountForService(name); - } - - allItems.add(new InstalledServiceAdapter.ServiceItem( - name, - svc.getDisplayName(), - svc.getManifest() != null ? svc.getManifest().getDescription() : null, - svc.isEnabled(), - "builtin".equals(svc.getSource()), - instanceCount - )); - } - - // Apply current filter - String filter = filterInput.getText() != null ? filterInput.getText().toString() : ""; - filterServices(filter); - }); - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/ui/InstanceAdapter.java b/android/app/src/main/java/seven/lab/wstun/ui/InstanceAdapter.java deleted file mode 100644 index 8119a31..0000000 --- a/android/app/src/main/java/seven/lab/wstun/ui/InstanceAdapter.java +++ /dev/null @@ -1,107 +0,0 @@ -package seven.lab.wstun.ui; - -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -import java.util.ArrayList; -import java.util.List; - -import seven.lab.wstun.R; - -/** - * Adapter for displaying running instances (rooms). - */ -public class InstanceAdapter extends RecyclerView.Adapter { - - private List instances = new ArrayList<>(); - private final InstanceActionListener listener; - - public interface InstanceActionListener { - void onDestroy(InstanceItem instance); - } - - public static class InstanceItem { - public final String uuid; - public final String name; - public final String serviceName; - public final String serviceDisplayName; - public final int userCount; - - public InstanceItem(String uuid, String name, String serviceName, - String serviceDisplayName, int userCount) { - this.uuid = uuid; - this.name = name; - this.serviceName = serviceName; - this.serviceDisplayName = serviceDisplayName; - this.userCount = userCount; - } - - public String getUuid() { - return uuid; - } - } - - public InstanceAdapter(InstanceActionListener listener) { - this.listener = listener; - } - - public void setInstances(List instances) { - this.instances = instances; - notifyDataSetChanged(); - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.item_instance, parent, false); - return new ViewHolder(view); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - InstanceItem item = instances.get(position); - holder.bind(item, listener); - } - - @Override - public int getItemCount() { - return instances.size(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - private final TextView nameText; - private final TextView serviceText; - private final TextView usersText; - private final TextView uuidText; - private final Button destroyButton; - - ViewHolder(View itemView) { - super(itemView); - nameText = itemView.findViewById(R.id.instanceNameText); - serviceText = itemView.findViewById(R.id.serviceNameText); - usersText = itemView.findViewById(R.id.usersText); - uuidText = itemView.findViewById(R.id.uuidText); - destroyButton = itemView.findViewById(R.id.destroyButton); - } - - void bind(InstanceItem item, InstanceActionListener listener) { - nameText.setText(item.name); - serviceText.setText(item.serviceDisplayName); - usersText.setText(item.userCount + " user(s)"); - uuidText.setText(item.uuid); - - destroyButton.setOnClickListener(v -> { - if (listener != null) { - listener.onDestroy(item); - } - }); - } - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/ui/MainActivity.java b/android/app/src/main/java/seven/lab/wstun/ui/MainActivity.java deleted file mode 100644 index ce5bd40..0000000 --- a/android/app/src/main/java/seven/lab/wstun/ui/MainActivity.java +++ /dev/null @@ -1,142 +0,0 @@ -package seven.lab.wstun.ui; - -import android.Manifest; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.ServiceConnection; -import android.content.pm.PackageManager; -import android.os.Build; -import android.os.Bundle; -import android.os.IBinder; - -import androidx.annotation.NonNull; -import androidx.appcompat.app.AppCompatActivity; -import androidx.core.app.ActivityCompat; -import androidx.core.content.ContextCompat; -import androidx.fragment.app.Fragment; -import androidx.viewpager2.adapter.FragmentStateAdapter; -import androidx.viewpager2.widget.ViewPager2; - -import com.google.android.material.tabs.TabLayout; -import com.google.android.material.tabs.TabLayoutMediator; - -import seven.lab.wstun.R; -import seven.lab.wstun.service.WSTunService; - -/** - * Main activity with tabs for server status and configuration. - * The Status tab contains 3 sub-tabs: Running Instances, Installed Services, Marketplace. - */ -public class MainActivity extends AppCompatActivity { - - private static final int PERMISSION_REQUEST_CODE = 100; - - private WSTunService service; - private boolean bound = false; - - private TabLayout tabLayout; - private ViewPager2 viewPager; - - private StatusFragment statusFragment; - private ConfigFragment configFragment; - - private final ServiceConnection connection = new ServiceConnection() { - @Override - public void onServiceConnected(ComponentName name, IBinder binder) { - WSTunService.LocalBinder localBinder = (WSTunService.LocalBinder) binder; - service = localBinder.getService(); - bound = true; - - // Notify fragments - if (statusFragment != null) { - statusFragment.onServiceConnected(service); - } - if (configFragment != null) { - configFragment.onServiceConnected(service); - } - } - - @Override - public void onServiceDisconnected(ComponentName name) { - bound = false; - service = null; - } - }; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - setContentView(R.layout.activity_main); - - requestPermissions(); - initViews(); - bindService(); - } - - @Override - protected void onDestroy() { - if (bound) { - unbindService(connection); - bound = false; - } - super.onDestroy(); - } - - private void requestPermissions() { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (ContextCompat.checkSelfPermission(this, Manifest.permission.POST_NOTIFICATIONS) - != PackageManager.PERMISSION_GRANTED) { - ActivityCompat.requestPermissions(this, - new String[]{Manifest.permission.POST_NOTIFICATIONS}, - PERMISSION_REQUEST_CODE); - } - } - } - - private void initViews() { - tabLayout = findViewById(R.id.tabLayout); - viewPager = findViewById(R.id.viewPager); - - statusFragment = new StatusFragment(); - configFragment = new ConfigFragment(); - - viewPager.setAdapter(new FragmentStateAdapter(this) { - @NonNull - @Override - public Fragment createFragment(int position) { - if (position == 0) { - return statusFragment; - } else { - return configFragment; - } - } - - @Override - public int getItemCount() { - return 2; - } - }); - - new TabLayoutMediator(tabLayout, viewPager, (tab, position) -> { - if (position == 0) { - tab.setText(R.string.tab_status); - } else { - tab.setText(R.string.tab_config); - } - }).attach(); - } - - private void bindService() { - Intent intent = new Intent(this, WSTunService.class); - bindService(intent, connection, Context.BIND_AUTO_CREATE); - } - - public WSTunService getWSTunService() { - return service; - } - - public boolean isServiceBound() { - return bound; - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/ui/MarketplaceAdapter.java b/android/app/src/main/java/seven/lab/wstun/ui/MarketplaceAdapter.java deleted file mode 100644 index 90222b7..0000000 --- a/android/app/src/main/java/seven/lab/wstun/ui/MarketplaceAdapter.java +++ /dev/null @@ -1,114 +0,0 @@ -package seven.lab.wstun.ui; - -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -import java.util.ArrayList; -import java.util.List; - -import seven.lab.wstun.R; - -/** - * Adapter for displaying marketplace services. - */ -public class MarketplaceAdapter extends RecyclerView.Adapter { - - private List services = new ArrayList<>(); - private final InstallListener listener; - - public interface InstallListener { - void onInstall(String serviceName); - } - - public static class MarketplaceItem { - public final String name; - public final String displayName; - public final String description; - public final String version; - public final boolean installed; - - public MarketplaceItem(String name, String displayName, String description, - String version, boolean installed) { - this.name = name; - this.displayName = displayName != null ? displayName : name; - this.description = description; - this.version = version; - this.installed = installed; - } - } - - public MarketplaceAdapter(InstallListener listener) { - this.listener = listener; - } - - public void setServices(List services) { - this.services = services; - notifyDataSetChanged(); - } - - @NonNull - @Override - public ViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.item_marketplace_service, parent, false); - return new ViewHolder(view); - } - - @Override - public void onBindViewHolder(@NonNull ViewHolder holder, int position) { - MarketplaceItem item = services.get(position); - holder.bind(item, listener); - } - - @Override - public int getItemCount() { - return services.size(); - } - - static class ViewHolder extends RecyclerView.ViewHolder { - private final TextView nameText; - private final TextView descText; - private final TextView versionText; - private final Button installButton; - - ViewHolder(View itemView) { - super(itemView); - nameText = itemView.findViewById(R.id.serviceNameText); - descText = itemView.findViewById(R.id.serviceDescText); - versionText = itemView.findViewById(R.id.versionText); - installButton = itemView.findViewById(R.id.installButton); - } - - void bind(MarketplaceItem item, InstallListener listener) { - nameText.setText(item.displayName); - - if (item.description != null && !item.description.isEmpty()) { - descText.setText(item.description); - descText.setVisibility(View.VISIBLE); - } else { - descText.setVisibility(View.GONE); - } - - versionText.setText("v" + item.version); - - if (item.installed) { - installButton.setText("Installed"); - installButton.setEnabled(false); - } else { - installButton.setText("Install"); - installButton.setEnabled(true); - installButton.setOnClickListener(v -> { - if (listener != null) { - listener.onInstall(item.name); - } - }); - } - } - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/ui/MarketplaceFragment.java b/android/app/src/main/java/seven/lab/wstun/ui/MarketplaceFragment.java deleted file mode 100644 index 702475b..0000000 --- a/android/app/src/main/java/seven/lab/wstun/ui/MarketplaceFragment.java +++ /dev/null @@ -1,205 +0,0 @@ -package seven.lab.wstun.ui; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.ProgressBar; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import com.google.android.material.textfield.TextInputEditText; -import com.google.gson.JsonObject; - -import java.util.ArrayList; -import java.util.List; - -import seven.lab.wstun.R; -import seven.lab.wstun.marketplace.MarketplaceService; -import seven.lab.wstun.service.WSTunService; - -/** - * Fragment for marketplace service browsing and installation. - */ -public class MarketplaceFragment extends Fragment { - - private WSTunService service; - - private TextInputEditText urlInput; - private Button fetchButton; - private ProgressBar progressBar; - private RecyclerView servicesRecyclerView; - private TextView emptyText; - private TextView errorText; - - private MarketplaceAdapter adapter; - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_marketplace, container, false); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - urlInput = view.findViewById(R.id.marketplaceUrlInput); - fetchButton = view.findViewById(R.id.fetchButton); - progressBar = view.findViewById(R.id.progressBar); - servicesRecyclerView = view.findViewById(R.id.servicesRecyclerView); - emptyText = view.findViewById(R.id.emptyText); - errorText = view.findViewById(R.id.errorText); - - servicesRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); - adapter = new MarketplaceAdapter(this::installService); - servicesRecyclerView.setAdapter(adapter); - - fetchButton.setOnClickListener(v -> fetchMarketplace()); - - // Load saved marketplace URL - if (service != null && service.getLocalServiceManager() != null) { - String savedUrl = service.getLocalServiceManager().getMarketplaceService().getMarketplaceUrl(); - if (savedUrl != null && !savedUrl.isEmpty()) { - urlInput.setText(savedUrl); - } - } - } - - public void onServiceConnected(WSTunService service) { - this.service = service; - - if (urlInput != null && service.getLocalServiceManager() != null) { - String savedUrl = service.getLocalServiceManager().getMarketplaceService().getMarketplaceUrl(); - if (savedUrl != null && !savedUrl.isEmpty()) { - urlInput.setText(savedUrl); - } - } - } - - private void fetchMarketplace() { - if (service == null || service.getLocalServiceManager() == null) { - showError("Service not connected"); - return; - } - - String url = urlInput.getText() != null ? urlInput.getText().toString().trim() : ""; - if (url.isEmpty()) { - showError("Please enter a marketplace URL"); - return; - } - - hideError(); - showLoading(true); - - MarketplaceService marketplace = service.getLocalServiceManager().getMarketplaceService(); - marketplace.setMarketplaceUrl(url); - - marketplace.listMarketplace(url, new MarketplaceService.MarketplaceCallback>() { - @Override - public void onSuccess(List result) { - if (getActivity() == null) return; - getActivity().runOnUiThread(() -> { - showLoading(false); - displayServices(result); - }); - } - - @Override - public void onError(String error) { - if (getActivity() == null) return; - getActivity().runOnUiThread(() -> { - showLoading(false); - showError("Failed to fetch: " + error); - adapter.setServices(new ArrayList<>()); - }); - } - }); - } - - private void displayServices(List services) { - List items = new ArrayList<>(); - MarketplaceService marketplace = service.getLocalServiceManager().getMarketplaceService(); - - for (JsonObject svc : services) { - String name = svc.has("name") ? svc.get("name").getAsString() : null; - if (name == null) continue; - - String displayName = svc.has("displayName") ? svc.get("displayName").getAsString() : name; - String description = svc.has("description") ? svc.get("description").getAsString() : null; - String version = svc.has("version") ? svc.get("version").getAsString() : "1.0.0"; - boolean installed = marketplace.isInstalled(name); - - items.add(new MarketplaceAdapter.MarketplaceItem(name, displayName, description, version, installed)); - } - - adapter.setServices(items); - - if (items.isEmpty()) { - emptyText.setVisibility(View.VISIBLE); - servicesRecyclerView.setVisibility(View.GONE); - } else { - emptyText.setVisibility(View.GONE); - servicesRecyclerView.setVisibility(View.VISIBLE); - } - } - - private void installService(String serviceName) { - if (service == null || service.getLocalServiceManager() == null) { - showError("Service not connected"); - return; - } - - String url = urlInput.getText() != null ? urlInput.getText().toString().trim() : ""; - if (url.isEmpty()) { - showError("No marketplace URL"); - return; - } - - showLoading(true); - - MarketplaceService marketplace = service.getLocalServiceManager().getMarketplaceService(); - marketplace.installService(url, serviceName, new MarketplaceService.MarketplaceCallback() { - @Override - public void onSuccess(seven.lab.wstun.marketplace.InstalledService result) { - if (getActivity() == null) return; - getActivity().runOnUiThread(() -> { - showLoading(false); - Toast.makeText(getContext(), "Service installed successfully", Toast.LENGTH_SHORT).show(); - fetchMarketplace(); // Refresh list - }); - } - - @Override - public void onError(String error) { - if (getActivity() == null) return; - getActivity().runOnUiThread(() -> { - showLoading(false); - showError("Install failed: " + error); - }); - } - }); - } - - private void showLoading(boolean show) { - progressBar.setVisibility(show ? View.VISIBLE : View.GONE); - fetchButton.setEnabled(!show); - } - - private void showError(String message) { - errorText.setText(message); - errorText.setVisibility(View.VISIBLE); - } - - private void hideError() { - errorText.setVisibility(View.GONE); - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/ui/RunningInstancesFragment.java b/android/app/src/main/java/seven/lab/wstun/ui/RunningInstancesFragment.java deleted file mode 100644 index d6ad09b..0000000 --- a/android/app/src/main/java/seven/lab/wstun/ui/RunningInstancesFragment.java +++ /dev/null @@ -1,126 +0,0 @@ -package seven.lab.wstun.ui; - -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.recyclerview.widget.LinearLayoutManager; -import androidx.recyclerview.widget.RecyclerView; - -import java.util.ArrayList; -import java.util.List; -import java.util.Map; - -import seven.lab.wstun.R; -import seven.lab.wstun.marketplace.InstalledService; -import seven.lab.wstun.server.LocalServiceManager; -import seven.lab.wstun.server.ServiceManager; -import seven.lab.wstun.service.WSTunService; - -/** - * Fragment showing running service instances (rooms). - */ -public class RunningInstancesFragment extends Fragment { - - private WSTunService service; - - private RecyclerView instancesRecyclerView; - private TextView emptyText; - - private InstanceAdapter adapter; - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_running_instances, container, false); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - instancesRecyclerView = view.findViewById(R.id.servicesRecyclerView); - emptyText = view.findViewById(R.id.emptyText); - - instancesRecyclerView.setLayoutManager(new LinearLayoutManager(getContext())); - adapter = new InstanceAdapter(instance -> { - if (service != null && service.getServiceManager() != null) { - service.getServiceManager().destroyInstance(instance.getUuid()); - updateInstanceList(); - } - }); - instancesRecyclerView.setAdapter(adapter); - - updateInstanceList(); - } - - public void onServiceConnected(WSTunService service) { - this.service = service; - updateInstanceList(); - } - - @Override - public void onResume() { - super.onResume(); - updateInstanceList(); - } - - public void updateInstanceList() { - if (getActivity() == null || !isAdded()) return; - - requireActivity().runOnUiThread(() -> { - if (service == null || service.getServiceManager() == null) { - adapter.setInstances(new ArrayList<>()); - emptyText.setVisibility(View.VISIBLE); - instancesRecyclerView.setVisibility(View.GONE); - return; - } - - LocalServiceManager lsm = service.getLocalServiceManager(); - ServiceManager sm = service.getServiceManager(); - - // Collect all instances across all services - List items = new ArrayList<>(); - - if (lsm != null) { - Map installed = lsm.getInstalledServices(); - for (String serviceName : installed.keySet()) { - List instances = sm.getInstancesForService(serviceName); - InstalledService svc = installed.get(serviceName); - String displayName = svc != null ? svc.getDisplayName() : serviceName; - - for (ServiceManager.ServiceInstance inst : instances) { - items.add(new InstanceAdapter.InstanceItem( - inst.getUuid(), - inst.getName(), - serviceName, - displayName, - inst.getUserCount() - )); - } - } - } - - adapter.setInstances(items); - - if (items.isEmpty()) { - emptyText.setVisibility(View.VISIBLE); - instancesRecyclerView.setVisibility(View.GONE); - } else { - emptyText.setVisibility(View.GONE); - instancesRecyclerView.setVisibility(View.VISIBLE); - } - }); - } - - // Renamed method to match StatusFragment expectations - public void updateServiceList() { - updateInstanceList(); - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/ui/ServiceAdapter.java b/android/app/src/main/java/seven/lab/wstun/ui/ServiceAdapter.java deleted file mode 100644 index 3c94506..0000000 --- a/android/app/src/main/java/seven/lab/wstun/ui/ServiceAdapter.java +++ /dev/null @@ -1,89 +0,0 @@ -package seven.lab.wstun.ui; - -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.TextView; - -import androidx.annotation.NonNull; -import androidx.recyclerview.widget.RecyclerView; - -import java.text.SimpleDateFormat; -import java.util.ArrayList; -import java.util.Date; -import java.util.List; -import java.util.Locale; - -import seven.lab.wstun.R; -import seven.lab.wstun.server.ServiceManager; - -/** - * RecyclerView adapter for displaying registered services. - */ -public class ServiceAdapter extends RecyclerView.Adapter { - - public interface KickCallback { - void onKick(ServiceManager.ServiceEntry service); - } - - private List services = new ArrayList<>(); - private final KickCallback callback; - private final SimpleDateFormat dateFormat = new SimpleDateFormat("HH:mm:ss", Locale.getDefault()); - - public ServiceAdapter(KickCallback callback) { - this.callback = callback; - } - - public void setServices(List services) { - this.services = new ArrayList<>(services); - notifyDataSetChanged(); - } - - @NonNull - @Override - public ServiceViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { - View view = LayoutInflater.from(parent.getContext()) - .inflate(R.layout.item_service, parent, false); - return new ServiceViewHolder(view); - } - - @Override - public void onBindViewHolder(@NonNull ServiceViewHolder holder, int position) { - ServiceManager.ServiceEntry service = services.get(position); - holder.bind(service); - } - - @Override - public int getItemCount() { - return services.size(); - } - - class ServiceViewHolder extends RecyclerView.ViewHolder { - private final TextView serviceName; - private final TextView serviceType; - private final TextView serviceEndpoint; - private final Button kickButton; - - ServiceViewHolder(View itemView) { - super(itemView); - serviceName = itemView.findViewById(R.id.serviceName); - serviceType = itemView.findViewById(R.id.serviceType); - serviceEndpoint = itemView.findViewById(R.id.serviceEndpoint); - kickButton = itemView.findViewById(R.id.kickButton); - } - - void bind(ServiceManager.ServiceEntry service) { - serviceName.setText(service.getName()); - serviceType.setText(service.getType() + " - Connected at " + - dateFormat.format(new Date(service.getRegisteredAt()))); - serviceEndpoint.setText("/" + service.getName() + "/main"); - - kickButton.setOnClickListener(v -> { - if (callback != null) { - callback.onKick(service); - } - }); - } - } -} diff --git a/android/app/src/main/java/seven/lab/wstun/ui/StatusFragment.java b/android/app/src/main/java/seven/lab/wstun/ui/StatusFragment.java deleted file mode 100644 index 467137e..0000000 --- a/android/app/src/main/java/seven/lab/wstun/ui/StatusFragment.java +++ /dev/null @@ -1,247 +0,0 @@ -package seven.lab.wstun.ui; - -import android.content.Intent; -import android.os.Bundle; -import android.view.LayoutInflater; -import android.view.View; -import android.view.ViewGroup; -import android.widget.Button; -import android.widget.TextView; -import android.widget.Toast; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.fragment.app.Fragment; -import androidx.viewpager2.adapter.FragmentStateAdapter; -import androidx.viewpager2.widget.ViewPager2; - -import com.google.android.material.tabs.TabLayout; -import com.google.android.material.tabs.TabLayoutMediator; - -import java.net.InetAddress; -import java.net.NetworkInterface; -import java.util.Collections; -import java.util.List; - -import seven.lab.wstun.R; -import seven.lab.wstun.server.ServiceManager; -import seven.lab.wstun.service.WSTunService; - -/** - * Fragment showing server status with nested tabs for: - * - Running Instances - * - Installed Services - * - Marketplace - */ -public class StatusFragment extends Fragment implements WSTunService.ServiceListener { - - private WSTunService service; - - private TextView statusText; - private TextView addressText; - private Button toggleButton; - - // Nested tabs - private TabLayout innerTabLayout; - private ViewPager2 innerViewPager; - - // Child fragments - private RunningInstancesFragment runningInstancesFragment; - private InstalledServicesFragment installedServicesFragment; - private MarketplaceFragment marketplaceFragment; - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState) { - return inflater.inflate(R.layout.fragment_status, container, false); - } - - @Override - public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - statusText = view.findViewById(R.id.statusText); - addressText = view.findViewById(R.id.addressText); - toggleButton = view.findViewById(R.id.toggleButton); - innerTabLayout = view.findViewById(R.id.innerTabLayout); - innerViewPager = view.findViewById(R.id.innerViewPager); - - // Set up nested tabs - setupInnerTabs(); - - toggleButton.setOnClickListener(v -> toggleServer()); - - updateUI(); - } - - private void setupInnerTabs() { - runningInstancesFragment = new RunningInstancesFragment(); - installedServicesFragment = new InstalledServicesFragment(); - marketplaceFragment = new MarketplaceFragment(); - - innerViewPager.setAdapter(new FragmentStateAdapter(this) { - @NonNull - @Override - public Fragment createFragment(int position) { - switch (position) { - case 0: - return runningInstancesFragment; - case 1: - return installedServicesFragment; - case 2: - return marketplaceFragment; - default: - return runningInstancesFragment; - } - } - - @Override - public int getItemCount() { - return 3; - } - }); - - new TabLayoutMediator(innerTabLayout, innerViewPager, (tab, position) -> { - switch (position) { - case 0: - tab.setText(R.string.tab_instances); - break; - case 1: - tab.setText(R.string.tab_services); - break; - case 2: - tab.setText(R.string.tab_marketplace); - break; - } - }).attach(); - } - - public void onServiceConnected(WSTunService service) { - this.service = service; - service.setListener(this); - updateUI(); - - // Pass service to child fragments - if (runningInstancesFragment != null) { - runningInstancesFragment.onServiceConnected(service); - } - if (installedServicesFragment != null) { - installedServicesFragment.onServiceConnected(service); - } - if (marketplaceFragment != null) { - marketplaceFragment.onServiceConnected(service); - } - } - - @Override - public void onResume() { - super.onResume(); - updateUI(); - // Refresh all child fragments when returning to the app - if (runningInstancesFragment != null) { - runningInstancesFragment.updateInstanceList(); - } - if (installedServicesFragment != null) { - installedServicesFragment.onResume(); - } - } - - private void toggleServer() { - if (service == null) return; - - if (service.isServerRunning()) { - Intent intent = new Intent(getContext(), WSTunService.class); - intent.setAction(WSTunService.ACTION_STOP); - requireContext().startService(intent); - } else { - Intent intent = new Intent(getContext(), WSTunService.class); - intent.setAction(WSTunService.ACTION_START); - requireContext().startForegroundService(intent); - } - } - - private void updateUI() { - if (getActivity() == null || !isAdded()) return; - - requireActivity().runOnUiThread(() -> { - boolean running = service != null && service.isServerRunning(); - - if (running) { - statusText.setText(R.string.server_running); - statusText.setTextColor(getResources().getColor(android.R.color.holo_green_dark)); - toggleButton.setText(R.string.stop_server); - - String protocol = service.isHttpsEnabled() ? "https" : "http"; - String ip = getLocalIpAddress(); - String address = protocol + "://" + ip + ":" + service.getPort(); - addressText.setText(address); - addressText.setVisibility(View.VISIBLE); - } else { - statusText.setText(R.string.server_stopped); - statusText.setTextColor(getResources().getColor(android.R.color.holo_red_dark)); - toggleButton.setText(R.string.start_server); - addressText.setVisibility(View.GONE); - } - }); - } - - private String getLocalIpAddress() { - try { - List interfaces = Collections.list(NetworkInterface.getNetworkInterfaces()); - for (NetworkInterface intf : interfaces) { - List addrs = Collections.list(intf.getInetAddresses()); - for (InetAddress addr : addrs) { - if (!addr.isLoopbackAddress()) { - String sAddr = addr.getHostAddress(); - if (sAddr != null && !sAddr.contains(":")) { - return sAddr; - } - } - } - } - } catch (Exception e) { - e.printStackTrace(); - } - return "localhost"; - } - - @Override - public void onServerStarted() { - updateUI(); - // Pass service to child fragments now that server is ready - if (runningInstancesFragment != null) { - runningInstancesFragment.onServiceConnected(service); - } - if (installedServicesFragment != null) { - installedServicesFragment.onServiceConnected(service); - } - if (marketplaceFragment != null) { - marketplaceFragment.onServiceConnected(service); - } - } - - @Override - public void onServerStopped() { - updateUI(); - if (runningInstancesFragment != null) { - runningInstancesFragment.updateServiceList(); - } - } - - @Override - public void onServiceChanged() { - if (getActivity() != null && runningInstancesFragment != null) { - requireActivity().runOnUiThread(() -> runningInstancesFragment.updateServiceList()); - } - } - - @Override - public void onError(String message) { - if (getActivity() != null) { - requireActivity().runOnUiThread(() -> - Toast.makeText(getContext(), message, Toast.LENGTH_LONG).show() - ); - } - } -} diff --git a/android/app/src/main/res/drawable/badge_background.xml b/android/app/src/main/res/drawable/badge_background.xml deleted file mode 100644 index 0b7dcf7..0000000 --- a/android/app/src/main/res/drawable/badge_background.xml +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml deleted file mode 100644 index ee0e042..0000000 --- a/android/app/src/main/res/layout/activity_main.xml +++ /dev/null @@ -1,21 +0,0 @@ - - - - - - - - diff --git a/android/app/src/main/res/layout/fragment_config.xml b/android/app/src/main/res/layout/fragment_config.xml deleted file mode 100644 index 71ea366..0000000 --- a/android/app/src/main/res/layout/fragment_config.xml +++ /dev/null @@ -1,203 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - -``` - -### User Client (main.html) - -```html - - - - FileShare - - -

FileShare

-
Connecting...
- -
- - - - - -``` - -## Message Types - -### Standard Messages - -| Type | Direction | Description | -|------|-----------|-------------| -| `create_instance` | Host → Server | Create instance | -| `instance_created` | Server → Host | Instance created | -| `join_instance` | Client → Server | Join instance | -| `list_instances` | Client → Server | List instances | -| `instance_list` | Server → Client | Instance list | -| `kick_client` | Host → Server | Kick user | -| `kick` | Server → Client | You were kicked | -| `ack` | Server → Client | Operation acknowledged | -| `error` | Server → Client | Error occurred | -| `client_connected` | Server → Host | User joined | -| `client_disconnected` | Server → Host | User left | - -### Custom Messages - -Services can define their own message types. Just use `send()` or `broadcast()`: - -```javascript -// From user client -client.send('my_custom_type', { data: 'value' }); - -// From host to all users -host.broadcast('my_custom_type', { data: 'value' }); -``` - -## Error Handling - -Both host and client support error callbacks: - -```javascript -const client = WSTun.createInstanceClient({ - // ... - onError: (err) => { - console.error('Connection error:', err.message); - // Possible errors: - // - Invalid or missing server auth token - // - Invalid instance token - // - Instance not found - // - Connection failed - } -}); -``` - -## Tips - -1. **Generate unique user IDs**: Use `WSTun.generateId()` or your own UUID generator -2. **Handle disconnections**: Users may disconnect at any time -3. **Validate data**: Don't trust data from other clients -4. **Use instance tokens**: For private rooms, always set an instance token -5. **Broadcast state changes**: Keep all users in sync by broadcasting updates diff --git a/android/docs/marketplace.md b/android/docs/marketplace.md deleted file mode 100644 index 6143b92..0000000 --- a/android/docs/marketplace.md +++ /dev/null @@ -1,377 +0,0 @@ -# WSTun Marketplace - -## Overview - -The WSTun Marketplace allows users to discover, install, and manage services from external sources. This document explains how to create a marketplace server and publish services. - -## Marketplace API - -A marketplace is simply an HTTP server that provides a specific API. WSTun clients fetch service information and files from the marketplace URL. - -### API Endpoints - -| Endpoint | Method | Description | -|----------|--------|-------------| -| `{baseUrl}/list` | GET | List all available services | -| `{baseUrl}/service/{name}.json` | GET | Get service manifest | -| `{baseUrl}/service/{name}/{file}` | GET | Download service file | - -### Example Marketplace Structure - -``` -https://example.com/wstun/marketplace/ -├── list # Service list JSON -└── service/ - ├── myservice.json # Service manifest - └── myservice/ - ├── index.html # Service controller - └── main.html # User client -``` - -## Creating a Marketplace - -### 1. Service List (`/list`) - -Returns a JSON array of available services: - -```json -[ - { - "name": "myservice", - "displayName": "My Awesome Service", - "description": "A great service for doing things", - "version": "1.0.0", - "author": "Your Name" - }, - { - "name": "anotherservice", - "displayName": "Another Service", - "description": "Does other things", - "version": "2.1.0", - "author": "Someone Else" - } -] -``` - -### 2. Service Manifest (`/service/{name}.json`) - -Each service needs a manifest file that describes its structure: - -```json -{ - "name": "myservice", - "displayName": "My Awesome Service", - "description": "A great service for doing things", - "version": "1.0.0", - "author": "Your Name", - "icon": "https://example.com/icon.png", - "endpoints": [ - { - "path": "/service", - "file": "index.html", - "type": "service" - }, - { - "path": "/main", - "file": "main.html", - "type": "client" - } - ] -} -``` - -#### Manifest Fields - -| Field | Required | Description | -|-------|----------|-------------| -| `name` | Yes | Unique service identifier (alphanumeric, lowercase) | -| `displayName` | No | Human-readable name | -| `description` | No | Short description of the service | -| `version` | No | Semantic version string | -| `author` | No | Author name or organization | -| `icon` | No | URL to service icon | -| `endpoints` | Yes | Array of endpoint definitions | - -#### Endpoint Fields - -| Field | Required | Description | -|-------|----------|-------------| -| `path` | Yes | URL path (e.g., `/service`, `/main`) | -| `file` | Yes | Filename to serve for this path | -| `type` | Yes | `service` (controller) or `client` (user) | -| `contentType` | No | MIME type (auto-detected from extension if not set) | - -### 3. Service Files (`/service/{name}/{file}`) - -The actual HTML/JS files for your service. These should use `libwstun.js` for communication. - -## Example: Creating a Simple Service - -### 1. Create the Service Controller (`index.html`) - -```html - - - - - My Service - Controller - - -

My Service

- -
-
- - - - - -``` - -### 2. Create the User Client (`main.html`) - -```html - - - - - My Service - - -

My Service

-
Loading...
- - -
- - - - - -``` - -### 3. Create the Manifest (`myservice.json`) - -```json -{ - "name": "myservice", - "displayName": "My Custom Service", - "description": "A simple example service", - "version": "1.0.0", - "author": "Your Name", - "endpoints": [ - { - "path": "/service", - "file": "index.html", - "type": "service" - }, - { - "path": "/main", - "file": "main.html", - "type": "client" - } - ] -} -``` - -### 4. Update the Service List (`list`) - -```json -[ - { - "name": "myservice", - "displayName": "My Custom Service", - "description": "A simple example service", - "version": "1.0.0", - "author": "Your Name" - } -] -``` - -## Hosting a Marketplace - -### Option 1: Static File Server - -Host files on any static file server (Apache, Nginx, S3, GitHub Pages): - -``` -/marketplace/ - list # Service list JSON - service/ - myservice.json # Manifest - myservice/ - index.html # Controller - main.html # Client -``` - -Make sure CORS headers allow access from the WSTun server. - -### Option 2: Dynamic Server - -Create a dynamic server (Node.js, Python, etc.) that generates the list and serves files: - -```javascript -// Express.js example -const express = require('express'); -const app = express(); - -// Enable CORS -app.use((req, res, next) => { - res.header('Access-Control-Allow-Origin', '*'); - next(); -}); - -// Service list -app.get('/marketplace/list', (req, res) => { - res.json([ - { name: 'myservice', displayName: 'My Service', ... } - ]); -}); - -// Service manifest -app.get('/marketplace/service/:name.json', (req, res) => { - res.sendFile(`./services/${req.params.name}/manifest.json`); -}); - -// Service files -app.get('/marketplace/service/:name/:file', (req, res) => { - res.sendFile(`./services/${req.params.name}/${req.params.file}`); -}); - -app.listen(9090); -``` - -## Installing Services from Marketplace - -### Via Admin UI - -1. Open the WSTun server in a browser -2. Click "Service Manager" (or go to `/admin`) -3. Go to the "Marketplace" tab -4. Enter the marketplace URL -5. Click "Fetch" to see available services -6. Click "Install" on the desired service - -### Via API - -```javascript -// POST /_api/marketplace/install -fetch('/_api/marketplace/install', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - url: 'https://example.com/wstun/marketplace', - name: 'myservice' - }) -}); -``` - -## Best Practices - -1. **Use semantic versioning**: Makes it easy to track updates -2. **Write clear descriptions**: Help users understand what your service does -3. **Test thoroughly**: Ensure your service works with the WSTun server -4. **Include documentation**: Add comments or a README in your service -5. **Handle errors gracefully**: Show user-friendly error messages -6. **Use HTTPS**: Secure your marketplace server -7. **Add CORS headers**: Allow cross-origin requests from WSTun servers - -## Security Considerations - -1. **Validate service names**: Only alphanumeric and lowercase -2. **Sanitize file paths**: Prevent directory traversal attacks -3. **Review code**: Marketplace services can run arbitrary JavaScript -4. **Use HTTPS**: Encrypt data in transit -5. **Implement rate limiting**: Prevent abuse of your marketplace - -## Troubleshooting - -### "Failed to fetch marketplace" -- Check the marketplace URL is correct -- Ensure CORS headers are set -- Verify the `/list` endpoint returns valid JSON - -### "Failed to install service" -- Check the manifest is valid JSON -- Verify all endpoint files exist -- Check file permissions on the server - -### Service doesn't appear after install -- Refresh the service list -- Check the browser console for errors -- Verify the service files were downloaded correctly diff --git a/android/gradle.properties b/android/gradle.properties deleted file mode 100644 index 2e11322..0000000 --- a/android/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -android.useAndroidX=true -android.nonTransitiveRClass=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 1b33c55..0000000 Binary files a/android/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/android/gradlew b/android/gradlew deleted file mode 100755 index 31296a5..0000000 --- a/android/gradlew +++ /dev/null @@ -1,207 +0,0 @@ -#!/bin/sh - -# -# Copyright © 2015-2021 the original authors. -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# https://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. -# - -############################################################################## -# -# Gradle start up script for POSIX generated by Gradle. -# -# Important for running: -# -# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is -# noncompliant, but you have some other compliant shell such as ksh or -# bash, then to run this script, type that shell name before the whole -# command line, like: -# -# ksh Gradle -# -# Busybox and similar reduced shells will NOT work, because this script -# requires all of these POSIX shell features: -# * functions; -# * expansions «$var)}», «## », «## */», «#%»; -# * compoundeli commands having a redirection after the closing «)»; -# * «else» with no «if» line at the same nesting level; -# * arrays. -# -# (2) This script passes all arguments to the gradle program as a single -# argument, but preserves internal quoting, so you can say -# -# gradlew "foo bar" baz -# -# and Gradle will receive two arguments: "foo bar" and "baz". -# -############################################################################## - -# Attempt to set APP_HOME - -# Resolve links: $0 may be a link -app_path=$0 - -# Need this for daisy-chained symlinks. -while - APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path - [ -h "$app_path" ] -do - ls=$( ls -ld "$app_path" ) - link=${ls#*' -> '} - case $link in #( - /*) app_path=$link ;; #( - *) app_path=$APP_HOME$link ;; - esac -done - -# This is normally unused -# shellcheck disable=SC2034 -APP_BASE_NAME=${0##*/} -# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) -APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit - -# Use the maximum available, or set MAX_FD != -1 to use that value. -MAX_FD=maximum - -warn () { - echo "$*" -} >&2 - -die () { - echo - echo "$*" - echo - exit 1 -} >&2 - -# OS specific support (must be 'true' or 'false'). -cygwin=false -msys=false -darwin=false -nonstop=false -case "$( uname )" in #( - CYGWIN* ) cygwin=true ;; #( - Darwin* ) darwin=true ;; #( - MSYS* | MINGW* ) msys=true ;; #( - NONSTOP* ) nonstop=true ;; -esac - -CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar - - -# Determine the Java command to use to start the JVM. -if [ -n "$JAVA_HOME" ] ; then - if [ -x "$JAVA_HOME/jre/sh/java" ] ; then - # IBM's JDK on AIX uses strange locations for the executables - JAVACMD=$JAVA_HOME/jre/sh/java - else - JAVACMD=$JAVA_HOME/bin/java - fi - if [ ! -x "$JAVACMD" ] ; then - die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -else - JAVACMD=java - if ! command -v java >/dev/null 2>&1 - then - die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. - -Please set the JAVA_HOME variable in your environment to match the -location of your Java installation." - fi -fi - -# Increase the maximum file descriptors if we can. -if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then - case $MAX_FD in #( - max*) - # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - MAX_FD=$( ulimit -H -n ) || - warn "Could not query maximum file descriptor limit" - esac - case $MAX_FD in #( - '' | soft) :;; #( - *) - # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. - # shellcheck disable=SC2039,SC3045 - ulimit -n "$MAX_FD" || - warn "Could not set maximum file descriptor limit to $MAX_FD" - esac -fi - -# Collect all arguments for the java command, stacking in reverse order: -# * args from the command line -# * the main class name -# * -classpath -# * -D...appname settings -# * --module-path (only if needed) -# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. - -# For Cygwin or MSYS, switch paths to Windows format before running java -if "$cygwin" || "$msys" ; then - APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) - CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" ) - - JAVACMD=$( cygpath --unix "$JAVACMD" ) - - # Now convert the arguments - kludge to limit ourselves to /bin/sh - for arg do - if - case $arg in #( - -*) false ;; # don't touch options #( - /?*) t=${arg#)}; t=/${t%%/*} # looks like a POSIX filepath - [ -e "$t" ] ;; #( - *) false ;; - esac - then - arg=$( cygpath --path --ignore --mixed "$arg" ) - fi - # Roll the args list around exactly as many times as the number of - # temporary args, so each arg winds up back in the position where - # it started, but possibly modified. - # - # NB: aass assignment://[[]= is used to discard the value - # temporarily args, so each arg winds up back in the position where - # it started, but possibly modified. - shift # remove old arg - set -- "$@" "$arg" # push replacement arg - done -fi - - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' - -# Collect all arguments for the java command: -# * DEFAULT_JVM_OPTS, JAVA_OPTS, GRADLE_OPTS, and GRADLE_USER_HOME are used to compute -# the final set of JVM options. -# * GRADLE_OPTS and GRADLE_USER_HOME are examined for their content because Gradle -# puts commonly-used JVM arguments there. -# * The user's JVM arguments or defaults are added to the java command's JVM arguments. - -set -- \ - "-Dorg.gradle.appname=$APP_BASE_NAME" \ - -classpath "$CLASSPATH" \ - org.gradle.wrapper.GradleWrapperMain \ - "$@" - -# Stop when "xeli" is not enabled or the shell is not bash. -if ! "$cygwin" && ! "$msys" ; then - exec "$JAVACMD" "$@" -fi - -exec "$JAVACMD" "$@" diff --git a/android/settings.gradle b/android/settings.gradle deleted file mode 100644 index 97edfd1..0000000 --- a/android/settings.gradle +++ /dev/null @@ -1,17 +0,0 @@ -pluginManagement { - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} -dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { - google() - mavenCentral() - } -} - -rootProject.name = "NodeBase" -include ':app' diff --git a/app/NodeBase.iml b/app/NodeBase.iml new file mode 100644 index 0000000..86243e6 --- /dev/null +++ b/app/NodeBase.iml @@ -0,0 +1,19 @@ + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/app/app.iml b/app/app/app.iml new file mode 100644 index 0000000..bfae97e --- /dev/null +++ b/app/app/app.iml @@ -0,0 +1,167 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/app/build.gradle b/app/app/build.gradle new file mode 100755 index 0000000..7d2ab74 --- /dev/null +++ b/app/app/build.gradle @@ -0,0 +1,38 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' + +android { + compileSdkVersion 25 + buildToolsVersion '27.0.3' + defaultConfig { + applicationId "seven.drawalive.nodebase" + minSdkVersion 15 + targetSdkVersion 25 + versionCode 1 + versionName "1.0" + testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner" + } + buildTypes { + release { + minifyEnabled false + proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro' + } + } + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_7 + targetCompatibility JavaVersion.VERSION_1_7 + } +} + +dependencies { + compile fileTree(include: ['*.jar'], dir: 'libs') + androidTestCompile('com.android.support.test.espresso:espresso-core:2.2.2', { + exclude group: 'com.android.support', module: 'support-annotations' + }) + compile 'com.android.support:appcompat-v7:25.0.1' + testCompile 'junit:junit:4.12' + compile "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" +} +repositories { + mavenCentral() +} diff --git a/app/app/proguard-rules.pro b/app/app/proguard-rules.pro new file mode 100644 index 0000000..fdcfb67 --- /dev/null +++ b/app/app/proguard-rules.pro @@ -0,0 +1,17 @@ +# Add project specific ProGuard rules here. +# By default, the flags in this file are appended to flags specified +# in /lab/android-sdk/tools/proguard/proguard-android.txt +# You can edit the include path and order by changing the proguardFiles +# directive in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# Add any project specific keep options here: + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} diff --git a/app/app/src/androidTest/java/seven/drawalive/nodebase/ExampleInstrumentedTest.kt b/app/app/src/androidTest/java/seven/drawalive/nodebase/ExampleInstrumentedTest.kt new file mode 100644 index 0000000..1aef59e --- /dev/null +++ b/app/app/src/androidTest/java/seven/drawalive/nodebase/ExampleInstrumentedTest.kt @@ -0,0 +1,27 @@ +package seven.drawalive.nodebase + +import android.content.Context +import android.support.test.InstrumentationRegistry +import android.support.test.runner.AndroidJUnit4 + +import org.junit.Test +import org.junit.runner.RunWith + +import org.junit.Assert.* + +/** + * Instrumentation test, which will execute on an Android device. + * + * @see [Testing documentation](http://d.android.com/tools/testing) + */ +@RunWith(AndroidJUnit4::class) +class ExampleInstrumentedTest { + @Test + @Throws(Exception::class) + fun useAppContext() { + // Context of the app under test. + val appContext = InstrumentationRegistry.getTargetContext() + + assertEquals("seven.drawalive.nodebase", appContext.packageName) + } +} diff --git a/app/app/src/main/AndroidManifest.xml b/app/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..b29c6f9 --- /dev/null +++ b/app/app/src/main/AndroidManifest.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/app/src/main/java/seven/drawalive/nodebase/Alarm.kt b/app/app/src/main/java/seven/drawalive/nodebase/Alarm.kt new file mode 100644 index 0000000..869dc02 --- /dev/null +++ b/app/app/src/main/java/seven/drawalive/nodebase/Alarm.kt @@ -0,0 +1,19 @@ +package seven.drawalive.nodebase + +import android.content.Context +import android.support.v7.app.AlertDialog +import android.widget.Toast + +object Alarm { + @JvmOverloads + fun showMessage(context: Context, text: String, title: String? = null) { + val builder = AlertDialog.Builder(context) + builder.setMessage(text) + if (title != null) builder.setTitle(title) + builder.create().show() + } + + fun showToast(context: Context, text: String) { + Toast.makeText(context.applicationContext, text, Toast.LENGTH_SHORT).show() + } +} diff --git a/app/app/src/main/java/seven/drawalive/nodebase/Configuration.kt b/app/app/src/main/java/seven/drawalive/nodebase/Configuration.kt new file mode 100644 index 0000000..d4279d4 --- /dev/null +++ b/app/app/src/main/java/seven/drawalive/nodebase/Configuration.kt @@ -0,0 +1,122 @@ +package seven.drawalive.nodebase + +import android.content.Context + +import java.util.HashMap + + +class Configuration(context: Context) { + + private var firstrun: Boolean = false + private val datadir: String + private var keyval: HashMap? = null + + init { + datadir = context.applicationInfo.dataDir + firstrun = false + load() + } + + fun load() { + val infile = String.format("%s/config", datadir) + keyval = parse(Storage.read(infile)) + if (keyval == null) { + firstrun = true + keyval = HashMap() + } + if (!keyval!!.containsKey(KEYVAL_NODEBASE_DIR)) { + keyval!![KEYVAL_NODEBASE_DIR] = "/sdcard/.nodebase" + } + } + + fun save() { + val outfile = String.format("%s/config", datadir) + val buf = StringBuffer() + var `val`: String? + for (key in keyval!!.keys) { + buf.append(key) + buf.append('\n') + buf.append(" ") + `val` = keyval!![key] + if (`val` == null) `val` = "" + if (`val`.indexOf('\n') >= 0) { + `val` = `val`.replace("\n".toRegex(), " ") + } + buf.append(`val`) + } + Storage.write(String(buf), outfile) + } + + fun dataDir(): String { + return datadir + } + + fun workDir(): String { + return keyval!![KEYVAL_NODEBASE_DIR].orEmpty() + } + + fun nodeBin(): String { + return String.format("%s/node/node", datadir) + } + + fun firstRun(): Boolean { + return firstrun + } + + fun prepareEnvironment() { + Storage.makeDirectory(String.format("%s/node", datadir)) + } + + operator fun get(key: String): String? { + return if (keyval!!.containsKey(key)) keyval!![key] else null + } + + operator fun set(key: String, `val`: String) { + keyval!![key] = `val` + } + + companion object { + + const val NODE_URL = "https://raw.githubusercontent.com/wiki/dna2github/NodeBase/binary/v0/node" + const val NPM_URL = "https://raw.githubusercontent.com/wiki/dna2github/NodeBase/binary/v0/npm.zip" + const val KEYVAL_NODEBASE_DIR = "nodebase_dir" + + fun parse(text: String?): HashMap? { + if (text == null) return null + val keyval = HashMap() + val lines = text.split("\n".toRegex()).dropLastWhile({ it.isEmpty() }).toTypedArray() + var key: String + var `val`: String + var i = 0 + val n = lines.size + while (i < n) { + key = lines[i].trim({ it <= ' ' }) + if (key.length == 0) { + i++ + continue + } + i++ + if (i >= n) break + val last = key[key.length - 1] + var multiple_line = false + if (last == '+') { + key = key.substring(0, key.length - 1).trim({ it <= ' ' }) + multiple_line = true + } + if (key.length == 0) /* after all comments */ break + if (multiple_line) { + `val` = "" + for (j in i until n) { + `val` += "\n" + lines[j] + } + i = n + } else { + `val` = lines[i].trim({ it <= ' ' }) + } + keyval[key] = `val` + i++ + } + return keyval + } + } +} diff --git a/app/app/src/main/java/seven/drawalive/nodebase/Downloader.kt b/app/app/src/main/java/seven/drawalive/nodebase/Downloader.kt new file mode 100644 index 0000000..a215b85 --- /dev/null +++ b/app/app/src/main/java/seven/drawalive/nodebase/Downloader.kt @@ -0,0 +1,118 @@ +package seven.drawalive.nodebase + +import android.app.ProgressDialog +import android.content.Context +import android.os.AsyncTask + +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.HttpURLConnection +import java.net.MalformedURLException +import java.net.URL + +class Downloader(private val context: Context, private val callback: Runnable?) { + private val progress: ProgressDialog + + class DownloadTask(private val downloader: Downloader) : AsyncTask() { + + override fun doInBackground(vararg strings: String): String? { + val url = strings[0] + val outfile = strings[1] + var download_stream: InputStream? = null + var output_stream: OutputStream? = null + var conn: HttpURLConnection? = null + publishProgress("Starting ...") + try { + val urlobj = URL(url) + conn = urlobj.openConnection() as HttpURLConnection + if (conn.responseCode / 100 != 2) { + throw IOException("server error: " + conn.responseCode) + } + val file_len = conn.contentLength + val buf = ByteArray(1024 * 1024) + var read_len = 0 + var total_read_len = 0 + download_stream = conn.inputStream + Storage.unlink(outfile) + Storage.touch(outfile) + output_stream = FileOutputStream(outfile) + read_len = download_stream!!.read(buf) + while (read_len >= 0) { + if (isCancelled) { + throw IOException("user cancelled") + } + total_read_len += read_len + output_stream.write(buf, 0, read_len) + var read_size = Storage.readableSize(total_read_len) + if (file_len > 0) { + read_size += " / " + Storage.readableSize(file_len) + } + publishProgress(read_size) + read_len = download_stream!!.read(buf) + } + output_stream.close() + download_stream!!.close() + publishProgress("Finishing ...") + } catch (e: MalformedURLException) { + e.printStackTrace() + return e.toString() + } catch (e: IOException) { + e.printStackTrace() + return e.toString() + } finally { + if (download_stream != null) try { + download_stream.close() + } catch (e: IOException) { + } + + if (output_stream != null) try { + output_stream.close() + } catch (e: IOException) { + } + + if (conn != null) conn.disconnect() + } + return null + } + + override fun onPreExecute() { + super.onPreExecute() + downloader.progress.max = 100 + downloader.progress.progress = 0 + downloader.progress.show() + } + + override fun onProgressUpdate(vararg data: String) { + downloader.progress.setMessage(data[0]) + } + + override fun onPostExecute(result: String?) { + downloader.progress.setMessage("do post actions ...") + if (downloader.callback != null) { + downloader.callback.run() + } + downloader.progress.dismiss() + if (result == null) { + Alarm.showToast(downloader.context, "Download successful") + } else { + Alarm.showToast(downloader.context, "Download failed: $result") + } + } + } + + init { + progress = ProgressDialog(context) + progress.isIndeterminate = true + progress.setProgressStyle(ProgressDialog.STYLE_SPINNER) + progress.setCancelable(true) + } + + fun act(title: String, url: String, outfile: String) { + val task = DownloadTask(this) + progress.setTitle(title) + progress.setOnCancelListener { task.cancel(true) } + task.execute(url, outfile) + } +} diff --git a/app/app/src/main/java/seven/drawalive/nodebase/External.kt b/app/app/src/main/java/seven/drawalive/nodebase/External.kt new file mode 100644 index 0000000..dd265bc --- /dev/null +++ b/app/app/src/main/java/seven/drawalive/nodebase/External.kt @@ -0,0 +1,36 @@ +package seven.drawalive.nodebase + +import android.content.Context +import android.content.Intent +import android.net.Uri + +import java.io.File + +object External { + fun openBrowser(context: Context, url: String) { + val intent = Intent(Intent.ACTION_SEND) + intent.action = "android.intent.action.VIEW" + intent.data = Uri.parse(url) + context.startActivity(intent) + } + + fun shareInformation( + context: Context, title: String, + label: String, text: String, imgFilePath: String?) { + val intent = Intent(Intent.ACTION_SEND) + if (imgFilePath == null || imgFilePath == "") { + intent.type = "text/plain" + } else { + val f = File(imgFilePath) + if (f != null && f.exists() && f.isFile) { + intent.type = "image/jpg" + val u = Uri.fromFile(f) + intent.putExtra(Intent.EXTRA_STREAM, u) + } + } + intent.putExtra(Intent.EXTRA_SUBJECT, label) + intent.putExtra(Intent.EXTRA_TEXT, text) + intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK + context.startActivity(Intent.createChooser(intent, title)) + } +} diff --git a/app/app/src/main/java/seven/drawalive/nodebase/ModuleAppManager.kt b/app/app/src/main/java/seven/drawalive/nodebase/ModuleAppManager.kt new file mode 100644 index 0000000..cafc091 --- /dev/null +++ b/app/app/src/main/java/seven/drawalive/nodebase/ModuleAppManager.kt @@ -0,0 +1,46 @@ +package seven.drawalive.nodebase + +import android.content.Context + +import java.io.File +import java.io.IOException +import java.nio.charset.Charset + +object ModuleAppManager { + fun js(context: Context): String? { + val reader = context.resources.openRawResource(R.raw.app_manager) + try { + val buf = ByteArray(reader!!.available()) + reader.read(buf) + return buf.toString(Charset.defaultCharset()) + } catch (e: IOException) { + return null + } finally { + if (reader != null) try { + reader.close() + } catch (e: Exception) { + } + + } + } + + fun readme(): String { + return "# NodeBase Application Manager\nrunning: 20180\nparams: (no params)\n" + } + + fun config(): String { + return "name=NodeBase Application Manager\nport=20180\n" + } + + fun install(context: Context, workdir: String) { + val appdir = "$workdir/app_manager" + val dir = File(appdir) + if (dir.exists()) { + return + } + dir.mkdir() + Storage.write(js(context)!!, "$appdir/index.js") + Storage.write(readme(), "$appdir/readme") + Storage.write(config(), "$appdir/config") + } +} diff --git a/app/app/src/main/java/seven/drawalive/nodebase/ModuleNpm.kt b/app/app/src/main/java/seven/drawalive/nodebase/ModuleNpm.kt new file mode 100644 index 0000000..35b79d3 --- /dev/null +++ b/app/app/src/main/java/seven/drawalive/nodebase/ModuleNpm.kt @@ -0,0 +1,7 @@ +package seven.drawalive.nodebase + +object ModuleNpm { + fun InstallNpmFromZip(zipfile: String, target_dir: String) { + Storage.unzip(zipfile, target_dir) + } +} diff --git a/app/app/src/main/java/seven/drawalive/nodebase/Network.kt b/app/app/src/main/java/seven/drawalive/nodebase/Network.kt new file mode 100644 index 0000000..d336c4a --- /dev/null +++ b/app/app/src/main/java/seven/drawalive/nodebase/Network.kt @@ -0,0 +1,43 @@ +package seven.drawalive.nodebase + +import android.content.Context +import android.net.wifi.WifiManager + +import java.net.NetworkInterface +import java.net.SocketException +import java.util.Collections +import java.util.HashMap + +object Network { + val nicIps: HashMap> + get() { + val name_ip = HashMap>() + try { + for (nic in Collections.list(NetworkInterface.getNetworkInterfaces())) { + val nic_addr = nic.interfaceAddresses + if (nic_addr.size == 0) continue + val ips = Array(nic_addr.size, { it -> "" }); + val name = nic.name + var index = 0 + for (ia in nic_addr) { + var addr = ia.address.hostAddress + if (addr.indexOf('%') >= 0) { + addr = addr.split("%".toRegex()).dropLastWhile({ it.isEmpty() }).toTypedArray()[0] + } + ips[index++] = addr.orEmpty() + } + name_ip[name] = ips + } + } catch (e: SocketException) { + } + + return name_ip + } + + fun getWifiIpv4(context: Context): String { + val wifiManager = context.getSystemService(Context.WIFI_SERVICE) as WifiManager + val wifiInfo = wifiManager.connectionInfo + val ip = wifiInfo.ipAddress + return String.format("%d.%d.%d.%d", ip and 0xff, ip shr 8 and 0xff, ip shr 16 and 0xff, ip shr 24 and 0xff) + } +} diff --git a/app/app/src/main/java/seven/drawalive/nodebase/NodeBase.kt b/app/app/src/main/java/seven/drawalive/nodebase/NodeBase.kt new file mode 100644 index 0000000..f61511c --- /dev/null +++ b/app/app/src/main/java/seven/drawalive/nodebase/NodeBase.kt @@ -0,0 +1,287 @@ +package seven.drawalive.nodebase + +import android.support.v7.app.AppCompatActivity +import android.os.Bundle +import android.text.Editable +import android.text.TextWatcher +import android.util.Log +import android.view.Gravity +import android.view.Menu +import android.view.MenuItem +import android.view.View +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.ScrollView +import android.widget.TextView + +import java.io.File +import java.util.ArrayList +import java.util.HashMap + + +class NodeBase : AppCompatActivity() { + + // state + private var config: Configuration? = null + private var _appList: ArrayList? = null + + // view components + private var _txtAppRootDir: EditText? = null + private var _labelIp: Button? = null + private var _btnRefreshAppList: Button? = null + private var _txtAppFilter: EditText? = null + private var _panelAppList: LinearLayout? = null + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // setContentView(R.layout.activity_node_base); + + config = Configuration(this) + config!!.prepareEnvironment() + + val view = prepareLayout() + prepareState() + prepareEvents() + Permission.request(this) + Permission.keepScreen(this, true) + + setContentView(view) + if (!Storage.exists(config!!.nodeBin())) { + resetNode() + } + if (Storage.exists(config!!.workDir())) { + refreshAppList() + } + } + + override fun onDestroy() { + Permission.keepScreen(this, false) + // if want to keep service running in backend + // comment out this line and add "Stop Service" somewhere + NodeService.stopService(this) + super.onDestroy() + } + + override fun onCreateOptionsMenu(menu: Menu): Boolean { + menu.add(Menu.NONE, 101, Menu.NONE, "NICs") + menu.add(Menu.NONE, 110, Menu.NONE, "Install Npm") + menu.add(Menu.NONE, 111, Menu.NONE, "Install App Manager") + menu.add(Menu.NONE, 120, Menu.NONE, "Node Version") + menu.add(Menu.NONE, 121, Menu.NONE, "Node Upgrade") + menu.add(Menu.NONE, 199, Menu.NONE, "Reset") + return true + } + + override fun onOptionsItemSelected(item: MenuItem): Boolean { + when (item.itemId) { + 101 // Show Network Interfaces + -> showNicIps() + 110 -> installNpm() + 111 // Install App Manager + -> installAppManager() + 120 // Show NodeJS Version + -> showNodeVersion() + 121 // Upgrade NodeJS + -> copyBinNodeFromNodebaseWorkdir() + 199 // Reset NodeJS + -> resetNode() + else -> return super.onOptionsItemSelected(item) + } + return true + } + + protected fun prepareState() { + _appList = ArrayList() + } + + protected fun prepareLayout(): LinearLayout { + val view: LinearLayout + val subview: LinearLayout + val label: TextView + val param: LinearLayout.LayoutParams + + view = LinearLayout(this) + view.orientation = LinearLayout.VERTICAL + + _labelIp = Button(this) + _labelIp!!.setText(String.format(" Network (%s)", Network.getWifiIpv4(this))) + _labelIp!!.gravity = Gravity.LEFT + _labelIp!!.isClickable = false + _labelIp!!.layoutParams = UserInterface.buttonFillStyle + UserInterface.themeAppTitleButton(_labelIp!!, false) + view.addView(_labelIp) + + label = TextView(this) + label.text = "App Root Dir:" + view.addView(label) + + subview = LinearLayout(this) + subview.orientation = LinearLayout.HORIZONTAL + _txtAppRootDir = EditText(this) + _txtAppRootDir!!.setText(config!!.workDir()) + param = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.WRAP_CONTENT, 1.0f) + _txtAppRootDir!!.layoutParams = param + subview.addView(_txtAppRootDir) + _btnRefreshAppList = Button(this) + _btnRefreshAppList!!.text = "Refresh" + subview.addView(_btnRefreshAppList) + view.addView(subview) + + _txtAppFilter = EditText(this) + _txtAppFilter!!.setText("") + _txtAppFilter!!.hint = "Filter app ..." + _txtAppFilter!!.visibility = View.GONE + view.addView(_txtAppFilter) + + val scroll = ScrollView(this) + _panelAppList = LinearLayout(this) + _panelAppList!!.orientation = LinearLayout.VERTICAL + scroll.addView(_panelAppList) + view.addView(scroll) + + return view + } + + protected fun prepareEvents() { + _btnRefreshAppList!!.setOnClickListener { + Log.i("UI:Button", "Refresh app list ...") + val appdir = _txtAppRootDir!!.text.toString() + if (appdir.compareTo(config!!.workDir()) != 0) { + config!![Configuration.KEYVAL_NODEBASE_DIR] = appdir + config!!.save() + } + Storage.makeDirectory(config!!.workDir()) + refreshAppList() + } + + _txtAppFilter!!.addTextChangedListener(object : TextWatcher { + override fun beforeTextChanged(s: CharSequence, start: Int, count: Int, after: Int) {} + + override fun onTextChanged(s: CharSequence, start: Int, before: Int, count: Int) { + for (app in _appList!!) { + if (s.length == 0) { + app.visibility = View.VISIBLE + } else if (app.appName.indexOf(s.toString()) >= 0) { + app.visibility = View.VISIBLE + } else { + app.visibility = View.GONE + } + } + } + + override fun afterTextChanged(s: Editable) {} + }) + } + + protected fun refreshAppList() { + val dirname = _txtAppRootDir!!.text.toString() + val approot = File(dirname) + _panelAppList!!.removeAllViews() + if (!approot.isDirectory) { + Alarm.showToast(this, String.format("\"%s\" is not a directory", dirname)) + return + } + try { + _appList!!.clear() + val files = Storage.listDirectories(dirname) + for (f in files!!) { + val name = f.name + // skip the folders of node_modules and which whose name starts with '.' + if ("node_modules".compareTo(name) == 0) continue + if (name.indexOf('.') == 0) continue + Log.i("UI:AppList", f.absolutePath) + val env = HashMap() + env["appdir"] = f + env["datadir"] = config!!.dataDir() + val app = NodeBaseApp(this, env) + _appList!!.add(app) + _panelAppList!!.addView(app) + } + if (_appList!!.size > 0) { + _txtAppFilter!!.setText("") + _txtAppFilter!!.visibility = View.VISIBLE + } + } catch (e: Exception) { + Log.w("UI:NodeBase", "fail", e) + } + + } + + private fun copyBinNodeFromNodebaseWorkdir() { + val dirname = config!!.workDir() + val upgrade_node_filename = String.format("%s/.bin/node", dirname) + val f = File(upgrade_node_filename) + if (!f.exists()) { + Alarm.showMessage( + this, + String.format("%s does not exists.", upgrade_node_filename), + "Upgrade Failed" + ) + return + } + val nodeBin = config!!.nodeBin() + if (!Storage.copy(upgrade_node_filename, nodeBin)) { + Log.e("NodeBase:upgrade_node", + "Cannot copy binary file of \"node\"") + } + Storage.executablize(nodeBin) + } + + private fun showNodeVersion() { + val version = NodeService.checkOutput(arrayOf(String.format("%s/node/node", config!!.dataDir()), "--version")) + var text: String? = null + if (version == null) { + text = "NodeJS: (not found)" + } else { + text = String.format("NodeJS: %s", version) + } + Alarm.showMessage(this, text!!, "Node Version") + } + + private fun showNicIps() { + val name_ip = Network.nicIps + val nic_list = StringBuffer() + for (name in name_ip.keys) { + nic_list.append(name) + nic_list.append(':') + for (ip in name_ip[name]!!.iterator()) { + nic_list.append(' ') + nic_list.append('[') + nic_list.append(ip) + nic_list.append(']') + } + nic_list.append('\n') + } + val text = String(nic_list) + Alarm.showMessage(this, text, "NetworkInterface(s)") + } + + private fun resetNode() { + val workdir = config!!.workDir() + val workdir_bin = String.format("%s/.bin", workdir) + Storage.makeDirectory(workdir_bin) + val upgrade_node_filename = String.format("%s/node", workdir_bin) + Storage.unlink(upgrade_node_filename) + Downloader(this, Runnable { copyBinNodeFromNodebaseWorkdir() }).act("Downlaod NodeJS", Configuration.NODE_URL, upgrade_node_filename) + } + + private fun installAppManager() { + val workdir = config!!.workDir() + ModuleAppManager.install(this, workdir) + Alarm.showToast(this, "successful") + } + + private fun installNpm() { + val workdir = config!!.workDir() + if (Storage.exists(String.format("%s/node_modules/npm", workdir))) return + val workdir_node_modules = String.format("%s/node_modules", workdir) + Storage.makeDirectory(workdir_node_modules) + val upgrade_npm_filename = String.format("%s/npm.zip", workdir_node_modules) + Storage.unlink(upgrade_npm_filename) + Downloader(this, Runnable { + ModuleNpm.InstallNpmFromZip(upgrade_npm_filename, workdir_node_modules) + Storage.unlink(upgrade_npm_filename) + }).act("Downlaod Npm", Configuration.NPM_URL, upgrade_npm_filename) + } +} diff --git a/app/app/src/main/java/seven/drawalive/nodebase/NodeBaseApp.kt b/app/app/src/main/java/seven/drawalive/nodebase/NodeBaseApp.kt new file mode 100644 index 0000000..45b785d --- /dev/null +++ b/app/app/src/main/java/seven/drawalive/nodebase/NodeBaseApp.kt @@ -0,0 +1,309 @@ +package seven.drawalive.nodebase + +import android.content.Context +import android.util.Log +import android.view.Gravity +import android.view.View +import android.widget.ArrayAdapter +import android.widget.Button +import android.widget.EditText +import android.widget.LinearLayout +import android.widget.Spinner +import android.widget.TableLayout +import android.widget.TableRow +import android.widget.TextView + +import java.io.File +import java.util.HashMap + +class NodeBaseApp(context: Context, private val _env: HashMap) : LinearLayout(context), NodeMonitorEvent { + + val appName: String + get() = _appdir.name + private val _appdir: File + private var _appentries: Array? = null + private var _panelDetails: LinearLayout? = null + private var _btnTitle: Button? = null + private var _btnStart: Button? = null + private var _btnStop: Button? = null + private var _btnOpen: Button? = null + private var _btnShare: Button? = null + private var _listEntries: Spinner? = null + private var _txtParams: EditText? = null + private var _readme: String? = null + private var _config: NodeBaseAppConfigFile? = null + + init { + orientation = LinearLayout.VERTICAL + _appdir = _env["appdir"] as File + + collectAppInformation() + prepareLayout() + prepareEvents() + } + + fun collectAppInformation() { + try { + // get all app entries + // e.g. /sdcard/.nodebase/app1/{entry1.js,entry2.js,...} + val fentries = _appdir.listFiles() + val entries = arrayOfNulls(fentries.size) + var count = 0 + _readme = "(This is a NodeBase app)" + for (i in fentries.indices.reversed()) { + val fentry = fentries[i] + entries[i] = null + if (!fentry.isFile) continue + val name = fentry.name + if (name.endsWith(".js")) { + entries[i] = name + count++ + } else if (name.toLowerCase().compareTo("readme") == 0) { + _readme = Storage.read(fentry.absolutePath) + } else if (name.toLowerCase().compareTo("config") == 0) { + _config = NodeBaseAppConfigFile(Storage.read(fentry.absolutePath)!!) + } + } + + _appentries = arrayOfNulls(count) + for (i in entries.indices.reversed()) { + if (entries[i] == null) continue + count-- + _appentries!![count] = entries[i] + } + } catch (e: Exception) { + Log.w("UI:NodeBaseApp", "fail", e) + } + + } + + fun prepareLayout() { + val context = context + val frame = LinearLayout(context) + frame.orientation = LinearLayout.HORIZONTAL + + /*ImageView image = new ImageView(context); + LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(64, 64); + params.setMargins(1, 1, 1, 1); + image.setLayoutParams(params); + image.setMaxHeight(64); + image.setMaxWidth(64); + image.setMinimumHeight(64); + image.setMinimumWidth(64); + try { + File imgfile = new File(_appdir.getAbsolutePath().concat("/icon.png")); + if (imgfile.exists()) { + Bitmap bmp = BitmapFactory.decodeFile(imgfile.getAbsolutePath()); + image.setImageBitmap(bmp); + } else { + image.setBackgroundResource(R.drawable.default_icon); + } + } catch (Exception e) { + } + frame.addView(image);*/ + + val param: LinearLayout.LayoutParams + val contents = LinearLayout(context) + contents.orientation = LinearLayout.VERTICAL + + _btnTitle = Button(context) + _btnTitle!!.setText(String.format(" App : %s", appName)) + _btnTitle!!.gravity = Gravity.LEFT + _btnTitle!!.setAllCaps(false) + _btnTitle!!.layoutParams = UserInterface.buttonLeftStyle + UserInterface.themeAppTitleButton(_btnTitle!!, false) + contents.addView(_btnTitle) + + _panelDetails = LinearLayout(context) + _panelDetails!!.orientation = LinearLayout.VERTICAL + _panelDetails!!.visibility = View.GONE + var label: TextView + label = TextView(context) + label.text = _readme + _readme = null // release memory + _panelDetails!!.addView(label) + + val tbl = TableLayout(context) + var tbl_r_t: TableRow? = null + tbl_r_t = TableRow(context) + label = TextView(context) + label.text = "Entry" + tbl_r_t.addView(label) + label = TextView(context) + label.text = "Params" + tbl_r_t.addView(label) + tbl.addView(tbl_r_t) + tbl_r_t = TableRow(context) + _listEntries = Spinner(context) + _listEntries!!.adapter = ArrayAdapter( + context, android.R.layout.simple_spinner_dropdown_item, _appentries!!) + tbl_r_t.addView(_listEntries) + _txtParams = EditText(context) + tbl_r_t.addView(_txtParams) + tbl.addView(tbl_r_t) + tbl.isStretchAllColumns = true + _panelDetails!!.addView(tbl) + + + val subview = LinearLayout(context) + subview.orientation = LinearLayout.HORIZONTAL + _btnStart = Button(context) + _btnStart!!.text = "Start" + subview.addView(_btnStart) + _btnStop = Button(context) + _btnStop!!.text = "Stop" + _btnStop!!.isEnabled = false + subview.addView(_btnStop) + _btnOpen = Button(context) + _btnOpen!!.text = "Open" + _btnOpen!!.isEnabled = false + subview.addView(_btnOpen) + _btnShare = Button(context) + _btnShare!!.text = "Share" + _btnShare!!.isEnabled = false + subview.addView(_btnShare) + _panelDetails!!.addView(subview) + + param = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + param.setMargins(0, 5, 0, 0) + contents.layoutParams = param + contents.addView(_panelDetails) + + frame.addView(contents) + addView(frame) + } + + fun prepareEvents() { + _btnTitle!!.setOnClickListener { + if (_panelDetails!!.visibility == View.GONE) { + _panelDetails!!.visibility = View.VISIBLE + } else { + _panelDetails!!.visibility = View.GONE + } + } + + _btnStart!!.setOnClickListener { + val appname = appName + _btnStart!!.isEnabled = false + _btnStop!!.isEnabled = true + _btnOpen!!.isEnabled = true + _btnShare!!.isEnabled = true + Thread(Runnable { + val appname = appName + val timestamp = System.currentTimeMillis() + while (System.currentTimeMillis() - timestamp < 3000 /* 3s timeout */) { + if (NodeService.services.containsKey(appname)) { + val monitor = NodeService.services[appname] + if (monitor!!.isDead) { + // not guarantee but give `after` get chance to run + // if want to guarantee, `synchronized` isDead + this@NodeBaseApp.after(monitor!!.command, null!!) + } else { + monitor!!.setEvent(this@NodeBaseApp) + } + break + } + } + }).start() + NodeService.touchService( + context, + arrayOf(NodeService.AUTH_TOKEN, "start", appname, String.format( + "%s/node/node %s/%s %s", + _env["datadir"].toString(), + _appdir.absolutePath, + _listEntries!!.selectedItem.toString(), + _txtParams!!.text.toString() + ))) + } + + _btnStop!!.setOnClickListener { + _btnStart!!.isEnabled = true + _btnStop!!.isEnabled = false + _btnOpen!!.isEnabled = false + _btnShare!!.isEnabled = false + NodeService.touchService(context, arrayOf(NodeService.AUTH_TOKEN, "stop", appName)) + } + + _btnOpen!!.setOnClickListener { + val app_url = String.format( + generateAppUrlTemplate(), + Network.getWifiIpv4(context) + ) + External.openBrowser(context, app_url) + } + + _btnShare!!.setOnClickListener { + val name = generateAppTitle() + val app_url = String.format( + generateAppUrlTemplate(), + Network.getWifiIpv4(context) + ) + External.shareInformation( + context, "Share", "NodeBase", + String.format("[%s] is running at %s", name, app_url), null + ) + } + } + + private fun generateAppUrlTemplate(): String { + var protocol: String? = null + var port: String? = null + var index: String? = null + if (_config != null) { + port = _config!![null, "port"] + protocol = _config!![null, "protocol"] + index = _config!![null, "index"] + } + if (port == null) port = "" else port = ":$port" + if (protocol == null) protocol = "http" + if (index == null) index = "" + return protocol + "://%s" + String.format("%s%s", port, index) + } + + private fun generateAppTitle(): String { + var name: String? = null + if (_config != null) { + name = _config!![null, "name"] + } + if (name == null) name = "NodeBase Service" + return name + } + + override fun before(cmd: Array) { + UserInterface.run(Runnable { + _btnStart!!.isEnabled = false + _btnStop!!.isEnabled = false + _btnOpen!!.isEnabled = false + _btnShare!!.isEnabled = false + }) + } + + override fun started(cmd: Array, process: Process) { + UserInterface.run(Runnable { + _btnStart!!.isEnabled = false + _btnStop!!.isEnabled = true + _btnOpen!!.isEnabled = true + _btnShare!!.isEnabled = true + UserInterface.themeAppTitleButton(_btnTitle!!, true) + }) + } + + override fun error(cmd: Array, process: Process) {} + + override fun after(cmd: Array, process: Process) { + UserInterface.run(Runnable { + _btnStart!!.isEnabled = true + _btnStop!!.isEnabled = false + _btnOpen!!.isEnabled = false + _btnShare!!.isEnabled = false + UserInterface.themeAppTitleButton(_btnTitle!!, false) + Alarm.showToast( + this@NodeBaseApp.context, + String.format("\"%s\" stopped", appName) + ) + }) + } +} diff --git a/app/app/src/main/java/seven/drawalive/nodebase/NodeBaseAppConfigFile.kt b/app/app/src/main/java/seven/drawalive/nodebase/NodeBaseAppConfigFile.kt new file mode 100644 index 0000000..7bbeb87 --- /dev/null +++ b/app/app/src/main/java/seven/drawalive/nodebase/NodeBaseAppConfigFile.kt @@ -0,0 +1,52 @@ +package seven.drawalive.nodebase + +import java.util.HashMap + +class NodeBaseAppConfigFile(config_text: String) { + + private val config: HashMap> + private val defaultconfig: HashMap + + init { + config = HashMap() + defaultconfig = HashMap() + config["\u0000"] = defaultconfig + var cur = defaultconfig + // parse simple ini + for (line in config_text.split("\n".toRegex()).dropLastWhile({ it.isEmpty() }).toTypedArray()) { + var xline = line.trim({ it <= ' ' }) + if (xline.length == 0) continue + if (xline.get(0) == '[' && xline.get(xline.length - 1) == ']') { + val section = xline.substring(1, xline.length - 1) + if (config.containsKey(section)) { + cur = config[section]!! + } else { + cur = HashMap() + config[section] = cur + } + continue + } + val eqpos = line.indexOf('=') + if (eqpos < 0) continue + val key = line.substring(0, eqpos).trim({ it <= ' ' }) + val `val` = line.substring(eqpos + 1).trim({ it <= ' ' }) + cur[key] = `val` + } + } + + operator fun get(section: String?, key: String): String? { + var section = section + if (section == null) { + section = "\u0000" + } + if (!config.containsKey(section)) { + return null + } + val secmap = config[section] + return if (secmap!!.containsKey(key)) { + secmap!!.get(key) + } else { + null + } + } +} diff --git a/app/app/src/main/java/seven/drawalive/nodebase/NodeMonitor.kt b/app/app/src/main/java/seven/drawalive/nodebase/NodeMonitor.kt new file mode 100644 index 0000000..4091da6 --- /dev/null +++ b/app/app/src/main/java/seven/drawalive/nodebase/NodeMonitor.kt @@ -0,0 +1,85 @@ +package seven.drawalive.nodebase + +import android.util.Log + +import java.io.IOException + +class NodeMonitor(val serviceName: String, val command: Array) : Thread() { + + val isRunning: Boolean + get() = state == STATE.RUNNING + + val isReady: Boolean + get() = state == STATE.READY + + val isDead: Boolean + get() = state == STATE.DEAD + + private var state: STATE? = null + private var node_process: Process? = null + private var event: NodeMonitorEvent? = null + + enum class STATE { + BORN, READY, RUNNING, DEAD + } + + init { + state = STATE.BORN + event = null + } + + fun setEvent(event: NodeMonitorEvent): NodeMonitor { + this.event = event + return this + } + + override fun run() { + try { + state = STATE.READY + if (event != null) event!!.before(command) + Log.i("NodeService:NodeMonitor", String.format("node process starting - %s", *command)) + node_process = Runtime.getRuntime().exec(command) + state = STATE.RUNNING + if (event != null) event!!.started(command, node_process!!) + Log.i("NodeService:NodeMonitor", "node process running ...") + node_process!!.waitFor() + /* + BufferedReader reader = new BufferedReader( + new InputStreamReader(_process.getInputStream())); + String line = null; + while ((line = reader.readLine()) != null) { + Log.d("NodeMonitor", line); + } + Log.d("-----", "=========================="); + reader = new BufferedReader( + new InputStreamReader(_process.getErrorStream())); + while ((line = reader.readLine()) != null) { + Log.d("NodeMonitor", line); + } + */ + } catch (e: IOException) { + Log.e("NodeService:NodeMonitor", "node process error", e) + node_process = null + if (event != null) event!!.error(command, null!!) + } catch (e: InterruptedException) { + Log.e("NodeService:NodeMonitor", "node process error", e) + if (event != null) event!!.error(command, node_process!!) + } finally { + state = STATE.DEAD + if (event != null) event!!.after(command, node_process!!) + Log.i("NodeService:NodeMonitor", "node process stopped ...") + } + } + + fun stopService(): Boolean { + if (state == STATE.RUNNING) node_process!!.destroy() + return true + } + + fun restartService(): NodeMonitor { + stopService() + val m = NodeMonitor(serviceName, command) + if (event != null) m.setEvent(event!!) + return m + } +} \ No newline at end of file diff --git a/app/app/src/main/java/seven/drawalive/nodebase/NodeMonitorEvent.kt b/app/app/src/main/java/seven/drawalive/nodebase/NodeMonitorEvent.kt new file mode 100644 index 0000000..c0e5275 --- /dev/null +++ b/app/app/src/main/java/seven/drawalive/nodebase/NodeMonitorEvent.kt @@ -0,0 +1,8 @@ +package seven.drawalive.nodebase + +interface NodeMonitorEvent { + fun before(cmd: Array) + fun started(cmd: Array, process: Process) + fun error(cmd: Array, process: Process) + fun after(cmd: Array, process: Process) +} diff --git a/app/app/src/main/java/seven/drawalive/nodebase/NodeService.kt b/app/app/src/main/java/seven/drawalive/nodebase/NodeService.kt new file mode 100644 index 0000000..3bf1e7c --- /dev/null +++ b/app/app/src/main/java/seven/drawalive/nodebase/NodeService.kt @@ -0,0 +1,146 @@ +package seven.drawalive.nodebase + + +import android.app.Service +import android.content.Context +import android.content.Intent +import android.os.IBinder +import android.util.Log + +import java.util.HashMap +import java.util.UUID + +class NodeService : Service() { + + override fun onBind(intent: Intent): IBinder? { + throw UnsupportedOperationException("Not yet implemented") + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + while (intent != null) { + val argv = intent.getStringArrayExtra(ARGV) + if (argv.size < 3) break + val auth_token = argv[0] + var cmd = argv[1] + val first = argv[2] + if (AUTH_TOKEN.compareTo(auth_token) != 0) break + /* + command: + - start + - start + - = ... + - restart + - restart -> restart node app by name + - stop + - stop ! -> stop all node apps + - stop -> stop node app by name + */ + when (cmd) { + "start" -> { + if (argv.size >= 4) { + cmd = argv[3] + startNodeApp(first /* name */, cmd) + } + } + "restart" -> restartNodeApp(first /* name */) + "stop" -> if ("!".compareTo(first) == 0) { + stopNodeApps() + } else { + stopNodeApp(first /* name */) + } + } + break + } + // running until explicitly stop + return Service.START_STICKY + } + + override fun onCreate() { + NodeService.refreshAuthToken() + stopNodeApps() + } + + override fun onDestroy() { + stopNodeApps() + } + + private fun stopNodeApps() { + val n = services.keys.size + val keys = arrayOfNulls(n) + for (name in services.keys.iterator()) { + stopNodeApp(name.orEmpty()) + } + } + + private fun stopNodeApp(name: String) { + if (!services.containsKey(name)) return + val monitor = services[name] + monitor!!.stopService() + services.remove(name) + } + + private fun restartNodeApp(name: String) { + if (!services.containsKey(name)) return + var monitor = services[name] + stopNodeApp(name) + monitor = monitor!!.restartService() + services[name] = monitor + monitor.start() + } + + private fun startNodeApp(name: String, cmd: String) { + stopNodeApp(name) + Log.d("NodeService:Command", String.format("%s", cmd)) + val exec = StringUtils.parseArgv(cmd) + val monitor = NodeMonitor(name, exec!!) + services[name] = monitor + monitor.start() + } + + companion object { + val ARGV = "NodeService" + val services = HashMap() + var AUTH_TOKEN = refreshAuthToken() + + fun refreshAuthToken(): String { + val uuid = UUID.randomUUID() + return uuid.toString() + } + + fun checkOutput(cmd: Array): String? { + try { + val p = Runtime.getRuntime().exec(cmd) + p.waitFor() + val `is` = p.inputStream + var len = `is`.available() + var b: ByteArray? = null + if (len > 0) { + b = ByteArray(len) + len = `is`.read(b) + } + `is`.close() + return if (b == null) { + null + } else String(b, 0, len) + } catch (e: Exception) { + return null + } + + } + + + fun touchService(context: Context, args: Array) { + Log.i("NodeService:Signal", "Start Service") + Log.i("NodeService:Signal", String.format("Command - %s", args[1])) + val intent = Intent(context, NodeService::class.java) + intent.putExtra(NodeService.ARGV, args) + context.startService(intent) + } + + fun stopService(context: Context) { + Log.i("NodeService:Signal", "Stop Service") + val intent = Intent(context, NodeService::class.java) + context.stopService(intent) + } + } +} diff --git a/app/app/src/main/java/seven/drawalive/nodebase/Permission.kt b/app/app/src/main/java/seven/drawalive/nodebase/Permission.kt new file mode 100644 index 0000000..12dfbf9 --- /dev/null +++ b/app/app/src/main/java/seven/drawalive/nodebase/Permission.kt @@ -0,0 +1,40 @@ +package seven.drawalive.nodebase + +import android.Manifest +import android.app.Activity +import android.content.Context +import android.content.pm.PackageManager +import android.os.PowerManager +import android.support.v4.app.ActivityCompat +import android.support.v4.content.ContextCompat + +object Permission { + + private var power_wake_lock: PowerManager.WakeLock? = null + private var PERMISSIONS_EXTERNAL_STORAGE = 1 + fun request(activity: Activity) { + val permission: Int + permission = ContextCompat.checkSelfPermission( + activity, Manifest.permission.WRITE_EXTERNAL_STORAGE) + if (permission != PackageManager.PERMISSION_GRANTED) { + ActivityCompat.requestPermissions( + activity, + arrayOf(Manifest.permission.WRITE_EXTERNAL_STORAGE, Manifest.permission.READ_EXTERNAL_STORAGE), + PERMISSIONS_EXTERNAL_STORAGE) + } + } + + fun keepScreen(activity: Activity, on: Boolean) { + val pm = activity.getSystemService(Context.POWER_SERVICE) as PowerManager + if (power_wake_lock == null) { + power_wake_lock = pm.newWakeLock( + PowerManager.PARTIAL_WAKE_LOCK, Permission::class.java!!.getName() + ) + } + if (on) { + power_wake_lock!!.acquire() + } else { + power_wake_lock!!.release() + } + } +} diff --git a/app/app/src/main/java/seven/drawalive/nodebase/Storage.kt b/app/app/src/main/java/seven/drawalive/nodebase/Storage.kt new file mode 100644 index 0000000..4e9e3c7 --- /dev/null +++ b/app/app/src/main/java/seven/drawalive/nodebase/Storage.kt @@ -0,0 +1,243 @@ +package seven.drawalive.nodebase + +import java.io.BufferedOutputStream +import java.io.File +import java.io.FileInputStream +import java.io.FileNotFoundException +import java.io.FileOutputStream +import java.io.IOException +import java.io.InputStream +import java.io.OutputStream +import java.net.MalformedURLException +import java.net.URL +import java.net.URLConnection +import java.nio.charset.Charset +import java.util.ArrayList +import java.util.zip.ZipEntry +import java.util.zip.ZipInputStream + +object Storage { + + private val READABLE_SIZE_UNIT = arrayOf("B", "KB", "MB", "GB", "TB") + fun download(url: String, outfile: String): Boolean { + var download_stream: InputStream? = null + var output_stream: OutputStream? = null + try { + val urlobj = URL(url) + val conn = urlobj.openConnection() + // int file_len = conn.getContentLength(); + val buf = ByteArray(4096) + var read_len = 0 + download_stream = conn.getInputStream() + Storage.unlink(outfile) + Storage.touch(outfile) + output_stream = FileOutputStream(outfile) + read_len = download_stream!!.read(buf) + while (read_len >= 0) { + output_stream.write(buf, 0, read_len) + read_len = download_stream!!.read(buf) + } + output_stream.close() + download_stream!!.close() + } catch (e: MalformedURLException) { + } catch (e: IOException) { + return false + } finally { + if (download_stream != null) try { + download_stream.close() + } catch (e: IOException) { + } + + if (output_stream != null) try { + output_stream.close() + } catch (e: IOException) { + } + + } + return true + } + + fun copy(infile: String, outfile: String): Boolean { + var in_stream: InputStream? = null + var out_stream: OutputStream? = null + try { + in_stream = FileInputStream(infile) + Storage.unlink(outfile) + Storage.touch(outfile) + out_stream = FileOutputStream(outfile) + val buf = ByteArray(4096) + var read_len = in_stream.read(buf) + while (read_len >= 0) { + out_stream.write(buf, 0, read_len) + read_len = in_stream.read(buf) + } + } catch (e: FileNotFoundException) { + return false + } catch (e: IOException) { + return false + } finally { + if (in_stream != null) try { + in_stream.close() + } catch (e: IOException) { + } + + if (out_stream != null) try { + out_stream.close() + } catch (e: IOException) { + } + + } + return true + } + + fun unlink(infile: String): Boolean { + val file = File(infile) + return if (file.exists()) file.delete() else false + } + + fun touch(infile: String): Boolean { + val file = File(infile) + if (!file.exists()) try { + file.createNewFile() + } catch (e: IOException) { + } + + return true + } + + fun move(infile: String, outfile: String): Boolean { + var r = Storage.copy(infile, outfile) + if (r) { + r = Storage.unlink(infile) + } else { + // rollback + Storage.unlink(outfile) + } + return r + } + + fun executablize(infile: String): Boolean { + val file = File(infile) + return file.setExecutable(true) + } + + fun makeDirectory(path: String): Boolean { + val dir = File(path) + return if (dir.exists()) dir.isDirectory else dir.mkdirs() + } + + fun read(infile: String): String? { + var reader: FileInputStream? = null + val file = File(infile) + try { + val buf = ByteArray(file.length().toInt()) + reader = FileInputStream(file) + reader.read(buf) + return buf.toString(Charset.defaultCharset()) + } catch (e: IOException) { + return null + } finally { + if (reader != null) try { + reader.close() + } catch (e: Exception) { + } + + } + } + + fun write(text: String, outfile: String): Boolean { + var writer: OutputStream? = null + try { + val buf = text.toByteArray() + Storage.touch(outfile) + writer = FileOutputStream(outfile) + writer.write(buf) + } catch (e: FileNotFoundException) { + return false + } catch (e: IOException) { + return false + } finally { + if (writer != null) try { + writer.close() + } catch (e: Exception) { + } + + } + return true + } + + fun listDirectories(path: String): Array? { + val filtered = ArrayList() + val dir = File(path) + if (!dir.exists()) return null + var list = dir.listFiles() + for (f in list) { + if (f.isDirectory) filtered.add(f) + } + return filtered.toTypedArray() + } + + fun listFiles(path: String): Array? { + val filtered = ArrayList() + val dir = File(path) + if (!dir.exists()) return null + var list = dir.listFiles() + for (f in list) { + if (f.isFile) filtered.add(f) + } + return filtered.toTypedArray() + } + + fun unzip(zipfile: String, target_dir: String): Boolean { + try { + val `in` = FileInputStream(zipfile) + val zip = ZipInputStream(`in`) + var entry: ZipEntry? = null + entry = zip.nextEntry + while (entry != null) { + val target_filename = String.format("%s/%s", target_dir, entry!!.name) + if (entry!!.isDirectory) { + Storage.makeDirectory(target_filename) + } else { + val out = FileOutputStream(target_filename) + val writer = BufferedOutputStream(out) + val buf = ByteArray(4096) + var count = zip.read(buf) + while (count != -1) { + writer.write(buf, 0, count) + count = zip.read(buf) + } + writer.close() + out.close() + zip.closeEntry() + } + entry = zip.nextEntry + } + zip.close() + `in`.close() + } catch (e: FileNotFoundException) { + e.printStackTrace() + return false + } catch (e: IOException) { + e.printStackTrace() + return false + } + + return true + } + + fun exists(infile: String): Boolean { + return File(infile).exists() + } + + fun readableSize(size: Int): String { + var index = 0 + val n = READABLE_SIZE_UNIT.size - 1 + var `val` = size.toDouble() + while (`val` > 1024 && index < n) { + index++ + `val` /= 1024.0 + } + return String.format("%.2f %s", `val`, READABLE_SIZE_UNIT[index]) + } +} diff --git a/app/app/src/main/java/seven/drawalive/nodebase/StringUtils.kt b/app/app/src/main/java/seven/drawalive/nodebase/StringUtils.kt new file mode 100644 index 0000000..1564fde --- /dev/null +++ b/app/app/src/main/java/seven/drawalive/nodebase/StringUtils.kt @@ -0,0 +1,65 @@ +package seven.drawalive.nodebase + +import java.util.ArrayList + +object StringUtils { + fun parseArgv(argv: String?): Array? { + val r = ArrayList() + if (argv == null) return null + var buf = StringBuffer() + var state = 0 + var last = ' ' + loop@ for (ch in argv.toCharArray()) { + when (state) { + 0 -> { + if (Character.isSpaceChar(ch)) { + if (Character.isSpaceChar(last)) { + continue@loop + } + if (buf.length > 0) { + r.add(String(buf)) + buf = StringBuffer() + } + last = ch + continue@loop + } else if (ch == '"') { + state = 1 + } else if (ch == '\'') { + state = 2 + } else if (ch == '\\') { + state += 90 + } + buf.append(ch) + last = ch + } + 1 -> { + buf.append(ch) + if (ch == '"' && last != '\\') { + last = ch + state = 0 + continue@loop + } + last = ch + } + 2 -> { + buf.append(ch) + if (ch == '\'' && last != '\\') { + last = ch + state = 0 + continue@loop + } + last = ch + } + 90, 91, 92 -> { + buf.append(ch) + last = ch + state -= 90 + } + } + } + if (buf.length > 0) { + r.add(String(buf)) + } + return r.toTypedArray() + } +} diff --git a/app/app/src/main/java/seven/drawalive/nodebase/UserInterface.kt b/app/app/src/main/java/seven/drawalive/nodebase/UserInterface.kt new file mode 100644 index 0000000..e652332 --- /dev/null +++ b/app/app/src/main/java/seven/drawalive/nodebase/UserInterface.kt @@ -0,0 +1,42 @@ +package seven.drawalive.nodebase + +import android.os.Handler +import android.os.Looper +import android.widget.Button +import android.widget.LinearLayout + +object UserInterface { + + val buttonLeftStyle = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + val buttonRightStyle = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + val buttonFillStyle = LinearLayout.LayoutParams( + LinearLayout.LayoutParams.MATCH_PARENT, + LinearLayout.LayoutParams.WRAP_CONTENT + ) + + fun run(runnable: Runnable) { + Handler(Looper.getMainLooper()).post(runnable) + } + + fun themeAppTitleButton(button: Button, running: Boolean) { + if (running) { + // light green + button.setBackgroundColor(-0x300a33) + } else { + // light grey + button.setBackgroundColor(-0x1d1d1e) + } + } + + init { + buttonLeftStyle.setMargins(0, 0, 10, 3) + buttonRightStyle.setMargins(10, 0, 0, 3) + buttonFillStyle.setMargins(0, 0, 0, 0) + } +} diff --git a/app/app/src/main/res/layout/activity_node_base.xml b/app/app/src/main/res/layout/activity_node_base.xml new file mode 100644 index 0000000..def90a5 --- /dev/null +++ b/app/app/src/main/res/layout/activity_node_base.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..254e2d6 Binary files /dev/null and b/app/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..dbae251 Binary files /dev/null and b/app/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..e36bb71 Binary files /dev/null and b/app/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..9faa940 Binary files /dev/null and b/app/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..5fc04e8 Binary files /dev/null and b/app/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/app/src/main/res/raw/app_manager.js b/app/app/src/main/res/raw/app_manager.js new file mode 100644 index 0000000..3c6cf56 --- /dev/null +++ b/app/app/src/main/res/raw/app_manager.js @@ -0,0 +1,559 @@ +const http = require('http'); +const url = require('url'); +const mime = require('mime'); +const path = require('path'); +const fs = require('fs'); +let html_index = ` + + + + + NodeBase Appliction Manager + + + +
NodeBase Application Manager
+ +
Ensure it is running in a safe Wi-Fi environment; otherwise data can be lost accidently by others or malwares.
+
+
Loading ...
+
+ +
+ +
Local Applications
+ + +
+
Shared Applications
+
+ +
+ +
+ +
+ +
+ +
Application List
+
+
+ +
+ +
Application
+
Name name
+ + + + +
+ + + +`; + +function get_ip (req) { + let ip = null; + if (req.headers['x-forwarded-for']) { + ip = req.headers['x-forwarded-for'].split(",")[0]; + } else if (req.connection && req.connection.remoteAddress) { + ip = req.connection.remoteAddress; + } else { + ip = req.ip; + } + return ip; +} + +function make_directory(dir) { + dir = path.resolve(dir); + let parent_dir = path.dirname(dir); + let state = true; + if (dir !== parent_dir) { + if (!fs.existsSync(parent_dir)) { + state = make_directory(parent_dir); + } else { + if (!fs.lstatSync(parent_dir).isDirectory()) { + state = false; + } + } + if (!state) { + return null; + } + } + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir); + return dir; + } else if (!fs.lstatSync(dir).isDirectory()) { + return null; + } else { + return dir; + } +} + +function process_app_import(req, res, api, appname, appfiles, rename) { + function random_tmp_name() { + return 'tmp-' + Math.random(); + } + function download_file(api, appname, appfiles, index, tmpdir, cb) { + if (index >= appfiles.length) { + cb(); + return; + } + if (!appfiles[index]) { + download_file(api, appname, appfiles, index+1, tmpdir, cb); + return; + } + let filename = appfiles[index]; + let tmpfile = path.join(tmpdir, filename); + let subtmpdir = path.dirname(tmpfile); + make_directory(subtmpdir); + let file = fs.createWriteStream(tmpfile); + let request = http.get(api + '/download/' + appname + '/' + filename, (obj) => { + obj.pipe(file); + download_file(api, appname, appfiles, index+1, tmpdir, cb); + }).on('error', (e) => { + errors.push('failed to download: ' + appfiles[index]); + download_file(api, appname, appfiles, index+1, tmpdir, cb); + }); + } + let errors = []; + let tmpdir = path.join(Storage.work_dir, random_tmp_name()); + let targetdir = path.join(Storage.work_dir, rename); + if (fs.existsSync(targetdir)) { + res.end('app exists: ' + rename); + return; + } + fs.mkdirSync(tmpdir); + download_file(api, appname, appfiles, 0, tmpdir, () => { + if (errors.length > 0) { + Storage.rmtree(tmpdir); + res.end(errors.join('\n')); + return; + } + fs.renameSync(tmpdir, targetdir); + res.end(''); + }); +} + +function route(req, res) { + let r = url.parse(req.url); + let f = router; + let path = r.pathname.split('/'); + let query = {}; + r.query && r.query.split('&').forEach((one) => { + let key, val; + let i = one.indexOf('='); + if (i < 0) { + key = one; + val = ''; + } else { + key = one.substring(0, i); + val = one.substring(i+1); + } + if (key in query) { + if(Array.isArray(query[key])) { + query[key].push(val); + } else { + query[key] = [query[key], val]; + } + } else { + query[key] = val; + } + }); + path.shift(); + while (path.length > 0) { + let key = path.shift(); + f = f[key]; + if (!f) break; + if (typeof(f) === 'function') { + return f(req, res, { + path: path, + query: query + }); + } + } + router.static(req, res, r.pathname); + // router.code(req, res, 404, 'Not Found'); +} + +const Storage = { + work_dir: path.dirname(__dirname), + list_directories: (dir) => { + return fs.readdirSync(dir).filter((name) => { + let subdir = path.join(dir, name); + let state = fs.lstatSync(subdir); + return state.isDirectory(); + }); + }, + list_files: (dir) => { + let queue = [dir], list = []; + while (queue.length > 0) { + list_dir(queue.shift(), queue, list); + } + return list; + + function list_dir(dir, queue, list) { + fs.readdirSync(dir).forEach((name) => { + let filename = path.join(dir, name); + let state = fs.lstatSync(filename); + if (state.isDirectory()) { + queue.push(filename); + } else { + list.push(filename); + } + }); + } + }, + rmtree: (dir) => { + if (dir.length < Storage.work_dir.length) { + return false; + } + if (dir.indexOf(Storage.work_dir) !== 0) { + return false; + } + if (!fs.existsSync(dir)) { + return false; + } + fs.readdirSync(dir).forEach(function(file, index){ + var curPath = path.join(dir, file); + if (fs.lstatSync(curPath).isDirectory()) { + // recurse + Storage.rmtree(curPath); + } else { // delete file + fs.unlinkSync(curPath); + } + }); + fs.rmdirSync(dir); + return true; + } +}; + +const router = { + app: { + list: (req, res, options) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + let dir = path.dirname(__dirname); + if (!fs.existsSync(dir)) { + return router.code(req, res, 404, 'Not Found'); + } + let names = Storage.list_directories(Storage.work_dir).filter((name) => { + if (name.startsWith('.')) return false; + if (name === 'node_modules') return false; + return true; + }); + res.end(names.join('\n')); + }, + files: (req, res, options) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + let name = options.path[0]; + if (!name) { + return router.code(req, res, 404, 'Not Found'); + } + let subdir = path.join(Storage.work_dir, name); + if (!fs.existsSync(subdir)) { + return router.code(req, res, 404, 'Not Found'); + } + let files = Storage.list_files(subdir).map((filename) => { + return filename.substring(subdir.length+1); + }); + res.end(files.join('\n')); + }, + download: (req, res, options) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + if (options.path.indexOf('..') >= 0) { + return router.code(req, res, 404, 'Not Found'); + } + let filename = path.join(...options.path); + filename = path.join(Storage.work_dir, filename); + if (!fs.existsSync(filename)) { + return router.code(req, res, 404, 'Not Found'); + } + res.setHeader('Content-Disposition', 'attachment; filename=' + path.basename(filename)); + //res.setHeader('Content-Type', 'application/octet-stream'); + res.setHeader('Content-Type', 'text/plain'); + let buf = fs.readFileSync(filename); + res.end(buf, 'binary'); + }, + delete: (req, res, options) => { + let name = options.path[0]; + if (!name) { + return router.code(req, res, 404, 'Not Found'); + } + let filename = path.join(Storage.work_dir, name); + // !!!! dangerous action + Storage.rmtree(filename); + res.end(''); + }, + import: (req, res, options) => { + // options.path [ip:port, appname, rename] + let host = options.path[0]; + let appname = options.path[1]; + let name = options.path[2] || appname; + let api = 'http://' + host + '/app'; + http.get(api + '/files/' + appname, (obj) => { + if (~~(obj.statusCode/100) !== 2) { + obj.resume(); + return router.code(req, res, 404, 'Not Found'); + } + let raw = ''; + obj.on('data', (chunk) => { raw += chunk; }); + obj.on('end', () => { + process_app_import(req, res, api, appname, raw.split('\n'), name); + }); + }).on('error', (e) => { + return router.code(req, res, 404, 'Not Found'); + }); + } + }, + test: (req, res, options) => { + res.end('hello'); + }, + static: (req, res, filename) => { + if (!filename || filename === '/') { + filename = 'index.html'; + res.end(html_index, 'utf-8'); + return; + } + filename = filename.split('/'); + if (!filename[0]) filename.shift(); + if (filename.length === 0 || filename.indexOf('..') >= 0) { + return router.code(req, res, 404, 'Not Found'); + } + filename = path.join(__dirname, 'static', ...filename); + if (!fs.existsSync(filename)) { + return router.code(req, res, 404, 'Not Found'); + } + res.setHeader('Content-Type', mime.lookup(filename)); + let buf = fs.readFileSync(filename); + res.end(buf, 'binary'); + }, + code: (req, res, code, text) => { + res.writeHead(code || 404, text || ''); + res.end(); + } +}; + +const server = http.createServer((req, res) => { + route(req, res); +}); + +const instance = server.listen(20180, '0.0.0.0', () => { + console.log(`NodeBase Application Manager is listening at 0.0.0.0:20180`); +}); diff --git a/app/app/src/main/res/raw/test.js b/app/app/src/main/res/raw/test.js new file mode 100644 index 0000000..984527d --- /dev/null +++ b/app/app/src/main/res/raw/test.js @@ -0,0 +1,14 @@ +const http = require('http'); + +const hostname = '127.0.0.1'; +const port = 3000; + +const server = http.createServer((req, res) => { + res.statusCode = 200; + res.setHeader('Content-Type', 'text/plain'); + res.end('Hello World\n'); +}); + +server.listen(port, hostname, () => { + console.log(`Server running at http://${hostname}:${port}/`); +}); diff --git a/app/app/src/main/res/values-w820dp/dimens.xml b/app/app/src/main/res/values-w820dp/dimens.xml new file mode 100644 index 0000000..dc1223c --- /dev/null +++ b/app/app/src/main/res/values-w820dp/dimens.xml @@ -0,0 +1,6 @@ + + + 64dp + diff --git a/app/app/src/main/res/values/colors.xml b/app/app/src/main/res/values/colors.xml new file mode 100644 index 0000000..3cde448 --- /dev/null +++ b/app/app/src/main/res/values/colors.xml @@ -0,0 +1,6 @@ + + + #3F51B5 + #303F9F + #FF4081 + diff --git a/app/app/src/main/res/values/dimens.xml b/app/app/src/main/res/values/dimens.xml new file mode 100644 index 0000000..804b9c9 --- /dev/null +++ b/app/app/src/main/res/values/dimens.xml @@ -0,0 +1,5 @@ + + + 16dp + 16dp + diff --git a/app/app/src/main/res/values/strings.xml b/app/app/src/main/res/values/strings.xml new file mode 100644 index 0000000..17c9c4e --- /dev/null +++ b/app/app/src/main/res/values/strings.xml @@ -0,0 +1,4 @@ + + NodeBase + android.permission.WRITE_EXTERNAL_STORAGE + diff --git a/app/app/src/main/res/values/styles.xml b/app/app/src/main/res/values/styles.xml new file mode 100644 index 0000000..2c1d6f9 --- /dev/null +++ b/app/app/src/main/res/values/styles.xml @@ -0,0 +1,11 @@ + + + + + + diff --git a/app/app/src/test/java/seven/drawalive/nodebase/ExampleUnitTest.kt b/app/app/src/test/java/seven/drawalive/nodebase/ExampleUnitTest.kt new file mode 100644 index 0000000..06a6e74 --- /dev/null +++ b/app/app/src/test/java/seven/drawalive/nodebase/ExampleUnitTest.kt @@ -0,0 +1,18 @@ +package seven.drawalive.nodebase + +import org.junit.Test + +import org.junit.Assert.* + +/** + * Example local unit test, which will execute on the development machine (host). + * + * @see [Testing documentation](http://d.android.com/tools/testing) + */ +class ExampleUnitTest { + @Test + @Throws(Exception::class) + fun addition_isCorrect() { + assertEquals(4, (2 + 2).toLong()) + } +} \ No newline at end of file diff --git a/app/build.gradle b/app/build.gradle new file mode 100755 index 0000000..7b62b1f --- /dev/null +++ b/app/build.gradle @@ -0,0 +1,27 @@ +// Top-level build file where you can add configuration options common to all sub-projects/modules. + +buildscript { + ext.kotlin_version = '1.2.31' + repositories { + jcenter() + google() + } + dependencies { + classpath 'com.android.tools.build:gradle:3.1.1' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + + // NOTE: Do not place your application dependencies here; they belong + // in the individual module build.gradle files + } +} + +allprojects { + repositories { + jcenter() + google() + } +} + +task clean(type: Delete) { + delete rootProject.buildDir +} diff --git a/app/gradle.properties b/app/gradle.properties new file mode 100644 index 0000000..aac7c9b --- /dev/null +++ b/app/gradle.properties @@ -0,0 +1,17 @@ +# Project-wide Gradle settings. + +# IDE (e.g. Android Studio) users: +# Gradle settings configured through the IDE *will override* +# any settings specified in this file. + +# For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html + +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +org.gradle.jvmargs=-Xmx1536m + +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. More details, visit +# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects +# org.gradle.parallel=true diff --git a/app/gradle/wrapper/gradle-wrapper.jar b/app/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..13372ae Binary files /dev/null and b/app/gradle/wrapper/gradle-wrapper.jar differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/app/gradle/wrapper/gradle-wrapper.properties similarity index 74% rename from android/gradle/wrapper/gradle-wrapper.properties rename to app/gradle/wrapper/gradle-wrapper.properties index 62f495d..21e121d 100644 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ b/app/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,6 @@ +#Fri Apr 20 00:05:40 CST 2018 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.2-bin.zip -networkTimeout=10000 -validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-4.4-all.zip diff --git a/app/gradlew b/app/gradlew new file mode 100755 index 0000000..9d82f78 --- /dev/null +++ b/app/gradlew @@ -0,0 +1,160 @@ +#!/usr/bin/env bash + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS="" + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn ( ) { + echo "$*" +} + +die ( ) { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; +esac + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin, switch paths to Windows format before running java +if $cygwin ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=$((i+1)) + done + case $i in + (0) set -- ;; + (1) set -- "$args0" ;; + (2) set -- "$args0" "$args1" ;; + (3) set -- "$args0" "$args1" "$args2" ;; + (4) set -- "$args0" "$args1" "$args2" "$args3" ;; + (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules +function splitJvmOpts() { + JVM_OPTS=("$@") +} +eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS +JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME" + +exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@" diff --git a/android/gradlew.bat b/app/gradlew.bat similarity index 58% rename from android/gradlew.bat rename to app/gradlew.bat index 93e3f59..aec9973 100644 --- a/android/gradlew.bat +++ b/app/gradlew.bat @@ -1,92 +1,90 @@ -@rem -@rem Copyright 2015 the original author or authors. -@rem -@rem Licensed under the Apache License, Version 2.0 (the "License"); -@rem you may not use this file except in compliance with the License. -@rem You may obtain a copy of the License at -@rem -@rem https://www.apache.org/licenses/LICENSE-2.0 -@rem -@rem Unless required by applicable law or agreed to in writing, software -@rem distributed under the License is distributed on an "AS IS" BASIS, -@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -@rem See the License for the specific language governing permissions and -@rem limitations under the License. -@rem - -@if "%DEBUG%"=="" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -set DIRNAME=%~dp0 -if "%DIRNAME%"=="" set DIRNAME=. -@rem This is normally unused -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Resolve any "." and ".." in APP_HOME to make it shorter. -for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if %ERRORLEVEL% equ 0 goto execute - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto execute - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* - -:end -@rem End local scope for the variables with windows NT shell -if %ERRORLEVEL% equ 0 goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -set EXIT_CODE=%ERRORLEVEL% -if %EXIT_CODE% equ 0 set EXIT_CODE=1 -if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% -exit /b %EXIT_CODE% - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS= + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto init + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto init + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:init +@rem Get command-line arguments, handling Windowz variants + +if not "%OS%" == "Windows_NT" goto win9xME_args +if "%@eval[2+2]" == "4" goto 4NT_args + +:win9xME_args +@rem Slurp the command line arguments. +set CMD_LINE_ARGS= +set _SKIP=2 + +:win9xME_args_slurp +if "x%~1" == "x" goto execute + +set CMD_LINE_ARGS=%* +goto execute + +:4NT_args +@rem Get arguments from the 4NT Shell from JP Software +set CMD_LINE_ARGS=%$ + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/app/settings.gradle b/app/settings.gradle new file mode 100644 index 0000000..e7b4def --- /dev/null +++ b/app/settings.gradle @@ -0,0 +1 @@ +include ':app' diff --git a/modules/LICENSE b/modules/LICENSE new file mode 100644 index 0000000..a700c87 --- /dev/null +++ b/modules/LICENSE @@ -0,0 +1,674 @@ + GNU GENERAL PUBLIC LICENSE + Version 3, 29 June 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU General Public License is a free, copyleft license for +software and other kinds of works. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +the GNU General Public License is intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. We, the Free Software Foundation, use the +GNU General Public License for most of our software; it applies also to +any other work released this way by its authors. You can apply it to +your programs, too. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + To protect your rights, we need to prevent others from denying you +these rights or asking you to surrender the rights. Therefore, you have +certain responsibilities if you distribute copies of the software, or if +you modify it: responsibilities to respect the freedom of others. + + For example, if you distribute copies of such a program, whether +gratis or for a fee, you must pass on to the recipients the same +freedoms that you received. You must make sure that they, too, receive +or can get the source code. And you must show them these terms so they +know their rights. + + Developers that use the GNU GPL protect your rights with two steps: +(1) assert copyright on the software, and (2) offer you this License +giving you legal permission to copy, distribute and/or modify it. + + For the developers' and authors' protection, the GPL clearly explains +that there is no warranty for this free software. For both users' and +authors' sake, the GPL requires that modified versions be marked as +changed, so that their problems will not be attributed erroneously to +authors of previous versions. + + Some devices are designed to deny users access to install or run +modified versions of the software inside them, although the manufacturer +can do so. This is fundamentally incompatible with the aim of +protecting users' freedom to change the software. The systematic +pattern of such abuse occurs in the area of products for individuals to +use, which is precisely where it is most unacceptable. Therefore, we +have designed this version of the GPL to prohibit the practice for those +products. If such problems arise substantially in other domains, we +stand ready to extend this provision to those domains in future versions +of the GPL, as needed to protect the freedom of users. + + Finally, every program is threatened constantly by software patents. +States should not allow patents to restrict development and use of +software on general-purpose computers, but in those that do, we wish to +avoid the special danger that patents applied to a free program could +make it effectively proprietary. To prevent this, the GPL assures that +patents cannot be used to render the program non-free. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Use with the GNU Affero General Public License. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU Affero General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the special requirements of the GNU Affero General Public License, +section 13, concerning interaction through a network will apply to the +combination as such. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU General Public License from time to time. Such new versions will +be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + NodeBase Modules: a project of NodeJS/Express based app on Android + Copyright (C) 2017 Seven Lju + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If the program does terminal interaction, make it output a short +notice like this when it starts in an interactive mode: + + NodeBase Modules Copyright (C) 2017 Seven Lju + This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. + This is free software, and you are welcome to redistribute it + under certain conditions; type `show c' for details. + +The hypothetical commands `show w' and `show c' should show the appropriate +parts of the General Public License. Of course, your program's commands +might be different; for a GUI interface, you would use an "about box". + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU GPL, see +. + + The GNU General Public License does not permit incorporating your program +into proprietary programs. If your program is a subroutine library, you +may consider it more useful to permit linking proprietary applications with +the library. If this is what you want to do, use the GNU Lesser General +Public License instead of this License. But first, please read +. diff --git a/modules/README.md b/modules/README.md new file mode 100644 index 0000000..57704f0 --- /dev/null +++ b/modules/README.md @@ -0,0 +1,18 @@ +# NodeBase Modules + +NodeJS/http based app (no depedency) + +- app manager + +NodeJS/Express based app on Android + +- file download/upload +- werewolf first ngiht helper +- a simple notepad of Nodepad +- Chinese cheese board game helper + +Toy Lab + +- piano +- agricola board game helper (drag-n-drop resources) +- tinychat \ No newline at end of file diff --git a/modules/app_manager/config b/modules/app_manager/config new file mode 100644 index 0000000..869a004 --- /dev/null +++ b/modules/app_manager/config @@ -0,0 +1,2 @@ +name=NodeBase Application Manager +port=20180 diff --git a/modules/app_manager/index.js b/modules/app_manager/index.js new file mode 100644 index 0000000..8618625 --- /dev/null +++ b/modules/app_manager/index.js @@ -0,0 +1,305 @@ +const http = require('http'); +const url = require('url'); +const path = require('path'); +const fs = require('fs'); + +function get_ip (req) { + let ip = null; + if (req.headers['x-forwarded-for']) { + ip = req.headers['x-forwarded-for'].split(",")[0]; + } else if (req.connection && req.connection.remoteAddress) { + ip = req.connection.remoteAddress; + } else { + ip = req.ip; + } + return ip; +} + +function make_directory(dir) { + dir = path.resolve(dir); + let parent_dir = path.dirname(dir); + let state = true; + if (dir !== parent_dir) { + if (!fs.existsSync(parent_dir)) { + state = make_directory(parent_dir); + } else { + if (!fs.lstatSync(parent_dir).isDirectory()) { + state = false; + } + } + if (!state) { + return null; + } + } + if (!fs.existsSync(dir)) { + fs.mkdirSync(dir); + return dir; + } else if (!fs.lstatSync(dir).isDirectory()) { + return null; + } else { + return dir; + } +} + +function process_app_import(req, res, api, appname, appfiles, rename) { + function random_tmp_name() { + return 'tmp-' + Math.random(); + } + function download_file(api, appname, appfiles, index, tmpdir, cb) { + if (index >= appfiles.length) { + cb(); + return; + } + if (!appfiles[index]) { + download_file(api, appname, appfiles, index+1, tmpdir, cb); + return; + } + let filename = appfiles[index]; + let tmpfile = path.join(tmpdir, filename); + let subtmpdir = path.dirname(tmpfile); + make_directory(subtmpdir); + let file = fs.createWriteStream(tmpfile); + let request = http.get(api + '/download/' + appname + '/' + filename, (obj) => { + obj.pipe(file); + download_file(api, appname, appfiles, index+1, tmpdir, cb); + }).on('error', (e) => { + errors.push('failed to download: ' + appfiles[index]); + download_file(api, appname, appfiles, index+1, tmpdir, cb); + }); + } + let errors = []; + let tmpdir = path.join(Storage.work_dir, random_tmp_name()); + let targetdir = path.join(Storage.work_dir, rename); + if (fs.existsSync(targetdir)) { + res.end('app exists: ' + rename); + return; + } + fs.mkdirSync(tmpdir); + download_file(api, appname, appfiles, 0, tmpdir, () => { + if (errors.length > 0) { + Storage.rmtree(tmpdir); + res.end(errors.join('\n')); + return; + } + fs.renameSync(tmpdir, targetdir); + res.end(''); + }); +} + + +function route(req, res) { + let r = url.parse(req.url); + let f = router; + let path = r.pathname.split('/'); + let query = {}; + r.query && r.query.split('&').forEach((one) => { + let key, val; + let i = one.indexOf('='); + if (i < 0) { + key = one; + val = ''; + } else { + key = one.substring(0, i); + val = one.substring(i+1); + } + if (key in query) { + if(Array.isArray(query[key])) { + query[key].push(val); + } else { + query[key] = [query[key], val]; + } + } else { + query[key] = val; + } + }); + path.shift(); + while (path.length > 0) { + let key = path.shift(); + f = f[key]; + if (!f) break; + if (typeof(f) === 'function') { + return f(req, res, { + path: path, + query: query + }); + } + } + router.static(req, res, r.pathname); + // router.code(req, res, 404, 'Not Found'); +} + +const Storage = { + work_dir: path.dirname(__dirname), + list_directories: (dir) => { + return fs.readdirSync(dir).filter((name) => { + let subdir = path.join(dir, name); + let state = fs.lstatSync(subdir); + return state.isDirectory(); + }); + }, + list_files: (dir) => { + let queue = [dir], list = []; + while (queue.length > 0) { + list_dir(queue.shift(), queue, list); + } + return list; + + function list_dir(dir, queue, list) { + fs.readdirSync(dir).forEach((name) => { + let filename = path.join(dir, name); + let state = fs.lstatSync(filename); + if (state.isDirectory()) { + queue.push(filename); + } else { + list.push(filename); + } + }); + } + }, + rmtree: (dir) => { + if (dir.length < Storage.work_dir.length) { + return false; + } + if (dir.indexOf(Storage.work_dir) !== 0) { + return false; + } + if (!fs.existsSync(dir)) { + return false; + } + fs.readdirSync(dir).forEach(function(file, index){ + var curPath = path.join(dir, file); + if (fs.lstatSync(curPath).isDirectory()) { + // recurse + Storage.rmtree(curPath); + } else { // delete file + fs.unlinkSync(curPath); + } + }); + fs.rmdirSync(dir); + return true; + } +}; + +function mime_lookup(filename) { + let extname = path.extname(filename); + switch(extname) { + case '.json': return 'application/json'; + case '.html': return 'text/html'; + case '.js': return 'text/javascript'; + case '.css': return 'text/css'; + default: return 'application/octet-stream' + } +} + + +const router = { + app: { + list: (req, res, options) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + let dir = path.dirname(__dirname); + if (!fs.existsSync(dir)) { + return router.code(req, res, 404, 'Not Found'); + } + let names = Storage.list_directories(Storage.work_dir).filter((name) => { + if (name.startsWith('.')) return false; + if (name === 'node_modules') return false; + return true; + }); + res.end(names.join('\n')); + }, + files: (req, res, options) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + let name = options.path[0]; + if (!name) { + return router.code(req, res, 404, 'Not Found'); + } + let subdir = path.join(Storage.work_dir, name); + if (!fs.existsSync(subdir)) { + return router.code(req, res, 404, 'Not Found'); + } + let files = Storage.list_files(subdir).map((filename) => { + return filename.substring(subdir.length+1); + }); + res.end(files.join('\n')); + }, + download: (req, res, options) => { + res.setHeader('Access-Control-Allow-Origin', '*'); + if (options.path.indexOf('..') >= 0) { + return router.code(req, res, 404, 'Not Found'); + } + let filename = path.join(...options.path); + filename = path.join(Storage.work_dir, filename); + if (!fs.existsSync(filename)) { + return router.code(req, res, 404, 'Not Found'); + } + res.setHeader('Content-Disposition', 'attachment; filename=' + path.basename(filename)); + res.setHeader('Content-Type', 'application/octet-stream'); + // res.setHeader('Content-Type', 'text/plain'); + let buf = fs.readFileSync(filename); + res.end(buf, 'binary'); + }, + delete: (req, res, options) => { + let name = options.path[0]; + if (!name) { + return router.code(req, res, 404, 'Not Found'); + } + let filename = path.join(Storage.work_dir, name); + // !!!! dangerous action + Storage.rmtree(filename); + res.end(''); + }, + import: (req, res, options) => { + // options.path [ip:port, appname, rename] + let host = options.path[0]; + let appname = options.path[1]; + let name = options.path[2] || appname; + let api = 'http://' + host + '/app'; + http.get(api + '/files/' + appname, (obj) => { + if (~~(obj.statusCode/100) !== 2) { + obj.resume(); + return router.code(req, res, 404, 'Not Found'); + } + let raw = ''; + obj.on('data', (chunk) => { raw += chunk; }); + obj.on('end', () => { + process_app_import(req, res, api, appname, raw.split('\n'), name); + }); + }).on('error', (e) => { + return router.code(req, res, 404, 'Not Found'); + }); + } + }, + test: (req, res, options) => { + res.end('hello'); + }, + static: (req, res, filename) => { + if (!filename || filename === '/') { + filename = 'index.html'; + } + filename = filename.split('/'); + if (!filename[0]) filename.shift(); + if (filename.length === 0 || filename.indexOf('..') >= 0) { + return router.code(req, res, 404, 'Not Found'); + } + filename = path.join(__dirname, 'static', ...filename); + if (!fs.existsSync(filename)) { + return router.code(req, res, 404, 'Not Found'); + } + res.setHeader('Content-Type', mime_lookup(filename)); + let buf = fs.readFileSync(filename); + res.end(buf, 'binary'); + }, + code: (req, res, code, text) => { + res.writeHead(code || 404, text || ''); + res.end(); + } +}; + +const server = http.createServer((req, res) => { + route(req, res); +}); + +const instance = server.listen(20180, '0.0.0.0', () => { + console.log(instance.address()); + console.log(`NodeBase Application Manager is listening at 0.0.0.0:20180`); +}); diff --git a/modules/app_manager/readme b/modules/app_manager/readme new file mode 100644 index 0000000..a354ac0 --- /dev/null +++ b/modules/app_manager/readme @@ -0,0 +1,3 @@ +# NodeBase Application Manager +running: 20180 +params: (no params) diff --git a/modules/app_manager/static/index.html b/modules/app_manager/static/index.html new file mode 100644 index 0000000..9841799 --- /dev/null +++ b/modules/app_manager/static/index.html @@ -0,0 +1,265 @@ + + + + + + NodeBase Appliction Manager + + + +
NodeBase Application Manager
+ +
Ensure it is running in a safe Wi-Fi environment; otherwise data can be lost accidently by others or malwares.
+
+
Loading ...
+
+ +
+ +
Local Applications
+ + +
+
Shared Application
+
+ +
+ +
+ +
+ +
+ +
Application List
+
+
+ +
+ +
Application
+
Name name
+ + + + +
+ + + + diff --git a/modules/bg_agricola/config b/modules/bg_agricola/config new file mode 100644 index 0000000..50c3787 --- /dev/null +++ b/modules/bg_agricola/config @@ -0,0 +1,2 @@ +name=Agricola +port=9090 diff --git a/modules/bg_agricola/index.js b/modules/bg_agricola/index.js new file mode 100644 index 0000000..fdda16d --- /dev/null +++ b/modules/bg_agricola/index.js @@ -0,0 +1,64 @@ +const buffer = require('buffer'); +const path = require('path'); +const express = require('express'); +const app = express(); + +const static_dir = path.join(__dirname, 'static'); + +const addr = '0.0.0.0'; +const port = 9090; + +function send_json(res, obj) { + res.setHeader('Content-Type', 'application/json'); + res.send(JSON.stringify(obj)); +} + +function get_ip (req) { + let ip = null; + if (req.headers['x-forwarded-for']) { + ip = req.headers['x-forwarded-for'].split(",")[0]; + } else if (req.connection && req.connection.remoteAddress) { + ip = req.connection.remoteAddress; + } else { + ip = req.ip; + } + return ip; +} + +function body (req, fn) { + let buf = new buffer.Buffer([]), obj; + req.on('data', (data) => { + buf = buffer.Buffer.concat([buf, buffer.Buffer.from(data)]); + }).on('end', () => { + try { + obj = JSON.parse(buf); + } catch (e) { + obj = null; + } + fn && fn(obj); + }); +} + +app.get('/test', (req, res) => { + send_json(res, { ip: get_ip(req), message: 'hello world!' }); +}); + +let objs = [], timestamp = 0; + +app.post('/api/set', (req, res) => { + body(req, (reqbody) => { + objs = reqbody.objs; + timestamp = new Date().getTime() + send_json(res, { ip: get_ip(req), timestamp }); + }); +}); + +app.post('/api/get', (req, res) => { + send_json(res, { ip: get_ip(req), objs, timestamp, now: new Date().getTime()+1 }); +}); + +app.use('/', express.static(static_dir)); + +app.listen(port, addr, () => { + console.log(`Agricola is listening at ${addr}:${port}`); +}); diff --git a/modules/bg_agricola/package.json b/modules/bg_agricola/package.json new file mode 100644 index 0000000..cc8106d --- /dev/null +++ b/modules/bg_agricola/package.json @@ -0,0 +1,24 @@ +{ + "name": "chinese_cheese", + "version": "0.1.0", + "description": "Chinese Cheese app for Android NodeBase", + "main": "index.js", + "dependencies": { + "body-parser": "^1.16.0", + "bootstrap": "^3.3.7", + "express": "^4.14.0" + }, + "devDependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "module", + "nodebase", + "android", + "boardgame", + "chinese cheese" + ], + "author": "Seven Lju", + "license": "MIT" +} diff --git a/modules/bg_agricola/readme b/modules/bg_agricola/readme new file mode 100644 index 0000000..57cb672 --- /dev/null +++ b/modules/bg_agricola/readme @@ -0,0 +1,5 @@ +# Agricola +- Board Game: Agricola +- Agricola Helpler +running: 9090 +params: (no params) diff --git a/modules/bg_agricola/static/brick.png b/modules/bg_agricola/static/brick.png new file mode 100755 index 0000000..778e5e0 Binary files /dev/null and b/modules/bg_agricola/static/brick.png differ diff --git a/modules/bg_agricola/static/brickhouse.png b/modules/bg_agricola/static/brickhouse.png new file mode 100755 index 0000000..d856ba8 Binary files /dev/null and b/modules/bg_agricola/static/brickhouse.png differ diff --git a/modules/bg_agricola/static/cattle.png b/modules/bg_agricola/static/cattle.png new file mode 100755 index 0000000..c5d22b6 Binary files /dev/null and b/modules/bg_agricola/static/cattle.png differ diff --git a/modules/bg_agricola/static/common.js b/modules/bg_agricola/static/common.js new file mode 100644 index 0000000..f338e00 --- /dev/null +++ b/modules/bg_agricola/static/common.js @@ -0,0 +1,116 @@ +'use strict'; + +function dom(id) { + return document.getElementById(id); +} + +function on(elem, event, func) { + elem.addEventListener(event, func, false); + return on; +} + +function $(id){ + var el = 'string' == typeof id + ? document.getElementById(id) + : id; + + el.on = function(event, fn){ + if ('content loaded' == event) { + event = window.attachEvent ? "load" : "DOMContentLoaded"; + } + el.addEventListener + ? el.addEventListener(event, fn, false) + : el.attachEvent("on" + event, fn); + }; + + el.all = function(selector){ + return $(el.querySelectorAll(selector)); + }; + + el.each = function(fn){ + for (var i = 0, len = el.length; i < len; ++i) { + fn($(el[i]), i); + } + }; + + el.getClasses = function(){ + return this.getAttribute('class').split(/\s+/); + }; + + el.addClass = function(name){ + var classes = this.getAttribute('class'); + el.setAttribute('class', classes + ? classes + ' ' + name + : name); + }; + + el.removeClass = function(name){ + var classes = this.getClasses().filter(function(curr){ + return curr != name; + }); + this.setAttribute('class', classes.join(' ')); + }; + + el.prepend = function (child) { + this.insertBefore(child, this.firstChild); + }; + + el.append = function (child) { + this.appendChild(child); + }; + + el.css = function (name, value) { + this.style[name] = value; + } + + el.click = function () { + var event = new MouseEvent('click', { + view: window, + bubbles: false, + cancelable: true + }); + this.dispatchEvent(event); + }; + + return el; +} + +function uriencode(data) { + if (!data) return data; + return '?' + Object.keys(data).map(function (x) { + return (encodeURIComponent(x) + '=' + encodeURIComponent(data[x]))}).join('&'); +} + +function ajax (options, done_fn, fail_fn) { + var xhr = new XMLHttpRequest(); + xhr.open(options.method || 'POST', options.url + (options.data?uriencode(options.data):'')); + //xhr.onreadystatechange = function (evt) { + xhr.addEventListener('readystatechange', function (evt) { + if (evt.target.readyState === 4 /*XMLHttpRequest.DONE*/) { + if (~~(evt.target.status/100) === 2) { + done_fn && done_fn(JSON.parse(evt.target.response || 'null')); + } else { + fail_fn && fail_fn(evt.target.status); + } + } + }); + if (options.json) { + xhr.send(JSON.stringify(options.json)); + } else { + xhr.send(); + } +} + +function green_border(element) { + element.style.border = '1px solid green'; +} +function red_border(element) { + element.style.border = '1px solid red'; +} + +function clear_element(element) { + while (element.hasChildNodes()) { + element.removeChild(element.lastChild); + } +} + diff --git a/modules/bg_agricola/static/field.png b/modules/bg_agricola/static/field.png new file mode 100755 index 0000000..480d572 Binary files /dev/null and b/modules/bg_agricola/static/field.png differ diff --git a/modules/bg_agricola/static/food.png b/modules/bg_agricola/static/food.png new file mode 100755 index 0000000..7d2b712 Binary files /dev/null and b/modules/bg_agricola/static/food.png differ diff --git a/modules/bg_agricola/static/index.html b/modules/bg_agricola/static/index.html new file mode 100644 index 0000000..853bbb6 --- /dev/null +++ b/modules/bg_agricola/static/index.html @@ -0,0 +1,275 @@ + + + +Agricola + + + + + + + + + + + diff --git a/modules/bg_agricola/static/majiu.png b/modules/bg_agricola/static/majiu.png new file mode 100755 index 0000000..3d53584 Binary files /dev/null and b/modules/bg_agricola/static/majiu.png differ diff --git a/modules/bg_agricola/static/panel.jpg b/modules/bg_agricola/static/panel.jpg new file mode 100755 index 0000000..84e23e7 Binary files /dev/null and b/modules/bg_agricola/static/panel.jpg differ diff --git a/modules/bg_agricola/static/paper.js b/modules/bg_agricola/static/paper.js new file mode 100644 index 0000000..0e7fcc8 --- /dev/null +++ b/modules/bg_agricola/static/paper.js @@ -0,0 +1,192 @@ +'use strict'; + +function Box (x, y, w, h) { + this.rect = [0, 0]; // w, h : width, height + this.viewport = [0, 0, 0, 0]; // x, y, w, h + this.objs = []; // [c/r:circle/rect, x, y, scale, d/w, d/h, lv, data] + this.as_obj = ['r', 0, 0, 1, 1, 1, 0, this]; + this.timestamp = 0; + + this.draw_fn = null; + + this.set_viewport(x, y, w, h); +} + +Box.prototype = { + set_viewport: function (x, y, w, h) { + this.rect[0] = w || 1; + this.rect[1] = h || 1; + this.viewport[0] = 0; + this.viewport[1] = 0; + this.viewport[2] = this.rect[0]; + this.viewport[3] = this.rect[1]; + this.as_obj[1] = x; + this.as_obj[2] = y; + this.as_obj[4] = this.rect[0]; + this.as_obj[5] = this.rect[1]; + }, + translate: function (dx, dy) { + this.viewport[0] += dx; + this.viewport[1] += dy; + }, + sort_by_lv: function () { + var i,j,k,max; + for (i=this.objs.length-1; i>=1; i--) { + max = this.objs[i][6]; + k = i; + for (j=i-1; j>=0; j--) { + if (this.objs[j][6]>max) { + max = this.objs[j][6]; + k = j; + } + } + if (i === k) continue; + j = this.objs[i]; + this.objs[i] = this.objs[k]; + this.objs[k] = j; + } + }, + paint: function (pen, x, y, clear) { + x = x || 0; + y = y || 0; + pen.save(); + pen.translate(x, y); + pen.beginPath(); + pen.moveTo(0, 0); + pen.lineTo(this.viewport[2], 0); + pen.lineTo(this.viewport[2], this.viewport[3]); + pen.lineTo(0, this.viewport[3]); + pen.lineTo(0, 0); + pen.clip(); + if (clear) { + pen.clearRect(0, 0, this.viewport[2], this.viewport[3]); + } + pen.translate(-this.viewport[0], -this.viewport[1]); + /*var objs = this.cross_all( + ['r', this.viewport[0], this.viewport[1], 1, this.viewport[2], this.viewport[3]] + );*/ + var objs = this.all(); + for (var i=objs.length-1; i>=0; i--) { + this.draw_fn && this.draw_fn(pen, this.objs[objs[i]]); + } + pen.restore(); + }, + _hit_c: function (x, y, obj) { + var r = obj[4]/2, dx = obj[1]+r-x, dy = obj[2]+r-y; + if (dx*dx+dy*dy<=r*r) return true; + return false; + }, + _hit_r: function (x, y, obj) { + if (x>=obj[1] && x<=obj[1]+obj[4] && y>=obj[2] && y<=obj[2]+obj[5]) + return true; + return false; + }, + _hit: function (x, y, obj) { + switch (obj[0]) { + case 'c': + return this._hit_c(x, y, obj); + case 'r': + return this._hit_r(x, y, obj); + } + return false; + }, + hit: function (x, y, special_cond_fn) { + for(var i=this.objs.length-1; i>=0; i--) { + if (this._hit(x, y, this.objs[i])) { + if (special_cond_fn && !special_cond_fn(this.objs[i], i)) { + continue; + } + return i; + } + } + return -1; + }, + hit_all: function (x, y, special_cond_fn) { + var r = []; + for(var i=this.objs.length-1; i>=0; i--) { + if (this._hit(x, y, this.objs[i])) { + if (special_cond_fn && !special_cond_fn(this.objs[i], i)) { + continue; + } + r.push(i); + } + } + return r; + }, + _cross_c_c: function (obj1, obj2) { + var r1 = obj1[4]/2, r2 = obj2[4]/2, + dx = obj1[1]-obj2[1]+r1-r2, + dy = obj1[2]-obj2[2]+r1-r2; + if (dx*dx+dy*dy<=(r1+r2)*(r1+r2)) return true; + return false; + }, + _cross_r_r: function (obj1, obj2) { + if ( + obj1[1]<=obj2[1]+obj2[4] && + obj1[1]+obj1[4]>=obj2[1] && + obj1[2]<=obj2[2]+obj2[5] && + obj1[2]+obj2[5]>=obj2[2] + ) { + return true; + } + return false; + }, + _cross_r_c: function (obj_c, obj_r) { + var v = [obj_c[1]-obj_r[1], obj_c[2]-obj_r[2]], + r = obj_c[4]/2; + if (v[0]<0) v[0] = -v[0]; + if (v[1]<0) v[1] = -v[1]; + v[0] -= obj_r[4]; + v[1] -= obj_r[5]; + if (v[0]<0) v[0] = 0; + if (v[1]<0) v[1] = 0; + if (v[0]*v[0]+v[1]*v[1]=0; i--) { + if (this._cross(obj, this.objs[i])) { + if (special_cond_fn && !special_cond_fn(this.objs[i], i)) { + continue; + } + return i; + } + } + return -1; + }, + cross_all: function (obj, special_cond_fn) { + var r = []; + for(var i=this.objs.length-1; i>=0; i--) { + if (this._cross(obj, this.objs[i])) { + if (special_cond_fn && !special_cond_fn(this.objs[i], i)) { + continue; + } + r.push(i); + } + } + return r; + }, + all: function () { + var r = []; + for(var i=this.objs.length-1; i>=0; i--) { + r.push(i); + } + return r; + } +}; diff --git a/modules/bg_agricola/static/pence_hon.png b/modules/bg_agricola/static/pence_hon.png new file mode 100755 index 0000000..f6ba2a8 Binary files /dev/null and b/modules/bg_agricola/static/pence_hon.png differ diff --git a/modules/bg_agricola/static/pence_vec.png b/modules/bg_agricola/static/pence_vec.png new file mode 100755 index 0000000..e5dfa8b Binary files /dev/null and b/modules/bg_agricola/static/pence_vec.png differ diff --git a/modules/bg_agricola/static/petal-interactions.js b/modules/bg_agricola/static/petal-interactions.js new file mode 100644 index 0000000..710c415 --- /dev/null +++ b/modules/bg_agricola/static/petal-interactions.js @@ -0,0 +1,578 @@ +/* + * @auther: Seven Lju + * @date: 2014-12-22 + * PetalInteraction + * callback : mouse event callback, e.g. + * {mousemove: function (target, positions, extra) { ... }} + * mosueevent = click, dblclick, comboclick, + * mousemove, mousedown, mouseup, + * mousehold, mouseframe, mousegesture + * positions = [x, y] or [x, y, t] or [[x1, y1], [x2, y2], ...] or + * [[x1, y1, t1], [x2, y2, t2], ...] or + * [[[x1, y1, t1], [x2, y2, t2], ...], + * [[x1, y1, t1], ...], ...] + * + * PetalMobileInteraction + * callback: pinch event, e.g. + * {touchpinch: function (target, positions) { ... }} + * positions = [[[xStart, yStart], [xCurrent, yCurrent]], ...] + */ +function PetalInteraction(callback) { + + var config = { + axis: { + x: 'clientX', + y: 'clientY' + }, + hold: { /* hold cursor in a place for some time */ + enable: true, + last: 1000 /* ms */, + tolerance: 5 /* px */ + }, + combo: { /* click for N times (N > 2) */ + enable: true, + holdable: false /* hold for seconds and combo */, + timingable: false /* count time for combo interval */, + tolerance: -1 /* px, combo click (x,y) diff */, + timeout: 200 /* ms, timeout to reset counting */ + }, + frame: { /* drag-n-drop to select an area of frame (rectangle) */ + enable: true, + minArea: 50 /* px^2, the minimum area that a frame will be */, + moving: false /* true: monitor mouse move, + if (x,y) changes then triggered; + false: + if mouse up by distance then triggered*/ + }, + gesture: { /* experimental feature, customized gesture recorder + FIXME: it is a little slow on mobile device */ + enable: false, + mode: 'relative' /* absolute / relative */, + timeout: 500 /* ms, timeout to complete gesture */, + absolute: { + resolution: 20 /* px, split into N*N boxes */ + }, + relative: { + resolution: 20 /* px, mark new point over N px */ + } + } + }; + + var target = null; + var state = {}, lock = {}; + interaction_init(); + if (!callback) callback = {}; + + function interaction_init() { + state.hold = null; + state.combo = null; + state.gesture = null; + state.frame = null; + + if (lock.checkHold !== null) clearTimeout(lock.checkHold); + if (lock.checkCombo !== null) clearTimeout(lock.checkCombo); + lock.holdBeatCombo = false; + lock.mouseDown = false; + lock.checkHold = null; + lock.checkCombo = null; + lock.checkGesture = null; + } + + function clone_mouse_event(e) { + var result = { + type: e.type, + altKey: e.altKey, + ctrlKey: e.ctrlKey, + shiftKey: e.shiftKey, + button: e.which || e.button + }; + result[config.axis.x] = e[config.axis.x]; + result[config.axis.y] = e[config.axis.y]; + return result; + } + + function check_distance(x0, y0, x, y, d) { + if (d < 0) return false; + var dx = x - x0, dy = y - y0; + return Math.sqrt(dx*dx+dy*dy) > d; + } + + function check_combo() { + switch (state.combo.count) { + case 0: // move out and then in + break; + case 1: // click + if (callback.click) callback.click(target, state.combo.positons[0]); + break; + case 2: // double click + if (callback.dblclick) callback.dblclick(target, state.combo.positions); + break; + default: // combo click + if (callback.comboclick) callback.comboclick(target, state.combo.positions); + } + lock.holdBeatCombo = false; + lock.checkCombo = null; + state.combo = null; + } + + function check_hold() { + if (lock.mouseDown) { + if (callback.mousehold) callback.mousehold(target, [state.hold.x, state.hold.y]); + if (!config.combo.holdable) lock.holdBeatCombo = true; + } + lock.checkHold = null; + state.hold = null; + } + + function check_gesture() { + var fullpath = state.gesture.fullpath; + if (fullpath.length) { + if (fullpath.length > 1 || fullpath[0].length > 1) { + if (callback.mousegesture) callback.mousegesture(target, fullpath); + } + } + fullpath = null; + lock.checkGesture = null; + state.gesture = null; + } + + function do_combo_down(x, y) { + if (!lock.mouseDown) return; + if (lock.checkCombo !== null) clearTimeout(lock.checkCombo); + if (!state.combo) state.combo = {count: 0, positions: []}; + state.combo.x = x; + state.combo.y = y; + state.combo.timestamp = new Date().getTime(); + state.combo.count ++; + } + + function do_combo_up(x, y) { + if (!state.combo) return; + var pos = [x, y]; + if (config.combo.timingable) { + pos[2] = new Date().getTime() - state.combo.timestamp; + } + state.combo.positions.push(pos); + if (config.combo.tolerance >= 0) { + if (check_distance(state.combo.x, state.combo.y, + x, y, config.combo.tolerance)) { + // mouse moved + check_combo(); + return; + } + } + if (!config.combo.holdable) { + if (lock.holdBeatCombo) { + // mouse hold + check_combo(); + return; + } + } + lock.checkCombo = setTimeout(check_combo, config.combo.timeout); + } + + function do_hold(x, y, checkMoved) { + // if mouse not down, skip + if (!lock.mouseDown) return; + if (checkMoved !== true) checkMoved = false; + if (checkMoved && state.hold) { + // if move a distance and stop to hold + if (!check_distance(state.hold.x, state.hold.y, + x, y, config.hold.tolerance)) { + return; + } + } + // recount time + if (lock.checkHold !== null) clearTimeout(lock.checkHold); + if (!state.hold) state.hold = {}; + state.hold.x = x; + state.hold.y = y; + lock.checkHold = setTimeout(check_hold, config.hold.last); + } + + function do_frame_down(x, y) { + if (!state.frame) state.frame = {}; + state.frame.start = true; + state.frame.x = x; + state.frame.y = y; + } + + function do_frame(x, y) { + if (!state.frame) return + if (!state.frame.start) return; + var dx, dy; + dx = x - state.frame.x; + dy = y - state.frame.y; + if (Math.abs(dx*dy) >= config.frame.minArea) { + if (callback.mouseframe) { + callback.mouseframe( + target, + [ [state.frame.x, state.frame.y], [x, y] ] + ); + } + } + } + + function do_frame_up(x, y) { + do_frame(x, y); + state.frame = null; + } + + function do_gesture_start(x, y) { + if (lock.checkGesture !== null) clearTimeout(lock.checkGesture); + if (!state.gesture) state.gesture = {}; + if (!state.gesture.fullpath) state.gesture.fullpath = []; + state.gesture.timestamp = new Date().getTime(); + state.gesture.positions = []; + state.gesture.x = x; + state.gesture.y = y; + } + + var _act_gesture_move_map_mode = { + absolute: do_gesture_absolute, + relative: do_gesture_relative + }; + function do_gesture_move(x, y) { + if (!lock.mouseDown) return; + var point = _act_gesture_move_map_mode[config.gesture.mode](x, y); + var timestamp = new Date().getTime(); + if (!point) return; + state.gesture.positions.push([ + point[0], point[1], + timestamp - state.gesture.timestamp + ]); + state.gesture.x = point[0]; + state.gesture.y = point[1]; + state.gesture.timestamp = timestamp; + point = null; + } + + function do_gesture_absolute(x, y) { + var resolution = config.gesture.absolute.resolution; + if (resolution < 1) resolution = 1; + if (Math.floor(x / resolution) === Math.floor(state.x /resolution) && + Math.floor(y / resolution) === Math.floor(state.y /resolution)) { + return null; + } + return [x, y]; + } + + function do_gesture_relative(x, y) { + if (!check_distance(state.gesture.x, state.gesture.y, x, y, + config.gesture.relative.resolution)) return null; + return [x, y]; + } + + function do_gesture_break(x, y) { + var timestamp = new Date().getTime(); + state.gesture.positions.push([ + state.gesture.x, state.gesture.y, + timestamp - state.gesture.timestamp + ]); + state.gesture.fullpath.push(state.gesture.positions); + state.gesture.positions = []; + state.gesture.timestamp = timestamp; + lock.checkGesture = setTimeout(check_gesture, config.gesture.timeout); + } + + function event_mousedown(e) { + var x = e[config.axis.x], y = e[config.axis.y]; + lock.mouseDown = true; + if (config.hold.enable) do_hold(x, y, false); + if (config.combo.enable) do_combo_down(x, y); + if (config.frame.enable) do_frame_down(x, y); + if (config.gesture.enable) do_gesture_start(x, y); + if (callback.mousedown) callback.mousedown(target, [x, y], clone_mouse_event(e)); + } + + function event_mousemove(e) { + var x = e[config.axis.x], y = e[config.axis.y]; + if (config.hold.enable) do_hold(x, y, true); + if (config.frame.enable && config.frame.moving) do_frame(x, y); + if (config.gesture.enable) do_gesture_move(x, y); + if (callback.mousemove) callback.mousemove(target, [x, y], clone_mouse_event(e)); + } + + function event_mouseup(e) { + var x = e[config.axis.x], y = e[config.axis.y]; + lock.mouseDown = false; + if (config.combo.enable) do_combo_up(x, y); + if (config.frame.enable) do_frame_up(x, y); + if (config.gesture.enable) do_gesture_break(x, y); + state.hold = null; + if (callback.mouseup) callback.mouseup(target, [x, y], clone_mouse_event(e)); + } + + function event_mouseout(e) { + var x = e[config.axis.x], y = e[config.axis.y]; + if (lock.mouseDown) { + if (lock.checkHold !== null) clearTimeout(lock.checkHold); + if (lock.checkCombo !== null) clearTimeout(lock.checkCombo); + if (config.combo.enable && state.combo) { + check_combo(); + } + state.hold = null; + state.combo = null; + lock.mouseDown = false; + } + if (callback.mouseout) callback.mouseout(target, [x, y], clone_mouse_event(e)); + } + + function event_mouseenter(e) { + var x = e[config.axis.x], y = e[config.axis.y]; + var button = e.which || e.button; + if (button > 0) { + lock.mouseDown = true; + if (config.hold.enable) do_hold(x, y, false); + if (config.combo.enable) state.combo = {count: 0, positions: []}; + } + if (callback.mouseenter) callback.mouseenter(target, [x, y], clone_mouse_event(e)); + } + + return { + config: function () { + return config; + }, + bind: function (element) { + target = element; + interaction_init(); + element.addEventListener('mousedown', event_mousedown); + element.addEventListener('mousemove', event_mousemove); + element.addEventListener('mouseup', event_mouseup); + element.addEventListener('mouseout', event_mouseout); + element.addEventListener('mouseenter', event_mouseenter); + }, + unbind: function () { + target.removeEventListener('mousedown', event_mousedown); + target.removeEventListener('mousemove', event_mousemove); + target.removeEventListener('mouseup', event_mouseup); + target.removeEventListener('mouseout', event_mouseout); + target.removeEventListener('mouseenter', event_mouseenter); + interaction_init(); + target = null; + } + }; +} + +function PetalMobileInteraction(callback) { + + var config = { + axis: { + x: 'clientX', + y: 'clientY' + }, + pinch: { /* experimental feature, support fingers pinch + FIXME: if touch pinch triggered, mouse down + and mouse up will triggered too*/ + enable: false, + moving: false, /* monitor finger moving on display */ + tolerance: 10 /* px, finger moving > N px, event triggered */ + }, + click: { + enable: true, + timeout: 150, + click: true, + dblclick: true + } + }; + + if (!callback) callback = {}; + var target = null; + var state = {}; + + function check_distance(x0, y0, x, y, d) { + if (d < 0) return false; + var dx = x - x0, dy = y - y0; + return Math.sqrt(dx*dx+dy*dy) > d; + } + + function check_pinch() { + var points = state.pinch.points; + var doit = false; + for(var i = 0, n = points.length; i < n; i++) { + if (check_distance(points[i][0][0], points[i][0][1], + points[i][1][0], points[i][1][1], + config.pinch.tolerance)) { + doit = true; + break; + } + } + if (doit) { + callback.touchpinch(target, state.pinch.points); + } + points = null; + } + + function do_pinch_down(e) { + if (!state.pinch) state.pinch = {points: null}; + } + + function do_pinch(e) { + var touches = e.changedTouches; + var points; + var newpinch = false; + if (!state.pinch.points) { + newpinch = true; + } else if (state.pinch.points.length < touches.length) { + // 2 fingers to 3 and more ... + newpinch = true; + } + if (newpinch) { + var i, n, x, y; + points = []; + for (i = 0, n = touches.length; i < n; i++) { + x = touches[i][config.axis.x]; + y = touches[i][config.axis.y]; + points.push([ [x, y], [x, y] ]); + } + state.pinch.points = points; + } else { + points = state.pinch.points + var i, n, x, y; + for (i = 0, n = points.length; i < n; i++) { + x = touches[i][config.axis.x]; + y = touches[i][config.axis.y]; + points[i][1][0] = x; + points[i][1][1] = y; + } + } + points = null; + } + + function do_pinch_up(e) { + if (state.pinch.points) { + check_pinch(); + state.pinch = null; + } + } + + function do_click_down(e) { + if (!state.click) { + state.click = { + count: 0, + once: true, + checkClick: null + }; + } + state.click.once = true; + if (state.click.checkClick !== null) clearTimeout(state.click.checkClick); + state.click.checkClick = setTimeout(check_mob_click, config.click.timeout); + } + + function do_click_up(e) { + if (!state.click) return; + if (!state.click.once) return; + var touch = e.changedTouches[0]; + state.click.count ++; + state.click.touch = { + screenX: touch.screenX, + screenY: touch.screenY, + clientX: touch.clientX, + clientY: touch.clientY, + ctrlKey: e.ctrlKey, + altKey: e.altKey, + shiftKey: e.shfitKey, + metaKey: e.metaKey + }; + if (state.click.checkClick !== null) clearTimeout(state.click.checkClick); + state.click.checkClick = setTimeout(check_mob_click, config.click.timeout); + } + + function check_mob_click() { + if (!state.click) return; + if (state.click.count === 0) { + } else if (state.click.count === 1 && config.click.click) { + fire_event('click', state.click.touch); + } else if (state.click.count === 2 && config.click.dblclick) { + fire_event('dblclick', state.click.touch); + } else { + // XXX: combo click + } + state.click.checkClick = null; + state.click.count = 0; + state.click.once = false; + state.click.touch = null; + } + + function fire_event(type, mouse_attr) { + // mouseAttr = {screenX, screenY, clientX, clientY, ctrlKey, altKey, shiftKey, metaKey} + var f = document.createEvent("MouseEvents"); + f.initMouseEvent(type, true, true, + target.ownerDocument.defaultView, 0, + mouse_attr.screenX, mouse_attr.screenY, + mouse_attr.clientX, mouse_attr.clientY, + mouse_attr.ctrlKey, mouse_attr.altKey, + mouse_attr.shiftKey, mouse_attr.metaKey, + 0, null); + target.dispatchEvent(f); + } + + var _event_touch_map_mouse = { + touchstart: 'mousedown', + touchmove: 'mousemove', + touchend: 'mouseup' + }; + function event_touch_to_mouse(e) { + e.preventDefault(); + var touches = e.changedTouches; + if (config.pinch.enable) { + if (callback.touchpinch) { + switch(e.type) { + case 'touchmove': + if (touches.length <= 1) break; + if (!config.pinch.enable) return; + if (state.pinch.count <= 1) return; + do_pinch(e); + if (!config.pinch.moving) return; + check_pinch(); + return; + case 'touchstart': + do_pinch_down(e); + break; + case 'touchend': + do_pinch_up(e); + // next, be aware of touchend => mouseup + break; + } + } + } + if (config.click.enable) { + switch(e.type) { + case 'touchstart': + do_click_down(e); + break; + case 'touchend': + do_click_up(e); + break; + } + } + fire_event(_event_touch_map_mouse[e.type], { + screenX: touches[0].screenX, + screenY: touches[0].screenY, + clientX: touches[0].clientX, + clientY: touches[0].clientY, + ctrlKey: e.ctrlKey, + altKey: e.altKey, + shiftKey: e.shfitKey, + metaKey: e.metaKey + }); + } + + return { + config: function() { + return config; + }, + bind: function (element) { + target = element; + element.addEventListener('touchstart', event_touch_to_mouse); + element.addEventListener('touchmove', event_touch_to_mouse); + element.addEventListener('touchend', event_touch_to_mouse); + }, + unbind: function () { + target.removeEventListener('touchstart', event_touch_to_mouse); + target.removeEventListener('touchmove', event_touch_to_mouse); + target.removeEventListener('touchend', event_touch_to_mouse); + target = null; + } + }; +} diff --git a/modules/bg_agricola/static/pig.png b/modules/bg_agricola/static/pig.png new file mode 100755 index 0000000..67efa53 Binary files /dev/null and b/modules/bg_agricola/static/pig.png differ diff --git a/modules/bg_agricola/static/sheep.png b/modules/bg_agricola/static/sheep.png new file mode 100755 index 0000000..3cd1489 Binary files /dev/null and b/modules/bg_agricola/static/sheep.png differ diff --git a/modules/bg_agricola/static/stone.png b/modules/bg_agricola/static/stone.png new file mode 100755 index 0000000..a2ed825 Binary files /dev/null and b/modules/bg_agricola/static/stone.png differ diff --git a/modules/bg_agricola/static/stonehouse.png b/modules/bg_agricola/static/stonehouse.png new file mode 100755 index 0000000..dd3430a Binary files /dev/null and b/modules/bg_agricola/static/stonehouse.png differ diff --git a/modules/bg_agricola/static/vege.png b/modules/bg_agricola/static/vege.png new file mode 100755 index 0000000..cff327c Binary files /dev/null and b/modules/bg_agricola/static/vege.png differ diff --git a/modules/bg_agricola/static/watergrass.png b/modules/bg_agricola/static/watergrass.png new file mode 100755 index 0000000..c99698d Binary files /dev/null and b/modules/bg_agricola/static/watergrass.png differ diff --git a/modules/bg_agricola/static/wheat.png b/modules/bg_agricola/static/wheat.png new file mode 100755 index 0000000..5a7e329 Binary files /dev/null and b/modules/bg_agricola/static/wheat.png differ diff --git a/modules/bg_agricola/static/wood.png b/modules/bg_agricola/static/wood.png new file mode 100755 index 0000000..d79e8f9 Binary files /dev/null and b/modules/bg_agricola/static/wood.png differ diff --git a/modules/bg_agricola/static/woodhouse.png b/modules/bg_agricola/static/woodhouse.png new file mode 100755 index 0000000..4290a03 Binary files /dev/null and b/modules/bg_agricola/static/woodhouse.png differ diff --git a/modules/bg_agricola/static/x4.png b/modules/bg_agricola/static/x4.png new file mode 100755 index 0000000..5fed12d Binary files /dev/null and b/modules/bg_agricola/static/x4.png differ diff --git a/modules/bg_chinese_cheese/config b/modules/bg_chinese_cheese/config new file mode 100644 index 0000000..5dc07a2 --- /dev/null +++ b/modules/bg_chinese_cheese/config @@ -0,0 +1,2 @@ +name=ChineseCheese +port=9090 diff --git a/modules/bg_chinese_cheese/index.js b/modules/bg_chinese_cheese/index.js new file mode 100644 index 0000000..28e2a71 --- /dev/null +++ b/modules/bg_chinese_cheese/index.js @@ -0,0 +1,64 @@ +const buffer = require('buffer'); +const path = require('path'); +const express = require('express'); +const app = express(); + +const static_dir = path.join(__dirname, 'static'); + +const addr = '0.0.0.0'; +const port = 9090; + +function send_json(res, obj) { + res.setHeader('Content-Type', 'application/json'); + res.send(JSON.stringify(obj)); +} + +function get_ip (req) { + let ip = null; + if (req.headers['x-forwarded-for']) { + ip = req.headers['x-forwarded-for'].split(",")[0]; + } else if (req.connection && req.connection.remoteAddress) { + ip = req.connection.remoteAddress; + } else { + ip = req.ip; + } + return ip; +} + +function body (req, fn) { + let buf = new buffer.Buffer([]), obj; + req.on('data', (data) => { + buf = buffer.Buffer.concat([buf, buffer.Buffer.from(data)]); + }).on('end', () => { + try { + obj = JSON.parse(buf); + } catch (e) { + obj = null; + } + fn && fn(obj); + }); +} + +app.get('/test', (req, res) => { + send_json(res, { ip: get_ip(req), message: 'hello world!' }); +}); + +let objs = [], timestamp = 0; + +app.post('/api/set', (req, res) => { + body(req, (reqbody) => { + objs = reqbody.objs; + timestamp = new Date().getTime(); + send_json(res, { ip: get_ip(req), timestamp }); + }); +}); + +app.post('/api/get', (req, res) => { + send_json(res, { ip: get_ip(req), objs, timestamp, now: new Date().getTime()+1 }); +}); + +app.use('/', express.static(static_dir)); + +app.listen(port, addr, () => { + console.log(`Chinese Cheese is listening at ${addr}:${port}`); +}); diff --git a/modules/bg_chinese_cheese/local_version/config b/modules/bg_chinese_cheese/local_version/config new file mode 100644 index 0000000..5dc07a2 --- /dev/null +++ b/modules/bg_chinese_cheese/local_version/config @@ -0,0 +1,2 @@ +name=ChineseCheese +port=9090 diff --git a/modules/bg_chinese_cheese/local_version/index.js b/modules/bg_chinese_cheese/local_version/index.js new file mode 100644 index 0000000..2c1de95 --- /dev/null +++ b/modules/bg_chinese_cheese/local_version/index.js @@ -0,0 +1,64 @@ +const buffer = require('buffer'); +const path = require('path'); +const express = require('express'); +const app = express(); + +const static_dir = path.join(__dirname, 'static'); + +const addr = '0.0.0.0'; +const port = 9090; + +function send_json(res, obj) { + res.setHeader('Content-Type', 'application/json'); + res.send(JSON.stringify(obj)); +} + +function get_ip (req) { + let ip = null; + if (req.headers['x-forwarded-for']) { + ip = req.headers['x-forwarded-for'].split(",")[0]; + } else if (req.connection && req.connection.remoteAddress) { + ip = req.connection.remoteAddress; + } else { + ip = req.ip; + } + return ip; +} + +function body (req, fn) { + let buf = new buffer.Buffer([]), obj; + req.on('data', (data) => { + buf = buffer.Buffer.concat([buf, buffer.Buffer.from(data)]); + }).on('end', () => { + try { + obj = JSON.parse(buf); + } catch (e) { + obj = null; + } + fn && fn(obj); + }); +} + +app.get('/test', (req, res) => { + send_json(res, { ip: get_ip(req), message: 'hello world!' }); +}); + +let objs = [], timestamp = 0; + +app.post('/api/set', (req, res) => { + body(req, (reqbody) => { + objs = reqbody.objs; + timestamp = new Date().getTime() + send_json(res, { ip: get_ip(req), timestamp }); + }); +}); + +app.post('/api/get', (req, res) => { + send_json(res, { ip: get_ip(req), objs, timestamp, now: new Date().getTime()+1 }); +}); + +app.use('/', express.static(static_dir)); + +app.listen(port, addr, () => { + console.log(`Chinese Cheese is listening at ${addr}:${port}`); +}); diff --git a/modules/bg_chinese_cheese/local_version/package.json b/modules/bg_chinese_cheese/local_version/package.json new file mode 100644 index 0000000..cc8106d --- /dev/null +++ b/modules/bg_chinese_cheese/local_version/package.json @@ -0,0 +1,24 @@ +{ + "name": "chinese_cheese", + "version": "0.1.0", + "description": "Chinese Cheese app for Android NodeBase", + "main": "index.js", + "dependencies": { + "body-parser": "^1.16.0", + "bootstrap": "^3.3.7", + "express": "^4.14.0" + }, + "devDependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "module", + "nodebase", + "android", + "boardgame", + "chinese cheese" + ], + "author": "Seven Lju", + "license": "MIT" +} diff --git a/modules/bg_chinese_cheese/local_version/readme b/modules/bg_chinese_cheese/local_version/readme new file mode 100644 index 0000000..401ef85 --- /dev/null +++ b/modules/bg_chinese_cheese/local_version/readme @@ -0,0 +1,6 @@ +# Chinese Cheese +- Board Game: Chinese Cheese +- Replace real objects to play chinese cheese +- Local Only (with multiple windows in one canvas) +running: 9090 +params: (no params) diff --git a/modules/bg_chinese_cheese/local_version/static/b_jiang.png b/modules/bg_chinese_cheese/local_version/static/b_jiang.png new file mode 100644 index 0000000..09d78e6 Binary files /dev/null and b/modules/bg_chinese_cheese/local_version/static/b_jiang.png differ diff --git a/modules/bg_chinese_cheese/local_version/static/b_ju.png b/modules/bg_chinese_cheese/local_version/static/b_ju.png new file mode 100644 index 0000000..d8862de Binary files /dev/null and b/modules/bg_chinese_cheese/local_version/static/b_ju.png differ diff --git a/modules/bg_chinese_cheese/local_version/static/b_ma.png b/modules/bg_chinese_cheese/local_version/static/b_ma.png new file mode 100644 index 0000000..45ad1de Binary files /dev/null and b/modules/bg_chinese_cheese/local_version/static/b_ma.png differ diff --git a/modules/bg_chinese_cheese/local_version/static/b_pao.png b/modules/bg_chinese_cheese/local_version/static/b_pao.png new file mode 100644 index 0000000..faf070e Binary files /dev/null and b/modules/bg_chinese_cheese/local_version/static/b_pao.png differ diff --git a/modules/bg_chinese_cheese/local_version/static/b_shi.png b/modules/bg_chinese_cheese/local_version/static/b_shi.png new file mode 100644 index 0000000..4b70b10 Binary files /dev/null and b/modules/bg_chinese_cheese/local_version/static/b_shi.png differ diff --git a/modules/bg_chinese_cheese/local_version/static/b_xiang.png b/modules/bg_chinese_cheese/local_version/static/b_xiang.png new file mode 100644 index 0000000..08e26df Binary files /dev/null and b/modules/bg_chinese_cheese/local_version/static/b_xiang.png differ diff --git a/modules/bg_chinese_cheese/local_version/static/b_zu.png b/modules/bg_chinese_cheese/local_version/static/b_zu.png new file mode 100644 index 0000000..b49f865 Binary files /dev/null and b/modules/bg_chinese_cheese/local_version/static/b_zu.png differ diff --git a/modules/bg_chinese_cheese/local_version/static/common.js b/modules/bg_chinese_cheese/local_version/static/common.js new file mode 100644 index 0000000..f338e00 --- /dev/null +++ b/modules/bg_chinese_cheese/local_version/static/common.js @@ -0,0 +1,116 @@ +'use strict'; + +function dom(id) { + return document.getElementById(id); +} + +function on(elem, event, func) { + elem.addEventListener(event, func, false); + return on; +} + +function $(id){ + var el = 'string' == typeof id + ? document.getElementById(id) + : id; + + el.on = function(event, fn){ + if ('content loaded' == event) { + event = window.attachEvent ? "load" : "DOMContentLoaded"; + } + el.addEventListener + ? el.addEventListener(event, fn, false) + : el.attachEvent("on" + event, fn); + }; + + el.all = function(selector){ + return $(el.querySelectorAll(selector)); + }; + + el.each = function(fn){ + for (var i = 0, len = el.length; i < len; ++i) { + fn($(el[i]), i); + } + }; + + el.getClasses = function(){ + return this.getAttribute('class').split(/\s+/); + }; + + el.addClass = function(name){ + var classes = this.getAttribute('class'); + el.setAttribute('class', classes + ? classes + ' ' + name + : name); + }; + + el.removeClass = function(name){ + var classes = this.getClasses().filter(function(curr){ + return curr != name; + }); + this.setAttribute('class', classes.join(' ')); + }; + + el.prepend = function (child) { + this.insertBefore(child, this.firstChild); + }; + + el.append = function (child) { + this.appendChild(child); + }; + + el.css = function (name, value) { + this.style[name] = value; + } + + el.click = function () { + var event = new MouseEvent('click', { + view: window, + bubbles: false, + cancelable: true + }); + this.dispatchEvent(event); + }; + + return el; +} + +function uriencode(data) { + if (!data) return data; + return '?' + Object.keys(data).map(function (x) { + return (encodeURIComponent(x) + '=' + encodeURIComponent(data[x]))}).join('&'); +} + +function ajax (options, done_fn, fail_fn) { + var xhr = new XMLHttpRequest(); + xhr.open(options.method || 'POST', options.url + (options.data?uriencode(options.data):'')); + //xhr.onreadystatechange = function (evt) { + xhr.addEventListener('readystatechange', function (evt) { + if (evt.target.readyState === 4 /*XMLHttpRequest.DONE*/) { + if (~~(evt.target.status/100) === 2) { + done_fn && done_fn(JSON.parse(evt.target.response || 'null')); + } else { + fail_fn && fail_fn(evt.target.status); + } + } + }); + if (options.json) { + xhr.send(JSON.stringify(options.json)); + } else { + xhr.send(); + } +} + +function green_border(element) { + element.style.border = '1px solid green'; +} +function red_border(element) { + element.style.border = '1px solid red'; +} + +function clear_element(element) { + while (element.hasChildNodes()) { + element.removeChild(element.lastChild); + } +} + diff --git a/modules/bg_chinese_cheese/local_version/static/index.html b/modules/bg_chinese_cheese/local_version/static/index.html new file mode 100644 index 0000000..d6f73d0 --- /dev/null +++ b/modules/bg_chinese_cheese/local_version/static/index.html @@ -0,0 +1,233 @@ + + + +ChineseCheese + + + + + + + + + + + diff --git a/modules/bg_chinese_cheese/local_version/static/item.png b/modules/bg_chinese_cheese/local_version/static/item.png new file mode 100644 index 0000000..e76c834 Binary files /dev/null and b/modules/bg_chinese_cheese/local_version/static/item.png differ diff --git a/modules/bg_chinese_cheese/local_version/static/panel.jpg b/modules/bg_chinese_cheese/local_version/static/panel.jpg new file mode 100644 index 0000000..02e660d Binary files /dev/null and b/modules/bg_chinese_cheese/local_version/static/panel.jpg differ diff --git a/modules/bg_chinese_cheese/local_version/static/paper.js b/modules/bg_chinese_cheese/local_version/static/paper.js new file mode 100644 index 0000000..e8cd531 --- /dev/null +++ b/modules/bg_chinese_cheese/local_version/static/paper.js @@ -0,0 +1,184 @@ +'use strict'; + +function Box (x, y, w, h) { + this.rect = [0, 0]; // w, h : width, height + this.viewport = [0, 0, 0, 0]; // x, y, w, h + this.objs = []; // [c/r:circle/rect, x, y, scale, d/w, d/h, lv, data] + this.as_obj = ['r', 0, 0, 1, 1, 1, 0, this]; + this.timestamp = 0; + + this.draw_fn = null; + + this.set_viewport(x, y, w, h); +} + +Box.prototype = { + set_viewport: function (x, y, w, h) { + this.rect[0] = w || 1; + this.rect[1] = h || 1; + this.viewport[0] = 0; + this.viewport[1] = 0; + this.viewport[2] = this.rect[0]; + this.viewport[3] = this.rect[1]; + this.as_obj[1] = x; + this.as_obj[2] = y; + this.as_obj[4] = this.rect[0]; + this.as_obj[5] = this.rect[1]; + }, + translate: function (dx, dy) { + this.viewport[0] += dx; + this.viewport[1] += dy; + }, + sort_by_lv: function () { + var i,j,k,max; + for (i=this.objs.length-1; i>=1; i--) { + max = this.objs[i][6]; + k = i; + for (j=i-1; j>=0; j--) { + if (this.objs[j][6]>max) { + max = this.objs[j][6]; + k = j; + } + } + if (i === k) continue; + j = this.objs[i]; + this.objs[i] = this.objs[k]; + this.objs[k] = j; + } + }, + paint: function (pen, x, y, clear) { + x = x || 0; + y = y || 0; + pen.save(); + pen.translate(x, y); + pen.beginPath(); + pen.moveTo(0, 0); + pen.lineTo(this.viewport[2], 0); + pen.lineTo(this.viewport[2], this.viewport[3]); + pen.lineTo(0, this.viewport[3]); + pen.lineTo(0, 0); + pen.clip(); + if (clear) { + pen.clearRect(0, 0, this.viewport[2], this.viewport[3]); + } + pen.translate(-this.viewport[0], -this.viewport[1]); + var objs = this.cross_all( + ['r', this.viewport[0], this.viewport[1], 1, this.viewport[2], this.viewport[3]] + ); + for (var i=objs.length-1; i>=0; i--) { + this.draw_fn && this.draw_fn(pen, this.objs[objs[i]]); + } + pen.restore(); + }, + _hit_c: function (x, y, obj) { + var r = obj[4]/2, dx = obj[1]+r-x, dy = obj[2]+r-y; + if (dx*dx+dy*dy<=r*r) return true; + return false; + }, + _hit_r: function (x, y, obj) { + if (x>=obj[1] && x<=obj[1]+obj[4] && y>=obj[2] && y<=obj[2]+obj[5]) + return true; + return false; + }, + _hit: function (x, y, obj) { + switch (obj[0]) { + case 'c': + return this._hit_c(x, y, obj); + case 'r': + return this._hit_r(x, y, obj); + } + return false; + }, + hit: function (x, y, special_cond_fn) { + for(var i=this.objs.length-1; i>=0; i--) { + if (this._hit(x, y, this.objs[i])) { + if (special_cond_fn && !special_cond_fn(this.objs[i], i)) { + continue; + } + return i; + } + } + return -1; + }, + hit_all: function (x, y, special_cond_fn) { + var r = []; + for(var i=this.objs.length-1; i>=0; i--) { + if (this._hit(x, y, this.objs[i])) { + if (special_cond_fn && !special_cond_fn(this.objs[i], i)) { + continue; + } + r.push(i); + } + } + return r; + }, + _cross_c_c: function (obj1, obj2) { + var r1 = obj1[4]/2, r2 = obj2[4]/2, + dx = obj1[1]-obj2[1]+r1-r2, + dy = obj1[2]-obj2[2]+r1-r2; + if (dx*dx+dy*dy<=(r1+r2)*(r1+r2)) return true; + return false; + }, + _cross_r_r: function (obj1, obj2) { + if ( + obj1[1]<=obj2[1]+obj2[4] && + obj1[1]+obj1[4]>=obj2[1] && + obj1[2]<=obj2[2]+obj2[5] && + obj1[2]+obj2[5]>=obj2[2] + ) { + return true; + } + return false; + }, + _cross_r_c: function (obj_c, obj_r) { + var v = [obj_c[1]-obj_r[1], obj_c[2]-obj_r[2]], + r = obj_c[4]/2; + if (v[0]<0) v[0] = -v[0]; + if (v[1]<0) v[1] = -v[1]; + v[0] -= obj_r[4]; + v[1] -= obj_r[5]; + if (v[0]<0) v[0] = 0; + if (v[1]<0) v[1] = 0; + if (v[0]*v[0]+v[1]*v[1]=0; i--) { + if (this._cross(obj, this.objs[i])) { + if (special_cond_fn && !special_cond_fn(this.objs[i], i)) { + continue; + } + return i; + } + } + return -1; + }, + cross_all: function (obj, special_cond_fn) { + var r = []; + for(var i=this.objs.length-1; i>=0; i--) { + if (this._cross(obj, this.objs[i])) { + if (special_cond_fn && !special_cond_fn(this.objs[i], i)) { + continue; + } + r.push(i); + } + } + return r; + } +}; diff --git a/modules/bg_chinese_cheese/local_version/static/petal-interactions.js b/modules/bg_chinese_cheese/local_version/static/petal-interactions.js new file mode 100644 index 0000000..710c415 --- /dev/null +++ b/modules/bg_chinese_cheese/local_version/static/petal-interactions.js @@ -0,0 +1,578 @@ +/* + * @auther: Seven Lju + * @date: 2014-12-22 + * PetalInteraction + * callback : mouse event callback, e.g. + * {mousemove: function (target, positions, extra) { ... }} + * mosueevent = click, dblclick, comboclick, + * mousemove, mousedown, mouseup, + * mousehold, mouseframe, mousegesture + * positions = [x, y] or [x, y, t] or [[x1, y1], [x2, y2], ...] or + * [[x1, y1, t1], [x2, y2, t2], ...] or + * [[[x1, y1, t1], [x2, y2, t2], ...], + * [[x1, y1, t1], ...], ...] + * + * PetalMobileInteraction + * callback: pinch event, e.g. + * {touchpinch: function (target, positions) { ... }} + * positions = [[[xStart, yStart], [xCurrent, yCurrent]], ...] + */ +function PetalInteraction(callback) { + + var config = { + axis: { + x: 'clientX', + y: 'clientY' + }, + hold: { /* hold cursor in a place for some time */ + enable: true, + last: 1000 /* ms */, + tolerance: 5 /* px */ + }, + combo: { /* click for N times (N > 2) */ + enable: true, + holdable: false /* hold for seconds and combo */, + timingable: false /* count time for combo interval */, + tolerance: -1 /* px, combo click (x,y) diff */, + timeout: 200 /* ms, timeout to reset counting */ + }, + frame: { /* drag-n-drop to select an area of frame (rectangle) */ + enable: true, + minArea: 50 /* px^2, the minimum area that a frame will be */, + moving: false /* true: monitor mouse move, + if (x,y) changes then triggered; + false: + if mouse up by distance then triggered*/ + }, + gesture: { /* experimental feature, customized gesture recorder + FIXME: it is a little slow on mobile device */ + enable: false, + mode: 'relative' /* absolute / relative */, + timeout: 500 /* ms, timeout to complete gesture */, + absolute: { + resolution: 20 /* px, split into N*N boxes */ + }, + relative: { + resolution: 20 /* px, mark new point over N px */ + } + } + }; + + var target = null; + var state = {}, lock = {}; + interaction_init(); + if (!callback) callback = {}; + + function interaction_init() { + state.hold = null; + state.combo = null; + state.gesture = null; + state.frame = null; + + if (lock.checkHold !== null) clearTimeout(lock.checkHold); + if (lock.checkCombo !== null) clearTimeout(lock.checkCombo); + lock.holdBeatCombo = false; + lock.mouseDown = false; + lock.checkHold = null; + lock.checkCombo = null; + lock.checkGesture = null; + } + + function clone_mouse_event(e) { + var result = { + type: e.type, + altKey: e.altKey, + ctrlKey: e.ctrlKey, + shiftKey: e.shiftKey, + button: e.which || e.button + }; + result[config.axis.x] = e[config.axis.x]; + result[config.axis.y] = e[config.axis.y]; + return result; + } + + function check_distance(x0, y0, x, y, d) { + if (d < 0) return false; + var dx = x - x0, dy = y - y0; + return Math.sqrt(dx*dx+dy*dy) > d; + } + + function check_combo() { + switch (state.combo.count) { + case 0: // move out and then in + break; + case 1: // click + if (callback.click) callback.click(target, state.combo.positons[0]); + break; + case 2: // double click + if (callback.dblclick) callback.dblclick(target, state.combo.positions); + break; + default: // combo click + if (callback.comboclick) callback.comboclick(target, state.combo.positions); + } + lock.holdBeatCombo = false; + lock.checkCombo = null; + state.combo = null; + } + + function check_hold() { + if (lock.mouseDown) { + if (callback.mousehold) callback.mousehold(target, [state.hold.x, state.hold.y]); + if (!config.combo.holdable) lock.holdBeatCombo = true; + } + lock.checkHold = null; + state.hold = null; + } + + function check_gesture() { + var fullpath = state.gesture.fullpath; + if (fullpath.length) { + if (fullpath.length > 1 || fullpath[0].length > 1) { + if (callback.mousegesture) callback.mousegesture(target, fullpath); + } + } + fullpath = null; + lock.checkGesture = null; + state.gesture = null; + } + + function do_combo_down(x, y) { + if (!lock.mouseDown) return; + if (lock.checkCombo !== null) clearTimeout(lock.checkCombo); + if (!state.combo) state.combo = {count: 0, positions: []}; + state.combo.x = x; + state.combo.y = y; + state.combo.timestamp = new Date().getTime(); + state.combo.count ++; + } + + function do_combo_up(x, y) { + if (!state.combo) return; + var pos = [x, y]; + if (config.combo.timingable) { + pos[2] = new Date().getTime() - state.combo.timestamp; + } + state.combo.positions.push(pos); + if (config.combo.tolerance >= 0) { + if (check_distance(state.combo.x, state.combo.y, + x, y, config.combo.tolerance)) { + // mouse moved + check_combo(); + return; + } + } + if (!config.combo.holdable) { + if (lock.holdBeatCombo) { + // mouse hold + check_combo(); + return; + } + } + lock.checkCombo = setTimeout(check_combo, config.combo.timeout); + } + + function do_hold(x, y, checkMoved) { + // if mouse not down, skip + if (!lock.mouseDown) return; + if (checkMoved !== true) checkMoved = false; + if (checkMoved && state.hold) { + // if move a distance and stop to hold + if (!check_distance(state.hold.x, state.hold.y, + x, y, config.hold.tolerance)) { + return; + } + } + // recount time + if (lock.checkHold !== null) clearTimeout(lock.checkHold); + if (!state.hold) state.hold = {}; + state.hold.x = x; + state.hold.y = y; + lock.checkHold = setTimeout(check_hold, config.hold.last); + } + + function do_frame_down(x, y) { + if (!state.frame) state.frame = {}; + state.frame.start = true; + state.frame.x = x; + state.frame.y = y; + } + + function do_frame(x, y) { + if (!state.frame) return + if (!state.frame.start) return; + var dx, dy; + dx = x - state.frame.x; + dy = y - state.frame.y; + if (Math.abs(dx*dy) >= config.frame.minArea) { + if (callback.mouseframe) { + callback.mouseframe( + target, + [ [state.frame.x, state.frame.y], [x, y] ] + ); + } + } + } + + function do_frame_up(x, y) { + do_frame(x, y); + state.frame = null; + } + + function do_gesture_start(x, y) { + if (lock.checkGesture !== null) clearTimeout(lock.checkGesture); + if (!state.gesture) state.gesture = {}; + if (!state.gesture.fullpath) state.gesture.fullpath = []; + state.gesture.timestamp = new Date().getTime(); + state.gesture.positions = []; + state.gesture.x = x; + state.gesture.y = y; + } + + var _act_gesture_move_map_mode = { + absolute: do_gesture_absolute, + relative: do_gesture_relative + }; + function do_gesture_move(x, y) { + if (!lock.mouseDown) return; + var point = _act_gesture_move_map_mode[config.gesture.mode](x, y); + var timestamp = new Date().getTime(); + if (!point) return; + state.gesture.positions.push([ + point[0], point[1], + timestamp - state.gesture.timestamp + ]); + state.gesture.x = point[0]; + state.gesture.y = point[1]; + state.gesture.timestamp = timestamp; + point = null; + } + + function do_gesture_absolute(x, y) { + var resolution = config.gesture.absolute.resolution; + if (resolution < 1) resolution = 1; + if (Math.floor(x / resolution) === Math.floor(state.x /resolution) && + Math.floor(y / resolution) === Math.floor(state.y /resolution)) { + return null; + } + return [x, y]; + } + + function do_gesture_relative(x, y) { + if (!check_distance(state.gesture.x, state.gesture.y, x, y, + config.gesture.relative.resolution)) return null; + return [x, y]; + } + + function do_gesture_break(x, y) { + var timestamp = new Date().getTime(); + state.gesture.positions.push([ + state.gesture.x, state.gesture.y, + timestamp - state.gesture.timestamp + ]); + state.gesture.fullpath.push(state.gesture.positions); + state.gesture.positions = []; + state.gesture.timestamp = timestamp; + lock.checkGesture = setTimeout(check_gesture, config.gesture.timeout); + } + + function event_mousedown(e) { + var x = e[config.axis.x], y = e[config.axis.y]; + lock.mouseDown = true; + if (config.hold.enable) do_hold(x, y, false); + if (config.combo.enable) do_combo_down(x, y); + if (config.frame.enable) do_frame_down(x, y); + if (config.gesture.enable) do_gesture_start(x, y); + if (callback.mousedown) callback.mousedown(target, [x, y], clone_mouse_event(e)); + } + + function event_mousemove(e) { + var x = e[config.axis.x], y = e[config.axis.y]; + if (config.hold.enable) do_hold(x, y, true); + if (config.frame.enable && config.frame.moving) do_frame(x, y); + if (config.gesture.enable) do_gesture_move(x, y); + if (callback.mousemove) callback.mousemove(target, [x, y], clone_mouse_event(e)); + } + + function event_mouseup(e) { + var x = e[config.axis.x], y = e[config.axis.y]; + lock.mouseDown = false; + if (config.combo.enable) do_combo_up(x, y); + if (config.frame.enable) do_frame_up(x, y); + if (config.gesture.enable) do_gesture_break(x, y); + state.hold = null; + if (callback.mouseup) callback.mouseup(target, [x, y], clone_mouse_event(e)); + } + + function event_mouseout(e) { + var x = e[config.axis.x], y = e[config.axis.y]; + if (lock.mouseDown) { + if (lock.checkHold !== null) clearTimeout(lock.checkHold); + if (lock.checkCombo !== null) clearTimeout(lock.checkCombo); + if (config.combo.enable && state.combo) { + check_combo(); + } + state.hold = null; + state.combo = null; + lock.mouseDown = false; + } + if (callback.mouseout) callback.mouseout(target, [x, y], clone_mouse_event(e)); + } + + function event_mouseenter(e) { + var x = e[config.axis.x], y = e[config.axis.y]; + var button = e.which || e.button; + if (button > 0) { + lock.mouseDown = true; + if (config.hold.enable) do_hold(x, y, false); + if (config.combo.enable) state.combo = {count: 0, positions: []}; + } + if (callback.mouseenter) callback.mouseenter(target, [x, y], clone_mouse_event(e)); + } + + return { + config: function () { + return config; + }, + bind: function (element) { + target = element; + interaction_init(); + element.addEventListener('mousedown', event_mousedown); + element.addEventListener('mousemove', event_mousemove); + element.addEventListener('mouseup', event_mouseup); + element.addEventListener('mouseout', event_mouseout); + element.addEventListener('mouseenter', event_mouseenter); + }, + unbind: function () { + target.removeEventListener('mousedown', event_mousedown); + target.removeEventListener('mousemove', event_mousemove); + target.removeEventListener('mouseup', event_mouseup); + target.removeEventListener('mouseout', event_mouseout); + target.removeEventListener('mouseenter', event_mouseenter); + interaction_init(); + target = null; + } + }; +} + +function PetalMobileInteraction(callback) { + + var config = { + axis: { + x: 'clientX', + y: 'clientY' + }, + pinch: { /* experimental feature, support fingers pinch + FIXME: if touch pinch triggered, mouse down + and mouse up will triggered too*/ + enable: false, + moving: false, /* monitor finger moving on display */ + tolerance: 10 /* px, finger moving > N px, event triggered */ + }, + click: { + enable: true, + timeout: 150, + click: true, + dblclick: true + } + }; + + if (!callback) callback = {}; + var target = null; + var state = {}; + + function check_distance(x0, y0, x, y, d) { + if (d < 0) return false; + var dx = x - x0, dy = y - y0; + return Math.sqrt(dx*dx+dy*dy) > d; + } + + function check_pinch() { + var points = state.pinch.points; + var doit = false; + for(var i = 0, n = points.length; i < n; i++) { + if (check_distance(points[i][0][0], points[i][0][1], + points[i][1][0], points[i][1][1], + config.pinch.tolerance)) { + doit = true; + break; + } + } + if (doit) { + callback.touchpinch(target, state.pinch.points); + } + points = null; + } + + function do_pinch_down(e) { + if (!state.pinch) state.pinch = {points: null}; + } + + function do_pinch(e) { + var touches = e.changedTouches; + var points; + var newpinch = false; + if (!state.pinch.points) { + newpinch = true; + } else if (state.pinch.points.length < touches.length) { + // 2 fingers to 3 and more ... + newpinch = true; + } + if (newpinch) { + var i, n, x, y; + points = []; + for (i = 0, n = touches.length; i < n; i++) { + x = touches[i][config.axis.x]; + y = touches[i][config.axis.y]; + points.push([ [x, y], [x, y] ]); + } + state.pinch.points = points; + } else { + points = state.pinch.points + var i, n, x, y; + for (i = 0, n = points.length; i < n; i++) { + x = touches[i][config.axis.x]; + y = touches[i][config.axis.y]; + points[i][1][0] = x; + points[i][1][1] = y; + } + } + points = null; + } + + function do_pinch_up(e) { + if (state.pinch.points) { + check_pinch(); + state.pinch = null; + } + } + + function do_click_down(e) { + if (!state.click) { + state.click = { + count: 0, + once: true, + checkClick: null + }; + } + state.click.once = true; + if (state.click.checkClick !== null) clearTimeout(state.click.checkClick); + state.click.checkClick = setTimeout(check_mob_click, config.click.timeout); + } + + function do_click_up(e) { + if (!state.click) return; + if (!state.click.once) return; + var touch = e.changedTouches[0]; + state.click.count ++; + state.click.touch = { + screenX: touch.screenX, + screenY: touch.screenY, + clientX: touch.clientX, + clientY: touch.clientY, + ctrlKey: e.ctrlKey, + altKey: e.altKey, + shiftKey: e.shfitKey, + metaKey: e.metaKey + }; + if (state.click.checkClick !== null) clearTimeout(state.click.checkClick); + state.click.checkClick = setTimeout(check_mob_click, config.click.timeout); + } + + function check_mob_click() { + if (!state.click) return; + if (state.click.count === 0) { + } else if (state.click.count === 1 && config.click.click) { + fire_event('click', state.click.touch); + } else if (state.click.count === 2 && config.click.dblclick) { + fire_event('dblclick', state.click.touch); + } else { + // XXX: combo click + } + state.click.checkClick = null; + state.click.count = 0; + state.click.once = false; + state.click.touch = null; + } + + function fire_event(type, mouse_attr) { + // mouseAttr = {screenX, screenY, clientX, clientY, ctrlKey, altKey, shiftKey, metaKey} + var f = document.createEvent("MouseEvents"); + f.initMouseEvent(type, true, true, + target.ownerDocument.defaultView, 0, + mouse_attr.screenX, mouse_attr.screenY, + mouse_attr.clientX, mouse_attr.clientY, + mouse_attr.ctrlKey, mouse_attr.altKey, + mouse_attr.shiftKey, mouse_attr.metaKey, + 0, null); + target.dispatchEvent(f); + } + + var _event_touch_map_mouse = { + touchstart: 'mousedown', + touchmove: 'mousemove', + touchend: 'mouseup' + }; + function event_touch_to_mouse(e) { + e.preventDefault(); + var touches = e.changedTouches; + if (config.pinch.enable) { + if (callback.touchpinch) { + switch(e.type) { + case 'touchmove': + if (touches.length <= 1) break; + if (!config.pinch.enable) return; + if (state.pinch.count <= 1) return; + do_pinch(e); + if (!config.pinch.moving) return; + check_pinch(); + return; + case 'touchstart': + do_pinch_down(e); + break; + case 'touchend': + do_pinch_up(e); + // next, be aware of touchend => mouseup + break; + } + } + } + if (config.click.enable) { + switch(e.type) { + case 'touchstart': + do_click_down(e); + break; + case 'touchend': + do_click_up(e); + break; + } + } + fire_event(_event_touch_map_mouse[e.type], { + screenX: touches[0].screenX, + screenY: touches[0].screenY, + clientX: touches[0].clientX, + clientY: touches[0].clientY, + ctrlKey: e.ctrlKey, + altKey: e.altKey, + shiftKey: e.shfitKey, + metaKey: e.metaKey + }); + } + + return { + config: function() { + return config; + }, + bind: function (element) { + target = element; + element.addEventListener('touchstart', event_touch_to_mouse); + element.addEventListener('touchmove', event_touch_to_mouse); + element.addEventListener('touchend', event_touch_to_mouse); + }, + unbind: function () { + target.removeEventListener('touchstart', event_touch_to_mouse); + target.removeEventListener('touchmove', event_touch_to_mouse); + target.removeEventListener('touchend', event_touch_to_mouse); + target = null; + } + }; +} diff --git a/modules/bg_chinese_cheese/local_version/static/r_bing.png b/modules/bg_chinese_cheese/local_version/static/r_bing.png new file mode 100644 index 0000000..ae37b33 Binary files /dev/null and b/modules/bg_chinese_cheese/local_version/static/r_bing.png differ diff --git a/modules/bg_chinese_cheese/local_version/static/r_ju.png b/modules/bg_chinese_cheese/local_version/static/r_ju.png new file mode 100644 index 0000000..c3bdba6 Binary files /dev/null and b/modules/bg_chinese_cheese/local_version/static/r_ju.png differ diff --git a/modules/bg_chinese_cheese/local_version/static/r_ma.png b/modules/bg_chinese_cheese/local_version/static/r_ma.png new file mode 100644 index 0000000..9b1d895 Binary files /dev/null and b/modules/bg_chinese_cheese/local_version/static/r_ma.png differ diff --git a/modules/bg_chinese_cheese/local_version/static/r_pao.png b/modules/bg_chinese_cheese/local_version/static/r_pao.png new file mode 100644 index 0000000..a93dc76 Binary files /dev/null and b/modules/bg_chinese_cheese/local_version/static/r_pao.png differ diff --git a/modules/bg_chinese_cheese/local_version/static/r_shi.png b/modules/bg_chinese_cheese/local_version/static/r_shi.png new file mode 100644 index 0000000..eb1915e Binary files /dev/null and b/modules/bg_chinese_cheese/local_version/static/r_shi.png differ diff --git a/modules/bg_chinese_cheese/local_version/static/r_shuai.png b/modules/bg_chinese_cheese/local_version/static/r_shuai.png new file mode 100644 index 0000000..0ef6de0 Binary files /dev/null and b/modules/bg_chinese_cheese/local_version/static/r_shuai.png differ diff --git a/modules/bg_chinese_cheese/local_version/static/r_xiang.png b/modules/bg_chinese_cheese/local_version/static/r_xiang.png new file mode 100644 index 0000000..6a4540e Binary files /dev/null and b/modules/bg_chinese_cheese/local_version/static/r_xiang.png differ diff --git a/modules/bg_chinese_cheese/package.json b/modules/bg_chinese_cheese/package.json new file mode 100644 index 0000000..cc8106d --- /dev/null +++ b/modules/bg_chinese_cheese/package.json @@ -0,0 +1,24 @@ +{ + "name": "chinese_cheese", + "version": "0.1.0", + "description": "Chinese Cheese app for Android NodeBase", + "main": "index.js", + "dependencies": { + "body-parser": "^1.16.0", + "bootstrap": "^3.3.7", + "express": "^4.14.0" + }, + "devDependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "module", + "nodebase", + "android", + "boardgame", + "chinese cheese" + ], + "author": "Seven Lju", + "license": "MIT" +} diff --git a/modules/bg_chinese_cheese/readme b/modules/bg_chinese_cheese/readme new file mode 100644 index 0000000..13285b3 --- /dev/null +++ b/modules/bg_chinese_cheese/readme @@ -0,0 +1,5 @@ +# Chinese Cheese +- Board Game: Chinese Cheese +- Replace real objects to play chinese cheese +running: 9090 +params: (no params) diff --git a/modules/bg_chinese_cheese/static/b_jiang.png b/modules/bg_chinese_cheese/static/b_jiang.png new file mode 100644 index 0000000..09d78e6 Binary files /dev/null and b/modules/bg_chinese_cheese/static/b_jiang.png differ diff --git a/modules/bg_chinese_cheese/static/b_ju.png b/modules/bg_chinese_cheese/static/b_ju.png new file mode 100644 index 0000000..d8862de Binary files /dev/null and b/modules/bg_chinese_cheese/static/b_ju.png differ diff --git a/modules/bg_chinese_cheese/static/b_ma.png b/modules/bg_chinese_cheese/static/b_ma.png new file mode 100644 index 0000000..45ad1de Binary files /dev/null and b/modules/bg_chinese_cheese/static/b_ma.png differ diff --git a/modules/bg_chinese_cheese/static/b_pao.png b/modules/bg_chinese_cheese/static/b_pao.png new file mode 100644 index 0000000..faf070e Binary files /dev/null and b/modules/bg_chinese_cheese/static/b_pao.png differ diff --git a/modules/bg_chinese_cheese/static/b_shi.png b/modules/bg_chinese_cheese/static/b_shi.png new file mode 100644 index 0000000..4b70b10 Binary files /dev/null and b/modules/bg_chinese_cheese/static/b_shi.png differ diff --git a/modules/bg_chinese_cheese/static/b_xiang.png b/modules/bg_chinese_cheese/static/b_xiang.png new file mode 100644 index 0000000..08e26df Binary files /dev/null and b/modules/bg_chinese_cheese/static/b_xiang.png differ diff --git a/modules/bg_chinese_cheese/static/b_zu.png b/modules/bg_chinese_cheese/static/b_zu.png new file mode 100644 index 0000000..b49f865 Binary files /dev/null and b/modules/bg_chinese_cheese/static/b_zu.png differ diff --git a/modules/bg_chinese_cheese/static/common.js b/modules/bg_chinese_cheese/static/common.js new file mode 100644 index 0000000..026953b --- /dev/null +++ b/modules/bg_chinese_cheese/static/common.js @@ -0,0 +1,106 @@ +'use strict'; +function $(id){ + var el = 'string' == typeof id + ? document.getElementById(id) + : id; + + el.on = function(event, fn){ + if ('content loaded' == event) { + event = window.attachEvent ? "load" : "DOMContentLoaded"; + } + el.addEventListener + ? el.addEventListener(event, fn, false) + : el.attachEvent("on" + event, fn); + }; + + el.all = function(selector){ + return $(el.querySelectorAll(selector)); + }; + + el.each = function(fn){ + for (var i = 0, len = el.length; i < len; ++i) { + fn($(el[i]), i); + } + }; + + el.getClasses = function(){ + return this.getAttribute('class').split(/\s+/); + }; + + el.addClass = function(name){ + var classes = this.getAttribute('class'); + el.setAttribute('class', classes + ? classes + ' ' + name + : name); + }; + + el.removeClass = function(name){ + var classes = this.getClasses().filter(function(curr){ + return curr != name; + }); + this.setAttribute('class', classes.join(' ')); + }; + + el.prepend = function (child) { + this.insertBefore(child, this.firstChild); + }; + + el.append = function (child) { + this.appendChild(child); + }; + + el.css = function (name, value) { + this.style[name] = value; + } + + el.click = function () { + var event = new MouseEvent('click', { + view: window, + bubbles: false, + cancelable: true + }); + this.dispatchEvent(event); + }; + + return el; +} + +function uriencode(data) { + if (!data) return data; + return '?' + Object.keys(data).map(function (x) { + return (encodeURIComponent(x) + '=' + encodeURIComponent(data[x]))}).join('&'); +} + +function ajax (options, done_fn, fail_fn) { + var xhr = new XMLHttpRequest(); + xhr.open(options.method || 'POST', options.url + (options.data?uriencode(options.data):'')); + //xhr.onreadystatechange = function (evt) { + xhr.addEventListener('readystatechange', function (evt) { + if (evt.target.readyState === 4 /*XMLHttpRequest.DONE*/) { + if (~~(evt.target.status/100) === 2) { + done_fn && done_fn(JSON.parse(evt.target.response || 'null')); + } else { + fail_fn && fail_fn(evt.target.status); + } + } + }); + if (options.json) { + xhr.send(JSON.stringify(options.json)); + } else { + xhr.send(); + } +} + +function green_border(element) { + element.style.border = '1px solid green'; +} +function red_border(element) { + element.style.border = '1px solid red'; +} + +function clear_element(element) { + while (element.hasChildNodes()) { + element.removeChild(element.lastChild); + } +} + diff --git a/modules/bg_chinese_cheese/static/index.html b/modules/bg_chinese_cheese/static/index.html new file mode 100644 index 0000000..5da7a40 --- /dev/null +++ b/modules/bg_chinese_cheese/static/index.html @@ -0,0 +1,221 @@ + + + +ChineseCheese + + + + + + + + + + + diff --git a/modules/bg_chinese_cheese/static/item.png b/modules/bg_chinese_cheese/static/item.png new file mode 100644 index 0000000..e76c834 Binary files /dev/null and b/modules/bg_chinese_cheese/static/item.png differ diff --git a/modules/bg_chinese_cheese/static/panel.jpg b/modules/bg_chinese_cheese/static/panel.jpg new file mode 100644 index 0000000..02e660d Binary files /dev/null and b/modules/bg_chinese_cheese/static/panel.jpg differ diff --git a/modules/bg_chinese_cheese/static/paper.js b/modules/bg_chinese_cheese/static/paper.js new file mode 100644 index 0000000..74f8c4f --- /dev/null +++ b/modules/bg_chinese_cheese/static/paper.js @@ -0,0 +1,167 @@ +'use strict'; + +function Box (x, y, w, h) { + this.rect = [0, 0]; // w, h : width, height + this.viewport = [0, 0, 0, 0]; // x, y, w, h + this.objs = []; // [c/r:circle/rect, x, y, scale, d/w, d/h, lv, data] + this.timestamp = 0; + + this.draw_fn = null; + + this.set_viewport(x, y, w, h); +} + +Box.prototype = { + set_viewport: function (x, y, w, h) { + this.rect[0] = w || 1; + this.rect[1] = h || 1; + this.viewport[0] = x || 0; + this.viewport[1] = y || 0; + this.viewport[2] = this.rect[0]; + this.viewport[3] = this.rect[1]; + }, + translate: function (dx, dy) { + this.viewport[0] += dx; + this.viewport[1] += dy; + }, + sort_by_lv: function () { + var i,j,k,max; + for (i=this.objs.length-1; i>=1; i--) { + max = this.objs[i][6]; + k = i; + for (j=i-1; j>=0; j--) { + if (this.objs[j][6]>max) { + max = this.objs[j][6]; + k = j; + } + } + if (i === k) continue; + j = this.objs[i]; + this.objs[i] = this.objs[k]; + this.objs[k] = j; + } + }, + paint: function (pen, x, y, clean) { + x = x || 0; + y = y || 0; + pen.save(); + pen.translate(x, y); + pen.beginPath(); + pen.moveTo(0, 0); + pen.lineTo(this.viewport[2], 0); + pen.lineTo(this.viewport[2], this.viewport[3]); + pen.lineTo(0, this.viewport[3]); + pen.lineTo(0, 0); + pen.clip(); + if (clean) { + pen.clearRect(0, 0, this.viewport[2], this.viewport[3]); + } + pen.translate(-this.viewport[0], -this.viewport[1]); + var objs = this.cross_all( + ['r', this.viewport[0], this.viewport[1], 1, this.viewport[2], this.viewport[3]] + ); + for (var i=objs.length-1; i>=0; i--) { + this.draw_fn && this.draw_fn(this.objs[objs[i]]); + } + pen.restore(); + }, + _hit_c: function (x, y, obj) { + var r = obj[4]/2, dx = obj[1]+r-x, dy = obj[2]+r-y; + if (dx*dx+dy*dy<=r*r) return true; + return false; + }, + _hit_r: function (x, y, obj) { + if (x>=obj[1] && x<=obj[1]+obj[4] && y>=obj[2] && y<=obj[2]+obj[5]) + return true; + return false; + }, + _hit: function (x, y, obj) { + switch (obj[0]) { + case 'c': + return this._hit_c(x, y, obj); + case 'r': + return this._hit_r(x, y, obj); + } + return false; + }, + hit: function (x, y) { + for(var i=this.objs.length-1; i>=0; i--) { + if (this._hit(x, y, this.objs[i])) { + return i; + } + } + return -1; + }, + hit_all: function (x, y) { + var r = []; + for(var i=this.objs.length-1; i>=0; i--) { + if (this._hit(x, y, this.objs[i])) { + r.push(i); + } + } + return r; + }, + _cross_c_c: function (obj1, obj2) { + var r1 = obj1[4]/2, r2 = obj2[4]/2, + dx = obj1[1]-obj2[1]+r1-r2, + dy = obj1[2]-obj2[2]+r1-r2; + if (dx*dx+dy*dy<=(r1+r2)*(r1+r2)) return true; + return false; + }, + _cross_r_r: function (obj1, obj2) { + if ( + obj1[1]<=obj2[1]+obj2[4] && + obj1[1]+obj1[4]>=obj2[1] && + obj1[2]<=obj2[2]+obj2[5] && + obj1[2]+obj2[5]>=obj2[2] + ) { + return true; + } + return false; + }, + _cross_r_c: function (obj_c, obj_r) { + var v = [obj_c[1]-obj_r[1], obj_c[2]-obj_r[2]], + r = obj_c[4]/2; + if (v[0]<0) v[0] = -v[0]; + if (v[1]<0) v[1] = -v[1]; + v[0] -= obj_r[4]; + v[1] -= obj_r[5]; + if (v[0]<0) v[0] = 0; + if (v[1]<0) v[1] = 0; + if (v[0]*v[0]+v[1]*v[1]=0; i--) { + if (this._cross(obj, this.objs[i])) { + return i; + } + } + return -1; + }, + cross_all: function (obj) { + var r = []; + for(var i=this.objs.length-1; i>=0; i--) { + if (this._cross(obj, this.objs[i])) { + r.push(i); + } + } + return r; + } +}; diff --git a/modules/bg_chinese_cheese/static/petal-interactions.js b/modules/bg_chinese_cheese/static/petal-interactions.js new file mode 100644 index 0000000..710c415 --- /dev/null +++ b/modules/bg_chinese_cheese/static/petal-interactions.js @@ -0,0 +1,578 @@ +/* + * @auther: Seven Lju + * @date: 2014-12-22 + * PetalInteraction + * callback : mouse event callback, e.g. + * {mousemove: function (target, positions, extra) { ... }} + * mosueevent = click, dblclick, comboclick, + * mousemove, mousedown, mouseup, + * mousehold, mouseframe, mousegesture + * positions = [x, y] or [x, y, t] or [[x1, y1], [x2, y2], ...] or + * [[x1, y1, t1], [x2, y2, t2], ...] or + * [[[x1, y1, t1], [x2, y2, t2], ...], + * [[x1, y1, t1], ...], ...] + * + * PetalMobileInteraction + * callback: pinch event, e.g. + * {touchpinch: function (target, positions) { ... }} + * positions = [[[xStart, yStart], [xCurrent, yCurrent]], ...] + */ +function PetalInteraction(callback) { + + var config = { + axis: { + x: 'clientX', + y: 'clientY' + }, + hold: { /* hold cursor in a place for some time */ + enable: true, + last: 1000 /* ms */, + tolerance: 5 /* px */ + }, + combo: { /* click for N times (N > 2) */ + enable: true, + holdable: false /* hold for seconds and combo */, + timingable: false /* count time for combo interval */, + tolerance: -1 /* px, combo click (x,y) diff */, + timeout: 200 /* ms, timeout to reset counting */ + }, + frame: { /* drag-n-drop to select an area of frame (rectangle) */ + enable: true, + minArea: 50 /* px^2, the minimum area that a frame will be */, + moving: false /* true: monitor mouse move, + if (x,y) changes then triggered; + false: + if mouse up by distance then triggered*/ + }, + gesture: { /* experimental feature, customized gesture recorder + FIXME: it is a little slow on mobile device */ + enable: false, + mode: 'relative' /* absolute / relative */, + timeout: 500 /* ms, timeout to complete gesture */, + absolute: { + resolution: 20 /* px, split into N*N boxes */ + }, + relative: { + resolution: 20 /* px, mark new point over N px */ + } + } + }; + + var target = null; + var state = {}, lock = {}; + interaction_init(); + if (!callback) callback = {}; + + function interaction_init() { + state.hold = null; + state.combo = null; + state.gesture = null; + state.frame = null; + + if (lock.checkHold !== null) clearTimeout(lock.checkHold); + if (lock.checkCombo !== null) clearTimeout(lock.checkCombo); + lock.holdBeatCombo = false; + lock.mouseDown = false; + lock.checkHold = null; + lock.checkCombo = null; + lock.checkGesture = null; + } + + function clone_mouse_event(e) { + var result = { + type: e.type, + altKey: e.altKey, + ctrlKey: e.ctrlKey, + shiftKey: e.shiftKey, + button: e.which || e.button + }; + result[config.axis.x] = e[config.axis.x]; + result[config.axis.y] = e[config.axis.y]; + return result; + } + + function check_distance(x0, y0, x, y, d) { + if (d < 0) return false; + var dx = x - x0, dy = y - y0; + return Math.sqrt(dx*dx+dy*dy) > d; + } + + function check_combo() { + switch (state.combo.count) { + case 0: // move out and then in + break; + case 1: // click + if (callback.click) callback.click(target, state.combo.positons[0]); + break; + case 2: // double click + if (callback.dblclick) callback.dblclick(target, state.combo.positions); + break; + default: // combo click + if (callback.comboclick) callback.comboclick(target, state.combo.positions); + } + lock.holdBeatCombo = false; + lock.checkCombo = null; + state.combo = null; + } + + function check_hold() { + if (lock.mouseDown) { + if (callback.mousehold) callback.mousehold(target, [state.hold.x, state.hold.y]); + if (!config.combo.holdable) lock.holdBeatCombo = true; + } + lock.checkHold = null; + state.hold = null; + } + + function check_gesture() { + var fullpath = state.gesture.fullpath; + if (fullpath.length) { + if (fullpath.length > 1 || fullpath[0].length > 1) { + if (callback.mousegesture) callback.mousegesture(target, fullpath); + } + } + fullpath = null; + lock.checkGesture = null; + state.gesture = null; + } + + function do_combo_down(x, y) { + if (!lock.mouseDown) return; + if (lock.checkCombo !== null) clearTimeout(lock.checkCombo); + if (!state.combo) state.combo = {count: 0, positions: []}; + state.combo.x = x; + state.combo.y = y; + state.combo.timestamp = new Date().getTime(); + state.combo.count ++; + } + + function do_combo_up(x, y) { + if (!state.combo) return; + var pos = [x, y]; + if (config.combo.timingable) { + pos[2] = new Date().getTime() - state.combo.timestamp; + } + state.combo.positions.push(pos); + if (config.combo.tolerance >= 0) { + if (check_distance(state.combo.x, state.combo.y, + x, y, config.combo.tolerance)) { + // mouse moved + check_combo(); + return; + } + } + if (!config.combo.holdable) { + if (lock.holdBeatCombo) { + // mouse hold + check_combo(); + return; + } + } + lock.checkCombo = setTimeout(check_combo, config.combo.timeout); + } + + function do_hold(x, y, checkMoved) { + // if mouse not down, skip + if (!lock.mouseDown) return; + if (checkMoved !== true) checkMoved = false; + if (checkMoved && state.hold) { + // if move a distance and stop to hold + if (!check_distance(state.hold.x, state.hold.y, + x, y, config.hold.tolerance)) { + return; + } + } + // recount time + if (lock.checkHold !== null) clearTimeout(lock.checkHold); + if (!state.hold) state.hold = {}; + state.hold.x = x; + state.hold.y = y; + lock.checkHold = setTimeout(check_hold, config.hold.last); + } + + function do_frame_down(x, y) { + if (!state.frame) state.frame = {}; + state.frame.start = true; + state.frame.x = x; + state.frame.y = y; + } + + function do_frame(x, y) { + if (!state.frame) return + if (!state.frame.start) return; + var dx, dy; + dx = x - state.frame.x; + dy = y - state.frame.y; + if (Math.abs(dx*dy) >= config.frame.minArea) { + if (callback.mouseframe) { + callback.mouseframe( + target, + [ [state.frame.x, state.frame.y], [x, y] ] + ); + } + } + } + + function do_frame_up(x, y) { + do_frame(x, y); + state.frame = null; + } + + function do_gesture_start(x, y) { + if (lock.checkGesture !== null) clearTimeout(lock.checkGesture); + if (!state.gesture) state.gesture = {}; + if (!state.gesture.fullpath) state.gesture.fullpath = []; + state.gesture.timestamp = new Date().getTime(); + state.gesture.positions = []; + state.gesture.x = x; + state.gesture.y = y; + } + + var _act_gesture_move_map_mode = { + absolute: do_gesture_absolute, + relative: do_gesture_relative + }; + function do_gesture_move(x, y) { + if (!lock.mouseDown) return; + var point = _act_gesture_move_map_mode[config.gesture.mode](x, y); + var timestamp = new Date().getTime(); + if (!point) return; + state.gesture.positions.push([ + point[0], point[1], + timestamp - state.gesture.timestamp + ]); + state.gesture.x = point[0]; + state.gesture.y = point[1]; + state.gesture.timestamp = timestamp; + point = null; + } + + function do_gesture_absolute(x, y) { + var resolution = config.gesture.absolute.resolution; + if (resolution < 1) resolution = 1; + if (Math.floor(x / resolution) === Math.floor(state.x /resolution) && + Math.floor(y / resolution) === Math.floor(state.y /resolution)) { + return null; + } + return [x, y]; + } + + function do_gesture_relative(x, y) { + if (!check_distance(state.gesture.x, state.gesture.y, x, y, + config.gesture.relative.resolution)) return null; + return [x, y]; + } + + function do_gesture_break(x, y) { + var timestamp = new Date().getTime(); + state.gesture.positions.push([ + state.gesture.x, state.gesture.y, + timestamp - state.gesture.timestamp + ]); + state.gesture.fullpath.push(state.gesture.positions); + state.gesture.positions = []; + state.gesture.timestamp = timestamp; + lock.checkGesture = setTimeout(check_gesture, config.gesture.timeout); + } + + function event_mousedown(e) { + var x = e[config.axis.x], y = e[config.axis.y]; + lock.mouseDown = true; + if (config.hold.enable) do_hold(x, y, false); + if (config.combo.enable) do_combo_down(x, y); + if (config.frame.enable) do_frame_down(x, y); + if (config.gesture.enable) do_gesture_start(x, y); + if (callback.mousedown) callback.mousedown(target, [x, y], clone_mouse_event(e)); + } + + function event_mousemove(e) { + var x = e[config.axis.x], y = e[config.axis.y]; + if (config.hold.enable) do_hold(x, y, true); + if (config.frame.enable && config.frame.moving) do_frame(x, y); + if (config.gesture.enable) do_gesture_move(x, y); + if (callback.mousemove) callback.mousemove(target, [x, y], clone_mouse_event(e)); + } + + function event_mouseup(e) { + var x = e[config.axis.x], y = e[config.axis.y]; + lock.mouseDown = false; + if (config.combo.enable) do_combo_up(x, y); + if (config.frame.enable) do_frame_up(x, y); + if (config.gesture.enable) do_gesture_break(x, y); + state.hold = null; + if (callback.mouseup) callback.mouseup(target, [x, y], clone_mouse_event(e)); + } + + function event_mouseout(e) { + var x = e[config.axis.x], y = e[config.axis.y]; + if (lock.mouseDown) { + if (lock.checkHold !== null) clearTimeout(lock.checkHold); + if (lock.checkCombo !== null) clearTimeout(lock.checkCombo); + if (config.combo.enable && state.combo) { + check_combo(); + } + state.hold = null; + state.combo = null; + lock.mouseDown = false; + } + if (callback.mouseout) callback.mouseout(target, [x, y], clone_mouse_event(e)); + } + + function event_mouseenter(e) { + var x = e[config.axis.x], y = e[config.axis.y]; + var button = e.which || e.button; + if (button > 0) { + lock.mouseDown = true; + if (config.hold.enable) do_hold(x, y, false); + if (config.combo.enable) state.combo = {count: 0, positions: []}; + } + if (callback.mouseenter) callback.mouseenter(target, [x, y], clone_mouse_event(e)); + } + + return { + config: function () { + return config; + }, + bind: function (element) { + target = element; + interaction_init(); + element.addEventListener('mousedown', event_mousedown); + element.addEventListener('mousemove', event_mousemove); + element.addEventListener('mouseup', event_mouseup); + element.addEventListener('mouseout', event_mouseout); + element.addEventListener('mouseenter', event_mouseenter); + }, + unbind: function () { + target.removeEventListener('mousedown', event_mousedown); + target.removeEventListener('mousemove', event_mousemove); + target.removeEventListener('mouseup', event_mouseup); + target.removeEventListener('mouseout', event_mouseout); + target.removeEventListener('mouseenter', event_mouseenter); + interaction_init(); + target = null; + } + }; +} + +function PetalMobileInteraction(callback) { + + var config = { + axis: { + x: 'clientX', + y: 'clientY' + }, + pinch: { /* experimental feature, support fingers pinch + FIXME: if touch pinch triggered, mouse down + and mouse up will triggered too*/ + enable: false, + moving: false, /* monitor finger moving on display */ + tolerance: 10 /* px, finger moving > N px, event triggered */ + }, + click: { + enable: true, + timeout: 150, + click: true, + dblclick: true + } + }; + + if (!callback) callback = {}; + var target = null; + var state = {}; + + function check_distance(x0, y0, x, y, d) { + if (d < 0) return false; + var dx = x - x0, dy = y - y0; + return Math.sqrt(dx*dx+dy*dy) > d; + } + + function check_pinch() { + var points = state.pinch.points; + var doit = false; + for(var i = 0, n = points.length; i < n; i++) { + if (check_distance(points[i][0][0], points[i][0][1], + points[i][1][0], points[i][1][1], + config.pinch.tolerance)) { + doit = true; + break; + } + } + if (doit) { + callback.touchpinch(target, state.pinch.points); + } + points = null; + } + + function do_pinch_down(e) { + if (!state.pinch) state.pinch = {points: null}; + } + + function do_pinch(e) { + var touches = e.changedTouches; + var points; + var newpinch = false; + if (!state.pinch.points) { + newpinch = true; + } else if (state.pinch.points.length < touches.length) { + // 2 fingers to 3 and more ... + newpinch = true; + } + if (newpinch) { + var i, n, x, y; + points = []; + for (i = 0, n = touches.length; i < n; i++) { + x = touches[i][config.axis.x]; + y = touches[i][config.axis.y]; + points.push([ [x, y], [x, y] ]); + } + state.pinch.points = points; + } else { + points = state.pinch.points + var i, n, x, y; + for (i = 0, n = points.length; i < n; i++) { + x = touches[i][config.axis.x]; + y = touches[i][config.axis.y]; + points[i][1][0] = x; + points[i][1][1] = y; + } + } + points = null; + } + + function do_pinch_up(e) { + if (state.pinch.points) { + check_pinch(); + state.pinch = null; + } + } + + function do_click_down(e) { + if (!state.click) { + state.click = { + count: 0, + once: true, + checkClick: null + }; + } + state.click.once = true; + if (state.click.checkClick !== null) clearTimeout(state.click.checkClick); + state.click.checkClick = setTimeout(check_mob_click, config.click.timeout); + } + + function do_click_up(e) { + if (!state.click) return; + if (!state.click.once) return; + var touch = e.changedTouches[0]; + state.click.count ++; + state.click.touch = { + screenX: touch.screenX, + screenY: touch.screenY, + clientX: touch.clientX, + clientY: touch.clientY, + ctrlKey: e.ctrlKey, + altKey: e.altKey, + shiftKey: e.shfitKey, + metaKey: e.metaKey + }; + if (state.click.checkClick !== null) clearTimeout(state.click.checkClick); + state.click.checkClick = setTimeout(check_mob_click, config.click.timeout); + } + + function check_mob_click() { + if (!state.click) return; + if (state.click.count === 0) { + } else if (state.click.count === 1 && config.click.click) { + fire_event('click', state.click.touch); + } else if (state.click.count === 2 && config.click.dblclick) { + fire_event('dblclick', state.click.touch); + } else { + // XXX: combo click + } + state.click.checkClick = null; + state.click.count = 0; + state.click.once = false; + state.click.touch = null; + } + + function fire_event(type, mouse_attr) { + // mouseAttr = {screenX, screenY, clientX, clientY, ctrlKey, altKey, shiftKey, metaKey} + var f = document.createEvent("MouseEvents"); + f.initMouseEvent(type, true, true, + target.ownerDocument.defaultView, 0, + mouse_attr.screenX, mouse_attr.screenY, + mouse_attr.clientX, mouse_attr.clientY, + mouse_attr.ctrlKey, mouse_attr.altKey, + mouse_attr.shiftKey, mouse_attr.metaKey, + 0, null); + target.dispatchEvent(f); + } + + var _event_touch_map_mouse = { + touchstart: 'mousedown', + touchmove: 'mousemove', + touchend: 'mouseup' + }; + function event_touch_to_mouse(e) { + e.preventDefault(); + var touches = e.changedTouches; + if (config.pinch.enable) { + if (callback.touchpinch) { + switch(e.type) { + case 'touchmove': + if (touches.length <= 1) break; + if (!config.pinch.enable) return; + if (state.pinch.count <= 1) return; + do_pinch(e); + if (!config.pinch.moving) return; + check_pinch(); + return; + case 'touchstart': + do_pinch_down(e); + break; + case 'touchend': + do_pinch_up(e); + // next, be aware of touchend => mouseup + break; + } + } + } + if (config.click.enable) { + switch(e.type) { + case 'touchstart': + do_click_down(e); + break; + case 'touchend': + do_click_up(e); + break; + } + } + fire_event(_event_touch_map_mouse[e.type], { + screenX: touches[0].screenX, + screenY: touches[0].screenY, + clientX: touches[0].clientX, + clientY: touches[0].clientY, + ctrlKey: e.ctrlKey, + altKey: e.altKey, + shiftKey: e.shfitKey, + metaKey: e.metaKey + }); + } + + return { + config: function() { + return config; + }, + bind: function (element) { + target = element; + element.addEventListener('touchstart', event_touch_to_mouse); + element.addEventListener('touchmove', event_touch_to_mouse); + element.addEventListener('touchend', event_touch_to_mouse); + }, + unbind: function () { + target.removeEventListener('touchstart', event_touch_to_mouse); + target.removeEventListener('touchmove', event_touch_to_mouse); + target.removeEventListener('touchend', event_touch_to_mouse); + target = null; + } + }; +} diff --git a/modules/bg_chinese_cheese/static/r_bing.png b/modules/bg_chinese_cheese/static/r_bing.png new file mode 100644 index 0000000..ae37b33 Binary files /dev/null and b/modules/bg_chinese_cheese/static/r_bing.png differ diff --git a/modules/bg_chinese_cheese/static/r_ju.png b/modules/bg_chinese_cheese/static/r_ju.png new file mode 100644 index 0000000..c3bdba6 Binary files /dev/null and b/modules/bg_chinese_cheese/static/r_ju.png differ diff --git a/modules/bg_chinese_cheese/static/r_ma.png b/modules/bg_chinese_cheese/static/r_ma.png new file mode 100644 index 0000000..9b1d895 Binary files /dev/null and b/modules/bg_chinese_cheese/static/r_ma.png differ diff --git a/modules/bg_chinese_cheese/static/r_pao.png b/modules/bg_chinese_cheese/static/r_pao.png new file mode 100644 index 0000000..a93dc76 Binary files /dev/null and b/modules/bg_chinese_cheese/static/r_pao.png differ diff --git a/modules/bg_chinese_cheese/static/r_shi.png b/modules/bg_chinese_cheese/static/r_shi.png new file mode 100644 index 0000000..eb1915e Binary files /dev/null and b/modules/bg_chinese_cheese/static/r_shi.png differ diff --git a/modules/bg_chinese_cheese/static/r_shuai.png b/modules/bg_chinese_cheese/static/r_shuai.png new file mode 100644 index 0000000..0ef6de0 Binary files /dev/null and b/modules/bg_chinese_cheese/static/r_shuai.png differ diff --git a/modules/bg_chinese_cheese/static/r_xiang.png b/modules/bg_chinese_cheese/static/r_xiang.png new file mode 100644 index 0000000..6a4540e Binary files /dev/null and b/modules/bg_chinese_cheese/static/r_xiang.png differ diff --git a/modules/file_upload_download/config b/modules/file_upload_download/config new file mode 100644 index 0000000..d08995e --- /dev/null +++ b/modules/file_upload_download/config @@ -0,0 +1,3 @@ +name=File Cloud +port=9090 +index=/download diff --git a/modules/file_upload_download/directory.html b/modules/file_upload_download/directory.html new file mode 100644 index 0000000..c01bbc7 --- /dev/null +++ b/modules/file_upload_download/directory.html @@ -0,0 +1,177 @@ + + + + + + listing directory {directory} + + + + + + +
+

~{linked-path}

+ {files} +
+ + + diff --git a/modules/file_upload_download/index.js b/modules/file_upload_download/index.js new file mode 100644 index 0000000..49c572d --- /dev/null +++ b/modules/file_upload_download/index.js @@ -0,0 +1,50 @@ +const index_dir = process.argv[2] || __dirname; +const download_uri = '/download'; +const upload_uri = '/upload'; + +const path = require('path'); + +const serve_index = require('serve-index'); +const express = require('express'); +const app = express(); + +const multer = require('multer'); +const storage = multer.diskStorage({ + destination: (req, file, cb) => { + let dir = req.query.dir.substring(download_uri.length); + cb(null, path.join(index_dir, dir)); + }, + filename: function (req, file, cb) { + cb(null, file.originalname); + } +}); +const upload = multer({ + storage: storage, + fileFilter: (req, file, cb) => { + let dir = req.query.dir; + if (dir.indexOf(download_uri + '/') != 0) { + cb(null, false); + } else if (dir.indexOf('/../') >= 0) { + cb(null, false); + } else { + cb(null, true); + } + } +}); + +app.get('/test', (req, res) => { + res.send('hello world!'); +}); + +app.post(upload_uri, upload.array('uploads'), (req, res, next) => { + res.end('done'); +}); + +app.use(download_uri, express.static(index_dir)); +app.use(download_uri, serve_index(index_dir, { + icons: true, + template: path.join(__dirname, 'directory.html') +})); +app.listen(9090, '0.0.0.0', () => { + console.log(`Directory index is listening at 0.0.0.0:9090`); +}); diff --git a/modules/file_upload_download/package.json b/modules/file_upload_download/package.json new file mode 100644 index 0000000..49e2c2b --- /dev/null +++ b/modules/file_upload_download/package.json @@ -0,0 +1,25 @@ +{ + "name": "file_upload_download", + "version": "0.1.0", + "description": "File Upload and Download app for Android NodeBase", + "main": "index.js", + "dependencies": { + "express": "^4.14.0", + "multer": "^1.2.1", + "serve-index": "^1.8.0" + }, + "devDependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "module", + "nodebase", + "android", + "upload", + "donwload", + "file" + ], + "author": "Seven Lju", + "license": "MIT" +} diff --git a/modules/file_upload_download/readme b/modules/file_upload_download/readme new file mode 100644 index 0000000..3b3971f --- /dev/null +++ b/modules/file_upload_download/readme @@ -0,0 +1,6 @@ +# Directory Index Service +- https://github.com/expressjs/multer +- https://github.com/expressjs/serve-index +- file server (download / upload) +running: 9090 +params: directory path for sharing diff --git a/modules/mxnet/README.md b/modules/mxnet/README.md new file mode 100644 index 0000000..66480e7 --- /dev/null +++ b/modules/mxnet/README.md @@ -0,0 +1,10 @@ +# Simple MXNetJS Example +========== + +- Copy `Emscripten` version of mxnet.js and related models from: https://github.com/dmlc/mxnet.js/ +- Modify `test_on_node.js` and addd `Jimp` for convert image data into array + +- run `prepare_mxnet.sh` to download mxnet.js and models +- upload image to `images` folder +- push app to android device +- run `node index.js` then visit `http://127.0.0.1:9090/test/cat.jpg` (`cat.jpg` is in `images` folder) diff --git a/modules/mxnet/config b/modules/mxnet/config new file mode 100644 index 0000000..a726b89 --- /dev/null +++ b/modules/mxnet/config @@ -0,0 +1,2 @@ +name=Simple MXNetJS Example +port=9090 diff --git a/modules/mxnet/images/cat.jpg b/modules/mxnet/images/cat.jpg new file mode 100644 index 0000000..58ba56b Binary files /dev/null and b/modules/mxnet/images/cat.jpg differ diff --git a/modules/mxnet/index.js b/modules/mxnet/index.js new file mode 100644 index 0000000..f294cf9 --- /dev/null +++ b/modules/mxnet/index.js @@ -0,0 +1,118 @@ +const http = require('http'); +const url = require('url'); +const path = require('path'); +const fs = require('fs'); +const wrap = require('./wrap'); + +function get_ip (req) { + let ip = null; + if (req.headers['x-forwarded-for']) { + ip = req.headers['x-forwarded-for'].split(",")[0]; + } else if (req.connection && req.connection.remoteAddress) { + ip = req.connection.remoteAddress; + } else { + ip = req.ip; + } + return ip; +} + +function route(req, res) { + let r = url.parse(req.url); + let f = router; + let path = r.pathname.split('/'); + let query = {}; + r.query && r.query.split('&').forEach((one) => { + let key, val; + let i = one.indexOf('='); + if (i < 0) { + key = one; + val = ''; + } else { + key = one.substring(0, i); + val = one.substring(i+1); + } + if (key in query) { + if(Array.isArray(query[key])) { + query[key].push(val); + } else { + query[key] = [query[key], val]; + } + } else { + query[key] = val; + } + }); + path.shift(); + while (path.length > 0) { + let key = path.shift(); + f = f[key]; + if (!f) break; + if (typeof(f) === 'function') { + return f(req, res, { + path: path, + query: query + }); + } + } + router.static(req, res, r.pathname); + // router.code(req, res, 404, 'Not Found'); +} + +function mime_lookup(filename) { + let extname = path.extname(filename); + switch(extname) { + case '.json': return 'application/json'; + case '.html': return 'text/html'; + case '.js': return 'text/javascript'; + case '.css': return 'text/css'; + default: return 'application/octet-stream' + } +} + + +const router = { + test: (req, res, options) => { + let image_name = options.path[0]; + wrap.predict(path.join(__dirname, 'images', image_name)).then(function (result) { + let html = ''; + html += '
' + result.join('\n') + '
'; + html += '
'; + html += ''; + res.setHeader('Context-Type', 'text/html'); + res.end(html); + }); + }, + static: (req, res, filename) => { + if (!filename || filename === '/') { + filename = 'index.html'; + } + filename = filename.split('/'); + if (!filename[0]) filename.shift(); + if (filename.length === 0 || filename.indexOf('..') >= 0) { + return router.code(req, res, 404, 'Not Found'); + } + if (filename[0] === 'images') { + filename = path.join(__dirname, ...filename); + } else { + filename = path.join(__dirname, 'static', ...filename); + } + if (!fs.existsSync(filename)) { + return router.code(req, res, 404, 'Not Found'); + } + res.setHeader('Content-Type', mime_lookup(filename)); + let buf = fs.readFileSync(filename); + res.end(buf, 'binary'); + }, + code: (req, res, code, text) => { + res.writeHead(code || 404, text || ''); + res.end(); + } +}; + +const server = http.createServer((req, res) => { + route(req, res); +}); + +const instance = server.listen(9090, '0.0.0.0', () => { + console.log(instance.address()); + console.log(`NodeBase MXNetJS Example is listening at 0.0.0.0:9090`); +}); diff --git a/modules/mxnet/package.json b/modules/mxnet/package.json new file mode 100644 index 0000000..45e0d0d --- /dev/null +++ b/modules/mxnet/package.json @@ -0,0 +1,22 @@ +{ + "name": "mxnet.js", + "version": "0.1.0", + "description": "Simple MXNetJS Example for Android NodeBase", + "main": "index.js", + "dependencies": { + "jimp": "^0.2.28" + }, + "devDependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "module", + "nodebase", + "android", + "machine learning", + "mxnet" + ], + "author": "MXNet.js", + "license": "MIT" +} diff --git a/modules/mxnet/prepare_mxnet.sh b/modules/mxnet/prepare_mxnet.sh new file mode 100644 index 0000000..344f4d9 --- /dev/null +++ b/modules/mxnet/prepare_mxnet.sh @@ -0,0 +1,10 @@ +#!/bin/bash + +git clone https://github.com/dmlc/mxnet.js/ +cd mxnet.js +cp -r libmxnet_predict.js libmxnet_predict.js.mem mxnet_predict.js model ../ + +# patch file to make sure libmxnet_predict.js.mem can be found +cd .. +# macosx sed -i '' 's...' +sed -i 's|"libmxnet_predict.js.mem"|__dirname+"/libmxnet_predict.js.mem"|g' libmxnet_predict.js diff --git a/modules/mxnet/readme b/modules/mxnet/readme new file mode 100644 index 0000000..4969ef8 --- /dev/null +++ b/modules/mxnet/readme @@ -0,0 +1,3 @@ +# Simple MXNetJS Example +running: 9090 +params: (no params) diff --git a/modules/mxnet/wrap.js b/modules/mxnet/wrap.js new file mode 100644 index 0000000..7992c41 --- /dev/null +++ b/modules/mxnet/wrap.js @@ -0,0 +1,66 @@ +const path = require('path'); +const jimp = require('jimp'); +const mx = require("./mxnet_predict.js"); + +function runModel(modelJson, image) { + var result = []; + var model = require(modelJson); + pred = new mx.Predictor(model, {'data': [1, 3, 224, 224]}); + pred.setinput('data', image); + var nleft = 1; + + var start = new Date().getTime(); + var end = new Date().getTime(); + var time = (end - start) / 1000; + + for (var step = 0; nleft != 0; ++step) { + nleft = pred.partialforward(step); + end = new Date().getTime(); + time = (end - start) / 1000; + } + out = pred.output(0); + + out = pred.output(0); + var index = new Array(); + for (var i=0;i { + res.send('hello world! ' + get_ip(req)); +}); + +app.post('/api/nodebase/nodepad/v1/list', (req, res) => { + if (!req.body) return res.sendStatus(400); + if (!req.body.path) return res.sendStatus(400); + let parent = req.body.path, + symbols = fs.readdirSync(parent), + files = [], + dirs = []; + symbols.forEach((x) => { + try { + if (fs.lstatSync(path.join(parent, x)).isDirectory()) { + dirs.push(x); + } else { + files.push(x); + } + } catch (e) { + // no permission + } + }); + send_json(res, { dirs, files }); +}); + +app.post('/api/nodebase/nodepad/v1/open', (req, res) => { + let file = req.body.path; + send_json(res, { + path: file, + text: fs.readFileSync(file).toString() + }); +}); + +app.post('/api/nodebase/nodepad/v1/save', (req, res) => { + let file = req.body.path, + text = req.body.text; + fs.writeFileSync(file, text); + send_json(res, { path: file }); +}); + +app.post('/api/nodebase/nodepad/v1/plugins', (req, res) => { + let parent = path.join(static_dir, 'plugin'), + symbols = fs.readdirSync(parent), + plugins = []; + symbols.forEach((x) => { + try { + if (fs.lstatSync(path.join(parent, x)).isDirectory()) { + plugins.push(x); + } + } catch (e) { + // no permission + } + }); + send_json(res, { plugins, path: parent }); +}); + +app.use('/', express.static(static_dir)); + +app.listen(9090, '0.0.0.0', () => { + console.log(`Nodepad is listening at 0.0.0.0:9090`); +}); diff --git a/modules/nodepad/package.json b/modules/nodepad/package.json new file mode 100644 index 0000000..991f1f3 --- /dev/null +++ b/modules/nodepad/package.json @@ -0,0 +1,25 @@ +{ + "name": "nodepad", + "version": "0.1.0", + "description": "Simple Notepad for Android NodeBase", + "main": "index.js", + "dependencies": { + "body-parser": "^1.16.0", + "bootstrap": "^3.3.7", + "express": "^4.14.0", + "uuid": "^3.0.1" + }, + "devDependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "module", + "nodebase", + "android", + "editor", + "notepad" + ], + "author": "Seven Lju", + "license": "MIT" +} diff --git a/modules/nodepad/readme b/modules/nodepad/readme new file mode 100644 index 0000000..aabf828 --- /dev/null +++ b/modules/nodepad/readme @@ -0,0 +1,3 @@ +# Simple Notepad +running: 9090 +params: (no params) diff --git a/modules/nodepad/static/common.js b/modules/nodepad/static/common.js new file mode 100644 index 0000000..cfa92b8 --- /dev/null +++ b/modules/nodepad/static/common.js @@ -0,0 +1,124 @@ +'use strict'; +function $(id){ + var el = 'string' == typeof id + ? document.getElementById(id) + : id; + + el.on = function(event, fn){ + if ('content loaded' == event) { + event = window.attachEvent ? "load" : "DOMContentLoaded"; + } + el.addEventListener + ? el.addEventListener(event, fn, false) + : el.attachEvent("on" + event, fn); + }; + + el.all = function(selector){ + return $(el.querySelectorAll(selector)); + }; + + el.each = function(fn){ + for (var i = 0, len = el.length; i < len; ++i) { + fn($(el[i]), i); + } + }; + + el.getClasses = function(){ + return this.getAttribute('class').split(/\s+/); + }; + + el.addClass = function(name){ + var classes = this.getAttribute('class'); + el.setAttribute('class', classes + ? classes + ' ' + name + : name); + }; + + el.removeClass = function(name){ + var classes = this.getClasses().filter(function(curr){ + return curr != name; + }); + this.setAttribute('class', classes.join(' ')); + }; + + el.prepend = function (child) { + this.insertBefore(child, this.firstChild); + }; + + el.append = function (child) { + this.appendChild(child); + }; + + el.css = function (name, value) { + this.style[name] = value; + } + + el.click = function () { + var event = new MouseEvent('click', { + view: window, + bubbles: false, + cancelable: true + }); + this.dispatchEvent(event); + }; + + return el; +} + +function uriencode(data) { + if (!data) return data; + return '?' + Object.keys(data).map(function (x) { + return (encodeURIComponent(x) + '=' + encodeURIComponent(data[x]))}).join('&'); +} + +function ajax (options, done_fn, fail_fn) { + var xhr = new XMLHttpRequest(), + payload = null; + xhr.open(options.method || 'POST', options.url + (options.data?uriencode(options.data):''), true); + xhr.addEventListener('readystatechange', function (evt) { + if (evt.target.readyState === 4 /*XMLHttpRequest.DONE*/) { + if (~~(evt.target.status/100) === 2) { + done_fn && done_fn(JSON.parse(evt.target.response || 'null')); + } else { + fail_fn && fail_fn(evt.target.status); + } + } + }); + if (options.json) { + xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); + payload = JSON.stringify(options.json); + } + xhr.send(payload); +} +function html (url, done_fn, fail_fn) { + var xhr = new XMLHttpRequest(), + payload = null; + xhr.open('GET', url, true); + xhr.addEventListener('readystatechange', function (evt) { + if (evt.target.readyState === 4 /*XMLHttpRequest.DONE*/) { + if (~~(evt.target.status/100) === 2) { + done_fn && done_fn(evt.target.response || ''); + } else { + fail_fn && fail_fn(evt.target.status); + } + } + }); + xhr.send(null); +} + +function green_border(element) { + element.style.border = '1px solid green'; +} +function red_border(element) { + element.style.border = '1px solid red'; +} + +function clear_element(element) { + while (element.hasChildNodes()) { + element.removeChild(element.lastChild); + } +} + +function ip_encode(ip) { + return ip.split('.').join('-'); +} diff --git a/modules/nodepad/static/index.css b/modules/nodepad/static/index.css new file mode 100644 index 0000000..6d55ce3 --- /dev/null +++ b/modules/nodepad/static/index.css @@ -0,0 +1,68 @@ +body { + overflow-x: hidden; +} +.disabled { + pointer-events: none; +} +.item { + display: block; + width: 100%; + margin-top: 2px; + padding: 10px 0px 10px 10px; + text-decoration: none; + color: black; +} +.item-r { + display: block; + width: 100%; + margin-top: 2px; + margin-left: -10px; + padding: 10px 10px 10px 0; + text-decoration: none; + text-align: right; + color: black; +} +.btn { + display: inline-block; + padding: 5px; + background-color: white; + border-radius: 3px; + border: 1px solid black; +} +.badge { + border: 1px solid black; + padding: 0 5px 0 5px; + margin-left: 10px; +} +.input { + padding: 5px; + border-top: 0; + border-left: 0; + border-right: 0; + border-bottom: 1px solid black; + width: 100%; +} +a.item-r:hover { + opacity: 0.5; + cursor: pointer; +} +a.item:hover { + opacity: 0.5; + cursor: pointer; +} +.btn:hover { + border: 1px solid black; + color: white; + background-color: black; +} +.hide { + display: none; +} +.grey { background-color: #e2e2e2; } +.red { background-color: #f5cdcd; } +.green { background-color: #cff5cd; } +.blue { background-color: #cdebf5; } +.yellow { background-color: #fbf59f; } +.orange { background-color: #ffe6cc; } +.pink { background-color: #f5cde8; } +.purple { background-color: #dfcdf5; } diff --git a/modules/nodepad/static/index.html b/modules/nodepad/static/index.html new file mode 100644 index 0000000..33da366 --- /dev/null +++ b/modules/nodepad/static/index.html @@ -0,0 +1,151 @@ + + + + + + + Notepad + + +
NodePad
+
+
+ +
+ +
(No Files)
+
+
+ +
+ +
+ + +
+
+
+ + + + + + + + + + + + + diff --git a/modules/nodepad/static/simple.html b/modules/nodepad/static/simple.html new file mode 100644 index 0000000..acab0de --- /dev/null +++ b/modules/nodepad/static/simple.html @@ -0,0 +1,249 @@ + + + + + + + Notepad + + +
NodePad
+
+
+ +
+ +
(No Files)
+
+
+ +
+ +
+ +
+ + " " + ' ' + ( ) + { } + < > + + + + < + SelectWord + > + +
+
+ +
+
+ +
+ +
+
+ +
+ + +
+
+ + + + diff --git a/modules/package.json b/modules/package.json new file mode 100644 index 0000000..86725ac --- /dev/null +++ b/modules/package.json @@ -0,0 +1,41 @@ +{ + "name": "nodebase_modules", + "version": "0.0.1", + "description": "NodeBase modules; JavaScript app on Android", + "main": "index.js", + "scripts": { + "test": "echo no test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/dna2github/dna2mtgol.git" + }, + "keywords": [ + "innocent" + ], + "author": "Seven Lju", + "license": "GPL-3.0", + "bugs": { + "url": "https://github.com/dna2github/dna2mtgol/issues" + }, + "homepage": "https://github.com/dna2github/dna2mtgol/tree/master/nodeBase/modules", + "dependencies": { + "body-parser": "^1.15.2", + "bootstrap": "^3.3.7", + "express": "^4.14.0", + "jquery": "^3.1.1", + "lodash": "^4.17.2", + "multer": "^1.3.0", + "serve-index": "^1.8.0", + "uuid": "^3.0.1", + "ws": "^7.2.0" + }, + "devDependencies": { + "gulp": "^3.9.1", + "gulp-clean": "^0.3.2", + "gulp-concat": "^2.6.1", + "gulp-connect": "^5.0.0", + "http-proxy-middleware": "^0.17.3", + "npm": "^6.14.6" + } +} diff --git a/modules/piano/config b/modules/piano/config new file mode 100644 index 0000000..de18a13 --- /dev/null +++ b/modules/piano/config @@ -0,0 +1,2 @@ +name=Simple Piano +port=9090 diff --git a/modules/piano/index.js b/modules/piano/index.js new file mode 100644 index 0000000..67ffb07 --- /dev/null +++ b/modules/piano/index.js @@ -0,0 +1,13 @@ +const path = require('path'); +const fs = require('fs'); +const express = require('express'); +const body_parser = require('body-parser'); +const app = express(); + +const static_dir = path.join(__dirname, 'static'); + +app.use('/', express.static(static_dir)); + +app.listen(9090, '0.0.0.0', () => { + console.log(`Nodepad is listening at 0.0.0.0:9090`); +}); diff --git a/modules/piano/package.json b/modules/piano/package.json new file mode 100644 index 0000000..ca63d1d --- /dev/null +++ b/modules/piano/package.json @@ -0,0 +1,28 @@ +{ + "name": "Simple Piano", + "version": "0.1.0", + "description": "Simple Piano for Android NodeBase", + "main": "index.js", + "dependencies": { + "body-parser": "^1.16.0", + "bootstrap": "^3.3.7", + "express": "^4.16.2", + "tonegenerator": "^0.3.0", + "uuid": "^3.0.1", + "waud.js": "^0.9.16", + "waveheader": "0.0.2" + }, + "devDependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "module", + "nodebase", + "android", + "editor", + "notepad" + ], + "author": "Seven Lju", + "license": "MIT" +} diff --git a/modules/piano/readme b/modules/piano/readme new file mode 100644 index 0000000..29bd7ed --- /dev/null +++ b/modules/piano/readme @@ -0,0 +1,3 @@ +# Simple Piano +running: 9090 +params: (no params) diff --git a/modules/piano/static/index.html b/modules/piano/static/index.html new file mode 100644 index 0000000..6d483c9 --- /dev/null +++ b/modules/piano/static/index.html @@ -0,0 +1,132 @@ + + + + + + Piano + + + +
+
C
+
D
+
E
+
F
+
G
+
A
+
B
+
+
+
C
+
D
+
E
+
F
+
G
+
A
+
B
+
+
+
C
+
D
+
E
+
F
+
G
+
A
+
B
+
+
+
C
+
D
+
E
+
F
+
G
+
+ + + + + diff --git a/modules/piano/static/soundutils.js b/modules/piano/static/soundutils.js new file mode 100644 index 0000000..488dc8e --- /dev/null +++ b/modules/piano/static/soundutils.js @@ -0,0 +1,200 @@ +var module = { + exports: {} +}; + +(function () { +// https://github.com/karlwestin/node-tonegenerator +/* + * ToneGenerator for node.js + * generates raw PCM data for a tone, + * specify frequency, length, volume and sampling rate + */ + +var shapes = { + sine: function (i, cycle, volume) { + // i / cycle => value between 0 and 1 + // 0 = beginning of circly + // 0.25 Math.sin = 1 + // 0.5 Math.sin = 0 + // 0.75 Math.sin = -1 + // 1 Math.sin = 1 + return Math.min(volume * Math.sin((i/cycle) * Math.PI * 2), volume - 1); + }, + triangle: function (i, cycle, volume) { + var halfCycle = cycle / 2 + var level + + if (i < halfCycle) { + level = (volume * 2) * (i / halfCycle) - volume; + } else { + i = i - halfCycle + level = -(volume * 2) * (i / halfCycle) + volume; + } + + return Math.min(level, volume - 1); + }, + saw: function (i, cycle, volume) { + return Math.min((volume * 2) * (i / cycle) - volume, volume - 1); + }, + square: function (i, cycle, volume) { + if(i > cycle / 2) { + return volume - 1; + } + + return -volume; + } +} + +function generateCycle(cycle, volume, shape) { + var data = []; + var tmp; + var generator = typeof shape == 'function' ? shape : shapes[shape]; + if (!generator) { + throw new Error('Invalid wave form: "' + shape + '" choose between: ' + Object.keys(shapes)); + } + + for(var i = 0; i < cycle; i++) { + tmp = generator(i, cycle, volume); + data[i] = Math.round(tmp); + } + return data; +} + +function generateWaveForm(opts) { + opts = opts || {} + var freq = opts.freq || 440; + var rate = opts.rate || 22050 + var lengthInSecs = opts.lengthInSecs || 2.0; + var volume = opts.volume || 30; + var shape = opts.shape || 'sine'; + + var cycle = Math.floor(rate/freq); + var samplesLeft = lengthInSecs * rate; + var cycles = samplesLeft/cycle; + var ret = []; + + for(var i = 0; i < cycles; i++) { + ret = ret.concat(generateCycle(cycle, volume, shape)); + } + return ret; +}; + +module.exports.tone = function() { + // to support both old interface and the new one: + var opts = arguments[0] + if (arguments.length > 1 && typeof opts === "number") { + opts = {} + opts.freq = arguments[0] + opts.lengthInSecs = arguments[1] + opts.volume = arguments[2] + opts.rate = arguments[3] + } + + return generateWaveForm(opts) +} + +module.exports.MAX_16 = 32768; +module.exports.MAX_8 = 128; + +})(); + +(function() { + +function pack_32b_le(num) { + var a = 0, b = 0, c = 0, d = 0; + a = num % 256; + num >>= 8; + b = num % 256; + num >>= 8; + c = num % 256; + num >>= 8; + d = num % 256; + return ( + String.fromCharCode(a) + + String.fromCharCode(b) + + String.fromCharCode(c) + + String.fromCharCode(d) + ); +} + +function pack_16b_le(num) { + var a = 0, b = 0; + a = num % 256; + num >>= 8; + b = num % 256; + return ( + String.fromCharCode(a) + + String.fromCharCode(b) + ); +} +// https://github.com/karlwestin/node-waveheadera +// modified to browserify :-- Seven Lju + +/* + * WaveHeader + * + * writes a pcm wave header to a buffer + returns it + * + * taken form + * from github.com/tooTallNate/node-wav + * lib/writer.js + * + * the only reason for this module to exist is that i couldn't + * understand how to use the one above, so I made my own. + * You propably wanna use that one + */ +module.exports.wavheader = function generateHeader(length, options) { + options = options || {}; + var RIFF = 'RIFF'; + var WAVE = 'WAVE'; + var fmt = 'fmt '; + var data = 'data'; + + var MAX_WAV = 4294967295 - 100; + var format = 1; // raw PCM + var channels = options.channels || 1; + var sampleRate = options.sampleRate || 44100; + var bitDepth = options.bitDepth || 8; + + var headerLength = 44; + var dataLength = length || MAX_WAV; + var fileSize = dataLength + headerLength; + var header = ''; + + // write the "RIFF" identifier + header += RIFF; + // write the file size minus the identifier and this 32-bit int + header += pack_32b_le(fileSize - 8); + // write the "WAVE" identifier + header += WAVE; + // write the "fmt " sub-chunk identifier + header += fmt; + + // write the size of the "fmt " chunk + // XXX: value of 16 is hard-coded for raw PCM format. other formats have + // different size. + header += pack_32b_le(16); + // write the audio format code + header += pack_16b_le(format); + // write the number of channels + header += pack_16b_le(channels); + // write the sample rate + header += pack_32b_le(sampleRate); + // write the byte rate + var byteRate = sampleRate * channels * bitDepth / 8; + header += pack_32b_le(byteRate); + // write the block align + var blockAlign = channels * bitDepth / 8; + header += pack_16b_le(blockAlign); + // write the bits per sample + header += pack_16b_le(bitDepth); + // write the "data" sub-chunk ID + header += data; + // write the remaining length of the rest of the data + header += pack_32b_le(dataLength); + + // flush the header and after that pass-through "dataLength" bytes + return header; +}; + +})(); diff --git a/modules/service_template/client/css/clr-ui.min.css b/modules/service_template/client/css/clr-ui.min.css new file mode 100644 index 0000000..3a7a928 --- /dev/null +++ b/modules/service_template/client/css/clr-ui.min.css @@ -0,0 +1,17 @@ +/*! + * Copyright (c) 2016-2017 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + * + * Clarity v0.11.3 | MIT license | https://github.com/vmware/clarity + * + */ +/*! normalize.css v4.2.0 | MIT License | github.com/necolas/normalize.css */html{font-family:sans-serif;line-height:1.15;-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}article,aside,details,figcaption,figure,footer,header,main,menu,nav,section,summary{display:block}audio,canvas,progress,video{display:inline-block}audio:not([controls]){display:none;height:0}progress{vertical-align:baseline}[hidden],template{display:none}a{background-color:transparent;-webkit-text-decoration-skip:objects}a:active,a:hover{outline-width:0}abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}b,strong{font-weight:inherit;font-weight:bolder}dfn{font-style:italic}h1{font-size:2em;margin:.67em 0}mark{background-color:#ff0;color:#000}small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}sub{bottom:-.25em}sup{top:-.5em}img{border-style:none}svg:not(:root){overflow:hidden}code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}figure{margin:1em 40px}hr{box-sizing:content-box;height:0;overflow:visible}button,input,optgroup,select,textarea{font:inherit;margin:0}optgroup{font-weight:700}button,input{overflow:visible}button,select{text-transform:none}[type=reset],[type=submit],button,html [type=button]{-webkit-appearance:button}[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner,button::-moz-focus-inner{border-style:none;padding:0}[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring,button:-moz-focusring{outline:1px dotted ButtonText}fieldset{border:1px solid silver;margin:0 2px;padding:.35em .625em .75em}legend{box-sizing:border-box;color:inherit;display:table;max-width:100%;white-space:normal}textarea{overflow:auto}[type=checkbox],[type=radio]{box-sizing:border-box;padding:0}[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}[type=search]{-webkit-appearance:textfield;outline-offset:-2px}[type=search]::-webkit-search-cancel-button,[type=search]::-webkit-search-decoration{-webkit-appearance:none}::-webkit-input-placeholder{color:inherit;opacity:.54}::-webkit-file-upload-button{-webkit-appearance:button;font:inherit} + +/** + * Copyright (c) 2016-2017 VMware, Inc. All Rights Reserved. + * This software is released under MIT license. + * The full license information can be found in LICENSE in the root directory of this project. + */.media{display:-webkit-box;display:-ms-flexbox;display:flex}.media-body{-webkit-box-flex:1;-ms-flex:1;flex:1}.media-middle{-ms-flex-item-align:center;-ms-grid-row-align:center;align-self:center}.media-bottom{-ms-flex-item-align:end;align-self:flex-end}.media-object{display:block}.media-object.img-thumbnail{max-width:none}.media-right{padding-left:10px}.media-left{padding-right:10px}.media-heading{margin-top:0;margin-bottom:5px}.media-list{padding-left:0;list-style:none}.align-baseline{vertical-align:baseline!important}.align-top{vertical-align:top!important}.align-middle{vertical-align:middle!important}.align-bottom{vertical-align:bottom!important}.align-text-bottom{vertical-align:text-bottom!important}.align-text-top{vertical-align:text-top!important}.bg-faded{background-color:#fff}.bg-primary{background-color:#0275d8!important}a.bg-primary:focus,a.bg-primary:hover{background-color:#025aa5!important}.bg-success{background-color:#5cb85c!important}a.bg-success:focus,a.bg-success:hover{background-color:#449d44!important}.bg-info{background-color:#5bc0de!important}a.bg-info:focus,a.bg-info:hover{background-color:#31b0d5!important}.bg-warning{background-color:#f0ad4e!important}a.bg-warning:focus,a.bg-warning:hover{background-color:#ec971f!important}.bg-danger{background-color:#d9534f!important}a.bg-danger:focus,a.bg-danger:hover{background-color:#c9302c!important}.bg-inverse{background-color:#373a3c!important}a.bg-inverse:focus,a.bg-inverse:hover{background-color:#1f2021!important}.rounded{border-radius:.25rem}.rounded-top{border-top-left-radius:.25rem}.rounded-right,.rounded-top{border-top-right-radius:.25rem}.rounded-bottom,.rounded-right{border-bottom-right-radius:.25rem}.rounded-bottom,.rounded-left{border-bottom-left-radius:.25rem}.rounded-left{border-top-left-radius:.25rem}.rounded-circle{border-radius:50%}.clearfix:after{content:"";display:table;clear:both}.d-block{display:block!important}.d-inline-block{display:inline-block!important}.d-inline{display:inline!important}.flex-xs-first{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.flex-xs-last{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.flex-xs-unordered{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}.flex-items-xs-top{-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start}.flex-items-xs-middle{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.flex-items-xs-bottom{-webkit-box-align:end;-ms-flex-align:end;align-items:flex-end}.flex-xs-top{-ms-flex-item-align:start;align-self:flex-start}.flex-xs-middle{-ms-flex-item-align:center;-ms-grid-row-align:center;align-self:center}.flex-xs-bottom{-ms-flex-item-align:end;align-self:flex-end}.flex-items-xs-left{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.flex-items-xs-center{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.flex-items-xs-right{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.flex-items-xs-around{-ms-flex-pack:distribute;justify-content:space-around}.flex-items-xs-between{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}@media (min-width:576px){.flex-sm-first{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.flex-sm-last{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.flex-sm-unordered{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}}@media (min-width:576px){.flex-items-sm-top{-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start}.flex-items-sm-middle{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.flex-items-sm-bottom{-webkit-box-align:end;-ms-flex-align:end;align-items:flex-end}}@media (min-width:576px){.flex-sm-top{-ms-flex-item-align:start;align-self:flex-start}.flex-sm-middle{-ms-flex-item-align:center;-ms-grid-row-align:center;align-self:center}.flex-sm-bottom{-ms-flex-item-align:end;align-self:flex-end}}@media (min-width:576px){.flex-items-sm-left{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.flex-items-sm-center{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.flex-items-sm-right{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.flex-items-sm-around{-ms-flex-pack:distribute;justify-content:space-around}.flex-items-sm-between{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}}@media (min-width:768px){.flex-md-first{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.flex-md-last{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.flex-md-unordered{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}}@media (min-width:768px){.flex-items-md-top{-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start}.flex-items-md-middle{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.flex-items-md-bottom{-webkit-box-align:end;-ms-flex-align:end;align-items:flex-end}}@media (min-width:768px){.flex-md-top{-ms-flex-item-align:start;align-self:flex-start}.flex-md-middle{-ms-flex-item-align:center;-ms-grid-row-align:center;align-self:center}.flex-md-bottom{-ms-flex-item-align:end;align-self:flex-end}}@media (min-width:768px){.flex-items-md-left{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.flex-items-md-center{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.flex-items-md-right{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.flex-items-md-around{-ms-flex-pack:distribute;justify-content:space-around}.flex-items-md-between{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}}@media (min-width:992px){.flex-lg-first{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.flex-lg-last{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.flex-lg-unordered{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}}@media (min-width:992px){.flex-items-lg-top{-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start}.flex-items-lg-middle{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.flex-items-lg-bottom{-webkit-box-align:end;-ms-flex-align:end;align-items:flex-end}}@media (min-width:992px){.flex-lg-top{-ms-flex-item-align:start;align-self:flex-start}.flex-lg-middle{-ms-flex-item-align:center;-ms-grid-row-align:center;align-self:center}.flex-lg-bottom{-ms-flex-item-align:end;align-self:flex-end}}@media (min-width:992px){.flex-items-lg-left{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.flex-items-lg-center{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.flex-items-lg-right{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.flex-items-lg-around{-ms-flex-pack:distribute;justify-content:space-around}.flex-items-lg-between{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}}@media (min-width:1200px){.flex-xl-first{-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}.flex-xl-last{-webkit-box-ordinal-group:2;-ms-flex-order:1;order:1}.flex-xl-unordered{-webkit-box-ordinal-group:1;-ms-flex-order:0;order:0}}@media (min-width:1200px){.flex-items-xl-top{-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start}.flex-items-xl-middle{-webkit-box-align:center;-ms-flex-align:center;align-items:center}.flex-items-xl-bottom{-webkit-box-align:end;-ms-flex-align:end;align-items:flex-end}}@media (min-width:1200px){.flex-xl-top{-ms-flex-item-align:start;align-self:flex-start}.flex-xl-middle{-ms-flex-item-align:center;-ms-grid-row-align:center;align-self:center}.flex-xl-bottom{-ms-flex-item-align:end;align-self:flex-end}}@media (min-width:1200px){.flex-items-xl-left{-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start}.flex-items-xl-center{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.flex-items-xl-right{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.flex-items-xl-around{-ms-flex-pack:distribute;justify-content:space-around}.flex-items-xl-between{-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between}}.float-xs-left{float:left!important}.float-xs-right{float:right!important}.float-xs-none{float:none!important}@media (min-width:576px){.float-sm-left{float:left!important}.float-sm-right{float:right!important}.float-sm-none{float:none!important}}@media (min-width:768px){.float-md-left{float:left!important}.float-md-right{float:right!important}.float-md-none{float:none!important}}@media (min-width:992px){.float-lg-left{float:left!important}.float-lg-right{float:right!important}.float-lg-none{float:none!important}}@media (min-width:1200px){.float-xl-left{float:left!important}.float-xl-right{float:right!important}.float-xl-none{float:none!important}}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);border:0}.sr-only-focusable:active,.sr-only-focusable:focus{position:static;width:auto;height:auto;margin:0;overflow:visible;clip:auto}.w-100{width:100%!important}.h-100{height:100%!important}.mx-auto{margin-right:auto!important;margin-left:auto!important}.m-0{margin:0 0!important}.mt-0{margin-top:0!important}.mr-0{margin-right:0!important}.mb-0{margin-bottom:0!important}.ml-0,.mx-0{margin-left:0!important}.mx-0{margin-right:0!important}.my-0{margin-top:0!important;margin-bottom:0!important}.m-1{margin:1rem 1rem!important}.mt-1{margin-top:1rem!important}.mr-1{margin-right:1rem!important}.mb-1{margin-bottom:1rem!important}.ml-1,.mx-1{margin-left:1rem!important}.mx-1{margin-right:1rem!important}.my-1{margin-top:1rem!important;margin-bottom:1rem!important}.m-2{margin:1.5rem 1.5rem!important}.mt-2{margin-top:1.5rem!important}.mr-2{margin-right:1.5rem!important}.mb-2{margin-bottom:1.5rem!important}.ml-2,.mx-2{margin-left:1.5rem!important}.mx-2{margin-right:1.5rem!important}.my-2{margin-top:1.5rem!important;margin-bottom:1.5rem!important}.m-3{margin:3rem 3rem!important}.mt-3{margin-top:3rem!important}.mr-3{margin-right:3rem!important}.mb-3{margin-bottom:3rem!important}.ml-3,.mx-3{margin-left:3rem!important}.mx-3{margin-right:3rem!important}.my-3{margin-top:3rem!important;margin-bottom:3rem!important}.p-0{padding:0 0!important}.pt-0{padding-top:0!important}.pr-0{padding-right:0!important}.pb-0{padding-bottom:0!important}.pl-0,.px-0{padding-left:0!important}.px-0{padding-right:0!important}.py-0{padding-top:0!important;padding-bottom:0!important}.p-1{padding:1rem 1rem!important}.pt-1{padding-top:1rem!important}.pr-1{padding-right:1rem!important}.pb-1{padding-bottom:1rem!important}.pl-1,.px-1{padding-left:1rem!important}.px-1{padding-right:1rem!important}.py-1{padding-top:1rem!important;padding-bottom:1rem!important}.p-2{padding:1.5rem 1.5rem!important}.pt-2{padding-top:1.5rem!important}.pr-2{padding-right:1.5rem!important}.pb-2{padding-bottom:1.5rem!important}.pl-2,.px-2{padding-left:1.5rem!important}.px-2{padding-right:1.5rem!important}.py-2{padding-top:1.5rem!important;padding-bottom:1.5rem!important}.p-3{padding:3rem 3rem!important}.pt-3{padding-top:3rem!important}.pr-3{padding-right:3rem!important}.pb-3{padding-bottom:3rem!important}.pl-3,.px-3{padding-left:3rem!important}.px-3{padding-right:3rem!important}.py-3{padding-top:3rem!important;padding-bottom:3rem!important}.pos-f-t{position:fixed;top:0;right:0;left:0;z-index:1030}.text-nowrap{white-space:nowrap!important}.text-truncate{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.text-xs-left{text-align:left!important}.text-xs-right{text-align:right!important}.text-xs-center{text-align:center!important}@media (min-width:576px){.text-sm-left{text-align:left!important}.text-sm-right{text-align:right!important}.text-sm-center{text-align:center!important}}@media (min-width:768px){.text-md-left{text-align:left!important}.text-md-right{text-align:right!important}.text-md-center{text-align:center!important}}@media (min-width:992px){.text-lg-left{text-align:left!important}.text-lg-right{text-align:right!important}.text-lg-center{text-align:center!important}}@media (min-width:1200px){.text-xl-left{text-align:left!important}.text-xl-right{text-align:right!important}.text-xl-center{text-align:center!important}}.text-lowercase{text-transform:lowercase!important}.text-uppercase{text-transform:uppercase!important}.text-capitalize{text-transform:capitalize!important}.font-weight-normal{font-weight:400}.font-weight-bold{font-weight:700}.font-italic{font-style:italic}.text-white{color:#fff!important}.text-muted{color:#818a91!important}a.text-muted:focus,a.text-muted:hover{color:#687077!important}.text-primary{color:#0275d8!important}a.text-primary:focus,a.text-primary:hover{color:#025aa5!important}.text-success{color:#5cb85c!important}a.text-success:focus,a.text-success:hover{color:#449d44!important}.text-info{color:#5bc0de!important}a.text-info:focus,a.text-info:hover{color:#31b0d5!important}.text-warning{color:#f0ad4e!important}a.text-warning:focus,a.text-warning:hover{color:#ec971f!important}.text-danger{color:#d9534f!important}a.text-danger:focus,a.text-danger:hover{color:#c9302c!important}.text-gray-dark{color:#737373!important}a.text-gray-dark:focus,a.text-gray-dark:hover{color:#5a5a5a!important}.text-hide{font:0/0 a;color:transparent;text-shadow:none;background-color:transparent;border:0}.invisible{visibility:hidden!important}.hidden-xs-up{display:none!important}@media (max-width:575px){.hidden-xs-down{display:none!important}}@media (min-width:576px){.hidden-sm-up{display:none!important}}@media (max-width:767px){.hidden-sm-down{display:none!important}}@media (min-width:768px){.hidden-md-up{display:none!important}}@media (max-width:991px){.hidden-md-down{display:none!important}}@media (min-width:992px){.hidden-lg-up{display:none!important}}@media (max-width:1199px){.hidden-lg-down{display:none!important}}@media (min-width:1200px){.hidden-xl-up{display:none!important}}.hidden-xl-down,.visible-print-block{display:none!important}@media print{.visible-print-block{display:block!important}}.visible-print-inline{display:none!important}@media print{.visible-print-inline{display:inline!important}}.visible-print-inline-block{display:none!important}@media print{.visible-print-inline-block{display:inline-block!important}}@media print{.hidden-print{display:none!important}}.img-fluid,.img-thumbnail{max-width:100%;height:auto}.img-thumbnail{padding:.25rem;background-color:#fff;border:1px solid #ddd;border-radius:.25rem;transition:all .2s ease-in-out}.figure{display:inline-block}.figure-img{margin-bottom:.5rem;line-height:1}.figure-caption{font-size:90%;color:#eee}.list-group{padding-left:0;margin-bottom:0}.list-group-item{position:relative;display:block;padding:.75rem 1.25rem;margin-bottom:-1px;background-color:#fff;border:1px solid #ddd}.list-group-item:first-child{border-top-right-radius:.25rem;border-top-left-radius:.25rem}.list-group-item:last-child{margin-bottom:0;border-bottom-right-radius:.25rem;border-bottom-left-radius:.25rem}.list-group-item.disabled,.list-group-item.disabled:focus,.list-group-item.disabled:hover{color:#818a91;cursor:not-allowed;background-color:#eceeef}.list-group-item.disabled .list-group-item-heading,.list-group-item.disabled:focus .list-group-item-heading,.list-group-item.disabled:hover .list-group-item-heading{color:inherit}.list-group-item.disabled .list-group-item-text,.list-group-item.disabled:focus .list-group-item-text,.list-group-item.disabled:hover .list-group-item-text{color:#818a91}.list-group-item.active,.list-group-item.active:focus,.list-group-item.active:hover{z-index:2;color:#fff;text-decoration:none;background-color:#0275d8;border-color:#0275d8}.list-group-item.active .list-group-item-heading,.list-group-item.active .list-group-item-heading>.small,.list-group-item.active .list-group-item-heading>small,.list-group-item.active:focus .list-group-item-heading,.list-group-item.active:focus .list-group-item-heading>.small,.list-group-item.active:focus .list-group-item-heading>small,.list-group-item.active:hover .list-group-item-heading,.list-group-item.active:hover .list-group-item-heading>.small,.list-group-item.active:hover .list-group-item-heading>small{color:inherit}.list-group-item.active .list-group-item-text,.list-group-item.active:focus .list-group-item-text,.list-group-item.active:hover .list-group-item-text{color:#a8d6fe}.list-group-flush .list-group-item{border-right:0;border-left:0;border-radius:0}.list-group-item-action{width:100%;color:#555;text-align:inherit}.list-group-item-action .list-group-item-heading{color:#333}.list-group-item-action:focus,.list-group-item-action:hover{color:#555;text-decoration:none;background-color:#f5f5f5}.list-group-item-success{color:#3c763d;background-color:#dff0d8}a.list-group-item-success,button.list-group-item-success{color:#3c763d}a.list-group-item-success .list-group-item-heading,button.list-group-item-success .list-group-item-heading{color:inherit}a.list-group-item-success:focus,a.list-group-item-success:hover,button.list-group-item-success:focus,button.list-group-item-success:hover{color:#3c763d;background-color:#d0e9c6}a.list-group-item-success.active,a.list-group-item-success.active:focus,a.list-group-item-success.active:hover,button.list-group-item-success.active,button.list-group-item-success.active:focus,button.list-group-item-success.active:hover{color:#fff;background-color:#3c763d;border-color:#3c763d}.list-group-item-info{color:#31708f;background-color:#d9edf7}a.list-group-item-info,button.list-group-item-info{color:#31708f}a.list-group-item-info .list-group-item-heading,button.list-group-item-info .list-group-item-heading{color:inherit}a.list-group-item-info:focus,a.list-group-item-info:hover,button.list-group-item-info:focus,button.list-group-item-info:hover{color:#31708f;background-color:#c4e3f3}a.list-group-item-info.active,a.list-group-item-info.active:focus,a.list-group-item-info.active:hover,button.list-group-item-info.active,button.list-group-item-info.active:focus,button.list-group-item-info.active:hover{color:#fff;background-color:#31708f;border-color:#31708f}.list-group-item-warning{color:#8a6d3b;background-color:#fcf8e3}a.list-group-item-warning,button.list-group-item-warning{color:#8a6d3b}a.list-group-item-warning .list-group-item-heading,button.list-group-item-warning .list-group-item-heading{color:inherit}a.list-group-item-warning:focus,a.list-group-item-warning:hover,button.list-group-item-warning:focus,button.list-group-item-warning:hover{color:#8a6d3b;background-color:#faf2cc}a.list-group-item-warning.active,a.list-group-item-warning.active:focus,a.list-group-item-warning.active:hover,button.list-group-item-warning.active,button.list-group-item-warning.active:focus,button.list-group-item-warning.active:hover{color:#fff;background-color:#8a6d3b;border-color:#8a6d3b}.list-group-item-danger{color:#a94442;background-color:#f2dede}a.list-group-item-danger,button.list-group-item-danger{color:#a94442}a.list-group-item-danger .list-group-item-heading,button.list-group-item-danger .list-group-item-heading{color:inherit}a.list-group-item-danger:focus,a.list-group-item-danger:hover,button.list-group-item-danger:focus,button.list-group-item-danger:hover{color:#a94442;background-color:#ebcccc}a.list-group-item-danger.active,a.list-group-item-danger.active:focus,a.list-group-item-danger.active:hover,button.list-group-item-danger.active,button.list-group-item-danger.active:focus,button.list-group-item-danger.active:hover{color:#fff;background-color:#a94442;border-color:#a94442}.list-group-item-heading{margin-top:0;margin-bottom:5px}.list-group-item-text{margin-bottom:0;line-height:1.3}.close{float:right;font-size:1.5rem;font-weight:700;line-height:1;color:#000;text-shadow:0 1px 0 #fff;opacity:.2}.close:focus,.close:hover{color:#000;text-decoration:none;cursor:pointer;opacity:.5}button.close{padding:0;cursor:pointer;background:transparent;border:0;-webkit-appearance:none}.container{margin-left:auto;margin-right:auto;padding-left:.5rem;padding-right:.5rem}@media (min-width:576px){.container{width:540px;max-width:100%}}@media (min-width:768px){.container{width:720px;max-width:100%}}@media (min-width:992px){.container{width:960px;max-width:100%}}@media (min-width:1200px){.container{width:1140px;max-width:100%}}.container-fluid{margin-left:auto;margin-right:auto;padding-left:.5rem;padding-right:.5rem}.row{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;margin-right:-.5rem;margin-left:-.5rem}@media (min-width:576px){.row{margin-right:-.5rem;margin-left:-.5rem}}@media (min-width:768px){.row{margin-right:-.5rem;margin-left:-.5rem}}@media (min-width:992px){.row{margin-right:-.5rem;margin-left:-.5rem}}@media (min-width:1200px){.row{margin-right:-.5rem;margin-left:-.5rem}}.col-lg,.col-lg-1,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-10,.col-lg-11,.col-lg-12,.col-md,.col-md-1,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-10,.col-md-11,.col-md-12,.col-sm,.col-sm-1,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-10,.col-sm-11,.col-sm-12,.col-xl,.col-xl-1,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-10,.col-xl-11,.col-xl-12,.col-xs,.col-xs-1,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9,.col-xs-10,.col-xs-11,.col-xs-12{position:relative;min-height:1px;width:100%;padding-right:.5rem;padding-left:.5rem}@media (min-width:576px){.col-lg,.col-lg-1,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-10,.col-lg-11,.col-lg-12,.col-md,.col-md-1,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-10,.col-md-11,.col-md-12,.col-sm,.col-sm-1,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-10,.col-sm-11,.col-sm-12,.col-xl,.col-xl-1,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-10,.col-xl-11,.col-xl-12,.col-xs,.col-xs-1,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9,.col-xs-10,.col-xs-11,.col-xs-12{padding-right:.5rem;padding-left:.5rem}}@media (min-width:768px){.col-lg,.col-lg-1,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-10,.col-lg-11,.col-lg-12,.col-md,.col-md-1,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-10,.col-md-11,.col-md-12,.col-sm,.col-sm-1,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-10,.col-sm-11,.col-sm-12,.col-xl,.col-xl-1,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-10,.col-xl-11,.col-xl-12,.col-xs,.col-xs-1,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9,.col-xs-10,.col-xs-11,.col-xs-12{padding-right:.5rem;padding-left:.5rem}}@media (min-width:992px){.col-lg,.col-lg-1,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-10,.col-lg-11,.col-lg-12,.col-md,.col-md-1,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-10,.col-md-11,.col-md-12,.col-sm,.col-sm-1,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-10,.col-sm-11,.col-sm-12,.col-xl,.col-xl-1,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-10,.col-xl-11,.col-xl-12,.col-xs,.col-xs-1,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9,.col-xs-10,.col-xs-11,.col-xs-12{padding-right:.5rem;padding-left:.5rem}}@media (min-width:1200px){.col-lg,.col-lg-1,.col-lg-2,.col-lg-3,.col-lg-4,.col-lg-5,.col-lg-6,.col-lg-7,.col-lg-8,.col-lg-9,.col-lg-10,.col-lg-11,.col-lg-12,.col-md,.col-md-1,.col-md-2,.col-md-3,.col-md-4,.col-md-5,.col-md-6,.col-md-7,.col-md-8,.col-md-9,.col-md-10,.col-md-11,.col-md-12,.col-sm,.col-sm-1,.col-sm-2,.col-sm-3,.col-sm-4,.col-sm-5,.col-sm-6,.col-sm-7,.col-sm-8,.col-sm-9,.col-sm-10,.col-sm-11,.col-sm-12,.col-xl,.col-xl-1,.col-xl-2,.col-xl-3,.col-xl-4,.col-xl-5,.col-xl-6,.col-xl-7,.col-xl-8,.col-xl-9,.col-xl-10,.col-xl-11,.col-xl-12,.col-xs,.col-xs-1,.col-xs-2,.col-xs-3,.col-xs-4,.col-xs-5,.col-xs-6,.col-xs-7,.col-xs-8,.col-xs-9,.col-xs-10,.col-xs-11,.col-xs-12{padding-right:.5rem;padding-left:.5rem}}.col-xs{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-xs-1{-webkit-box-flex:0;-ms-flex:0 0 8.33333%;flex:0 0 8.33333%;max-width:8.33333%}.col-xs-2{-webkit-box-flex:0;-ms-flex:0 0 16.66667%;flex:0 0 16.66667%;max-width:16.66667%}.col-xs-3{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-xs-4{-webkit-box-flex:0;-ms-flex:0 0 33.33333%;flex:0 0 33.33333%;max-width:33.33333%}.col-xs-5{-webkit-box-flex:0;-ms-flex:0 0 41.66667%;flex:0 0 41.66667%;max-width:41.66667%}.col-xs-6{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-xs-7{-webkit-box-flex:0;-ms-flex:0 0 58.33333%;flex:0 0 58.33333%;max-width:58.33333%}.col-xs-8{-webkit-box-flex:0;-ms-flex:0 0 66.66667%;flex:0 0 66.66667%;max-width:66.66667%}.col-xs-9{-webkit-box-flex:0;-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-xs-10{-webkit-box-flex:0;-ms-flex:0 0 83.33333%;flex:0 0 83.33333%;max-width:83.33333%}.col-xs-11{-webkit-box-flex:0;-ms-flex:0 0 91.66667%;flex:0 0 91.66667%;max-width:91.66667%}.col-xs-12{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.pull-xs-0{right:auto}.pull-xs-1{right:8.33333%}.pull-xs-2{right:16.66667%}.pull-xs-3{right:25%}.pull-xs-4{right:33.33333%}.pull-xs-5{right:41.66667%}.pull-xs-6{right:50%}.pull-xs-7{right:58.33333%}.pull-xs-8{right:66.66667%}.pull-xs-9{right:75%}.pull-xs-10{right:83.33333%}.pull-xs-11{right:91.66667%}.pull-xs-12{right:100%}.push-xs-0{left:auto}.push-xs-1{left:8.33333%}.push-xs-2{left:16.66667%}.push-xs-3{left:25%}.push-xs-4{left:33.33333%}.push-xs-5{left:41.66667%}.push-xs-6{left:50%}.push-xs-7{left:58.33333%}.push-xs-8{left:66.66667%}.push-xs-9{left:75%}.push-xs-10{left:83.33333%}.push-xs-11{left:91.66667%}.push-xs-12{left:100%}.offset-xs-1{margin-left:8.33333%}.offset-xs-2{margin-left:16.66667%}.offset-xs-3{margin-left:25%}.offset-xs-4{margin-left:33.33333%}.offset-xs-5{margin-left:41.66667%}.offset-xs-6{margin-left:50%}.offset-xs-7{margin-left:58.33333%}.offset-xs-8{margin-left:66.66667%}.offset-xs-9{margin-left:75%}.offset-xs-10{margin-left:83.33333%}.offset-xs-11{margin-left:91.66667%}@media (min-width:576px){.col-sm{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-sm-1{-webkit-box-flex:0;-ms-flex:0 0 8.33333%;flex:0 0 8.33333%;max-width:8.33333%}.col-sm-2{-webkit-box-flex:0;-ms-flex:0 0 16.66667%;flex:0 0 16.66667%;max-width:16.66667%}.col-sm-3{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-sm-4{-webkit-box-flex:0;-ms-flex:0 0 33.33333%;flex:0 0 33.33333%;max-width:33.33333%}.col-sm-5{-webkit-box-flex:0;-ms-flex:0 0 41.66667%;flex:0 0 41.66667%;max-width:41.66667%}.col-sm-6{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-sm-7{-webkit-box-flex:0;-ms-flex:0 0 58.33333%;flex:0 0 58.33333%;max-width:58.33333%}.col-sm-8{-webkit-box-flex:0;-ms-flex:0 0 66.66667%;flex:0 0 66.66667%;max-width:66.66667%}.col-sm-9{-webkit-box-flex:0;-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-sm-10{-webkit-box-flex:0;-ms-flex:0 0 83.33333%;flex:0 0 83.33333%;max-width:83.33333%}.col-sm-11{-webkit-box-flex:0;-ms-flex:0 0 91.66667%;flex:0 0 91.66667%;max-width:91.66667%}.col-sm-12{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.pull-sm-0{right:auto}.pull-sm-1{right:8.33333%}.pull-sm-2{right:16.66667%}.pull-sm-3{right:25%}.pull-sm-4{right:33.33333%}.pull-sm-5{right:41.66667%}.pull-sm-6{right:50%}.pull-sm-7{right:58.33333%}.pull-sm-8{right:66.66667%}.pull-sm-9{right:75%}.pull-sm-10{right:83.33333%}.pull-sm-11{right:91.66667%}.pull-sm-12{right:100%}.push-sm-0{left:auto}.push-sm-1{left:8.33333%}.push-sm-2{left:16.66667%}.push-sm-3{left:25%}.push-sm-4{left:33.33333%}.push-sm-5{left:41.66667%}.push-sm-6{left:50%}.push-sm-7{left:58.33333%}.push-sm-8{left:66.66667%}.push-sm-9{left:75%}.push-sm-10{left:83.33333%}.push-sm-11{left:91.66667%}.push-sm-12{left:100%}.offset-sm-0{margin-left:0}.offset-sm-1{margin-left:8.33333%}.offset-sm-2{margin-left:16.66667%}.offset-sm-3{margin-left:25%}.offset-sm-4{margin-left:33.33333%}.offset-sm-5{margin-left:41.66667%}.offset-sm-6{margin-left:50%}.offset-sm-7{margin-left:58.33333%}.offset-sm-8{margin-left:66.66667%}.offset-sm-9{margin-left:75%}.offset-sm-10{margin-left:83.33333%}.offset-sm-11{margin-left:91.66667%}}@media (min-width:768px){.col-md{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-md-1{-webkit-box-flex:0;-ms-flex:0 0 8.33333%;flex:0 0 8.33333%;max-width:8.33333%}.col-md-2{-webkit-box-flex:0;-ms-flex:0 0 16.66667%;flex:0 0 16.66667%;max-width:16.66667%}.col-md-3{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-md-4{-webkit-box-flex:0;-ms-flex:0 0 33.33333%;flex:0 0 33.33333%;max-width:33.33333%}.col-md-5{-webkit-box-flex:0;-ms-flex:0 0 41.66667%;flex:0 0 41.66667%;max-width:41.66667%}.col-md-6{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-md-7{-webkit-box-flex:0;-ms-flex:0 0 58.33333%;flex:0 0 58.33333%;max-width:58.33333%}.col-md-8{-webkit-box-flex:0;-ms-flex:0 0 66.66667%;flex:0 0 66.66667%;max-width:66.66667%}.col-md-9{-webkit-box-flex:0;-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-md-10{-webkit-box-flex:0;-ms-flex:0 0 83.33333%;flex:0 0 83.33333%;max-width:83.33333%}.col-md-11{-webkit-box-flex:0;-ms-flex:0 0 91.66667%;flex:0 0 91.66667%;max-width:91.66667%}.col-md-12{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.pull-md-0{right:auto}.pull-md-1{right:8.33333%}.pull-md-2{right:16.66667%}.pull-md-3{right:25%}.pull-md-4{right:33.33333%}.pull-md-5{right:41.66667%}.pull-md-6{right:50%}.pull-md-7{right:58.33333%}.pull-md-8{right:66.66667%}.pull-md-9{right:75%}.pull-md-10{right:83.33333%}.pull-md-11{right:91.66667%}.pull-md-12{right:100%}.push-md-0{left:auto}.push-md-1{left:8.33333%}.push-md-2{left:16.66667%}.push-md-3{left:25%}.push-md-4{left:33.33333%}.push-md-5{left:41.66667%}.push-md-6{left:50%}.push-md-7{left:58.33333%}.push-md-8{left:66.66667%}.push-md-9{left:75%}.push-md-10{left:83.33333%}.push-md-11{left:91.66667%}.push-md-12{left:100%}.offset-md-0{margin-left:0}.offset-md-1{margin-left:8.33333%}.offset-md-2{margin-left:16.66667%}.offset-md-3{margin-left:25%}.offset-md-4{margin-left:33.33333%}.offset-md-5{margin-left:41.66667%}.offset-md-6{margin-left:50%}.offset-md-7{margin-left:58.33333%}.offset-md-8{margin-left:66.66667%}.offset-md-9{margin-left:75%}.offset-md-10{margin-left:83.33333%}.offset-md-11{margin-left:91.66667%}}@media (min-width:992px){.col-lg{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-lg-1{-webkit-box-flex:0;-ms-flex:0 0 8.33333%;flex:0 0 8.33333%;max-width:8.33333%}.col-lg-2{-webkit-box-flex:0;-ms-flex:0 0 16.66667%;flex:0 0 16.66667%;max-width:16.66667%}.col-lg-3{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-lg-4{-webkit-box-flex:0;-ms-flex:0 0 33.33333%;flex:0 0 33.33333%;max-width:33.33333%}.col-lg-5{-webkit-box-flex:0;-ms-flex:0 0 41.66667%;flex:0 0 41.66667%;max-width:41.66667%}.col-lg-6{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-lg-7{-webkit-box-flex:0;-ms-flex:0 0 58.33333%;flex:0 0 58.33333%;max-width:58.33333%}.col-lg-8{-webkit-box-flex:0;-ms-flex:0 0 66.66667%;flex:0 0 66.66667%;max-width:66.66667%}.col-lg-9{-webkit-box-flex:0;-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-lg-10{-webkit-box-flex:0;-ms-flex:0 0 83.33333%;flex:0 0 83.33333%;max-width:83.33333%}.col-lg-11{-webkit-box-flex:0;-ms-flex:0 0 91.66667%;flex:0 0 91.66667%;max-width:91.66667%}.col-lg-12{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.pull-lg-0{right:auto}.pull-lg-1{right:8.33333%}.pull-lg-2{right:16.66667%}.pull-lg-3{right:25%}.pull-lg-4{right:33.33333%}.pull-lg-5{right:41.66667%}.pull-lg-6{right:50%}.pull-lg-7{right:58.33333%}.pull-lg-8{right:66.66667%}.pull-lg-9{right:75%}.pull-lg-10{right:83.33333%}.pull-lg-11{right:91.66667%}.pull-lg-12{right:100%}.push-lg-0{left:auto}.push-lg-1{left:8.33333%}.push-lg-2{left:16.66667%}.push-lg-3{left:25%}.push-lg-4{left:33.33333%}.push-lg-5{left:41.66667%}.push-lg-6{left:50%}.push-lg-7{left:58.33333%}.push-lg-8{left:66.66667%}.push-lg-9{left:75%}.push-lg-10{left:83.33333%}.push-lg-11{left:91.66667%}.push-lg-12{left:100%}.offset-lg-0{margin-left:0}.offset-lg-1{margin-left:8.33333%}.offset-lg-2{margin-left:16.66667%}.offset-lg-3{margin-left:25%}.offset-lg-4{margin-left:33.33333%}.offset-lg-5{margin-left:41.66667%}.offset-lg-6{margin-left:50%}.offset-lg-7{margin-left:58.33333%}.offset-lg-8{margin-left:66.66667%}.offset-lg-9{margin-left:75%}.offset-lg-10{margin-left:83.33333%}.offset-lg-11{margin-left:91.66667%}}@media (min-width:1200px){.col-xl{-ms-flex-preferred-size:0;flex-basis:0;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;max-width:100%}.col-xl-1{-webkit-box-flex:0;-ms-flex:0 0 8.33333%;flex:0 0 8.33333%;max-width:8.33333%}.col-xl-2{-webkit-box-flex:0;-ms-flex:0 0 16.66667%;flex:0 0 16.66667%;max-width:16.66667%}.col-xl-3{-webkit-box-flex:0;-ms-flex:0 0 25%;flex:0 0 25%;max-width:25%}.col-xl-4{-webkit-box-flex:0;-ms-flex:0 0 33.33333%;flex:0 0 33.33333%;max-width:33.33333%}.col-xl-5{-webkit-box-flex:0;-ms-flex:0 0 41.66667%;flex:0 0 41.66667%;max-width:41.66667%}.col-xl-6{-webkit-box-flex:0;-ms-flex:0 0 50%;flex:0 0 50%;max-width:50%}.col-xl-7{-webkit-box-flex:0;-ms-flex:0 0 58.33333%;flex:0 0 58.33333%;max-width:58.33333%}.col-xl-8{-webkit-box-flex:0;-ms-flex:0 0 66.66667%;flex:0 0 66.66667%;max-width:66.66667%}.col-xl-9{-webkit-box-flex:0;-ms-flex:0 0 75%;flex:0 0 75%;max-width:75%}.col-xl-10{-webkit-box-flex:0;-ms-flex:0 0 83.33333%;flex:0 0 83.33333%;max-width:83.33333%}.col-xl-11{-webkit-box-flex:0;-ms-flex:0 0 91.66667%;flex:0 0 91.66667%;max-width:91.66667%}.col-xl-12{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;max-width:100%}.pull-xl-0{right:auto}.pull-xl-1{right:8.33333%}.pull-xl-2{right:16.66667%}.pull-xl-3{right:25%}.pull-xl-4{right:33.33333%}.pull-xl-5{right:41.66667%}.pull-xl-6{right:50%}.pull-xl-7{right:58.33333%}.pull-xl-8{right:66.66667%}.pull-xl-9{right:75%}.pull-xl-10{right:83.33333%}.pull-xl-11{right:91.66667%}.pull-xl-12{right:100%}.push-xl-0{left:auto}.push-xl-1{left:8.33333%}.push-xl-2{left:16.66667%}.push-xl-3{left:25%}.push-xl-4{left:33.33333%}.push-xl-5{left:41.66667%}.push-xl-6{left:50%}.push-xl-7{left:58.33333%}.push-xl-8{left:66.66667%}.push-xl-9{left:75%}.push-xl-10{left:83.33333%}.push-xl-11{left:91.66667%}.push-xl-12{left:100%}.offset-xl-0{margin-left:0}.offset-xl-1{margin-left:8.33333%}.offset-xl-2{margin-left:16.66667%}.offset-xl-3{margin-left:25%}.offset-xl-4{margin-left:33.33333%}.offset-xl-5{margin-left:41.66667%}.offset-xl-6{margin-left:50%}.offset-xl-7{margin-left:58.33333%}.offset-xl-8{margin-left:66.66667%}.offset-xl-9{margin-left:75%}.offset-xl-10{margin-left:83.33333%}.offset-xl-11{margin-left:91.66667%}}.embed-responsive{position:relative;display:block;height:0;padding:0;overflow:hidden}.embed-responsive .embed-responsive-item,.embed-responsive embed,.embed-responsive iframe,.embed-responsive object,.embed-responsive video{position:absolute;top:0;bottom:0;left:0;width:100%;height:100%;border:0}.embed-responsive-21by9{padding-bottom:42.85714%}.embed-responsive-16by9{padding-bottom:56.25%}.embed-responsive-4by3{padding-bottom:75%}.embed-responsive-1by1{padding-bottom:100%}html{box-sizing:border-box}*,:after,:before{box-sizing:inherit}@-ms-viewport{width:device-width}html{-ms-overflow-style:scrollbar;-webkit-tap-highlight-color:transparent}[tabindex="-1"]:focus{outline:none!important}img{vertical-align:middle}[role=button]{cursor:pointer}[role=button],a,area,button,input,label,select,summary,textarea{-ms-touch-action:manipulation;touch-action:manipulation}button:focus{outline:1px dotted;outline:5px auto -webkit-focus-ring-color}button,input,select,textarea{border-radius:0}input[type=checkbox]:disabled,input[type=radio]:disabled{cursor:not-allowed}input[type=date],input[type=datetime-local],input[type=month],input[type=time]{-webkit-appearance:listbox}fieldset{min-width:0;padding:0;margin:0;border:0}legend{display:block;width:100%;padding:0;line-height:inherit}input[type=search]{-webkit-appearance:none}[hidden]{display:none!important}dl{margin-bottom:0;margin-top:1rem}table{border-spacing:0}a:link{color:#007cbb;text-decoration:none}a:hover{color:#007cbb}a:active,a:hover{text-decoration:underline}a:active{color:#9460b8}a:visited{color:#5659b9;text-decoration:none}.is-off-screen{position:fixed!important;border:none!important;height:1px!important;width:1px!important;left:0!important;top:-1px!important;overflow:hidden!important;visibility:hidden!important;padding:0!important;margin:0 0 -1px 0!important}.alert-icon,.clr-icon{display:inline-block;height:.66667rem;width:.66667rem;padding:0;background-repeat:no-repeat;background-size:contain;vertical-align:middle}.alert-icon.clr-icon-warning,.alert-icon.icon-warning,.clr-icon.clr-icon-warning,.clr-icon.icon-warning{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg%20version%3D%221.1%22%20viewBox%3D%225%205%2026%2026%22%20preserveAspectRatio%3D%22xMidYMid%20meet%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%3E%3Cdefs%3E%3Cstyle%20type%3D%22text%2Fcss%22%3E%0A%09.clr-i-outline%7Bfill-rule%3Aevenodd%3Bclip-rule%3Aevenodd%3Bfill%3A%23737373%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Ctitle%3Eexclamation-triangle-line%3C%2Ftitle%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpath%20class%3D%22clr-i-outline%20clr-i-outline-path-1%22%20d%3D%22M18%2C21.32a1.3%2C1.3%2C0%2C0%2C0%2C1.3-1.3V14a1.3%2C1.3%2C0%2C1%2C0-2.6%2C0v6A1.3%2C1.3%2C0%2C0%2C0%2C18%2C21.32Z%22%3E%3C%2Fpath%3E%3Ccircle%20class%3D%22clr-i-outline%20clr-i-outline-path-2%22%20cx%3D%2217.95%22%20cy%3D%2224.27%22%20r%3D%221.5%22%3E%3C%2Fcircle%3E%3Cpath%20class%3D%22clr-i-outline%20clr-i-outline-path-3%22%20d%3D%22M30.33%2C25.54%2C20.59%2C7.6a3%2C3%2C0%2C0%2C0-5.27%2C0L5.57%2C25.54A3%2C3%2C0%2C0%2C0%2C8.21%2C30H27.69a3%2C3%2C0%2C0%2C0%2C2.64-4.43Zm-1.78%2C1.94a1%2C1%2C0%2C0%2C1-.86.49H8.21a1%2C1%2C0%2C0%2C1-.88-1.48L17.07%2C8.55a1%2C1%2C0%2C0%2C1%2C1.76%2C0l9.74%2C17.94A1%2C1%2C0%2C0%2C1%2C28.55%2C27.48Z%22%3E%3C%2Fpath%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3C%2Fsvg%3E")}.alert-icon.clr-icon-warning-white,.clr-icon.clr-icon-warning-white{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg%20version%3D%221.1%22%20viewBox%3D%225%205%2026%2026%22%20preserveAspectRatio%3D%22xMidYMid%20meet%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%3E%3Cdefs%3E%3Cstyle%20type%3D%22text%2Fcss%22%3E%0A%09.clr-i-outline%7Bfill-rule%3Aevenodd%3Bclip-rule%3Aevenodd%3Bfill%3A%23fff%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Ctitle%3Eexclamation-triangle-line%3C%2Ftitle%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpath%20class%3D%22clr-i-outline%20clr-i-outline-path-1%22%20d%3D%22M18%2C21.32a1.3%2C1.3%2C0%2C0%2C0%2C1.3-1.3V14a1.3%2C1.3%2C0%2C1%2C0-2.6%2C0v6A1.3%2C1.3%2C0%2C0%2C0%2C18%2C21.32Z%22%3E%3C%2Fpath%3E%3Ccircle%20class%3D%22clr-i-outline%20clr-i-outline-path-2%22%20cx%3D%2217.95%22%20cy%3D%2224.27%22%20r%3D%221.5%22%3E%3C%2Fcircle%3E%3Cpath%20class%3D%22clr-i-outline%20clr-i-outline-path-3%22%20d%3D%22M30.33%2C25.54%2C20.59%2C7.6a3%2C3%2C0%2C0%2C0-5.27%2C0L5.57%2C25.54A3%2C3%2C0%2C0%2C0%2C8.21%2C30H27.69a3%2C3%2C0%2C0%2C0%2C2.64-4.43Zm-1.78%2C1.94a1%2C1%2C0%2C0%2C1-.86.49H8.21a1%2C1%2C0%2C0%2C1-.88-1.48L17.07%2C8.55a1%2C1%2C0%2C0%2C1%2C1.76%2C0l9.74%2C17.94A1%2C1%2C0%2C0%2C1%2C28.55%2C27.48Z%22%3E%3C%2Fpath%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3C%2Fsvg%3E")}.alert-icon.clr-vmw-logo,.clr-icon.clr-vmw-logo{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg%20viewBox%3D%220%200%2036%2036%22%20version%3D%221.1%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%3E%0A%20%20%20%20%3Ctitle%3Evm%20bug%3C%2Ftitle%3E%0A%20%20%20%20%3Cdefs%3E%3C%2Fdefs%3E%0A%20%20%20%20%3Cg%20id%3D%22Headers%22%20stroke%3D%22none%22%20stroke-width%3D%221%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%0A%20%20%20%20%20%20%20%20%3Cg%20id%3D%22CL-Headers-Specs%22%20transform%3D%22translate(-262.000000%2C%20-175.000000)%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Cg%20id%3D%2201%22%20transform%3D%22translate(238.000000%2C%20163.000000)%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cg%20id%3D%22vm-bug%22%20transform%3D%22translate(24.703125%2C%2012.000000)%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20id%3D%22Rectangle-42%22%20fill-opacity%3D%220.25%22%20fill%3D%22%23DDDDDD%22%20opacity%3D%220.6%22%20x%3D%220%22%20y%3D%220%22%20width%3D%2236%22%20height%3D%2236%22%20rx%3D%223%22%3E%3C%2Frect%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpath%20d%3D%22M7.63948376%2C13.8762402%20C7.32265324%2C13.2097082%206.53978152%2C12.9085139%205.80923042%2C13.219934%20C5.07771043%2C13.5322837%204.80932495%2C14.3103691%205.13972007%2C14.9769011%20L8.20725954%2C21.3744923%20C8.68977207%2C22.3784735%209.19844491%2C22.9037044%2010.1528121%2C22.9037044%20C11.1720955%2C22.9037044%2011.6168209%2C22.3310633%2012.0983646%2C21.3744923%20C12.0983646%2C21.3744923%2014.7744682%2C15.7847341%2014.8015974%2C15.7261685%20C14.8287266%2C15.6666733%2014.9149588%2C15.4863286%2015.1872199%2C15.4872582%20C15.4178182%2C15.490047%2015.6106294%2C15.6657437%2015.6106294%2C15.9018652%20L15.6106294%2C21.3698443%20C15.6106294%2C22.212073%2016.0979865%2C22.9037044%2017.0349134%2C22.9037044%20C17.9718403%2C22.9037044%2018.4785754%2C22.212073%2018.4785754%2C21.3698443%20L18.4785754%2C16.8965503%20C18.4785754%2C16.0338702%2019.1219254%2C15.4742436%2020.0007183%2C15.4742436%20C20.8785423%2C15.4742436%2021.4637583%2C16.0524624%2021.4637583%2C16.8965503%20L21.4637583%2C21.3698443%20C21.4637583%2C22.212073%2021.9520842%2C22.9037044%2022.8880423%2C22.9037044%20C23.8240003%2C22.9037044%2024.3326731%2C22.212073%2024.3326731%2C21.3698443%20L24.3326731%2C16.8965503%20C24.3326731%2C16.0338702%2024.9750543%2C15.4742436%2025.8538472%2C15.4742436%20C26.7307023%2C15.4742436%2027.3168871%2C16.0524624%2027.3168871%2C16.8965503%20L27.3168871%2C21.3698443%20C27.3168871%2C22.212073%2027.8052131%2C22.9037044%2028.74214%2C22.9037044%20C29.6771291%2C22.9037044%2030.1848331%2C22.212073%2030.1848331%2C21.3698443%20L30.1848331%2C16.2783582%20C30.1848331%2C14.4070488%2028.6181207%2C13.0962956%2026.7307023%2C13.0962956%20C24.8452216%2C13.0962956%2023.6651006%2C14.3475536%2023.6651006%2C14.3475536%20C23.037253%2C13.5666793%2022.1720247%2C13.0972252%2020.7089847%2C13.0972252%20C19.164557%2C13.0972252%2017.8129406%2C14.3475536%2017.8129406%2C14.3475536%20C17.1841241%2C13.5666793%2016.1154267%2C13.0972252%2015.2308204%2C13.0972252%20C13.8617638%2C13.0972252%2012.7746572%2C13.675444%2012.1119292%2C15.1302871%20L10.1528121%2C19.5608189%20L7.63948376%2C13.8762402%22%20id%3D%22Fill-4%22%20fill%3D%22%23FFFFFF%22%3E%3C%2Fpath%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3C%2Fg%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3C%2Fg%3E%0A%20%20%20%20%20%20%20%20%3C%2Fg%3E%0A%20%20%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E")}h1{font-size:1.33333rem}h1,h2{color:#000;font-weight:200;font-family:Metropolis,Avenir Next,Helvetica Neue,Arial,sans-serif;letter-spacing:normal;line-height:2rem;margin-top:1rem;margin-bottom:0}h2{font-size:1.16667rem}h3{font-size:.91667rem}h3,h4{color:#000;font-weight:200;font-family:Metropolis,Avenir Next,Helvetica Neue,Arial,sans-serif;letter-spacing:normal;line-height:1rem;margin-top:1rem;margin-bottom:0}h4{font-size:.75rem}h5{color:#565656;font-weight:400;font-family:Metropolis,Avenir Next,Helvetica Neue,Arial,sans-serif;font-size:.66667rem;letter-spacing:.01em;line-height:1rem;margin-top:1rem;margin-bottom:0}h6{font-weight:500;color:#313131}body,h6{font-family:Metropolis,Avenir Next,Helvetica Neue,Arial,sans-serif;font-size:.58333rem;letter-spacing:normal;line-height:1rem;margin-top:1rem;margin-bottom:0}body{color:#565656;font-weight:400;margin-top:0!important}body p{color:#565656;font-weight:400;font-size:.58333rem}body .p0,body p,body p.p0{letter-spacing:normal;line-height:1rem;margin-top:1rem;margin-bottom:0}body .p0,body p.p0{font-weight:200;font-size:.83333rem}body .p2,body p.p2{font-weight:500}body .p2,body .p3,body p.p2,body p.p3{color:#565656;font-size:.54167rem;letter-spacing:normal;line-height:1rem;margin-top:1rem;margin-bottom:0}body .p3,body p.p3{font-weight:400}body .p4,body p.p4{font-weight:600}body .p4,body .p5,body p.p4,body p.p5{color:#565656;font-size:.5rem;letter-spacing:normal;line-height:1rem;margin-top:1rem;margin-bottom:0}body .p5,body p.p5{font-weight:400}body .p6,body p.p6{font-weight:600}body .p6,body .p7,body p.p6,body p.p7{color:#565656;font-size:.45833rem;letter-spacing:.03em;line-height:.5rem;margin-top:1rem;margin-bottom:0}body .p7,body p.p7{font-weight:400}body .p8,body p.p8{color:#565656;font-weight:400;font-size:.41667rem;letter-spacing:.03em;line-height:.5rem;margin-top:1rem;margin-bottom:0}.text-light{font-weight:200}.text-right{text-align:right!important}.text-center{text-align:center!important}.text-left{text-align:left!important}.text-justify{text-align:justify!important}html{color:#565656;font-family:Metropolis,Avenir Next,Helvetica Neue,Arial,sans-serif;font-size:24px}.btn{cursor:pointer;display:inline-block;-webkit-appearance:none!important;border-radius:.125rem;border:1px solid;min-width:3rem;max-width:15rem;white-space:nowrap;text-overflow:ellipsis;overflow:hidden;text-align:center;text-transform:uppercase;vertical-align:middle;line-height:1.5rem;letter-spacing:.12em;font-size:.5rem;font-weight:500;height:1.5rem;padding:0 .5rem;border-color:#007cbb;background-color:transparent;color:#007cbb}.btn,.btn:hover{text-decoration:none}.btn:visited{color:#007cbb}.btn:hover{background-color:#e1f1f6;color:#004a70}.btn:active{box-shadow:inset 0 2px 0 0 #0094d2}.btn.disabled,.btn:disabled{color:#565656;cursor:not-allowed;background-color:transparent;border-color:#737373;opacity:.4}.btn-group>.btn clr-icon,.btn clr-icon{-webkit-transform:translate3d(0,-.08333rem,0);transform:translate3d(0,-.08333rem,0)}.btn-info-outline .btn,.btn-info .btn,.btn-outline-info .btn,.btn-outline-primary .btn,.btn-outline-secondary .btn,.btn-outline .btn,.btn-primary-outline .btn,.btn-secondary-outline .btn,.btn-secondary .btn,.btn.btn-info,.btn.btn-info-outline,.btn.btn-outline,.btn.btn-outline-primary,.btn.btn-outline-secondary,.btn.btn-primary-outline,.btn.btn-secondary,.btn.btn-secondary-outline{border-color:#007cbb;background-color:transparent;color:#007cbb}.btn-info-outline .btn:visited,.btn-info .btn:visited,.btn-outline-info .btn:visited,.btn-outline-primary .btn:visited,.btn-outline-secondary .btn:visited,.btn-outline .btn:visited,.btn-primary-outline .btn:visited,.btn-secondary-outline .btn:visited,.btn-secondary .btn:visited,.btn.btn-info-outline:visited,.btn.btn-info:visited,.btn.btn-outline-primary:visited,.btn.btn-outline-secondary:visited,.btn.btn-outline:visited,.btn.btn-primary-outline:visited,.btn.btn-secondary-outline:visited,.btn.btn-secondary:visited{color:#007cbb}.btn-info-outline .btn:hover,.btn-info .btn:hover,.btn-outline-info .btn:hover,.btn-outline-primary .btn:hover,.btn-outline-secondary .btn:hover,.btn-outline .btn:hover,.btn-primary-outline .btn:hover,.btn-secondary-outline .btn:hover,.btn-secondary .btn:hover,.btn.btn-info-outline:hover,.btn.btn-info:hover,.btn.btn-outline-primary:hover,.btn.btn-outline-secondary:hover,.btn.btn-outline:hover,.btn.btn-primary-outline:hover,.btn.btn-secondary-outline:hover,.btn.btn-secondary:hover{background-color:#e1f1f6;color:#004a70}.btn-info-outline .btn:active,.btn-info .btn:active,.btn-outline-info .btn:active,.btn-outline-primary .btn:active,.btn-outline-secondary .btn:active,.btn-outline .btn:active,.btn-primary-outline .btn:active,.btn-secondary-outline .btn:active,.btn-secondary .btn:active,.btn.btn-info-outline:active,.btn.btn-info:active,.btn.btn-outline-primary:active,.btn.btn-outline-secondary:active,.btn.btn-outline:active,.btn.btn-primary-outline:active,.btn.btn-secondary-outline:active,.btn.btn-secondary:active{box-shadow:inset 0 2px 0 0 #0094d2}.btn-info-outline .btn.disabled,.btn-info-outline .btn:disabled,.btn-info .btn.disabled,.btn-info .btn:disabled,.btn-outline-info .btn.disabled,.btn-outline-info .btn:disabled,.btn-outline-primary .btn.disabled,.btn-outline-primary .btn:disabled,.btn-outline-secondary .btn.disabled,.btn-outline-secondary .btn:disabled,.btn-outline .btn.disabled,.btn-outline .btn:disabled,.btn-primary-outline .btn.disabled,.btn-primary-outline .btn:disabled,.btn-secondary-outline .btn.disabled,.btn-secondary-outline .btn:disabled,.btn-secondary .btn.disabled,.btn-secondary .btn:disabled,.btn.btn-info-outline.disabled,.btn.btn-info-outline:disabled,.btn.btn-info.disabled,.btn.btn-info:disabled,.btn.btn-outline-primary.disabled,.btn.btn-outline-primary:disabled,.btn.btn-outline-secondary.disabled,.btn.btn-outline-secondary:disabled,.btn.btn-outline.disabled,.btn.btn-outline:disabled,.btn.btn-primary-outline.disabled,.btn.btn-primary-outline:disabled,.btn.btn-secondary-outline.disabled,.btn.btn-secondary-outline:disabled,.btn.btn-secondary.disabled,.btn.btn-secondary:disabled{color:#565656;cursor:not-allowed;background-color:transparent;border-color:#737373;opacity:.4}.btn-primary .btn,.btn.btn-primary{border-color:#007cbb;background-color:#007cbb;color:#fff}.btn-primary .btn:visited,.btn.btn-primary:visited{color:#fff}.btn-primary .btn:hover,.btn.btn-primary:hover{background-color:#004a70;color:#e1f1f6}.btn-primary .btn:active,.btn.btn-primary:active{box-shadow:inset 0 1px 0 0 #0094d2}.btn-primary .btn.disabled,.btn-primary .btn:disabled,.btn.btn-primary.disabled,.btn.btn-primary:disabled{color:#565656;cursor:not-allowed;background-color:#ccc;border-color:#ccc;opacity:.4}.btn-success .btn,.btn.btn-success{border-color:#62a420;background-color:#62a420;color:#fff}.btn-success .btn:visited,.btn.btn-success:visited{color:#fff}.btn-success .btn:hover,.btn.btn-success:hover{background-color:#266900;color:#fff}.btn-success .btn:active,.btn.btn-success:active{box-shadow:inset 0 2px 0 0 #1d5100}.btn-success .btn.disabled,.btn-success .btn:disabled,.btn.btn-success.disabled,.btn.btn-success:disabled{color:#565656;cursor:not-allowed;background-color:#ccc;border-color:#ccc;opacity:.4}.btn-danger .btn,.btn-warning .btn,.btn.btn-danger,.btn.btn-warning{border-color:#e62700;background-color:#e62700;color:#fff}.btn-danger .btn:visited,.btn-warning .btn:visited,.btn.btn-danger:visited,.btn.btn-warning:visited{color:#fff}.btn-danger .btn:hover,.btn-warning .btn:hover,.btn.btn-danger:hover,.btn.btn-warning:hover{background-color:#c92100;color:#fff}.btn-danger .btn:active,.btn-warning .btn:active,.btn.btn-danger:active,.btn.btn-warning:active{box-shadow:inset 0 2px 0 0 #a32100}.btn-danger .btn.disabled,.btn-danger .btn:disabled,.btn-warning .btn.disabled,.btn-warning .btn:disabled,.btn.btn-danger.disabled,.btn.btn-danger:disabled,.btn.btn-warning.disabled,.btn.btn-warning:disabled{color:#565656;cursor:not-allowed;background-color:#ccc;border-color:#ccc;opacity:.4}.btn-info-outline .btn,.btn-outline .btn,.btn.btn-info-outline,.btn.btn-outline,.btn.btn-outline-info,.btn.btn-outline .btn{border-color:#007cbb;background-color:transparent;color:#007cbb}.btn-info-outline .btn:visited,.btn-outline .btn:visited,.btn.btn-info-outline:visited,.btn.btn-outline-info:visited,.btn.btn-outline .btn:visited,.btn.btn-outline:visited{color:#007cbb}.btn-info-outline .btn:hover,.btn-outline .btn:hover,.btn.btn-info-outline:hover,.btn.btn-outline-info:hover,.btn.btn-outline .btn:hover,.btn.btn-outline:hover{background-color:#e1f1f6;color:#004a70}.btn-info-outline .btn:active,.btn-outline .btn:active,.btn.btn-info-outline:active,.btn.btn-outline-info:active,.btn.btn-outline .btn:active,.btn.btn-outline:active{box-shadow:inset 0 2px 0 0 #0094d2}.btn-info-outline .btn.disabled,.btn-info-outline .btn:disabled,.btn-outline .btn.disabled,.btn-outline .btn:disabled,.btn.btn-info-outline.disabled,.btn.btn-info-outline:disabled,.btn.btn-outline-info.disabled,.btn.btn-outline-info:disabled,.btn.btn-outline .btn.disabled,.btn.btn-outline .btn:disabled,.btn.btn-outline.disabled,.btn.btn-outline:disabled{color:#565656;cursor:not-allowed;background-color:transparent;border-color:#737373;opacity:.4}.btn-outline-success .btn,.btn-success-outline .btn,.btn.btn-outline-success,.btn.btn-success-outline{border-color:#266900;background-color:transparent;color:#318700}.btn-outline-success .btn:visited,.btn-success-outline .btn:visited,.btn.btn-outline-success:visited,.btn.btn-success-outline:visited{color:#318700}.btn-outline-success .btn:hover,.btn-success-outline .btn:hover,.btn.btn-outline-success:hover,.btn.btn-success-outline:hover{background-color:#dff0d0;color:#1d5100}.btn-outline-success .btn:active,.btn-success-outline .btn:active,.btn.btn-outline-success:active,.btn.btn-success-outline:active{box-shadow:inset 0 1px 0 0 #60b515}.btn-outline-success .btn.disabled,.btn-outline-success .btn:disabled,.btn-success-outline .btn.disabled,.btn-success-outline .btn:disabled,.btn.btn-outline-success.disabled,.btn.btn-outline-success:disabled,.btn.btn-success-outline.disabled,.btn.btn-success-outline:disabled{color:#565656;cursor:not-allowed;background-color:transparent;border-color:#737373;opacity:.4}.btn-danger-outline .btn,.btn-outline-danger .btn,.btn-outline-warning .btn,.btn-warning-outline .btn,.btn.btn-danger-outline,.btn.btn-outline-danger,.btn.btn-outline-warning,.btn.btn-warning-outline{border-color:#c92100;background-color:transparent;color:#e62700}.btn-danger-outline .btn:visited,.btn-outline-danger .btn:visited,.btn-outline-warning .btn:visited,.btn-warning-outline .btn:visited,.btn.btn-danger-outline:visited,.btn.btn-outline-danger:visited,.btn.btn-outline-warning:visited,.btn.btn-warning-outline:visited{color:#e62700}.btn-danger-outline .btn:hover,.btn-outline-danger .btn:hover,.btn-outline-warning .btn:hover,.btn-warning-outline .btn:hover,.btn.btn-danger-outline:hover,.btn.btn-outline-danger:hover,.btn.btn-outline-warning:hover,.btn.btn-warning-outline:hover{background-color:#f5dbd9;color:#a32100}.btn-danger-outline .btn:active,.btn-outline-danger .btn:active,.btn-outline-warning .btn:active,.btn-warning-outline .btn:active,.btn.btn-danger-outline:active,.btn.btn-outline-danger:active,.btn.btn-outline-warning:active,.btn.btn-warning-outline:active{box-shadow:inset 0 1px 0 0 #ebafa6}.btn-danger-outline .btn.disabled,.btn-danger-outline .btn:disabled,.btn-outline-danger .btn.disabled,.btn-outline-danger .btn:disabled,.btn-outline-warning .btn.disabled,.btn-outline-warning .btn:disabled,.btn-warning-outline .btn.disabled,.btn-warning-outline .btn:disabled,.btn.btn-danger-outline.disabled,.btn.btn-danger-outline:disabled,.btn.btn-outline-danger.disabled,.btn.btn-outline-danger:disabled,.btn.btn-outline-warning.disabled,.btn.btn-outline-warning:disabled,.btn.btn-warning-outline.disabled,.btn.btn-warning-outline:disabled{color:#565656;cursor:not-allowed;background-color:transparent;border-color:#565656;opacity:.4}.btn-link .btn,.btn.btn-link{border-color:transparent;background-color:transparent;color:#007cbb}.btn-link .btn:visited,.btn.btn-link:visited{color:#007cbb}.btn-link .btn:hover,.btn.btn-link:hover{background-color:transparent;color:#004a70}.btn-link .btn:active,.btn.btn-link:active{box-shadow:inset 0 0 0 0 transparent}.btn-link .btn.disabled,.btn-link .btn:disabled,.btn.btn-link.disabled,.btn.btn-link:disabled{color:#565656;cursor:not-allowed;background-color:transparent;border-color:transparent;opacity:.4}.alert-app-level .alert-item .btn,.alert-app-level .alert-item .btn .btn,.btn-inverse .btn,.btn.btn-inverse{border-color:#fff;background-color:transparent;color:#fff}.alert-app-level .alert-item .btn .btn:visited,.alert-app-level .alert-item .btn:visited,.btn-inverse .btn:visited,.btn.btn-inverse:visited{color:#fff}.alert-app-level .alert-item .btn .btn:hover,.alert-app-level .alert-item .btn:hover,.btn-inverse .btn:hover,.btn.btn-inverse:hover{background-color:hsla(0,0%,100%,.15);color:#fff}.alert-app-level .alert-item .btn .btn:active,.alert-app-level .alert-item .btn:active,.btn-inverse .btn:active,.btn.btn-inverse:active{box-shadow:inset 0 1px 0 0 rgba(0,0,0,.25)}.alert-app-level .alert-item .btn .btn.disabled,.alert-app-level .alert-item .btn .btn:disabled,.alert-app-level .alert-item .btn.disabled,.alert-app-level .alert-item .btn:disabled,.btn-inverse .btn.disabled,.btn-inverse .btn:disabled,.btn.btn-inverse.disabled,.btn.btn-inverse:disabled{color:#fff;cursor:not-allowed;background-color:transparent;border-color:#fff;opacity:.4}.alert-app-level .alert-item .btn,.alert-app-level .alert-item .btn .btn,.btn-sm .btn,.btn.btn-sm{line-height:calc(1rem - 1px);letter-spacing:.073em;font-size:.45833rem;font-weight:500;height:1rem;padding:0 .5rem}.btn-block{display:block;width:100%;max-width:100%}.btn{margin:.25rem .5rem .25rem 0}.btn.btn-link{margin:.25rem 0}.alert-app-level .alert-item .btn:not(.btn-link) clr-icon,.btn-sm:not(.btn-link) clr-icon{width:.5rem;height:.5rem;-webkit-transform:translate3d(0,-.04167rem,0);transform:translate3d(0,-.04167rem,0)}.btn-icon{min-width:0}.btn-icon.disabled,.btn-icon:disabled{color:#565656}.btn-group.btn-danger .dropdown-toggle,.btn-group.btn-primary .dropdown-toggle,.btn-group.btn-success .dropdown-toggle,.btn-group.btn-warning .dropdown-toggle{border-color:#007cbb;background-color:#007cbb;color:#fff}.btn-group.btn-danger .dropdown-toggle:visited,.btn-group.btn-primary .dropdown-toggle:visited,.btn-group.btn-success .dropdown-toggle:visited,.btn-group.btn-warning .dropdown-toggle:visited{color:#fff}.btn-group.btn-danger .dropdown-toggle:hover,.btn-group.btn-primary .dropdown-toggle:hover,.btn-group.btn-success .dropdown-toggle:hover,.btn-group.btn-warning .dropdown-toggle:hover{background-color:#004a70;color:#e1f1f6}.btn-group.btn-danger .dropdown-toggle:active,.btn-group.btn-primary .dropdown-toggle:active,.btn-group.btn-success .dropdown-toggle:active,.btn-group.btn-warning .dropdown-toggle:active{box-shadow:inset 0 1px 0 0 #0094d2}.btn-group.btn-danger .dropdown-toggle.disabled,.btn-group.btn-danger .dropdown-toggle:disabled,.btn-group.btn-primary .dropdown-toggle.disabled,.btn-group.btn-primary .dropdown-toggle:disabled,.btn-group.btn-success .dropdown-toggle.disabled,.btn-group.btn-success .dropdown-toggle:disabled,.btn-group.btn-warning .dropdown-toggle.disabled,.btn-group.btn-warning .dropdown-toggle:disabled{color:#565656;cursor:not-allowed;background-color:#ccc;border-color:#ccc;opacity:.4}.btn-group.btn-link .dropdown-toggle{border-color:transparent;background-color:transparent;color:#007cbb}.btn-group.btn-link .dropdown-toggle:visited{color:#007cbb}.btn-group.btn-link .dropdown-toggle:hover{background-color:transparent;color:#004a70}.btn-group.btn-link .dropdown-toggle:active{box-shadow:inset 0 0 0 0 transparent}.btn-group.btn-link .dropdown-toggle.disabled,.btn-group.btn-link .dropdown-toggle:disabled{color:#565656;cursor:not-allowed;background-color:transparent;border-color:transparent;opacity:.4}.alert-app-level .alert-item .btn-group.btn .btn-group-overflow>.dropdown-toggle,.btn-group.btn-sm .btn-group-overflow>.dropdown-toggle{line-height:calc(1rem - 1px);letter-spacing:.073em;font-size:.45833rem;font-weight:500;height:1rem;padding:0 .5rem}.checkbox-inline.btn,.checkbox.btn,.radio-inline.btn,.radio.btn{padding:0}.checkbox-inline.btn label,.checkbox.btn label,.radio-inline.btn label,.radio.btn label{display:inline-table;line-height:inherit;padding:0 .5rem}.checkbox-inline.btn input[type=checkbox]+label:after,.checkbox-inline.btn input[type=checkbox]+label:before,.checkbox.btn input[type=checkbox]+label:after,.checkbox.btn input[type=checkbox]+label:before,.radio-inline.btn input[type=radio]+label:after,.radio-inline.btn input[type=radio]+label:before,.radio.btn input[type=radio]+label:after,.radio.btn input[type=radio]+label:before{content:none}.checkbox-inline.btn input[type=checkbox]:checked+label,.checkbox.btn input[type=checkbox]:checked+label{background-color:#007cbb;color:#fff}.checkbox-inline.btn label,.checkbox.btn label{width:100%}.checkbox-inline.btn.btn-info-outline input[type=checkbox]:checked+label,.checkbox-inline.btn.btn-info input[type=checkbox]:checked+label,.checkbox-inline.btn.btn-outline-info input[type=checkbox]:checked+label,.checkbox-inline.btn.btn-outline-primary input[type=checkbox]:checked+label,.checkbox-inline.btn.btn-outline-secondary input[type=checkbox]:checked+label,.checkbox-inline.btn.btn-outline input[type=checkbox]:checked+label,.checkbox-inline.btn.btn-primary-outline input[type=checkbox]:checked+label,.checkbox-inline.btn.btn-secondary-outline input[type=checkbox]:checked+label,.checkbox-inline.btn.btn-secondary input[type=checkbox]:checked+label,.checkbox.btn.btn-info-outline input[type=checkbox]:checked+label,.checkbox.btn.btn-info input[type=checkbox]:checked+label,.checkbox.btn.btn-outline-info input[type=checkbox]:checked+label,.checkbox.btn.btn-outline-primary input[type=checkbox]:checked+label,.checkbox.btn.btn-outline-secondary input[type=checkbox]:checked+label,.checkbox.btn.btn-outline input[type=checkbox]:checked+label,.checkbox.btn.btn-primary-outline input[type=checkbox]:checked+label,.checkbox.btn.btn-secondary-outline input[type=checkbox]:checked+label,.checkbox.btn.btn-secondary input[type=checkbox]:checked+label{background-color:#007cbb;color:#fff}.checkbox-inline.btn.btn-primary input[type=checkbox]:checked+label,.checkbox.btn.btn-primary input[type=checkbox]:checked+label{background-color:#fff;color:#fff}.checkbox-inline.btn.btn-success input[type=checkbox]:checked+label,.checkbox.btn.btn-success input[type=checkbox]:checked+label{background-color:#266900;color:#fff}.checkbox-inline.btn.btn-danger input[type=checkbox]:checked+label,.checkbox-inline.btn.btn-warning input[type=checkbox]:checked+label,.checkbox.btn.btn-danger input[type=checkbox]:checked+label,.checkbox.btn.btn-warning input[type=checkbox]:checked+label{background-color:#c92100;color:#fff}.checkbox-inline.btn.btn-outline-success input[type=checkbox]:checked+label,.checkbox-inline.btn.btn-success-outline input[type=checkbox]:checked+label,.checkbox.btn.btn-outline-success input[type=checkbox]:checked+label,.checkbox.btn.btn-success-outline input[type=checkbox]:checked+label{background-color:#266900;color:#fff}.checkbox-inline.btn.btn-danger-outline input[type=checkbox]:checked+label,.checkbox-inline.btn.btn-outline-danger input[type=checkbox]:checked+label,.checkbox-inline.btn.btn-outline-warning input[type=checkbox]:checked+label,.checkbox-inline.btn.btn-warning-outline input[type=checkbox]:checked+label,.checkbox.btn.btn-danger-outline input[type=checkbox]:checked+label,.checkbox.btn.btn-outline-danger input[type=checkbox]:checked+label,.checkbox.btn.btn-outline-warning input[type=checkbox]:checked+label,.checkbox.btn.btn-warning-outline input[type=checkbox]:checked+label{background-color:#c92100;color:#fff}.checkbox-inline.btn.btn-link input[type=checkbox]:checked+label,.checkbox.btn.btn-link input[type=checkbox]:checked+label{background-color:transparent;color:#004a70}.alert-app-level .alert-item .checkbox-inline.btn input[type=checkbox]:checked+label,.alert-app-level .alert-item .checkbox.btn input[type=checkbox]:checked+label,.checkbox-inline.btn.btn-inverse input[type=checkbox]:checked+label,.checkbox.btn.btn-inverse input[type=checkbox]:checked+label{background-color:hsla(0,0%,100%,.15);color:#fff}.radio.btn input[type=radio]:checked+label{background-color:#007cbb;color:#fff}.radio.btn label{width:100%}.radio.btn.btn-info-outline input[type=radio]:checked+label,.radio.btn.btn-info input[type=radio]:checked+label,.radio.btn.btn-outline-info input[type=radio]:checked+label,.radio.btn.btn-outline-primary input[type=radio]:checked+label,.radio.btn.btn-outline-secondary input[type=radio]:checked+label,.radio.btn.btn-outline input[type=radio]:checked+label,.radio.btn.btn-primary-outline input[type=radio]:checked+label,.radio.btn.btn-secondary-outline input[type=radio]:checked+label,.radio.btn.btn-secondary input[type=radio]:checked+label{background-color:#007cbb;color:#fff}.radio.btn.btn-primary input[type=radio]:checked+label{background-color:#fff;color:#fff}.radio.btn.btn-success input[type=radio]:checked+label{background-color:#266900;color:#fff}.radio.btn.btn-danger input[type=radio]:checked+label,.radio.btn.btn-warning input[type=radio]:checked+label{background-color:#c92100;color:#fff}.radio.btn.btn-outline-success input[type=radio]:checked+label,.radio.btn.btn-success-outline input[type=radio]:checked+label{background-color:#266900;color:#fff}.radio.btn.btn-danger-outline input[type=radio]:checked+label,.radio.btn.btn-outline-danger input[type=radio]:checked+label,.radio.btn.btn-outline-warning input[type=radio]:checked+label,.radio.btn.btn-warning-outline input[type=radio]:checked+label{background-color:#c92100;color:#fff}.radio.btn.btn-link input[type=radio]:checked+label{background-color:transparent;color:#004a70}.alert-app-level .alert-item .radio.btn input[type=radio]:checked+label,.radio.btn.btn-inverse input[type=radio]:checked+label{background-color:hsla(0,0%,100%,.15);color:#fff}.btn-group{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;margin-right:.5rem}.btn-group .btn{margin:0}.btn-group .btn:not(:first-child){border-top-left-radius:0;border-bottom-left-radius:0}.btn-group .btn:not(:last-child){border-top-right-radius:0;border-bottom-right-radius:0}.btn-group.btn-danger .btn:not(:last-child),.btn-group.btn-primary .btn:not(:last-child),.btn-group.btn-success .btn:not(:last-child),.btn-group.btn-warning .btn:not(:last-child){margin:0 1px 0 0}.btn-group.btn-danger .dropdown-menu .btn,.btn-group.btn-primary .dropdown-menu .btn,.btn-group.btn-success .dropdown-menu .btn,.btn-group.btn-warning .dropdown-menu .btn{margin:0}.btn-group>.btn-group-overflow{position:relative}.btn-group>.btn-group-overflow:last-child:not(:first-child)>.btn:first-child{border-radius:0 .125rem .125rem 0}.btn-group>.btn-group-overflow:last-child:first-child>.btn:first-child{border-radius:.125rem}.btn-group .btn+.btn,.btn-group .btn+.btn-group-overflow .btn{border-left:none}.btn-group.btn-icon-link.btn-link .btn,.btn-group.btn-icon .btn,.btn-group.btn-link .dropdown-toggle{min-width:0}.btn-group .clr-icon-title{display:none;text-transform:none}.btn-group .dropdown-toggle{display:block}.btn-group .dropdown-menu clr-icon{display:none}.btn-group .dropdown-menu .clr-icon-title{display:inline}.card-footer .checkbox-inline.btn label,.card-footer .checkbox.btn label,.card-footer .radio-inline.btn label,.card-footer .radio.btn label{line-height:calc(1rem - 1px)}.toggle-switch input[type=checkbox]{-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none}.toggle-switch{margin-right:1rem;vertical-align:middle}.toggle-switch,.toggle-switch label{display:inline-block;height:1rem;position:relative}.toggle-switch label{cursor:pointer;margin-right:2rem}.toggle-switch input[type=checkbox]{position:absolute;top:.25rem;right:.25rem;height:.66667rem;width:.66667rem;opacity:0}.toggle-switch input[type=checkbox]+label:before{position:absolute;display:inline-block;content:"";height:.75rem;width:1.375rem;border:2px solid;border-radius:.375rem;border-color:#737373;background-color:#737373;top:.125rem;right:-1.75rem;transition:.15s ease-in;transition-property:border-color,background-color}.toggle-switch input[type=checkbox]:focus+label:before{outline:0;box-shadow:0 0 2px 2px #6bc1e3}.toggle-switch input[type=checkbox]:checked+label:before{border-color:#62a420;background-color:#62a420;transition:.15s ease-in;transition-property:border-color,background-color}.toggle-switch input[type=checkbox]+label:after{position:absolute;display:inline-block;content:"";height:.5833rem;width:.5833rem;border:1px solid #fff;border-radius:50%;background-color:#fff;top:.20835rem;right:-1.04167rem;transition:right .15s ease-in}.toggle-switch input[type=checkbox]:checked+label:after{right:-1.66667rem;transition:right .15s ease-in}.toggle-switch.disabled label{opacity:.4;cursor:not-allowed}.toggle-switch.disabled input[type=checkbox]:checked+label:before{border-color:#ccc;background-color:#ccc}.toggle-switch input[type=checkbox]:disabled+label{cursor:not-allowed}.toggle-switch input[type=checkbox]:disabled+label:before{background-color:#fff;border-color:#ccc}.toggle-switch input[type=checkbox]:disabled+label:after{background-color:#fff;border:2px solid #ccc;width:.75rem;height:.75rem;top:3px}.toggle-switch input[type=checkbox]:checked:disabled+label:before{border-color:#ccc;background-color:#ccc}.toggle-switch input[type=checkbox]:checked:disabled+label:after{border-color:#fff;width:.5833rem;height:.5833rem;top:5px}.toggle-switch.right-label label{margin-left:1.75rem;margin-right:0}.toggle-switch.right-label input[type=checkbox]+label:before{right:0;left:-1.75rem}.toggle-switch.right-label input[type=checkbox]+label:after{right:0;left:-1.66667rem;transition-property:left}.toggle-switch.right-label input[type=checkbox]:checked+label:after{left:-1.04167rem;transition-property:left}.close{transition:color .2s linear;font-weight:200;text-shadow:none;line-height:inherit;color:#737373;opacity:1}.close clr-icon{fill:#737373}.close:active,.close:focus,.close:hover{opacity:1;color:#000}.close:active clr-icon,.close:focus clr-icon,.close:hover clr-icon{fill:#000}.close:focus{outline:0;box-shadow:0 0 2px 2px #6bc1e3}.alert-icon{height:1rem;width:1rem;margin-left:-.125rem;margin-top:-.166667rem}.alert-icon-wrapper{-webkit-box-flex:0;-ms-flex:0 0 1.04167rem;flex:0 0 1.04167rem;padding-top:.04167rem;height:.75rem}.alert-item{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:nowrap;flex-wrap:nowrap;min-height:.75rem;margin-bottom:.25rem}.alert-item:last-child{margin-bottom:0}.alert-items{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-flow:column nowrap;flex-flow:column nowrap;padding:.33333rem calc(.5rem - 1px);display:-webkit-box;display:-ms-flexbox;display:flex}.alert-item>span,.alert-text{display:inline-block;-webkit-box-flex:1;-ms-flex-positive:1;flex-grow:1;-ms-flex-negative:1;flex-shrink:1;-ms-flex-preferred-size:98%;flex-basis:98%;max-width:98%;margin-right:.5rem;text-align:left}.alert{font-size:.54167rem;letter-spacing:normal;line-height:.75rem;position:relative;box-sizing:border-box;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row;width:auto;border-radius:.125rem;margin-top:.25rem;background:#e1f1f6;color:#565656;border:1px solid #49afd9}.alert .alert-icon{color:#007cbb}.alert.alert-info{background:#e1f1f6;color:#565656;border:1px solid #49afd9}.alert.alert-info .alert-icon{color:#007cbb}.alert.alert-success{background:#dff0d0;color:#565656;border:1px solid #60b515}.alert.alert-success .alert-icon{color:#318700}.alert.alert-warning{background:#feecb5;color:#565656;border:1px solid #ffdc0b}.alert.alert-warning .alert-icon{color:#565656}.alert.alert-danger{background:#f5dbd9;color:#565656;border:1px solid #ebafa6}.alert.alert-danger .alert-icon{color:#c92100}.alert .alert-item .clr-icon{height:.75rem;width:.75rem;margin-right:.25rem}.alert .alert-item .clr-icon+.alert-text{padding-left:0}.alert .alert-item .clr-icon+.alert-text:before{content:none}.alert .alert-actions{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;white-space:nowrap}.alert .alert-actions .dropdown:last-child{margin-right:-.08333rem}.alert .alert-actions .dropdown-item{color:#565656;font-size:.58333rem;line-height:1rem;letter-spacing:normal}.alert .alert-actions .dropdown .dropdown-toggle{color:#565656}.alert .alert-action:not(:last-child){margin-right:.5rem}.alert .alert-action,.alert .dropdown-toggle{color:#565656;text-decoration:underline}.alert .alert-action:active,.alert .dropdown-toggle:active{color:#50266b}.alert .alert-action button.dropdown-toggle:not(.btn){background:transparent;cursor:pointer;color:#565656}.alert .dropdown-toggle:not(.btn){display:inline-block;background:transparent;border:none}.alert .close{width:1rem;display:block;height:1.5rem;-webkit-box-flex:0;-ms-flex:0 0 1.16667rem;flex:0 0 1.16667rem;-webkit-box-ordinal-group:101;-ms-flex-order:100;order:100;padding-right:.16667rem}.alert .close clr-icon{margin-top:-.125rem;height:calc(1rem - 1px);width:calc(1rem - 1px)}.alert .close~.alert-item>.alert-actions{padding-right:.5rem}.alert .close~.alert-item>.alert-actions>.alert-action:last-child{margin-right:.5rem}.alert-app-level{margin:0;border-radius:0;max-height:4rem;overflow-y:auto;background:#007cbb;color:#fff;border:none}.alert-app-level .alert-icon{color:#fff}.alert-app-level.alert-info{background:#007cbb;color:#fff;border:none}.alert-app-level.alert-info .alert-icon{color:#fff}.alert-app-level.alert-danger{background:#c92100;color:#fff;border:none}.alert-app-level.alert-danger .alert-icon{color:#fff}.alert-app-level.alert-warning{background:#c25400;color:#fff;border:none}.alert-app-level.alert-warning .alert-icon{color:#fff}.alert-app-level.alert-success{background:#62a420;color:#fff;border:none}.alert-app-level.alert-success .alert-icon{color:#fff}.alert-app-level .alert-items{padding-top:.25rem;padding-bottom:.25rem}.alert-app-level .alert-item{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;min-height:1rem}.alert-app-level .alert-item .btn{margin:0}.alert-app-level .alert-item>span,.alert-app-level .alert-text{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto}.alert-app-level .close{color:#fff;opacity:.8;height:1.5rem;overflow:hidden}.alert-app-level .close clr-icon{fill:#fff;margin-top:-.208333rem}.alert-app-level .close:active,.alert-app-level .close:focus,.alert-app-level .close:hover{opacity:1}.alert-app-level .alert-action,.alert-app-level .dropdown-toggle{text-decoration:none}.alert-sm{letter-spacing:normal;font-size:calc(.5rem - 1px);line-height:.66667rem}.alert-sm .alert-items{padding:calc(.16667rem - 1px) calc(.25rem - 1px)}.alert-sm .alert-item{padding-top:.04167rem;margin-bottom:.16667rem}.alert-sm .alert-item:last-child{margin-bottom:0}.alert-sm .alert-icon-wrapper{padding-top:0;height:.66667rem}.alert-sm .alert-icon{margin-left:-.16667rem;margin-top:-.16667rem}.alert-sm .alert-item>span,.alert-sm .alert-text{margin-right:.25rem}.alert-sm .close{padding-right:0;-webkit-box-flex:0;-ms-flex:0 0 1rem;flex:0 0 1rem;height:1rem;line-height:1rem}.alert-sm .close clr-icon{margin-top:-.20833rem;margin-right:-.04167rem;height:.83333rem;width:.83333rem;line-height:calc(.83333rem + 1px)}@media screen and (max-width:768px){.alert .alert-item{-ms-flex-wrap:wrap;flex-wrap:wrap}.alert .alert-text{margin-right:0;max-width:90%;width:90%;-ms-flex-preferred-size:90%;flex-basis:90%}.alert .alert-actions{-webkit-box-flex:1;-ms-flex:1 0 100%;flex:1 0 100%;padding-top:.125rem;padding-left:1rem}.alerts-pager{margin-top:.125rem}.alert-app-level .alert-actions{margin-left:0}}.alert-hidden{display:none}.card .alert{margin:.25rem 0}.modal .alert+.modal-header{margin-top:.5rem}.alerts.alert-info{background:#004a70}.alerts.alert-danger{background:#a32100}.alerts.alert-warning{background:#9e4100}.alerts.alert-success{background:#1d5100}.alerts-pager{color:#fff;float:left;font-size:.54167rem;letter-spacing:normal;min-height:1.5rem;text-align:center;width:144px}.alerts-pager-button{-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;margin:0;padding:0;border:none;border-radius:0;box-shadow:none;background:none;color:#fff;cursor:pointer}.alerts-pager-control{display:-webkit-box;display:-ms-flexbox;display:flex;margin-top:.25rem}.alerts-page-down{margin-left:1rem;width:33.33%}.alerts-page-up{margin-right:1rem;width:33.33%}.alerts-pager-text{width:33.33%}.card{box-shadow:0 .125rem 0 0 #d7d7d7;border-radius:.125rem;border:1px solid #d7d7d7}.card.clickable:hover{box-shadow:0 .125rem 0 0 #0094d2;border:1px solid #0094d2;cursor:pointer;text-decoration:none;-webkit-transform:translateY(-2px);transform:translateY(-2px);transition:border .2s ease,-webkit-transform .2s ease;transition:border .2s ease,transform .2s ease;transition:border .2s ease,transform .2s ease,-webkit-transform .2s ease}.card-block .card-divider,.card .card-media-block,.card .card-text,.card .card-title,.card .list,.card .list-unstyled{margin-top:0;margin-bottom:.5rem}.card-block .card-divider:last-child,.card .card-media-block:last-child,.card .card-text:last-child,.card .card-title:last-child,.card .list-unstyled:last-child,.card .list:last-child{margin-bottom:0}.card-img>img,.card.card-img>img,.card>.card-img:first-child:last-child>img{display:block;height:auto;width:100%;max-width:100%}.card{position:relative;display:block;background-color:#fff;width:100%;margin-top:1rem}.card .btn-link{min-width:0;padding:0}.card.clickable{color:inherit}.card>.list,.card>.list-unstyled{padding:.5rem .75rem}.card .list-group-item{font-size:clr-getTypePropertyValueForDomElement(card_text,font-size);border-left-width:0;border-right-width:0;border-color:#eee;padding:.5rem .75rem}.card .list-group-item:first-child{border-top-color:transparent}@supports (-ms-ime-align:auto){.card .dropdown>.dropdown-toggle:after{display:inline-block;margin-top:-.5rem}}.card-block,.card-footer,.card-header{padding:.5rem .75rem}.card-header,.card-title{color:#000;font-size:.75rem;font-weight:200;letter-spacing:normal}.card-text{font-size:clr-getTypePropertyValueForDomElement(card_text,font-size)}.card-img:first-child>img{border-radius:.125rem .125rem 0 0}.card-img:last-child>img{border-radius:0 0 .125rem .125rem}.card.card-img>img,.card>.card-img:first-child:last-child>img{border-radius:.125rem}.card-block .btn,.card-block .btn.btn-link,.card-block .card-link,.card-footer .btn,.card-footer .btn.btn-link,.card-footer .card-link{margin:0 .5rem 0 0}.card-block .btn-group .btn,.card-footer .btn-group .btn{margin:0}.card-block,.card-header{border-bottom:1px solid #eee}.card-block:last-child,.card-header:last-child{border-bottom:none}.card-divider{display:block;border-bottom:1px solid #eee}.card-block .card-divider{margin-left:-.75rem;margin-right:-.75rem;width:auto}.card-block+.card-divider,.card-header+.card-divider{display:none}.card-media-block{display:-webkit-box;display:-ms-flexbox;display:flex}.card-media-block .card-media-image{display:inline-block;-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;height:2.5rem;width:2.5rem;max-height:2.5rem;max-width:2.5rem}.card-media-block .card-media-description{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;margin:0 0 0 .5rem}.card-media-block .card-media-text,.card-media-block .card-media-title,.card-media-block span{display:inline-block}.card-media-block.wrap{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.card-media-block.wrap .card-media-description{margin:.25rem 0 0 0}.card-block>.list,.card-block>.list-unstyled{padding:0}@media screen and (min-width:544px){.card-columns{-webkit-column-count:3;-moz-column-count:3;column-count:3;-webkit-column-gap:.5rem;-moz-column-gap:.5rem;column-gap:.5rem;-webkit-column-break-inside:avoid;page-break-inside:avoid;break-inside:avoid;-webkit-column-fill:balance;-moz-column-fill:balance;column-fill:balance;-webkit-perspective:1}.card-columns.card-columns-2{-webkit-column-count:2;-moz-column-count:2;column-count:2}.card-columns.card-columns-4{-webkit-column-count:4;-moz-column-count:4;column-count:4}.card-columns .card{display:inline-block;margin:.25rem}.card-columns .clickable{-webkit-backface-visibility:hidden;backface-visibility:hidden}}@supports (-ms-ime-align:auto){.card .checkbox-inline.btn label,.card .checkbox.btn label,.card .radio-inline.btn label,.card .radio.btn label{display:inline-block}}pre,pre[class*=language-]{margin:.5rem 0}pre{border:1px solid #ccc;max-height:15rem;border-radius:.125rem;overflow:auto}pre code{white-space:pre}:not(pre)>code[class*=language-],code[class*=language-],pre,pre[class*=language-]{font-family:Consolas,Monaco,Courier,monospace!important;line-height:1rem;padding:0}code.clr-code{color:#c92100;padding:0;background:transparent}.dropdown-menu .btn,.dropdown-menu .btn-danger,.dropdown-menu .btn-info,.dropdown-menu .btn-link,.dropdown-menu .btn-outline,.dropdown-menu .btn-outline-danger,.dropdown-menu .btn-outline-primary,.dropdown-menu .btn-outline-secondary,.dropdown-menu .btn-outline-success,.dropdown-menu .btn-outline-warning,.dropdown-menu .btn-primary,.dropdown-menu .btn-secondary,.dropdown-menu .btn-success,.dropdown-menu .btn-warning,.dropdown-menu .dropdown-header,.dropdown-menu .dropdown-item{overflow:hidden;text-overflow:ellipsis;text-align:left}.dropdown,.dropdown .dropdown-toggle{position:relative;display:inline-block}.dropdown .dropdown-toggle{margin:0;white-space:nowrap;cursor:pointer}.dropdown .dropdown-toggle>*{margin:0}.dropdown .dropdown-toggle clr-icon[shape^=caret]{position:absolute;top:50%;-webkit-transform:translateY(-50%);transform:translateY(-50%);color:inherit;height:.41667rem;width:.41667rem}.dropdown .dropdown-toggle.btn{padding-right:1rem}.dropdown .dropdown-toggle.btn clr-icon[shape^=caret]{right:.5rem}.dropdown .dropdown-toggle:not(.btn){padding:0 .5rem 0 0;color:#000}.dropdown .dropdown-toggle:not(.btn) clr-icon[shape^=caret]{right:0}.dropdown button.dropdown-toggle:not(.btn){background:transparent;border:none;cursor:pointer;color:#000}.dropdown-menu>*{display:block;white-space:nowrap}.dropdown-menu{position:absolute;top:100%;left:0;margin-top:.083333rem;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;background:#fff;padding:.5rem 0;border:1px solid #ccc;box-shadow:0 1px .125rem hsla(0,0%,45%,.25);min-width:5rem;max-width:15rem;border-radius:.125rem;visibility:hidden;z-index:1000}.dropdown-menu .dropdown-header{font-size:.5rem;font-weight:600;letter-spacing:normal;padding:0 .5rem;line-height:.75rem;margin:0;color:#313131}.dropdown-menu .btn,.dropdown-menu .btn-danger,.dropdown-menu .btn-info,.dropdown-menu .btn-link,.dropdown-menu .btn-outline,.dropdown-menu .btn-outline-danger,.dropdown-menu .btn-outline-primary,.dropdown-menu .btn-outline-secondary,.dropdown-menu .btn-outline-success,.dropdown-menu .btn-outline-warning,.dropdown-menu .btn-primary,.dropdown-menu .btn-secondary,.dropdown-menu .btn-success,.dropdown-menu .btn-warning,.dropdown-menu .dropdown-item{font-size:.58333rem;letter-spacing:normal;font-weight:400;background:transparent;border:0;color:#565656;cursor:pointer;display:block;margin:0;padding:.04167rem 1rem 0;width:100%;text-transform:none}.dropdown-menu .btn-danger:focus,.dropdown-menu .btn-danger:hover,.dropdown-menu .btn-info:focus,.dropdown-menu .btn-info:hover,.dropdown-menu .btn-link:focus,.dropdown-menu .btn-link:hover,.dropdown-menu .btn-outline-danger:focus,.dropdown-menu .btn-outline-danger:hover,.dropdown-menu .btn-outline-primary:focus,.dropdown-menu .btn-outline-primary:hover,.dropdown-menu .btn-outline-secondary:focus,.dropdown-menu .btn-outline-secondary:hover,.dropdown-menu .btn-outline-success:focus,.dropdown-menu .btn-outline-success:hover,.dropdown-menu .btn-outline-warning:focus,.dropdown-menu .btn-outline-warning:hover,.dropdown-menu .btn-outline:focus,.dropdown-menu .btn-outline:hover,.dropdown-menu .btn-primary:focus,.dropdown-menu .btn-primary:hover,.dropdown-menu .btn-secondary:focus,.dropdown-menu .btn-secondary:hover,.dropdown-menu .btn-success:focus,.dropdown-menu .btn-success:hover,.dropdown-menu .btn-warning:focus,.dropdown-menu .btn-warning:hover,.dropdown-menu .btn:focus,.dropdown-menu .btn:hover,.dropdown-menu .dropdown-item:focus,.dropdown-menu .dropdown-item:hover{background-color:#eee;color:#565656;text-decoration:none}.dropdown-menu .btn-danger.expandable,.dropdown-menu .btn-info.expandable,.dropdown-menu .btn-link.expandable,.dropdown-menu .btn-outline-danger.expandable,.dropdown-menu .btn-outline-primary.expandable,.dropdown-menu .btn-outline-secondary.expandable,.dropdown-menu .btn-outline-success.expandable,.dropdown-menu .btn-outline-warning.expandable,.dropdown-menu .btn-outline.expandable,.dropdown-menu .btn-primary.expandable,.dropdown-menu .btn-secondary.expandable,.dropdown-menu .btn-success.expandable,.dropdown-menu .btn-warning.expandable,.dropdown-menu .btn.expandable,.dropdown-menu .dropdown-item.expandable{margin-right:1rem;padding-right:.5rem}.dropdown-menu .btn-danger.expandable:before,.dropdown-menu .btn-info.expandable:before,.dropdown-menu .btn-link.expandable:before,.dropdown-menu .btn-outline-danger.expandable:before,.dropdown-menu .btn-outline-primary.expandable:before,.dropdown-menu .btn-outline-secondary.expandable:before,.dropdown-menu .btn-outline-success.expandable:before,.dropdown-menu .btn-outline-warning.expandable:before,.dropdown-menu .btn-outline.expandable:before,.dropdown-menu .btn-primary.expandable:before,.dropdown-menu .btn-secondary.expandable:before,.dropdown-menu .btn-success.expandable:before,.dropdown-menu .btn-warning.expandable:before,.dropdown-menu .btn.expandable:before,.dropdown-menu .dropdown-item.expandable:before{content:"";float:right;height:.5rem;width:.5rem;-webkit-transform:rotate(-90deg) translateX(-.333rem);transform:rotate(-90deg) translateX(-.333rem);background-image:url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2012%22%3E%0A%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3Cstyle%3E.cls-1%7Bfill%3A%239a9a9a%3B%7D%3C%2Fstyle%3E%0A%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%3Ctitle%3ECaret%3C%2Ftitle%3E%0A%20%20%20%20%3Cpath%20class%3D%22cls-1%22%20d%3D%22M6%2C9L1.2%2C4.2a0.68%2C0.68%2C0%2C0%2C1%2C1-1L6%2C7.08%2C9.84%2C3.24a0.68%2C0.68%2C0%2C1%2C1%2C1%2C1Z%22%2F%3E%0A%3C%2Fsvg%3E%0A");background-repeat:no-repeat;background-size:contain;vertical-align:middle;margin:0}.dropdown-menu .btn-danger.active,.dropdown-menu .btn-info.active,.dropdown-menu .btn-link.active,.dropdown-menu .btn-outline-danger.active,.dropdown-menu .btn-outline-primary.active,.dropdown-menu .btn-outline-secondary.active,.dropdown-menu .btn-outline-success.active,.dropdown-menu .btn-outline-warning.active,.dropdown-menu .btn-outline.active,.dropdown-menu .btn-primary.active,.dropdown-menu .btn-secondary.active,.dropdown-menu .btn-success.active,.dropdown-menu .btn-warning.active,.dropdown-menu .btn.active,.dropdown-menu .dropdown-item.active{background:#d9e4ea;color:#000}.dropdown-menu .btn-danger:active,.dropdown-menu .btn-info:active,.dropdown-menu .btn-link:active,.dropdown-menu .btn-outline-danger:active,.dropdown-menu .btn-outline-primary:active,.dropdown-menu .btn-outline-secondary:active,.dropdown-menu .btn-outline-success:active,.dropdown-menu .btn-outline-warning:active,.dropdown-menu .btn-outline:active,.dropdown-menu .btn-primary:active,.dropdown-menu .btn-secondary:active,.dropdown-menu .btn-success:active,.dropdown-menu .btn-warning:active,.dropdown-menu .btn:active,.dropdown-menu .dropdown-item:active{box-shadow:none}.dropdown-menu .btn-danger:focus,.dropdown-menu .btn-info:focus,.dropdown-menu .btn-link:focus,.dropdown-menu .btn-outline-danger:focus,.dropdown-menu .btn-outline-primary:focus,.dropdown-menu .btn-outline-secondary:focus,.dropdown-menu .btn-outline-success:focus,.dropdown-menu .btn-outline-warning:focus,.dropdown-menu .btn-outline:focus,.dropdown-menu .btn-primary:focus,.dropdown-menu .btn-secondary:focus,.dropdown-menu .btn-success:focus,.dropdown-menu .btn-warning:focus,.dropdown-menu .btn:focus,.dropdown-menu .dropdown-item:focus{outline:0}.dropdown-menu .btn-danger.disabled,.dropdown-menu .btn-danger:disabled,.dropdown-menu .btn-info.disabled,.dropdown-menu .btn-info:disabled,.dropdown-menu .btn-link.disabled,.dropdown-menu .btn-link:disabled,.dropdown-menu .btn-outline-danger.disabled,.dropdown-menu .btn-outline-danger:disabled,.dropdown-menu .btn-outline-primary.disabled,.dropdown-menu .btn-outline-primary:disabled,.dropdown-menu .btn-outline-secondary.disabled,.dropdown-menu .btn-outline-secondary:disabled,.dropdown-menu .btn-outline-success.disabled,.dropdown-menu .btn-outline-success:disabled,.dropdown-menu .btn-outline-warning.disabled,.dropdown-menu .btn-outline-warning:disabled,.dropdown-menu .btn-outline.disabled,.dropdown-menu .btn-outline:disabled,.dropdown-menu .btn-primary.disabled,.dropdown-menu .btn-primary:disabled,.dropdown-menu .btn-secondary.disabled,.dropdown-menu .btn-secondary:disabled,.dropdown-menu .btn-success.disabled,.dropdown-menu .btn-success:disabled,.dropdown-menu .btn-warning.disabled,.dropdown-menu .btn-warning:disabled,.dropdown-menu .btn.disabled,.dropdown-menu .btn:disabled,.dropdown-menu .dropdown-item.disabled,.dropdown-menu .dropdown-item:disabled{cursor:not-allowed;opacity:.4;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.dropdown-menu .btn-danger.disabled:hover,.dropdown-menu .btn-danger:disabled:hover,.dropdown-menu .btn-info.disabled:hover,.dropdown-menu .btn-info:disabled:hover,.dropdown-menu .btn-link.disabled:hover,.dropdown-menu .btn-link:disabled:hover,.dropdown-menu .btn-outline-danger.disabled:hover,.dropdown-menu .btn-outline-danger:disabled:hover,.dropdown-menu .btn-outline-primary.disabled:hover,.dropdown-menu .btn-outline-primary:disabled:hover,.dropdown-menu .btn-outline-secondary.disabled:hover,.dropdown-menu .btn-outline-secondary:disabled:hover,.dropdown-menu .btn-outline-success.disabled:hover,.dropdown-menu .btn-outline-success:disabled:hover,.dropdown-menu .btn-outline-warning.disabled:hover,.dropdown-menu .btn-outline-warning:disabled:hover,.dropdown-menu .btn-outline.disabled:hover,.dropdown-menu .btn-outline:disabled:hover,.dropdown-menu .btn-primary.disabled:hover,.dropdown-menu .btn-primary:disabled:hover,.dropdown-menu .btn-secondary.disabled:hover,.dropdown-menu .btn-secondary:disabled:hover,.dropdown-menu .btn-success.disabled:hover,.dropdown-menu .btn-success:disabled:hover,.dropdown-menu .btn-warning.disabled:hover,.dropdown-menu .btn-warning:disabled:hover,.dropdown-menu .btn.disabled:hover,.dropdown-menu .btn:disabled:hover,.dropdown-menu .dropdown-item.disabled:hover,.dropdown-menu .dropdown-item:disabled:hover{background:none}.dropdown-menu .btn-danger.disabled:active,.dropdown-menu .btn-danger.disabled:focus,.dropdown-menu .btn-danger:disabled:active,.dropdown-menu .btn-danger:disabled:focus,.dropdown-menu .btn-info.disabled:active,.dropdown-menu .btn-info.disabled:focus,.dropdown-menu .btn-info:disabled:active,.dropdown-menu .btn-info:disabled:focus,.dropdown-menu .btn-link.disabled:active,.dropdown-menu .btn-link.disabled:focus,.dropdown-menu .btn-link:disabled:active,.dropdown-menu .btn-link:disabled:focus,.dropdown-menu .btn-outline-danger.disabled:active,.dropdown-menu .btn-outline-danger.disabled:focus,.dropdown-menu .btn-outline-danger:disabled:active,.dropdown-menu .btn-outline-danger:disabled:focus,.dropdown-menu .btn-outline-primary.disabled:active,.dropdown-menu .btn-outline-primary.disabled:focus,.dropdown-menu .btn-outline-primary:disabled:active,.dropdown-menu .btn-outline-primary:disabled:focus,.dropdown-menu .btn-outline-secondary.disabled:active,.dropdown-menu .btn-outline-secondary.disabled:focus,.dropdown-menu .btn-outline-secondary:disabled:active,.dropdown-menu .btn-outline-secondary:disabled:focus,.dropdown-menu .btn-outline-success.disabled:active,.dropdown-menu .btn-outline-success.disabled:focus,.dropdown-menu .btn-outline-success:disabled:active,.dropdown-menu .btn-outline-success:disabled:focus,.dropdown-menu .btn-outline-warning.disabled:active,.dropdown-menu .btn-outline-warning.disabled:focus,.dropdown-menu .btn-outline-warning:disabled:active,.dropdown-menu .btn-outline-warning:disabled:focus,.dropdown-menu .btn-outline.disabled:active,.dropdown-menu .btn-outline.disabled:focus,.dropdown-menu .btn-outline:disabled:active,.dropdown-menu .btn-outline:disabled:focus,.dropdown-menu .btn-primary.disabled:active,.dropdown-menu .btn-primary.disabled:focus,.dropdown-menu .btn-primary:disabled:active,.dropdown-menu .btn-primary:disabled:focus,.dropdown-menu .btn-secondary.disabled:active,.dropdown-menu .btn-secondary.disabled:focus,.dropdown-menu .btn-secondary:disabled:active,.dropdown-menu .btn-secondary:disabled:focus,.dropdown-menu .btn-success.disabled:active,.dropdown-menu .btn-success.disabled:focus,.dropdown-menu .btn-success:disabled:active,.dropdown-menu .btn-success:disabled:focus,.dropdown-menu .btn-warning.disabled:active,.dropdown-menu .btn-warning.disabled:focus,.dropdown-menu .btn-warning:disabled:active,.dropdown-menu .btn-warning:disabled:focus,.dropdown-menu .btn.disabled:active,.dropdown-menu .btn.disabled:focus,.dropdown-menu .btn:disabled:active,.dropdown-menu .btn:disabled:focus,.dropdown-menu .dropdown-item.disabled:active,.dropdown-menu .dropdown-item.disabled:focus,.dropdown-menu .dropdown-item:disabled:active,.dropdown-menu .dropdown-item:disabled:focus{background:none;box-shadow:none}.dropdown-menu .btn,.dropdown-menu .dropdown-item{height:1.25rem;line-height:1.25rem}@media screen and (max-width:544px){.dropdown-menu .btn,.dropdown-menu .dropdown-item{height:1.5rem;line-height:1.5rem}}.dropdown-menu .dropdown-divider{border-bottom:1px solid #eee;margin:.25rem 0}.btn-group-overflow.open>.dropdown-menu,.btn-group-overflow.open>.dropdown-menu-wrapper>.dropdown-menu,.dropdown.open>.dropdown-menu,.dropdown.open>.dropdown-menu-wrapper>.dropdown-menu,.tabs-overflow.open>.dropdown-menu,.tabs-overflow.open>.dropdown-menu-wrapper>.dropdown-menu{visibility:visible}.btn-group-overflow.bottom-left>.dropdown-menu,.btn-group-overflow.bottom-right>.dropdown-menu,.dropdown.bottom-left>.dropdown-menu,.dropdown.bottom-right>.dropdown-menu,.tabs-overflow.bottom-left>.dropdown-menu,.tabs-overflow.bottom-right>.dropdown-menu{top:100%;bottom:auto;margin:.08333rem 0 0 0}.btn-group-overflow.bottom-left>.dropdown-menu,.dropdown.bottom-left>.dropdown-menu,.tabs-overflow.bottom-left>.dropdown-menu{left:0;right:auto}.btn-group-overflow.bottom-right>.dropdown-menu,.dropdown.bottom-right>.dropdown-menu,.tabs-overflow.bottom-right>.dropdown-menu{right:0;left:auto}.btn-group-overflow.top-left>.dropdown-menu,.btn-group-overflow.top-right>.dropdown-menu,.dropdown.top-left>.dropdown-menu,.dropdown.top-right>.dropdown-menu,.tabs-overflow.top-left>.dropdown-menu,.tabs-overflow.top-right>.dropdown-menu{top:auto;bottom:100%;margin:0 0 .08333rem 0}.btn-group-overflow.top-left>.dropdown-menu,.dropdown.top-left>.dropdown-menu,.tabs-overflow.top-left>.dropdown-menu{left:0;right:auto}.btn-group-overflow.top-right>.dropdown-menu,.dropdown.top-right>.dropdown-menu,.tabs-overflow.top-right>.dropdown-menu{right:0;left:auto}.btn-group-overflow.left-bottom>.dropdown-menu,.btn-group-overflow.left-top>.dropdown-menu,.dropdown.left-bottom>.dropdown-menu,.dropdown.left-top>.dropdown-menu,.tabs-overflow.left-bottom>.dropdown-menu,.tabs-overflow.left-top>.dropdown-menu{right:100%;left:auto;margin:0 .08333rem 0 0}.btn-group-overflow.left-bottom>.dropdown-menu,.dropdown.left-bottom>.dropdown-menu,.tabs-overflow.left-bottom>.dropdown-menu{top:0;bottom:auto}.btn-group-overflow.left-top>.dropdown-menu,.dropdown.left-top>.dropdown-menu,.tabs-overflow.left-top>.dropdown-menu{bottom:0;top:auto}.btn-group-overflow.right-bottom>.dropdown-menu,.btn-group-overflow.right-top>.dropdown-menu,.dropdown.right-bottom>.dropdown-menu,.dropdown.right-top>.dropdown-menu,.tabs-overflow.right-bottom>.dropdown-menu,.tabs-overflow.right-top>.dropdown-menu{left:100%;right:auto;margin:0 0 0 .08333rem}.btn-group-overflow.right-bottom>.dropdown-menu,.dropdown.right-bottom>.dropdown-menu,.tabs-overflow.right-bottom>.dropdown-menu{top:0;bottom:auto}.btn-group-overflow.right-top>.dropdown-menu,.dropdown.right-top>.dropdown-menu,.tabs-overflow.right-top>.dropdown-menu{bottom:0;top:auto}.btn-group-overflow .dropdown .dropdown-menu,.dropdown .dropdown .dropdown-menu,.tabs-overflow .dropdown .dropdown-menu{border-color:#9a9a9a;position:absolute}.btn-group-overflow .dropdown.left-top>.dropdown-menu,.btn-group-overflow .dropdown.left-top>.dropdown-menu-wrapper>.dropdown-menu,.dropdown .dropdown.left-top>.dropdown-menu,.dropdown .dropdown.left-top>.dropdown-menu-wrapper>.dropdown-menu,.tabs-overflow .dropdown.left-top>.dropdown-menu,.tabs-overflow .dropdown.left-top>.dropdown-menu-wrapper>.dropdown-menu{top:0;bottom:auto;left:auto;right:100%;margin-top:-.79167rem;margin-right:-.16667rem}.btn-group-overflow .dropdown.right-top>.dropdown-menu,.btn-group-overflow .dropdown.right-top>.dropdown-menu-wrapper>.dropdown-menu,.dropdown .dropdown.right-top>.dropdown-menu,.dropdown .dropdown.right-top>.dropdown-menu-wrapper>.dropdown-menu,.tabs-overflow .dropdown.right-top>.dropdown-menu,.tabs-overflow .dropdown.right-top>.dropdown-menu-wrapper>.dropdown-menu{top:0;bottom:auto;left:100%;right:auto;margin-top:-.79167rem;margin-left:-.16667rem}.btn-group-overflow .dropdown.left-bottom>.dropdown-menu,.btn-group-overflow .dropdown.left-bottom>.dropdown-menu-wrapper>.dropdown-menu,.dropdown .dropdown.left-bottom>.dropdown-menu,.dropdown .dropdown.left-bottom>.dropdown-menu-wrapper>.dropdown-menu,.tabs-overflow .dropdown.left-bottom>.dropdown-menu,.tabs-overflow .dropdown.left-bottom>.dropdown-menu-wrapper>.dropdown-menu{top:auto;bottom:0;left:auto;right:100%;margin-bottom:-.79167rem;margin-right:-.16667rem}.btn-group-overflow .dropdown.right-bottom>.dropdown-menu,.btn-group-overflow .dropdown.right-bottom>.dropdown-menu-wrapper>.dropdown-menu,.dropdown .dropdown.right-bottom>.dropdown-menu,.dropdown .dropdown.right-bottom>.dropdown-menu-wrapper>.dropdown-menu,.tabs-overflow .dropdown.right-bottom>.dropdown-menu,.tabs-overflow .dropdown.right-bottom>.dropdown-menu-wrapper>.dropdown-menu{top:auto;bottom:0;left:100%;right:auto;margin-bottom:-.79167rem;margin-left:-.16667rem}.label,a.label{font-size:.45833rem;font-weight:400;letter-spacing:.03em;line-height:.458333rem;display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;padding:0 .5rem 1px;border-radius:.5rem;border:1px solid #737373;height:.875rem;margin:0 .25rem 0 0;white-space:nowrap;color:#565656}.label:visited,a.label:visited{color:#565656}.label:active,.label:focus,.label:hover,a.label:active,a.label:focus,a.label:hover{text-decoration:none}.label.clickable:active,.label.clickable:hover,a.label.clickable:active,a.label.clickable:hover{background:#eee}.label.clickable:active,a.label.clickable:active{box-shadow:inset 0 1px 0 0 #737373;-webkit-transform:translateY(.5px);transform:translateY(.5px)}.label.label-1,.label.label-gray,a.label.label-1,a.label.label-gray{border:1px solid #737373}.label.clickable.label-gray:active,.label.clickable.label-gray:hover,a.label.clickable.label-gray:active,a.label.clickable.label-gray:hover{text-decoration:none;background:#eee}.label.clickable.label-gray:active,a.label.clickable.label-gray:active{box-shadow:inset 0 1px 0 0 #737373;-webkit-transform:translateY(.5px);transform:translateY(.5px)}.label.label-gray>.badge,a.label.label-gray>.badge{background:#737373;color:#fff}.label.label-2,.label.label-purple,a.label.label-2,a.label.label-purple{border:1px solid #9460b8}.label.clickable.label-purple:active,.label.clickable.label-purple:hover,a.label.clickable.label-purple:active,a.label.clickable.label-purple:hover{text-decoration:none;background:#eee}.label.clickable.label-purple:active,a.label.clickable.label-purple:active{box-shadow:inset 0 1px 0 0 #9460b8;-webkit-transform:translateY(.5px);transform:translateY(.5px)}.label.label-purple>.badge,a.label.label-purple>.badge{background:#9460b8;color:#fff}.label.label-3,.label.label-blue,a.label.label-3,a.label.label-blue{border:1px solid #004a70}.label.clickable.label-blue:active,.label.clickable.label-blue:hover,a.label.clickable.label-blue:active,a.label.clickable.label-blue:hover{text-decoration:none;background:#eee}.label.clickable.label-blue:active,a.label.clickable.label-blue:active{box-shadow:inset 0 1px 0 0 #004a70;-webkit-transform:translateY(.5px);transform:translateY(.5px)}.label.label-blue>.badge,a.label.label-blue>.badge{background:#004a70;color:#fff}.label.label-4,.label.label-orange,a.label.label-4,a.label.label-orange{border:1px solid #eb8d00}.label.clickable.label-orange:active,.label.clickable.label-orange:hover,a.label.clickable.label-orange:active,a.label.clickable.label-orange:hover{text-decoration:none;background:#eee}.label.clickable.label-orange:active,a.label.clickable.label-orange:active{box-shadow:inset 0 1px 0 0 #eb8d00;-webkit-transform:translateY(.5px);transform:translateY(.5px)}.label.label-orange>.badge,a.label.label-orange>.badge{background:#eb8d00;color:#000}.label.label-5,.label.label-light-blue,a.label.label-5,a.label.label-light-blue{border:1px solid #89cbdf}.label.clickable.label-light-blue:active,.label.clickable.label-light-blue:hover,a.label.clickable.label-light-blue:active,a.label.clickable.label-light-blue:hover{text-decoration:none;background:#eee}.label.clickable.label-light-blue:active,a.label.clickable.label-light-blue:active{box-shadow:inset 0 1px 0 0 #89cbdf;-webkit-transform:translateY(.5px);transform:translateY(.5px)}.label.label-light-blue>.badge,a.label.label-light-blue>.badge{background:#89cbdf;color:#000}.label.label-info,a.label.label-info{background:#e1f1f6;color:#004a70;border:1px solid #49afd9}.label.label-success,a.label.label-success{background:#dff0d0;color:#266900;border:1px solid #60b515}.label.label-warning,a.label.label-warning{background:#feecb5;color:#313131;border:1px solid #ffdc0b}.label.label-danger,a.label.label-danger{background:#f5dbd9;color:#a32100;border:1px solid #ebafa6}.label>.badge,a.label>.badge{margin:0 -.375rem 0 .25rem}.badge{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;vertical-align:middle;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;min-width:.625rem;background:#737373;height:.625rem;line-height:normal;border-radius:.41667rem;font-size:.41667rem;padding:0 .166667rem;margin-right:.25rem;white-space:nowrap;text-align:center}.badge,.badge:visited{color:#fff}.badge.badge-1,.badge.badge-gray{background:#737373;color:#fff}.badge.badge-2,.badge.badge-purple{background:#9460b8;color:#fff}.badge.badge-3,.badge.badge-blue{background:#004a70;color:#fff}.badge.badge-4,.badge.badge-orange{background:#eb8d00;color:#000}.badge.badge-5,.badge.badge-light-blue{background:#89cbdf;color:#000}.badge.badge-info{background:#007cbb;color:#fff}.badge.badge-success{background:#318700;color:#fff}.badge.badge-danger{background:#c92100;color:#fff}.badge.badge-warning{background:#ffdc0b;color:#000}@-moz-document url-prefix(){.label,a.label{vertical-align:bottom}}:root .badge,_:-ms-input-placeholder .badge{padding:.083333rem .125rem 0}@supports (-ms-ime-align:auto){.badge{padding:.083333rem .125rem 0}}.row.force-fit{margin-left:0;margin-right:0}ul.list-unstyled{list-style:none}ol,ul,ul.list-unstyled{padding-left:0;margin-left:0}ol,ul{list-style-position:inside;margin-top:0;margin-bottom:0}ol.list,ul.list{list-style-position:outside;margin-left:1.1em}ol.list.compact,ul.list.compact{line-height:.75rem}ol.list.compact>li,ul.list.compact>li{margin-bottom:.25rem}ol.list.compact>li:last-child,ul.list.compact>li:last-child{margin-bottom:0}li>ul,ol>li>ul.list-unstyled,ul:not(.list-unstyled)>li>ul.list-unstyled{margin-left:1.1em}li>ul,ul.list-group{margin-top:0}ol.list-spacer,ul.list-spacer{margin-top:1rem}.login-wrapper{background:url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%20100%25%20100%25%22%20preserveAspectRatio%3D%22xMinYMin%20meet%22%3E%0A%20%20%20%20%3Cdesc%3ELogin%20Background%3C%2Fdesc%3E%0A%20%20%20%20%3Cg%20stroke%3D%22none%22%20stroke-width%3D%221%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%20transform%3D%22translate(0.000000%2C%20-4.000000)%22%3E%0A%20%20%20%20%20%20%20%20%3Cg%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Cg%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20fill%3D%22%23FAFAFA%22%20x%3D%220%22%20y%3D%224%22%20width%3D%222055.55%22%20height%3D%221440%22%3E%3C%2Frect%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23007CBB%22%20opacity%3D%220.4%22%20style%3D%22mix-blend-mode%3A%20multiply%3B%22%20points%3D%221108.43%201443.63%201109.08%201443.63%20443.44%20777.74%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2393D8CA%22%20opacity%3D%220.6%22%20style%3D%22mix-blend-mode%3A%20overlay%3B%22%20points%3D%220.79%20334.92%20443.44%20777.74%200.79%20334.49%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23A0DEEA%22%20points%3D%220.79%20211.88%200.79%20329.6%2059.62%20270.77%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2396C7DF%22%20points%3D%22160.65%20169.74%200.79%209.73%200.79%20211.88%2090.27%20301.46%2059.62%20270.77%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23CDE3EE%22%20points%3D%22503.77%201443.63%20697.47%201443.63%20803.74%201337.36%20706.93%201240.43%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23CDE3EE%22%20points%3D%22158.33%20691.15%200.79%20848.72%200.79%201427.43%20447.52%20980.7%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23CEDDE0%22%20points%3D%22257.71%20591.75%200.79%20334.49%200.79%20533.42%20158.33%20691.15%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23A9C9D5%22%20points%3D%220.79%20533.42%200.79%20848.72%20158.33%20691.15%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23AFD4E7%22%20points%3D%22806.46%201140.89%20546.94%20881.28%20447.52%20980.7%20706.93%201240.43%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%238FC4DF%22%20points%3D%22447.52%20980.7%200.79%201427.43%200.79%201443.63%20503.77%201443.63%20706.93%201240.43%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2370C0DC%22%20points%3D%22608.23%20819.99%20546.94%20881.28%20806.46%201140.89%20867.64%201079.7%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2396C7DF%22%20points%3D%22420.05%20429.39%20319.01%20530.45%20608.23%20819.99%20709.3%20718.91%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2369AFD4%22%20points%3D%22709.3%20718.91%20608.23%20819.99%20867.64%201079.7%20968.74%20978.6%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%238EB5BC%22%20points%3D%22619.59%20229.82%20393.42%203.12%20327.27%203.12%20160.65%20169.74%20420.05%20429.39%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%238EB5BC%22%20points%3D%22319.01%20530.45%20319.01%20530.45%2090.27%20301.46%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%237CB0C7%22%20points%3D%22160.65%20169.74%2059.62%20270.77%2090.27%20301.46%20319.01%20530.45%20420.05%20429.39%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2384C4D2%22%20points%3D%2259.62%20270.77%200.79%20329.6%200.79%20334.49%20257.71%20591.75%20319.01%20530.45%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%237CB0C7%22%20points%3D%22537.55%203.12%20393.42%203.12%20619.59%20229.82%20691.74%20157.66%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2387D1DB%22%20points%3D%22846.25%203.12%20537.55%203.12%20691.74%20157.66%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23CDE3EE%22%20points%3D%22909.87%201443.63%20850.19%201383.87%20790.43%201443.63%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23A0DEEA%22%20points%3D%22319.01%20530.45%20257.71%20591.75%20443.44%20777.74%20546.94%20881.28%20608.23%20819.99%20867.64%201079.7%20867.64%201079.7%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23A0DEEA%22%20points%3D%22867.64%201079.7%20806.46%201140.89%20903.31%201237.78%20964.46%201176.63%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2396C7DF%22%20points%3D%221065.57%201075.52%20968.74%20978.6%20867.64%201079.7%20964.46%201176.63%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2396C7DF%22%20points%3D%22964.46%201176.63%20867.64%201079.7%20867.64%201079.7%20964.46%201176.63%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2396C7DF%22%20points%3D%221010.92%201223.13%201231.16%201443.63%201010.92%201223.13%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2396C7DF%22%20points%3D%221240.08%20707.22%201167.9%20779.4%201264.68%20876.4%201336.87%20804.22%201240.08%20707.21%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2396C7DF%22%20points%3D%22980.83%20447.39%20691.74%20157.66%20619.59%20229.82%20908.66%20519.56%20980.83%20447.39%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23AFD4E7%22%20points%3D%22709.3%20718.91%20968.74%20978.6%201167.91%20779.4%20908.66%20519.55%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2369AFD4%22%20points%3D%22980.83%20447.39%20908.66%20519.55%201167.91%20779.4%201240.08%20707.21%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23A0DEEA%22%20points%3D%221034.59%203.12%20846.25%203.12%20691.74%20157.66%20980.83%20447.39%201229.75%20198.47%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23A0DEEA%22%20points%3D%221240.08%20707.21%201336.87%20804.22%201586.01%20555.08%201489.14%20458.12%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2370C0DC%22%20points%3D%221229.75%20198.47%20980.83%20447.39%201240.08%20707.21%201489.14%20458.12%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23B7CED2%22%20points%3D%221292.22%201302.38%201433.32%201443.63%201830.61%201443.63%201491.18%201103.42%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23A0DEEA%22%20points%3D%221010.92%201223.13%20949.78%201284.27%201109.08%201443.63%201150.98%201443.63%201191.09%201403.51%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2375B8C5%22%20points%3D%221150.98%201443.63%201231.16%201443.63%201191.09%201403.51%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2396C7DF%22%20points%3D%221292.22%201302.38%201112.03%201122.02%201010.92%201223.13%201191.09%201403.51%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%236EA4BC%22%20points%3D%221191.09%201403.51%201231.16%201443.63%201433.32%201443.63%201292.22%201302.38%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23007CBB%22%20opacity%3D%220.4%22%20style%3D%22mix-blend-mode%3A%20multiply%3B%22%20points%3D%221383.3%20850.75%201311.12%20922.94%201491.18%201103.42%201563.37%201031.23%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23B7CED2%22%20points%3D%221491.18%201103.42%201830.61%201443.63%201974.86%201443.63%201563.37%201031.23%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%236EA4BC%22%20points%3D%221491.18%201103.42%201830.61%201443.63%201974.86%201443.63%201563.37%201031.23%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23A0DEEA%22%20points%3D%221812.65%20781.95%201632.46%20601.59%201383.3%20850.75%201563.37%201031.23%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23B7CED2%22%20points%3D%221563.37%201031.23%201974.86%201443.63%202054.45%201443.63%202054.45%201023.99%201812.65%20781.95%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2378CAD4%22%20points%3D%221563.37%201031.23%201974.86%201443.63%202054.45%201443.63%202054.45%201023.99%201812.65%20781.95%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2391C5E0%22%20points%3D%22803.74%201337.36%20850.19%201383.87%20949.78%201284.27%20903.31%201237.78%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2391C5E0%22%20points%3D%221065.57%201075.52%201112.03%201122.02%201311.12%20922.94%201264.69%20876.4%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2377B8D9%22%20points%3D%22697.47%201443.63%20790.43%201443.63%20850.19%201383.87%20803.74%201337.36%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23A0DEEA%22%20points%3D%22964.46%201176.63%20903.31%201237.78%20949.78%201284.27%201010.92%201223.13%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%235DB5D6%22%20points%3D%22964.46%201176.63%20903.31%201237.78%20949.78%201284.27%201010.92%201223.13%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20fill%3D%22%2396C7DF%22%20transform%3D%22translate(1038.247297%2C%201149.275429)%20rotate(-44.970000)%20translate(-1038.247297%2C%20-1149.275429)%20%22%20x%3D%22966.752297%22%20y%3D%221116.41043%22%20width%3D%22142.99%22%20height%3D%2265.73%22%3E%3C%2Frect%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20fill%3D%22%2357A8D0%22%20transform%3D%22translate(1038.247297%2C%201149.275429)%20rotate(-44.970000)%20translate(-1038.247297%2C%20-1149.275429)%20%22%20x%3D%22966.752297%22%20y%3D%221116.41043%22%20width%3D%22142.99%22%20height%3D%2265.73%22%3E%3C%2Frect%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2396C7DF%22%20points%3D%221010.92%201223.13%201010.92%201223.13%20964.46%201176.63%20964.46%201176.63%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23000000%22%20opacity%3D%220.42%22%20points%3D%221010.92%201223.13%201010.92%201223.13%20964.46%201176.63%20964.46%201176.63%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23007CBB%22%20opacity%3D%220.4%22%20style%3D%22mix-blend-mode%3A%20multiply%3B%22%20points%3D%221336.87%20804.22%201264.69%20876.4%201311.12%20922.94%201383.3%20850.75%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2357A8D0%22%20points%3D%221336.87%20804.22%201264.69%20876.4%201311.12%20922.94%201383.3%20850.75%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2393D8CA%22%20opacity%3D%220.6%22%20style%3D%22mix-blend-mode%3A%20overlay%3B%22%20points%3D%221336.87%20804.22%201383.3%20850.75%201632.46%20601.59%201586.01%20555.08%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%235DB5D6%22%20points%3D%221336.87%20804.22%201383.3%20850.75%201632.46%20601.59%201586.01%20555.08%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23AFD3E6%22%20points%3D%222056%200.12%201645.49%200.12%201648.49%203.12%201944.07%203.12%201796.22%20150.99%201893.12%20247.97%202054.45%2086.64%202054.45%20179.6%201939.58%20294.47%202056%20411%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%237AB9D9%22%20points%3D%221648.49%203.12%201796.22%20150.99%201944.07%203.12%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2366AED4%22%20points%3D%222054.45%2086.64%201893.12%20247.97%201939.58%20294.47%202054.45%20179.6%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23B7CED2%22%20points%3D%221884.82%20709.78%202054.45%20879.57%202054.45%20540.15%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23AFD4E7%22%20points%3D%221489.14%20458.12%201489.14%20458.12%201371.13%20339.99%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23AFD4E7%22%20points%3D%221796.22%20150.99%201648.49%203.12%201425.1%203.12%201301.91%20126.31%201561.3%20385.95%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20fill%3D%22%2391C5E0%22%20transform%3D%22translate(1798.954066%2C%20388.798781)%20rotate(-44.970000)%20translate(-1798.954066%2C%20-388.798781)%20%22%20x%3D%221632.82407%22%20y%3D%22355.933781%22%20width%3D%22332.26%22%20height%3D%2265.73%22%3E%3C%2Frect%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2391C5E0%22%20points%3D%221586.01%20555.08%201632.46%20601.59%201632.46%20601.59%201586.01%20555.08%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20fill%3D%22%23B3EAEE%22%20transform%3D%22translate(1573.711577%2C%20470.620263)%20rotate(-45.000000)%20translate(-1573.711577%2C%20-470.620263)%20%22%20x%3D%221522.68158%22%20y%3D%22402.085263%22%20width%3D%22102.06%22%20height%3D%22137.07%22%3E%3C%2Frect%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20fill%3D%22%23B3EAEE%22%20transform%3D%22translate(1758.676758%2C%20655.767120)%20rotate(-44.970000)%20translate(-1758.676758%2C%20-655.767120)%20%22%20x%3D%221707.64676%22%20y%3D%22528.29212%22%20width%3D%22102.06%22%20height%3D%22254.95%22%3E%3C%2Frect%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%23B3EAEE%22%20points%3D%221301.91%20126.31%201178.84%203.12%201034.59%203.12%201229.75%20198.47%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpolygon%20fill%3D%22%2383C0C8%22%20points%3D%221812.65%20781.95%202054.45%201023.99%202054.45%20879.57%201884.82%20709.78%22%3E%3C%2Fpolygon%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20fill%3D%22%237DC6DC%22%20transform%3D%22translate(1395.516901%2C%20292.206519)%20rotate(-45.000000)%20translate(-1395.516901%2C%20-292.206519)%20%22%20x%3D%221344.4919%22%20y%3D%22108.701519%22%20width%3D%22102.05%22%20height%3D%22367.01%22%3E%3C%2Frect%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Crect%20fill%3D%22%2368B8D5%22%20transform%3D%22translate(1645.313619%2C%20542.249760)%20rotate(-45.000000)%20translate(-1645.313619%2C%20-542.249760)%20%22%20x%3D%221594.28362%22%20y%3D%22509.38476%22%20width%3D%22102.06%22%20height%3D%2265.73%22%3E%3C%2Frect%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3C%2Fg%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Cg%20transform%3D%22translate(0.000000%2C%203.000000)%22%20stroke%3D%22%23000000%22%20opacity%3D%220.15%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpath%20d%3D%22M0.95%2C0.12%20L0.95%2C840.12%22%20id%3D%22Shape%22%3E%3C%2Fpath%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3C%2Fg%3E%0A%20%20%20%20%20%20%20%20%3C%2Fg%3E%0A%20%20%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E");background-size:cover;background-position:21rem 0;background-repeat:no-repeat}.login-wrapper,.login-wrapper .login{display:-webkit-box;display:-ms-flexbox;display:flex}.login-wrapper .login{background:#fafafa;position:relative;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;padding:1rem 2.5rem;height:auto;min-height:100vh;width:21rem}.login-wrapper .login .title{color:#000;font-weight:200;font-family:Metropolis,Avenir Next,Helvetica Neue,Arial,sans-serif;font-size:1.33333rem;letter-spacing:normal;line-height:1.5rem}.login-wrapper .login .title .welcome{line-height:1.5rem}.login-wrapper .login .title .hint{color:#000;margin-top:1.25rem;font-size:.5833rem}.login-wrapper .login .trademark{font-size:1.16667rem}.login-wrapper .login .subtitle,.login-wrapper .login .trademark{color:#000;font-weight:200;font-family:Metropolis,Avenir Next,Helvetica Neue,Arial,sans-serif;letter-spacing:normal}.login-wrapper .login .subtitle{font-size:.91667rem;line-height:1.5rem}.login-wrapper .login .login-group{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;padding:2rem 0 0 0}.login-wrapper .login .login-group .auth-source,.login-wrapper .login .login-group .checkbox,.login-wrapper .login .login-group .password,.login-wrapper .login .login-group .username{margin:.25rem 0 .75rem 0}.login-wrapper .login .login-group .tooltip-validation{margin-top:.25rem}.login-wrapper .login .login-group .tooltip-validation .password,.login-wrapper .login .login-group .tooltip-validation .username{width:100%;margin-top:0}.login-wrapper .login .login-group .error{display:none;margin:.25rem 0 0 0;padding:.375rem .5rem;background:#c92100;color:#fafafa;border-radius:.125rem;line-height:.75rem}.login-wrapper .login .login-group .error:before{display:inline-block;content:"";background:url("data:image/svg+xml;charset=utf8,%3Csvg%20version%3D%221.1%22%20viewBox%3D%225%205%2026%2026%22%20preserveAspectRatio%3D%22xMidYMid%20meet%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%3E%3Cdefs%3E%3Cstyle%3E.clr-i-outline%7Bfill%3A%23fafafa%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Ctitle%3Eexclamation-circle-line%3C%2Ftitle%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpath%20class%3D%22clr-i-outline%20clr-i-outline-path-1%22%20d%3D%22M18%2C6A12%2C12%2C0%2C1%2C0%2C30%2C18%2C12%2C12%2C0%2C0%2C0%2C18%2C6Zm0%2C22A10%2C10%2C0%2C1%2C1%2C28%2C18%2C10%2C10%2C0%2C0%2C1%2C18%2C28Z%22%3E%3C%2Fpath%3E%3Cpath%20class%3D%22clr-i-outline%20clr-i-outline-path-2%22%20d%3D%22M18%2C20.07a1.3%2C1.3%2C0%2C0%2C1-1.3-1.3v-6a1.3%2C1.3%2C0%2C1%2C1%2C2.6%2C0v6A1.3%2C1.3%2C0%2C0%2C1%2C18%2C20.07Z%22%3E%3C%2Fpath%3E%3Ccircle%20class%3D%22clr-i-outline%20clr-i-outline-path-3%22%20cx%3D%2217.95%22%20cy%3D%2223.02%22%20r%3D%221.5%22%3E%3C%2Fcircle%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3C%2Fsvg%3E");margin:.04167rem .25rem 0 0;height:.66667rem;width:.66667rem}.login-wrapper .login .login-group .error.active{display:-webkit-box;display:-ms-flexbox;display:flex}.login-wrapper .login .login-group .error.active:before{-webkit-box-flex:0;-ms-flex:0 0 0.66667rem;flex:0 0 0.66667rem}.login-wrapper .login .login-group .btn{margin:3rem 0 0 0;max-width:none}.login-wrapper .login .login-group .error+.btn{margin:1rem 0 0 0}.login-wrapper .login .login-group .signup{margin-top:.5rem;font-size:.5833rem;text-align:center}.login-wrapper .login:after{position:absolute;content:"";display:block;width:1px;height:100%;background:rgba(0,0,0,.1);top:0;right:-2px}@media screen and (max-width:768px){.login-wrapper{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;background:#fafafa}.login-wrapper .login{width:100%;margin-left:0;padding:1rem 20%}.login-wrapper .login:after{content:none}}@media screen and (max-width:544px){.login-wrapper .login{padding:1rem 15%}}.main-container{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;height:100vh;background:#fafafa}.main-container .alert.alert-app-level{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;overflow-x:hidden}.main-container .header,.main-container header{-webkit-box-flex:0;-ms-flex:0 0 2.5rem;flex:0 0 2.5rem}.main-container .sub-nav,.main-container .subnav{-webkit-box-flex:0;-ms-flex:0 0 1.5rem;flex:0 0 1.5rem}.main-container .u-main-container{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;overflow:hidden}.main-container .content-container,.main-container .u-main-container{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto}.main-container .content-container{min-height:1px}.main-container .content-container .content-area{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;overflow-y:auto;-webkit-overflow-scrolling:touch;padding:1rem 1rem 1rem 1rem}.main-container .content-container .content-area>:first-child{margin-top:0}.main-container .content-container .sidenav{overflow:hidden}.main-container .content-container .clr-vertical-nav,.main-container .content-container .sidenav{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1}@media print{.main-container{height:auto}}body.no-scrolling,body.no-scrolling .main-container .content-container .content-area{overflow:hidden}.modal{position:fixed;top:0;bottom:0;right:0;left:0;z-index:1050;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;padding:2rem}@media screen and (max-width:544px){.modal{padding:.5rem}}.modal-dialog{position:relative;z-index:1050;width:24rem;max-width:100%}.modal-dialog.modal-sm{width:12rem}.modal-dialog.modal-lg{width:36rem}.modal-dialog.modal-xl{width:48rem}.modal-dialog .modal-content{padding:1rem 1rem 1rem 1rem;background-color:#fff;border-radius:.125rem;box-shadow:0 1px 2px 2px rgba(0,0,0,.2)}.modal-header{border-bottom:none;padding:0 0 1rem 0}.modal-header .modal-title{color:#000;font-size:.91667rem;font-family:Metropolis,Avenir Next,Helvetica Neue,Arial,sans-serif;font-weight:200;line-height:1rem;letter-spacing:normal;margin:0;padding:0 .125rem}.modal-header .close{margin-top:-.0416667rem;margin-right:-.208333rem;font-size:1.0833rem;line-height:1rem}.modal-header .close clr-icon{fill:#737373;width:1rem;height:1rem}.modal-body{max-height:70vh;overflow-y:auto;overflow-x:hidden;padding:0 .125rem}.modal-body>:first-child{margin-top:0}.modal-body>:last-child{margin-bottom:0}.modal-footer{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end;padding:1rem 0 0 0}.modal-footer .btn{margin:0 0 0 .5rem}@media screen and (max-width:768px) and (orientation:landscape){.modal-body{max-height:55vh}}@media screen and (max-width:544px){.modal-content{padding:.5rem 0 .5rem 1rem}.modal-header{padding:0 1rem .5rem 0}.modal-body{max-height:55vh}.modal-footer{padding:.5rem 1rem 0 0}}.modal-backdrop{position:fixed;top:0;bottom:0;right:0;left:0;background-color:#313131;opacity:.85;z-index:1040}.modal .modal-nav{display:none}.modal-outer-wrapper{height:100%;width:100%}.modal-ghost-wrapper{display:none}.header,.header.header-1,header,header.header-1{background-color:#313131}.header.header-2,header.header-2{background-color:#485969}.header.header-3,header.header-3{background-color:#281336}.header.header-4,header.header-4{background-color:#006a91}.header.header-5,header.header-5{background-color:#004a70}.header.header-6,header.header-6{background-color:#002538}.header.header-7,header.header-7{background-color:#314351}.header,header{display:-webkit-box;display:-ms-flexbox;display:flex;color:#fafafa;height:2.5rem;white-space:nowrap}.header .branding,.header .divider,.header .header-actions,.header .header-nav,.header .search,.header .search-box,.header .settings,header .branding,header .divider,header .header-actions,header .header-nav,header .search,header .search-box,header .settings{height:2.5rem}.header .branding,header .branding{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;min-width:8.5rem;padding:0 1rem}.header .branding>.nav-link,.header .branding>a,header .branding>.nav-link,header .branding>a{display:-webkit-inline-box;display:-ms-inline-flexbox;display:inline-flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;height:2.5rem}.header .branding>.nav-link:active,.header .branding>.nav-link:hover,.header .branding>a:active,.header .branding>a:hover,header .branding>.nav-link:active,header .branding>.nav-link:hover,header .branding>a:active,header .branding>a:hover{text-decoration:none}.header .branding>.nav-link:focus,.header .branding>a:focus,header .branding>.nav-link:focus,header .branding>a:focus{outline-offset:-.20833rem}.header .branding .clr-icon,.header .branding clr-icon,header .branding .clr-icon,header .branding clr-icon{-webkit-box-flex:0;-ms-flex-positive:0;flex-grow:0;-ms-flex-negative:0;flex-shrink:0;height:1.5rem;width:1.5rem;margin-right:.375rem}.header .branding .title,header .branding .title{font-size:.66667rem;font-weight:400;font-family:Metropolis,Avenir Next,Helvetica Neue,Arial,sans-serif;letter-spacing:.01em;color:#fafafa;line-height:2.5rem;text-decoration:none}.header .header-nav,header .header-nav{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto}.header .header-nav .nav-text,header .header-nav .nav-text{padding:0 1rem;font-weight:500}.header .header-nav .nav-icon,header .header-nav .nav-icon{height:2.5rem;width:2.5rem}.header .header-nav .nav-link,header .header-nav .nav-link{position:relative;display:inline-block;color:#fafafa;opacity:.65}.header .header-nav .nav-link:active,.header .header-nav .nav-link:hover,header .header-nav .nav-link:active,header .header-nav .nav-link:hover{text-decoration:none}.header .header-nav .nav-link:hover,header .header-nav .nav-link:hover{opacity:1}.header .header-nav .nav-link .fa,.header .header-nav .nav-link.nav-icon,.header .header-nav .nav-link .nav-icon,.header .header-nav .nav-link.nav-text,.header .header-nav .nav-link .nav-text,header .header-nav .nav-link .fa,header .header-nav .nav-link.nav-icon,header .header-nav .nav-link .nav-icon,header .header-nav .nav-link.nav-text,header .header-nav .nav-link .nav-text{line-height:2.5rem}.header .header-nav .nav-link .fa,.header .header-nav .nav-link .nav-icon,header .header-nav .nav-link .fa,header .header-nav .nav-link .nav-icon{font-size:.91667rem;text-align:center}.header .header-nav .nav-link clr-icon,header .header-nav .nav-link clr-icon{position:absolute;top:50%;left:50%;-webkit-transform:translate(-50%,-50%);transform:translate(-50%,-50%);height:1rem;width:1rem}.header .header-nav .nav-link .nav-icon+.nav-text,header .header-nav .nav-link .nav-icon+.nav-text{display:none}.header .header-nav .nav-link.active,header .header-nav .nav-link.active{background:hsla(0,0%,100%,.15);opacity:1}.header .header-nav .nav-link.active.nav-icon,.header .header-nav .nav-link.active .nav-icon,.header .header-nav .nav-link.active.nav-text,.header .header-nav .nav-link.active .nav-text,header .header-nav .nav-link.active.nav-icon,header .header-nav .nav-link.active .nav-icon,header .header-nav .nav-link.active.nav-text,header .header-nav .nav-link.active .nav-text{opacity:1}.header .header-nav .nav-link:focus,header .header-nav .nav-link:focus{outline-offset:-.20833rem}.header .header-nav .nav-link:first-of-type,.header .header-nav .nav-link:last-of-type,header .header-nav .nav-link:first-of-type,header .header-nav .nav-link:last-of-type{position:relative}.header .header-nav .nav-link:first-of-type:before,.header .header-nav .nav-link:last-of-type:after,header .header-nav .nav-link:first-of-type:before,header .header-nav .nav-link:last-of-type:after{position:absolute;content:"";display:inline-block;background:#fafafa;opacity:.15;height:1.66667rem;width:1px;top:.41667rem}.header .header-nav .nav-link:first-of-type:before,header .header-nav .nav-link:first-of-type:before{left:0}.header .header-nav .nav-link:last-of-type:after,header .header-nav .nav-link:last-of-type:after{right:0}.header .header-nav .nav-link.active:first-of-type:before,.header .header-nav .nav-link.active:last-of-type:after,header .header-nav .nav-link.active:first-of-type:before,header .header-nav .nav-link.active:last-of-type:after{content:none}.header .search,.header .search-box,header .search,header .search-box{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;max-width:12rem;padding:0;color:#fafafa;opacity:.65}.header .search-box:hover,.header .search:hover,header .search-box:hover,header .search:hover{opacity:1}.header .search-box>.nav-icon,.header .search>.nav-icon,header .search-box>.nav-icon,header .search>.nav-icon{margin:0 .25rem .125rem 1rem}.header .search-box label,.header .search label,header .search-box label,header .search label{display:inline-block;height:2.5rem;line-height:2.5rem;padding-left:1rem;text-align:center}.header .search-box label:before,.header .search label:before,header .search-box label:before,header .search label:before{display:inline-block;content:"";background-image:url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2036%2036%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%23fff%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3ESearch%3C%2Ftitle%3E%3Cg%20id%3D%22icons%22%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M15%2C4.05A10.95%2C10.95%2C0%2C1%2C1%2C4.05%2C15%2C11%2C11%2C0%2C0%2C1%2C15%2C4.05M15%2C2A13%2C13%2C0%2C1%2C0%2C28%2C15%2C13%2C13%2C0%2C0%2C0%2C15%2C2Z%22%2F%3E%3Cpath%20class%3D%22cls-1%22%20%20d%3D%22M33.71%2C32.29l-7.37-7.42-1.42%2C1.41%2C7.37%2C7.42a1%2C1%2C0%2C1%2C0%2C1.42-1.41Z%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");background-repeat:no-repeat;background-size:contain;cursor:pointer;height:.83333rem;width:.83333rem;margin:.83335rem 0 0 0;vertical-align:top}.header .search-box label input,.header .search label input,header .search-box label input,header .search label input{line-height:1rem}.header .search-box input[type=text],.header .search input[type=text],header .search-box input[type=text],header .search input[type=text]{border:none;background:none;color:#fafafa;padding:0;vertical-align:middle}.header .search-box input[type=text]:active,.header .search-box input[type=text]:focus,.header .search input[type=text]:active,.header .search input[type=text]:focus,header .search-box input[type=text]:active,header .search-box input[type=text]:focus,header .search input[type=text]:active,header .search input[type=text]:focus{background:none}.header .header-actions,.header .settings,header .header-actions,header .settings{-webkit-box-flex:1;-ms-flex:1 0 auto;flex:1 0 auto;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.header .header-actions .nav-text,.header .settings .nav-text,header .header-actions .nav-text,header .settings .nav-text{padding:0 1rem;font-weight:500}.header .header-actions .nav-icon,.header .settings .nav-icon,header .header-actions .nav-icon,header .settings .nav-icon{height:2.5rem;width:2.5rem}.header .header-actions .nav-link,.header .settings .nav-link,header .header-actions .nav-link,header .settings .nav-link{position:relative;display:inline-block;color:#fafafa;opacity:.65}.header .header-actions .nav-link:active,.header .header-actions .nav-link:hover,.header .settings .nav-link:active,.header .settings .nav-link:hover,header .header-actions .nav-link:active,header .header-actions .nav-link:hover,header .settings .nav-link:active,header .settings .nav-link:hover{text-decoration:none}.header .header-actions .nav-link:hover,.header .settings .nav-link:hover,header .header-actions .nav-link:hover,header .settings .nav-link:hover{opacity:1}.header .header-actions .nav-link .fa,.header .header-actions .nav-link.nav-icon,.header .header-actions .nav-link .nav-icon,.header .header-actions .nav-link.nav-text,.header .header-actions .nav-link .nav-text,.header .settings .nav-link .fa,.header .settings .nav-link.nav-icon,.header .settings .nav-link .nav-icon,.header .settings .nav-link.nav-text,.header .settings .nav-link .nav-text,header .header-actions .nav-link .fa,header .header-actions .nav-link.nav-icon,header .header-actions .nav-link .nav-icon,header .header-actions .nav-link.nav-text,header .header-actions .nav-link .nav-text,header .settings .nav-link .fa,header .settings .nav-link.nav-icon,header .settings .nav-link .nav-icon,header .settings .nav-link.nav-text,header .settings .nav-link .nav-text{line-height:2.5rem}.header .header-actions .nav-link .fa,.header .header-actions .nav-link .nav-icon,.header .settings .nav-link .fa,.header .settings .nav-link .nav-icon,header .header-actions .nav-link .fa,header .header-actions .nav-link .nav-icon,header .settings .nav-link .fa,header .settings .nav-link .nav-icon{font-size:.91667rem;text-align:center}.header .header-actions .nav-link clr-icon,.header .settings .nav-link clr-icon,header .header-actions .nav-link clr-icon,header .settings .nav-link clr-icon{position:absolute;top:50%;left:50%;-webkit-transform:translate(-50%,-50%);transform:translate(-50%,-50%);height:1rem;width:1rem}.header .header-actions .nav-link .nav-icon+.nav-text,.header .settings .nav-link .nav-icon+.nav-text,header .header-actions .nav-link .nav-icon+.nav-text,header .settings .nav-link .nav-icon+.nav-text{display:none}.header .header-actions .nav-link.active,.header .settings .nav-link.active,header .header-actions .nav-link.active,header .settings .nav-link.active{background:hsla(0,0%,100%,.15);opacity:1}.header .header-actions .nav-link.active.nav-icon,.header .header-actions .nav-link.active .nav-icon,.header .header-actions .nav-link.active.nav-text,.header .header-actions .nav-link.active .nav-text,.header .settings .nav-link.active.nav-icon,.header .settings .nav-link.active .nav-icon,.header .settings .nav-link.active.nav-text,.header .settings .nav-link.active .nav-text,header .header-actions .nav-link.active.nav-icon,header .header-actions .nav-link.active .nav-icon,header .header-actions .nav-link.active.nav-text,header .header-actions .nav-link.active .nav-text,header .settings .nav-link.active.nav-icon,header .settings .nav-link.active .nav-icon,header .settings .nav-link.active.nav-text,header .settings .nav-link.active .nav-text{opacity:1}.header .header-actions .nav-link:focus,.header .settings .nav-link:focus,header .header-actions .nav-link:focus,header .settings .nav-link:focus{outline-offset:-.20833rem}.header .header-actions>.dropdown>.dropdown-toggle,.header .settings>.dropdown>.dropdown-toggle,header .header-actions>.dropdown>.dropdown-toggle,header .settings>.dropdown>.dropdown-toggle{position:relative;line-height:2.5rem;height:2.5rem;outline-offset:-.20833rem;color:#fafafa;opacity:.65}.header .header-actions>.dropdown>.dropdown-toggle:hover,.header .settings>.dropdown>.dropdown-toggle:hover,header .header-actions>.dropdown>.dropdown-toggle:hover,header .settings>.dropdown>.dropdown-toggle:hover{opacity:1}.header .header-actions>.dropdown clr-icon:not([shape^=caret]),.header .settings>.dropdown clr-icon:not([shape^=caret]),header .header-actions>.dropdown clr-icon:not([shape^=caret]),header .settings>.dropdown clr-icon:not([shape^=caret]){position:absolute;top:50%;-webkit-transform:translateY(-50%);transform:translateY(-50%);height:.916667rem;width:.916667rem;right:1rem}.header .header-actions>.dropdown .dropdown-toggle.nav-icon clr-icon[shape^=caret],.header .settings>.dropdown .dropdown-toggle.nav-icon clr-icon[shape^=caret],header .header-actions>.dropdown .dropdown-toggle.nav-icon clr-icon[shape^=caret],header .settings>.dropdown .dropdown-toggle.nav-icon clr-icon[shape^=caret]{right:.5rem}.header .header-actions>.dropdown .dropdown-toggle.nav-text,.header .settings>.dropdown .dropdown-toggle.nav-text,header .header-actions>.dropdown .dropdown-toggle.nav-text,header .settings>.dropdown .dropdown-toggle.nav-text{padding:0 1.5rem 0 1rem}.header .header-actions>.dropdown .dropdown-toggle.nav-text clr-icon[shape^=caret],.header .settings>.dropdown .dropdown-toggle.nav-text clr-icon[shape^=caret],header .header-actions>.dropdown .dropdown-toggle.nav-text clr-icon[shape^=caret],header .settings>.dropdown .dropdown-toggle.nav-text clr-icon[shape^=caret]{right:1rem}.header .header-actions>.dropdown .dropdown-toggle.nav-icon,.header .settings>.dropdown .dropdown-toggle.nav-icon,header .header-actions>.dropdown .dropdown-toggle.nav-icon,header .settings>.dropdown .dropdown-toggle.nav-icon{width:2.5rem;padding-right:0}.header .header-actions>.dropdown.bottom-left>.dropdown-menu,.header .header-actions>.dropdown.bottom-right>.dropdown-menu,.header .settings>.dropdown.bottom-left>.dropdown-menu,.header .settings>.dropdown.bottom-right>.dropdown-menu,header .header-actions>.dropdown.bottom-left>.dropdown-menu,header .header-actions>.dropdown.bottom-right>.dropdown-menu,header .settings>.dropdown.bottom-left>.dropdown-menu,header .settings>.dropdown.bottom-right>.dropdown-menu{top:85%}.header .header-actions>.dropdown:last-child.bottom-right>.dropdown-menu,.header .settings>.dropdown:last-child.bottom-right>.dropdown-menu,header .header-actions>.dropdown:last-child.bottom-right>.dropdown-menu,header .settings>.dropdown:last-child.bottom-right>.dropdown-menu{right:.125rem}.header .header-actions>.dropdown .dropdown-menu,.header .settings>.dropdown .dropdown-menu,header .header-actions>.dropdown .dropdown-menu,header .settings>.dropdown .dropdown-menu{margin-top:-.166667rem;left:auto;right:0}.header .header-actions>.dropdown :last-child.dropdown-menu,.header .settings>.dropdown :last-child.dropdown-menu,header .header-actions>.dropdown :last-child.dropdown-menu,header .settings>.dropdown :last-child.dropdown-menu{margin-right:.333333rem}.header .branding+.search,.header .branding+.search-box,header .branding+.search,header .branding+.search-box{position:relative}.header .branding+.search-box:after,.header .branding+.search:after,header .branding+.search-box:after,header .branding+.search:after{position:absolute;left:0;content:"";display:inline-block;background:#fafafa;opacity:.15;height:1.66667rem;width:1px;top:.41667rem}.header .header-nav:last-child>.nav-link:last-child:after,header .header-nav:last-child>.nav-link:last-child:after{content:none}@media screen and (max-width:768px){.header .search,.header .search-box,header .search,header .search-box{-webkit-box-flex:1;-ms-flex:1 0 auto;flex:1 0 auto;-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end;max-width:none}.header .search-box label,.header .search label,header .search-box label,header .search label{padding:0;width:2.5rem}.header .search-box label:before,.header .search label:before,header .search-box label:before,header .search label:before{left:.83333rem}.header .search-box label input,.header .search label input,header .search-box label input,header .search label input{display:none}.header .branding+.search-box:after,.header .branding+.search:after,header .branding+.search-box:after,header .branding+.search:after{content:none}.header .search+.header-actions,.header .search+.settings,.header .search-box+.header-actions,.header .search-box+.settings,header .search+.header-actions,header .search+.settings,header .search-box+.header-actions,header .search-box+.settings{position:relative;-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto}.header .search+.header-actions:after,.header .search+.settings:after,.header .search-box+.header-actions:after,.header .search-box+.settings:after,header .search+.header-actions:after,header .search+.settings:after,header .search-box+.header-actions:after,header .search-box+.settings:after{position:absolute;content:"";display:inline-block;background:#fafafa;opacity:.15;height:1.66667rem;width:1px;top:.41667rem;left:0}}.nav{display:-webkit-box;display:-ms-flexbox;display:flex;height:1.5rem;list-style-type:none;-webkit-box-align:center;-ms-flex-align:center;align-items:center;box-shadow:inset 0 -1px 0 #ccc;margin:0;width:100%;white-space:nowrap}.nav .nav-item{display:inline-block;margin-right:1rem}.nav .nav-item.active>.nav-link{color:#000;box-shadow:inset 0 -1px 0 #ccc}.nav .nav-link{font-size:.58333rem;font-weight:400;letter-spacing:normal;display:inline-block;color:#737373;padding:0 .125rem;box-shadow:none;line-height:1.5rem}.nav .nav-link.btn{text-transform:none;margin:0;margin-bottom:-1px;border-radius:0}.nav .nav-link:active,.nav .nav-link:focus,.nav .nav-link:hover{color:inherit}.nav .nav-link.active,.nav .nav-link:hover{box-shadow:inset 0 -3px 0 #007cbb;transition:box-shadow .2s ease-in}.nav .nav-link.active,.nav .nav-link:active,.nav .nav-link:focus,.nav .nav-link:hover{text-decoration:none}.nav .nav-link.active{color:#000}.nav .nav-link.nav-item{margin-right:1rem}.sub-nav,.subnav{display:-webkit-box;display:-ms-flexbox;display:flex;box-shadow:inset 0 -1px 0 #ccc;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;-webkit-box-align:center;-ms-flex-align:center;align-items:center;background-color:#fff;height:1.5rem}.sub-nav .nav,.subnav .nav{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;padding-left:1rem}.sub-nav aside,.subnav aside{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;height:1.5rem;padding:0 1rem}.sub-nav aside>:last-child,.subnav aside>:last-child{margin-right:0;padding-right:0}.sidenav{line-height:1rem;max-width:13rem;min-width:9rem;width:18%;border-right:1px solid #ccc;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.sidenav .sidenav-content{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;overflow-x:hidden;padding-bottom:1rem}.sidenav .sidenav-content .nav-link{display:inline-block;border-radius:.125rem 0 0 .125rem;color:inherit;cursor:pointer;text-decoration:none;width:100%}.sidenav .sidenav-content>.nav-link{color:#313131;font-size:.58333rem;font-weight:500;line-height:1rem;font-family:Metropolis,Avenir Next,Helvetica Neue,Arial,sans-serif;letter-spacing:normal;margin:1rem 0 0 1.25rem;padding-left:.5rem}.sidenav .sidenav-content>.nav-link:hover{background:#eee}.sidenav .sidenav-content>.nav-link.active{background:#d9e4ea;color:#000}.sidenav .nav-group{color:#565656;font-size:.58333rem;font-weight:400;letter-spacing:normal;margin-top:1rem;width:100%}.sidenav .nav-group .nav-list,.sidenav .nav-group label{padding:0 0 0 1.75rem}.sidenav .nav-group .nav-list{list-style:none;margin-top:0;overflow:hidden}.sidenav .nav-group .nav-list .nav-link{line-height:.666667rem;padding:.16667rem 0 .16667rem .5rem}.sidenav .nav-group .nav-list .nav-link:hover{background:#eee}.sidenav .nav-group .nav-list .nav-link.active{background:#d9e4ea;color:#000}.sidenav .nav-group label{color:#313131;font-size:.58333rem;font-weight:500;line-height:1rem;font-family:Metropolis,Avenir Next,Helvetica Neue,Arial,sans-serif;letter-spacing:normal}.sidenav .nav-group input[type=checkbox]{display:none}.sidenav .collapsible label{cursor:pointer;display:inline-block;width:100%;padding:0 0 0 1.33333rem}.sidenav .collapsible label:after{content:"";float:left;height:.41667rem;width:.41667rem;-webkit-transform:translateX(-.33333rem) translateY(.29167rem);transform:translateX(-.33333rem) translateY(.29167rem);background-image:url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2012%22%3E%0A%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3Cstyle%3E.cls-1%7Bfill%3A%239a9a9a%3B%7D%3C%2Fstyle%3E%0A%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%3Ctitle%3ECaret%3C%2Ftitle%3E%0A%20%20%20%20%3Cpath%20class%3D%22cls-1%22%20d%3D%22M6%2C9L1.2%2C4.2a0.68%2C0.68%2C0%2C0%2C1%2C1-1L6%2C7.08%2C9.84%2C3.24a0.68%2C0.68%2C0%2C1%2C1%2C1%2C1Z%22%2F%3E%0A%3C%2Fsvg%3E%0A");background-repeat:no-repeat;background-size:contain;vertical-align:middle;margin:0}.sidenav .collapsible .nav-list,.sidenav .collapsible ul{overflow:hidden}.sidenav .collapsible input[type=checkbox]:checked~.nav-list,.sidenav .collapsible input[type=checkbox]:checked~ul{height:0}.sidenav .collapsible input[type=checkbox]~.nav-list,.sidenav .collapsible input[type=checkbox]~ul{height:auto}.sidenav .collapsible input[type=checkbox]:checked~label:after{-webkit-transform:rotate(-90deg) translateX(-.29167rem) translateY(-.33333rem);transform:rotate(-90deg) translateX(-.29167rem) translateY(-.33333rem)}.clr-vertical-nav{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;padding-top:.75rem;width:10rem;min-width:2rem;background-color:#eee;will-change:width;transition:width .2s ease-in-out}.clr-vertical-nav .nav-divider{border:1px solid #565656;margin:.5rem 0;opacity:.2}.clr-vertical-nav .nav-content{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;overflow-y:auto;overflow-x:hidden}.clr-vertical-nav .nav-group{display:block;-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;height:auto;min-height:1.5rem}.clr-vertical-nav .nav-group-content{display:-webkit-box;display:-ms-flexbox;display:flex;color:#565656}.clr-vertical-nav .nav-group-content.active,.clr-vertical-nav .nav-group-content:hover{color:#565656;background-color:#fff}.clr-vertical-nav .nav-group-content.active .nav-icon,.clr-vertical-nav .nav-group-content:hover .nav-icon{fill:#007cbb}.clr-vertical-nav .nav-group-content:hover{text-decoration:none}.clr-vertical-nav .nav-group-content .nav-link{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;padding-left:0;min-width:0}.clr-vertical-nav .nav-group-content .nav-icon{margin-left:1rem}.clr-vertical-nav .nav-group-content .nav-text{padding-left:1rem}.clr-vertical-nav .nav-group-content .nav-icon+.nav-text{padding-left:0}.clr-vertical-nav .nav-group-content .nav-link+.nav-group-text{display:none}.clr-vertical-nav .nav-group-trigger,.clr-vertical-nav .nav-trigger{-webkit-box-flex:0;-ms-flex:0 0 1.5rem;flex:0 0 1.5rem;border:none;height:1.5rem;padding:0;color:#000;background-color:transparent;cursor:pointer;outline-offset:-5px}.clr-vertical-nav .nav-trigger{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:start;-ms-flex-pack:start;justify-content:flex-start;height:1.5rem;margin-top:-.75rem}.clr-vertical-nav .nav-group-trigger{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;color:inherit;overflow:hidden;text-align:left}.clr-vertical-nav .nav-group-trigger .nav-group-trigger-icon{-ms-flex-negative:0;flex-shrink:0;height:1.5rem;width:.66667rem;margin-left:.41667rem;margin-right:.41667rem;transition:all .2s ease-in-out}.clr-vertical-nav .nav-trigger-icon{margin-left:auto;margin-right:.41667rem;transition:all .2s ease-in-out}.clr-vertical-nav .nav-trigger+.nav-content{border-top:1px solid rgba(86,86,86,.2);padding-top:.5rem}.clr-vertical-nav .nav-group-text,.clr-vertical-nav .nav-link{height:1.5rem;padding:0 .5rem 0 1rem;line-height:1.5rem;outline-offset:-5px}.clr-vertical-nav .nav-group-text,.clr-vertical-nav .nav-text{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.clr-vertical-nav .nav-link{display:-webkit-box;display:-ms-flexbox;display:flex;color:#565656}.clr-vertical-nav .nav-link.active,.clr-vertical-nav .nav-link:hover{color:#565656;background-color:#fff}.clr-vertical-nav .nav-link.active .nav-icon,.clr-vertical-nav .nav-link:hover .nav-icon{fill:#007cbb}.clr-vertical-nav .nav-link:hover{text-decoration:none}.clr-vertical-nav .nav-header{padding:0 .5rem 0 1rem;font-size:.5rem;font-weight:600;letter-spacing:normal;line-height:1.5rem}.clr-vertical-nav .nav-icon{-webkit-box-flex:0;-ms-flex:0 0 0.66667rem;flex:0 0 0.66667rem;-ms-flex-item-align:center;-ms-grid-row-align:center;align-self:center;height:.66667rem;width:.66667rem;margin-right:.25rem;vertical-align:middle}.clr-vertical-nav clr-vertical-nav-group-children{display:block}.clr-vertical-nav .nav-btn{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;padding:0;margin:0;background:transparent;border:none;cursor:pointer;outline-offset:-5px}.clr-vertical-nav .nav-content>.nav-link,.clr-vertical-nav .nav-link+.nav-group-trigger,.clr-vertical-nav>.nav-link{-webkit-box-flex:0;-ms-flex:0 0 1.5rem;flex:0 0 1.5rem}.clr-vertical-nav .nav-link+.nav-group-trigger .nav-group-text{display:none}.clr-vertical-nav .nav-icon+.nav-group-text{padding-left:0}.clr-vertical-nav.has-nav-groups .nav-group .nav-group-text,.clr-vertical-nav.has-nav-groups .nav-group .nav-group-trigger,.clr-vertical-nav.has-nav-groups .nav-link{font-weight:600}.clr-vertical-nav.has-nav-groups .nav-group-children .nav-link{font-weight:400}.clr-vertical-nav.has-icons .nav-group-children .nav-link{padding-left:1.91667rem}.clr-vertical-nav .nav-group.active:not(.is-expanded) .nav-group-content{background-color:#fff}.clr-vertical-nav .nav-group.active:not(.is-expanded) .nav-group-content .nav-icon{fill:#007cbb}.clr-vertical-nav .nav-group-content .nav-link.active~.nav-group-trigger{background-color:#fff}.main-container:not([class*=open-overflow-menu]):not([class*=open-hamburger-menu]) .clr-vertical-nav.is-collapsed{width:2rem;min-width:2rem;cursor:pointer}.main-container:not([class*=open-overflow-menu]):not([class*=open-hamburger-menu]) .clr-vertical-nav.is-collapsed .nav-trigger{margin-right:.125rem}.main-container:not([class*=open-overflow-menu]):not([class*=open-hamburger-menu]) .clr-vertical-nav.is-collapsed .nav-icon{margin:0;margin-left:.666667rem}.main-container:not([class*=open-overflow-menu]):not([class*=open-hamburger-menu]) .clr-vertical-nav.is-collapsed .nav-group-content .nav-link{-webkit-box-flex:0;-ms-flex:0 0 2rem;flex:0 0 2rem}.main-container:not([class*=open-overflow-menu]):not([class*=open-hamburger-menu]) .clr-vertical-nav.is-collapsed .nav-group-content .nav-link~.nav-group-trigger{-webkit-box-flex:0;-ms-flex:0 0 0.66667rem;flex:0 0 0.66667rem;-webkit-transform:translateX(-.66667rem);transform:translateX(-.66667rem);pointer-events:none}.main-container:not([class*=open-overflow-menu]):not([class*=open-hamburger-menu]) .clr-vertical-nav.is-collapsed .nav-group-trigger,.main-container:not([class*=open-overflow-menu]):not([class*=open-hamburger-menu]) .clr-vertical-nav.is-collapsed .nav-link{padding:0}.main-container:not([class*=open-overflow-menu]):not([class*=open-hamburger-menu]) .clr-vertical-nav.is-collapsed .nav-group-trigger{padding-left:0}.main-container:not([class*=open-overflow-menu]):not([class*=open-hamburger-menu]) .clr-vertical-nav.is-collapsed .nav-group-trigger .nav-group-trigger-icon{height:1.5rem;width:.41667rem;margin-left:.125rem;margin-right:0}.main-container:not([class*=open-overflow-menu]):not([class*=open-hamburger-menu]) .clr-vertical-nav.is-collapsed .nav-group,.main-container:not([class*=open-overflow-menu]):not([class*=open-hamburger-menu]) .clr-vertical-nav.is-collapsed .nav-link{display:none}.main-container:not([class*=open-overflow-menu]):not([class*=open-hamburger-menu]) .clr-vertical-nav.is-collapsed.has-icons .nav-group{display:block}.main-container:not([class*=open-overflow-menu]):not([class*=open-hamburger-menu]) .clr-vertical-nav.is-collapsed.has-icons .nav-link{display:-webkit-box;display:-ms-flexbox;display:flex}.main-container:not([class*=open-overflow-menu]):not([class*=open-hamburger-menu]) .clr-vertical-nav.is-collapsed.has-icons .nav-group-text,.main-container:not([class*=open-overflow-menu]):not([class*=open-hamburger-menu]) .clr-vertical-nav.is-collapsed.has-icons .nav-text{display:none}.clr-vertical-nav.nav-trigger--bottom .nav-trigger{-webkit-box-ordinal-group:3;-ms-flex-order:2;order:2;margin-top:0}.clr-vertical-nav.nav-trigger--bottom .nav-trigger+.nav-content{border-bottom:1px solid rgba(86,86,86,.2);border-top:none;padding-top:0}.header-hamburger-trigger,.header-overflow-trigger{display:none}.header-hamburger-trigger>span,.header-hamburger-trigger>span:after,.header-hamburger-trigger>span:before{display:inline-block;height:.0833333rem;width:1rem;background:#fff;border-radius:.125rem}.header-hamburger-trigger>span{position:relative;vertical-align:middle}.header-hamburger-trigger>span:after,.header-hamburger-trigger>span:before{content:"";position:absolute;left:0}.header-hamburger-trigger>span:before{top:-.29167rem}.header-hamburger-trigger>span:after{bottom:-.29167rem}.header-hamburger-trigger.active>span{background:transparent}.header-hamburger-trigger.active>span:after,.header-hamburger-trigger.active>span:before{left:.125rem;-webkit-transform-origin:9%;transform-origin:9%;transition:-webkit-transform .6s ease;transition:transform .6s ease;transition:transform .6s ease,-webkit-transform .6s ease}.header-hamburger-trigger.active>span:before{-webkit-transform:rotate(45deg);transform:rotate(45deg)}.header-hamburger-trigger.active>span:after{-webkit-transform:rotate(-45deg);transform:rotate(-45deg)}.header-overflow-trigger>span,.header-overflow-trigger>span:after,.header-overflow-trigger>span:before{display:inline-block;height:.166667rem;width:.166667rem;background:#fff;border-radius:.166667rem}.header-overflow-trigger>span{position:relative;vertical-align:middle}.header-overflow-trigger>span:after,.header-overflow-trigger>span:before{content:"";position:absolute;left:0}.header-overflow-trigger>span:before{top:-.33333rem}.header-overflow-trigger>span:after{bottom:-.33333rem}.header-overflow-trigger.active>span{background:transparent}.header-overflow-trigger.active>span:after,.header-overflow-trigger.active>span:before{height:.0833333rem;width:1rem;left:-.25rem;-webkit-transform-origin:-3%;transform-origin:-3%;transition:-webkit-transform .6s ease;transition:transform .6s ease;transition:transform .6s ease,-webkit-transform .6s ease}.header-overflow-trigger.active>span:before{-webkit-transform:rotate(45deg);transform:rotate(45deg)}.header-overflow-trigger.active>span:after{-webkit-transform:rotate(-45deg);transform:rotate(-45deg)}@media screen and (max-width:768px){.main-container .header-hamburger-trigger,.main-container .header-overflow-trigger{display:inline-block;border:none;background:none;cursor:pointer;font-size:1rem;height:2.5rem;width:2.5rem;padding:0 0 .166667rem 0;text-align:center;white-space:nowrap;color:#fafafa;opacity:.65}.main-container .header-hamburger-trigger:focus,.main-container .header-overflow-trigger:focus{outline-offset:-.208333rem}.main-container .header-hamburger-trigger:hover,.main-container .header-overflow-trigger:hover{opacity:1}.main-container .clr-vertical-nav.clr-nav-level-1,.main-container .header-nav.clr-nav-level-1,.main-container .sidenav.clr-nav-level-1,.main-container .sub-nav.clr-nav-level-1,.main-container .subnav.clr-nav-level-1{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;position:fixed;top:0;right:auto;bottom:0;left:0;background:#eee;z-index:1039;height:100vh;-webkit-transform:translateX(-15rem);transform:translateX(-15rem);transition:-webkit-transform .3s ease;transition:transform .3s ease;transition:transform .3s ease,-webkit-transform .3s ease}.main-container .clr-vertical-nav.clr-nav-level-2,.main-container .header-nav.clr-nav-level-2,.main-container .sidenav.clr-nav-level-2,.main-container .sub-nav.clr-nav-level-2,.main-container .subnav.clr-nav-level-2{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;position:fixed;top:0;right:0;bottom:0;left:auto;background:#eee;z-index:1039;height:100vh;-webkit-transform:translateX(15rem);transform:translateX(15rem);transition:-webkit-transform .3s ease;transition:transform .3s ease;transition:transform .3s ease,-webkit-transform .3s ease}.main-container .sub-nav.clr-nav-level-1 .nav,.main-container .sub-nav.clr-nav-level-1 aside,.main-container .sub-nav.clr-nav-level-2 .nav,.main-container .sub-nav.clr-nav-level-2 aside,.main-container .subnav.clr-nav-level-1 .nav,.main-container .subnav.clr-nav-level-1 aside,.main-container .subnav.clr-nav-level-2 .nav,.main-container .subnav.clr-nav-level-2 aside{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch}.main-container .sub-nav.clr-nav-level-1 aside,.main-container .sub-nav.clr-nav-level-2 aside,.main-container .subnav.clr-nav-level-1 aside,.main-container .subnav.clr-nav-level-2 aside{-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;width:100%}.main-container .sub-nav.clr-nav-level-1 .nav,.main-container .sub-nav.clr-nav-level-2 .nav,.main-container .subnav.clr-nav-level-1 .nav,.main-container .subnav.clr-nav-level-2 .nav{padding-left:0}.main-container .sub-nav.clr-nav-level-1 .nav .nav-item,.main-container .sub-nav.clr-nav-level-2 .nav .nav-item,.main-container .subnav.clr-nav-level-1 .nav .nav-item,.main-container .subnav.clr-nav-level-2 .nav .nav-item{height:1.5rem;margin-right:0}.main-container .sub-nav.clr-nav-level-1 .nav .nav-link,.main-container .sub-nav.clr-nav-level-2 .nav .nav-link,.main-container .subnav.clr-nav-level-1 .nav .nav-link,.main-container .subnav.clr-nav-level-2 .nav .nav-link{padding:0 .5rem 0 1rem;width:100%;max-width:100%;overflow:hidden;text-overflow:ellipsis;border-radius:.125rem 0 0 .125rem;color:#565656}.main-container .sub-nav.clr-nav-level-1 .nav .nav-link.active,.main-container .sub-nav.clr-nav-level-1 .nav .nav-link:hover,.main-container .sub-nav.clr-nav-level-2 .nav .nav-link.active,.main-container .sub-nav.clr-nav-level-2 .nav .nav-link:hover,.main-container .subnav.clr-nav-level-1 .nav .nav-link.active,.main-container .subnav.clr-nav-level-1 .nav .nav-link:hover,.main-container .subnav.clr-nav-level-2 .nav .nav-link.active,.main-container .subnav.clr-nav-level-2 .nav .nav-link:hover{color:#565656;background-color:#fff}.main-container .sub-nav.clr-nav-level-1 .nav .nav-link.active .nav-icon,.main-container .sub-nav.clr-nav-level-1 .nav .nav-link:hover .nav-icon,.main-container .sub-nav.clr-nav-level-2 .nav .nav-link.active .nav-icon,.main-container .sub-nav.clr-nav-level-2 .nav .nav-link:hover .nav-icon,.main-container .subnav.clr-nav-level-1 .nav .nav-link.active .nav-icon,.main-container .subnav.clr-nav-level-1 .nav .nav-link:hover .nav-icon,.main-container .subnav.clr-nav-level-2 .nav .nav-link.active .nav-icon,.main-container .subnav.clr-nav-level-2 .nav .nav-link:hover .nav-icon{fill:#007cbb}.main-container .sub-nav.clr-nav-level-1 .nav .nav-link:hover,.main-container .sub-nav.clr-nav-level-2 .nav .nav-link:hover,.main-container .subnav.clr-nav-level-1 .nav .nav-link:hover,.main-container .subnav.clr-nav-level-2 .nav .nav-link:hover{text-decoration:none}.main-container .sub-nav.clr-nav-level-1 .nav .nav-link.active,.main-container .sub-nav.clr-nav-level-1 .nav .nav-link:hover,.main-container .sub-nav.clr-nav-level-2 .nav .nav-link.active,.main-container .sub-nav.clr-nav-level-2 .nav .nav-link:hover,.main-container .subnav.clr-nav-level-1 .nav .nav-link.active,.main-container .subnav.clr-nav-level-1 .nav .nav-link:hover,.main-container .subnav.clr-nav-level-2 .nav .nav-link.active,.main-container .subnav.clr-nav-level-2 .nav .nav-link:hover{box-shadow:none}.main-container .sidenav.clr-nav-level-1 .nav-link.active,.main-container .sidenav.clr-nav-level-1 .nav-link:hover,.main-container .sidenav.clr-nav-level-2 .nav-link.active,.main-container .sidenav.clr-nav-level-2 .nav-link:hover{color:inherit;background:#fff}.main-container .clr-vertical-nav.clr-nav-level-1,.main-container .clr-vertical-nav.clr-nav-level-2,.main-container .sidenav.clr-nav-level-1,.main-container .sidenav.clr-nav-level-2{border-right:none}.main-container .header-overflow-trigger{position:relative}.main-container .header-overflow-trigger:after{position:absolute;content:"";display:inline-block;background:#fafafa;opacity:.15;height:1.66667rem;width:1px;top:.41667rem;left:0}.main-container .header .branding{max-width:10rem;min-width:0;overflow:hidden}.main-container .header .header-hamburger-trigger+.branding{padding-left:0}.main-container .header .header-hamburger-trigger+.branding .clr-icon,.main-container .header .header-hamburger-trigger+.branding .logo,.main-container .header .header-hamburger-trigger+.branding clr-icon{display:none}.main-container .header .branding+.header-overflow-trigger,.main-container .header .header-nav+.header-overflow-trigger{margin-left:auto}.main-container.open-hamburger-menu .header .header-backdrop,.main-container.open-overflow-menu .header .header-backdrop{position:fixed;top:0;bottom:0;left:0;right:0;background:rgba(0,0,0,.85);cursor:pointer;z-index:1038}.main-container.open-hamburger-menu .header .header-nav.clr-nav-level-1 .nav-link,.main-container.open-hamburger-menu .header .header-nav.clr-nav-level-2 .nav-link,.main-container.open-overflow-menu .header .header-nav.clr-nav-level-1 .nav-link,.main-container.open-overflow-menu .header .header-nav.clr-nav-level-2 .nav-link{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;opacity:1;color:#565656}.main-container.open-hamburger-menu .header .header-nav.clr-nav-level-1 .nav-link .fa,.main-container.open-hamburger-menu .header .header-nav.clr-nav-level-1 .nav-link .nav-icon,.main-container.open-hamburger-menu .header .header-nav.clr-nav-level-2 .nav-link .fa,.main-container.open-hamburger-menu .header .header-nav.clr-nav-level-2 .nav-link .nav-icon,.main-container.open-overflow-menu .header .header-nav.clr-nav-level-1 .nav-link .fa,.main-container.open-overflow-menu .header .header-nav.clr-nav-level-1 .nav-link .nav-icon,.main-container.open-overflow-menu .header .header-nav.clr-nav-level-2 .nav-link .fa,.main-container.open-overflow-menu .header .header-nav.clr-nav-level-2 .nav-link .nav-icon{display:none}.main-container.open-hamburger-menu .header .header-nav.clr-nav-level-1 .nav-link .nav-text,.main-container.open-hamburger-menu .header .header-nav.clr-nav-level-2 .nav-link .nav-text,.main-container.open-overflow-menu .header .header-nav.clr-nav-level-1 .nav-link .nav-text,.main-container.open-overflow-menu .header .header-nav.clr-nav-level-2 .nav-link .nav-text{display:inline-block;color:#565656;line-height:1rem;padding:.25rem 0 .25rem 1rem;white-space:normal;font-weight:400}.main-container.open-hamburger-menu .header .header-nav.clr-nav-level-1 .nav-link .nav-icon+.nav-text,.main-container.open-hamburger-menu .header .header-nav.clr-nav-level-2 .nav-link .nav-icon+.nav-text,.main-container.open-overflow-menu .header .header-nav.clr-nav-level-1 .nav-link .nav-icon+.nav-text,.main-container.open-overflow-menu .header .header-nav.clr-nav-level-2 .nav-link .nav-icon+.nav-text{display:inline-block}.main-container.open-hamburger-menu .header .header-nav.clr-nav-level-1 .nav-link.active,.main-container.open-hamburger-menu .header .header-nav.clr-nav-level-1 .nav-link:hover,.main-container.open-hamburger-menu .header .header-nav.clr-nav-level-2 .nav-link.active,.main-container.open-hamburger-menu .header .header-nav.clr-nav-level-2 .nav-link:hover,.main-container.open-overflow-menu .header .header-nav.clr-nav-level-1 .nav-link.active,.main-container.open-overflow-menu .header .header-nav.clr-nav-level-1 .nav-link:hover,.main-container.open-overflow-menu .header .header-nav.clr-nav-level-2 .nav-link.active,.main-container.open-overflow-menu .header .header-nav.clr-nav-level-2 .nav-link:hover{color:#565656;background-color:#fff}.main-container.open-hamburger-menu .header .header-nav.clr-nav-level-1 .nav-link.active .nav-icon,.main-container.open-hamburger-menu .header .header-nav.clr-nav-level-1 .nav-link:hover .nav-icon,.main-container.open-hamburger-menu .header .header-nav.clr-nav-level-2 .nav-link.active .nav-icon,.main-container.open-hamburger-menu .header .header-nav.clr-nav-level-2 .nav-link:hover .nav-icon,.main-container.open-overflow-menu .header .header-nav.clr-nav-level-1 .nav-link.active .nav-icon,.main-container.open-overflow-menu .header .header-nav.clr-nav-level-1 .nav-link:hover .nav-icon,.main-container.open-overflow-menu .header .header-nav.clr-nav-level-2 .nav-link.active .nav-icon,.main-container.open-overflow-menu .header .header-nav.clr-nav-level-2 .nav-link:hover .nav-icon{fill:#007cbb}.main-container.open-hamburger-menu .header .header-nav.clr-nav-level-1 .nav-link:hover,.main-container.open-hamburger-menu .header .header-nav.clr-nav-level-2 .nav-link:hover,.main-container.open-overflow-menu .header .header-nav.clr-nav-level-1 .nav-link:hover,.main-container.open-overflow-menu .header .header-nav.clr-nav-level-2 .nav-link:hover{text-decoration:none}.main-container.open-hamburger-menu .header .header-nav.clr-nav-level-1 .nav-link.active>.nav-text,.main-container.open-hamburger-menu .header .header-nav.clr-nav-level-2 .nav-link.active>.nav-text,.main-container.open-overflow-menu .header .header-nav.clr-nav-level-1 .nav-link.active>.nav-text,.main-container.open-overflow-menu .header .header-nav.clr-nav-level-2 .nav-link.active>.nav-text{color:inherit}.main-container.open-hamburger-menu .clr-vertical-nav .nav-trigger,.main-container.open-overflow-menu .clr-vertical-nav .nav-trigger{display:none}.main-container.open-hamburger-menu .header .branding{position:fixed;top:0;left:0;overflow:hidden;width:15rem;max-width:15rem;z-index:1040;padding-left:1rem}.main-container.open-hamburger-menu .header .branding>.nav-link{overflow:hidden}.main-container.open-hamburger-menu .header .branding .clr-icon,.main-container.open-hamburger-menu .header .branding .logo,.main-container.open-hamburger-menu .header .branding clr-icon{display:inline-block}.main-container.open-hamburger-menu .header .branding .clr-vmw-logo,.main-container.open-hamburger-menu .header .branding clr-icon[shape=vm-bug]{background-color:#737373;border-radius:.125rem}.main-container.open-hamburger-menu .header .branding .title{color:#565656;text-overflow:ellipsis;overflow:hidden}.main-container.open-hamburger-menu .header-hamburger-trigger{position:fixed;top:0;right:auto;left:0;z-index:1039;-webkit-transform:translateX(15.5rem);transform:translateX(15.5rem);transition:-webkit-transform .6s ease;transition:transform .6s ease;transition:transform .6s ease,-webkit-transform .6s ease}.main-container.open-hamburger-menu .header-hamburger-trigger:after{content:none}.main-container.open-hamburger-menu .header-hamburger-trigger>span{background:transparent}.main-container.open-hamburger-menu .header-hamburger-trigger>span:after,.main-container.open-hamburger-menu .header-hamburger-trigger>span:before{left:.125rem;-webkit-transform-origin:9%;transform-origin:9%;transition:-webkit-transform .6s ease;transition:transform .6s ease;transition:transform .6s ease,-webkit-transform .6s ease}.main-container.open-hamburger-menu .header-hamburger-trigger>span:before{-webkit-transform:rotate(45deg);transform:rotate(45deg)}.main-container.open-hamburger-menu .header-hamburger-trigger>span:after{-webkit-transform:rotate(-45deg);transform:rotate(-45deg)}.main-container.open-hamburger-menu .clr-vertical-nav.clr-nav-level-1,.main-container.open-hamburger-menu .header-nav.clr-nav-level-1,.main-container.open-hamburger-menu .sidenav.clr-nav-level-1,.main-container.open-hamburger-menu .sub-nav.clr-nav-level-1,.main-container.open-hamburger-menu .subnav.clr-nav-level-1{padding-top:3.5rem;-webkit-transform:translateX(0);transform:translateX(0);transition:-webkit-transform .3s ease;transition:transform .3s ease;transition:transform .3s ease,-webkit-transform .3s ease}.main-container.open-hamburger-menu .clr-vertical-nav.clr-nav-level-1 .sidenav-content,.main-container.open-hamburger-menu .header-nav.clr-nav-level-1 .sidenav-content,.main-container.open-hamburger-menu .sidenav.clr-nav-level-1 .sidenav-content,.main-container.open-hamburger-menu .sub-nav.clr-nav-level-1 .sidenav-content,.main-container.open-hamburger-menu .subnav.clr-nav-level-1 .sidenav-content{padding-bottom:1rem}.main-container.open-overflow-menu .clr-vertical-nav.clr-nav-level-2,.main-container.open-overflow-menu .header-nav.clr-nav-level-2,.main-container.open-overflow-menu .sidenav.clr-nav-level-2,.main-container.open-overflow-menu .sub-nav.clr-nav-level-2,.main-container.open-overflow-menu .subnav.clr-nav-level-2{-webkit-transform:translateX(0);transform:translateX(0);transition:-webkit-transform .3s ease;transition:transform .3s ease;transition:transform .3s ease,-webkit-transform .3s ease}.main-container.open-overflow-menu .header-nav.clr-nav-level-2,.main-container.open-overflow-menu .sub-nav.clr-nav-level-2,.main-container.open-overflow-menu .subnav.clr-nav-level-2{padding-top:1rem}.main-container.open-overflow-menu .header-overflow-trigger{position:fixed;top:0;right:0;left:auto;z-index:1039;-webkit-transform:translateX(-15.5rem);transform:translateX(-15.5rem);transition:-webkit-transform .6s ease;transition:transform .6s ease;transition:transform .6s ease,-webkit-transform .6s ease}.main-container.open-overflow-menu .header-overflow-trigger:after{content:none}.main-container.open-overflow-menu .header-overflow-trigger>span{background:transparent}.main-container.open-overflow-menu .header-overflow-trigger>span:after,.main-container.open-overflow-menu .header-overflow-trigger>span:before{height:.0833333rem;width:1rem;left:-.25rem;-webkit-transform-origin:-3%;transform-origin:-3%;transition:-webkit-transform .6s ease;transition:transform .6s ease;transition:transform .6s ease,-webkit-transform .6s ease}.main-container.open-overflow-menu .header-overflow-trigger>span:before{-webkit-transform:rotate(45deg);transform:rotate(45deg)}.main-container.open-overflow-menu .header-overflow-trigger>span:after{-webkit-transform:rotate(-45deg);transform:rotate(-45deg)}.main-container.open-hamburger-menu .clr-vertical-nav.clr-nav-level-1,.main-container.open-hamburger-menu .header-nav.clr-nav-level-1,.main-container.open-hamburger-menu .sidenav.clr-nav-level-1,.main-container.open-hamburger-menu .sub-nav.clr-nav-level-1,.main-container.open-hamburger-menu .subnav.clr-nav-level-1,.main-container.open-overflow-menu .clr-vertical-nav.clr-nav-level-2,.main-container.open-overflow-menu .header-nav.clr-nav-level-2,.main-container.open-overflow-menu .sidenav.clr-nav-level-2,.main-container.open-overflow-menu .sub-nav.clr-nav-level-2,.main-container.open-overflow-menu .subnav.clr-nav-level-2{width:15rem;max-width:15rem}}@media screen and (max-width:544px){.main-container .header .branding{max-width:6rem;min-width:0;overflow:hidden}.main-container .clr-vertical-nav.clr-nav-level-1,.main-container .header-nav.clr-nav-level-1,.main-container .sidenav.clr-nav-level-1,.main-container .sub-nav.clr-nav-level-1,.main-container .subnav.clr-nav-level-1{-webkit-transform:translateX(-12rem);transform:translateX(-12rem)}.main-container .clr-vertical-nav.clr-nav-level-2,.main-container .header-nav.clr-nav-level-2,.main-container .sidenav.clr-nav-level-2,.main-container .sub-nav.clr-nav-level-2,.main-container .subnav.clr-nav-level-2{-webkit-transform:translateX(12rem);transform:translateX(12rem)}.main-container.open-hamburger-menu .clr-vertical-nav.clr-nav-level-1,.main-container.open-hamburger-menu .header-nav.clr-nav-level-1,.main-container.open-hamburger-menu .header .branding,.main-container.open-hamburger-menu .sidenav.clr-nav-level-1,.main-container.open-hamburger-menu .sub-nav.clr-nav-level-1,.main-container.open-hamburger-menu .subnav.clr-nav-level-1{width:12rem;max-width:12rem}.main-container.open-hamburger-menu .header-hamburger-trigger{position:fixed;top:0;right:auto;left:0;z-index:1039;-webkit-transform:translateX(12.5rem);transform:translateX(12.5rem);transition:-webkit-transform .6s ease;transition:transform .6s ease;transition:transform .6s ease,-webkit-transform .6s ease}.main-container.open-hamburger-menu .header-hamburger-trigger:after{content:none}.main-container.open-overflow-menu .clr-vertical-nav.clr-nav-level-2,.main-container.open-overflow-menu .header-nav.clr-nav-level-2,.main-container.open-overflow-menu .sidenav.clr-nav-level-2,.main-container.open-overflow-menu .sub-nav.clr-nav-level-2,.main-container.open-overflow-menu .subnav.clr-nav-level-2{width:12rem;max-width:12rem}.main-container.open-overflow-menu .header-overflow-trigger{position:fixed;top:0;right:0;left:auto;z-index:1039;-webkit-transform:translateX(-12.5rem);transform:translateX(-12.5rem);transition:-webkit-transform .6s ease;transition:transform .6s ease;transition:transform .6s ease,-webkit-transform .6s ease}.main-container.open-overflow-menu .header-overflow-trigger:after{content:none}}.progress,.progress-static{background-color:transparent;border-radius:0;font-size:inherit;height:2em;margin:0;max-height:.583333rem;min-height:.166667rem;overflow:hidden;display:block;width:100%}.progress>progress{-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;background-color:#eee;border:none;color:#007cbb;height:100%;width:100%}.progress>progress::-moz-progress-bar{background-color:#007cbb}.progress>progress[value="0"]::-moz-progress-bar{-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;color:#eee;min-width:2rem;background-color:transparent;background-image:none}.progress>progress[value="0"]::-webkit-progress-value{transition:none}.progress>progress::-webkit-progress-bar{background-color:#eee;border-radius:0}.progress>progress::-webkit-progress-inner-element{-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none}.progress>progress::-webkit-progress-value{background-color:#007cbb;transition:width .23s ease-in;border-radius:0}.progress.success>progress{color:#60b515}.progress.success>progress::-webkit-progress-value{background-color:#60b515}.progress.success>progress::-moz-progress-bar{background-color:#60b515}.progress.warning>progress{color:#c92100}.progress.warning>progress::-webkit-progress-value{background-color:#c92100}.progress.warning>progress::-moz-progress-bar{background-color:#c92100}.progress.danger>progress{color:#c92100}.progress.danger>progress::-webkit-progress-value{background-color:#c92100}.progress.danger>progress::-moz-progress-bar{background-color:#c92100}.progress-static.labeled,.progress.labeled{position:relative;padding-right:3em}.progress-static.labeled>span,.progress.labeled>span{display:block;font-size:1em;position:absolute;top:50%;right:0;line-height:1em;margin-top:-.375em}@-webkit-keyframes clr-progress-fade{0%{opacity:1}to{opacity:0}}@keyframes clr-progress-fade{0%{opacity:1}to{opacity:0}}.progress.progress-fade>progress[value="100"],.progress.progress-fade>progress[value="100"]+span{-webkit-animation:clr-progress-fade .3s linear .5s forwards;animation:clr-progress-fade .3s linear .5s forwards}.progress.flash-danger>progress,.progress.flash>progress{transition:color .1s ease-out 1s}.progress.flash-danger>progress::-webkit-progress-value,.progress.flash>progress::-webkit-progress-value{transition:width .23s ease-in,background-color .1s ease-out .3s}.progress.flash-danger>progress[value="0"]::-webkit-progress-value,.progress.flash>progress[value="0"]::-webkit-progress-value{transition:none}.progress.flash-danger>progress::-moz-progress-bar,.progress.flash>progress::-moz-progress-bar{transition:width .23s ease-in,background-color .1s ease-out .3s}.progress.flash>progress[value="100"]{color:#60b515}.progress.flash>progress[value="100"]::-webkit-progress-value{background-color:#60b515}.progress.flash>progress[value="100"]::-moz-progress-bar{background-color:#60b515}.progress.progress-fade.flash>progress[value="100"],.progress.progress-fade.flash>progress[value="100"]+span{-webkit-animation:clr-progress-fade .6s linear 1s forwards;animation:clr-progress-fade .6s linear 1s forwards}.progress.flash-danger>progress[value="100"]{color:#c92100}.progress.flash-danger>progress[value="100"]::-webkit-progress-value{background-color:#c92100}.progress.flash-danger>progress[value="100"]::-moz-progress-bar{background-color:#c92100}@-webkit-keyframes clr-progress-looper{0%{left:-100%}to{left:100%}}@keyframes clr-progress-looper{0%{left:-100%}to{left:100%}}.progress.loop{position:relative}.progress.loop>progress{overflow:hidden;color:transparent}.progress.loop>progress::-webkit-progress-value{background-color:transparent}.progress.loop>progress::-moz-progress-bar{background-color:transparent}.progress.loop:after{-webkit-animation:clr-progress-looper 2s ease-in-out infinite;animation:clr-progress-looper 2s ease-in-out infinite;content:" ";top:0;bottom:0;left:0;position:absolute;display:block;background-color:#007cbb;width:75%}.progress.loop.danger:after,.progress.loop.warning:after{background-color:#c92100}.progress.loop.success:after{background-color:#60b515}.nav-item .progress:after{top:0}.progress-static{position:relative;border:none;width:100%}.progress-static>.progress-meter{background-color:#eee;display:block;position:absolute;top:0;left:0;bottom:0;right:0}.progress-static>.progress-meter:before{background-color:#007cbb;top:0;bottom:0;left:0;position:absolute;display:block;width:0;content:" "}.progress-static>.progress-meter[data-value="1"]:before,.progress-static>.progress-meter[data-value="2"]:before,.progress-static>.progress-meter[data-value="3"]:before{width:2%}.progress-static>.progress-meter[data-value="4"]:before,.progress-static>.progress-meter[data-value="5"]:before,.progress-static>.progress-meter[data-value="6"]:before,.progress-static>.progress-meter[data-value="7"]:before{width:5%}.progress-static>.progress-meter[data-value="8"]:before,.progress-static>.progress-meter[data-value="9"]:before,.progress-static>.progress-meter[data-value="10"]:before,.progress-static>.progress-meter[data-value="11"]:before,.progress-static>.progress-meter[data-value="12"]:before{width:10%}.progress-static>.progress-meter[data-value="13"]:before,.progress-static>.progress-meter[data-value="14"]:before,.progress-static>.progress-meter[data-value="15"]:before,.progress-static>.progress-meter[data-value="16"]:before,.progress-static>.progress-meter[data-value="17"]:before{width:15%}.progress-static>.progress-meter[data-value="18"]:before,.progress-static>.progress-meter[data-value="19"]:before,.progress-static>.progress-meter[data-value="20"]:before,.progress-static>.progress-meter[data-value="21"]:before,.progress-static>.progress-meter[data-value="22"]:before{width:20%}.progress-static>.progress-meter[data-value="23"]:before,.progress-static>.progress-meter[data-value="24"]:before,.progress-static>.progress-meter[data-value="25"]:before,.progress-static>.progress-meter[data-value="26"]:before,.progress-static>.progress-meter[data-value="27"]:before{width:25%}.progress-static>.progress-meter[data-value="28"]:before,.progress-static>.progress-meter[data-value="29"]:before,.progress-static>.progress-meter[data-value="30"]:before,.progress-static>.progress-meter[data-value="31"]:before,.progress-static>.progress-meter[data-value="32"]:before{width:30%}.progress-static>.progress-meter[data-value="33"]:before,.progress-static>.progress-meter[data-value="34"]:before,.progress-static>.progress-meter[data-value="35"]:before,.progress-static>.progress-meter[data-value="36"]:before,.progress-static>.progress-meter[data-value="37"]:before{width:35%}.progress-static>.progress-meter[data-value="38"]:before,.progress-static>.progress-meter[data-value="39"]:before,.progress-static>.progress-meter[data-value="40"]:before,.progress-static>.progress-meter[data-value="41"]:before,.progress-static>.progress-meter[data-value="42"]:before{width:40%}.progress-static>.progress-meter[data-value="43"]:before,.progress-static>.progress-meter[data-value="44"]:before,.progress-static>.progress-meter[data-value="45"]:before,.progress-static>.progress-meter[data-value="46"]:before,.progress-static>.progress-meter[data-value="47"]:before{width:45%}.progress-static>.progress-meter[data-value="48"]:before,.progress-static>.progress-meter[data-value="49"]:before,.progress-static>.progress-meter[data-value="50"]:before,.progress-static>.progress-meter[data-value="51"]:before,.progress-static>.progress-meter[data-value="52"]:before{width:50%}.progress-static>.progress-meter[data-value="53"]:before,.progress-static>.progress-meter[data-value="54"]:before,.progress-static>.progress-meter[data-value="55"]:before,.progress-static>.progress-meter[data-value="56"]:before,.progress-static>.progress-meter[data-value="57"]:before{width:55%}.progress-static>.progress-meter[data-value="58"]:before,.progress-static>.progress-meter[data-value="59"]:before,.progress-static>.progress-meter[data-value="60"]:before,.progress-static>.progress-meter[data-value="61"]:before,.progress-static>.progress-meter[data-value="62"]:before{width:60%}.progress-static>.progress-meter[data-value="63"]:before,.progress-static>.progress-meter[data-value="64"]:before,.progress-static>.progress-meter[data-value="65"]:before,.progress-static>.progress-meter[data-value="66"]:before,.progress-static>.progress-meter[data-value="67"]:before{width:65%}.progress-static>.progress-meter[data-value="68"]:before,.progress-static>.progress-meter[data-value="69"]:before,.progress-static>.progress-meter[data-value="70"]:before,.progress-static>.progress-meter[data-value="71"]:before,.progress-static>.progress-meter[data-value="72"]:before{width:70%}.progress-static>.progress-meter[data-value="73"]:before,.progress-static>.progress-meter[data-value="74"]:before,.progress-static>.progress-meter[data-value="75"]:before,.progress-static>.progress-meter[data-value="76"]:before,.progress-static>.progress-meter[data-value="77"]:before{width:75%}.progress-static>.progress-meter[data-value="78"]:before,.progress-static>.progress-meter[data-value="79"]:before,.progress-static>.progress-meter[data-value="80"]:before,.progress-static>.progress-meter[data-value="81"]:before,.progress-static>.progress-meter[data-value="82"]:before{width:80%}.progress-static>.progress-meter[data-value="83"]:before,.progress-static>.progress-meter[data-value="84"]:before,.progress-static>.progress-meter[data-value="85"]:before,.progress-static>.progress-meter[data-value="86"]:before,.progress-static>.progress-meter[data-value="87"]:before{width:85%}.progress-static>.progress-meter[data-value="88"]:before,.progress-static>.progress-meter[data-value="89"]:before,.progress-static>.progress-meter[data-value="90"]:before,.progress-static>.progress-meter[data-value="91"]:before,.progress-static>.progress-meter[data-value="92"]:before{width:90%}.progress-static>.progress-meter[data-value="93"]:before,.progress-static>.progress-meter[data-value="94"]:before,.progress-static>.progress-meter[data-value="95"]:before,.progress-static>.progress-meter[data-value="96"]:before{width:95%}.progress-static>.progress-meter[data-value="97"]:before,.progress-static>.progress-meter[data-value="98"]:before,.progress-static>.progress-meter[data-value="99"]:before{width:98%}.progress-static>.progress-meter[data-value="100"]:before{width:100%}.progress-static.labeled>.progress-meter{right:3em}.progress-static.success>.progress-meter:before{background-color:#60b515}.progress-static.danger>.progress-meter:before,.progress-static.warning>.progress-meter:before{background-color:#c92100}.card-block .progress,.card-block .progress-static,.card-footer .progress,.card-footer .progress-static{margin:0;margin-top:-.5rem;height:.15625rem;position:absolute;left:0}.card-block .progress-static>.progress-meter,.card-block .progress>progress,.card-footer .progress-static>.progress-meter,.card-footer .progress>progress{height:.15625rem;position:absolute}.card-block .progress-static.top,.card-block .progress.top,.card-footer .progress-static.top,.card-footer .progress.top{margin-top:0;top:0}.nav-item .progress,.nav-item .progress-static{margin:0;left:0}.nav-item .progress,.nav-item .progress-static,.nav-item .progress-static>.progress-meter,.nav-item .progress>progress{height:.2rem;min-height:.2rem;max-height:.2rem;position:absolute}.progress-block{display:-webkit-box;display:-ms-flexbox;display:flex;width:100%;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.progress-block>*{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;padding-right:.5rem}.progress-block>:first-child{padding-right:.75rem}.progress-block>:last-child{padding-right:0}.progress-block>label{font-weight:600}.progress-block>.progress,.progress-block>.progress-static{-webkit-box-flex:0;-ms-flex:0 1 auto;flex:0 1 auto}.progress-block>.progress-group{-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;height:auto;-webkit-box-flex:0;-ms-flex:0 1 auto;flex:0 1 auto;display:-webkit-box;display:-ms-flexbox;display:flex;width:100%}.progress-block>.progress-group .row{margin-left:0;margin-right:0}.progress-block>.progress-group .row>[class*=col-]{padding-left:0;padding-right:0}.card-block .progress-block{margin-bottom:.5rem;padding:0}.card-block .progress-block:last-child{margin-bottom:0}.card-block .progress-block>label{max-width:33%;line-height:.75rem}.card-block .progress-block .progress,.card-block .progress-block .progress-static{position:relative;height:.53329rem;margin-top:0}.card-block .progress-block .progress-static>.progress-meter,.card-block .progress-block .progress-static>progress,.card-block .progress-block .progress>.progress-meter,.card-block .progress-block .progress>progress{height:.53329rem}:root .progress-block>label,_:-ms-input-placeholder .progress-block>label{display:inline-block}.spinner{position:relative;display:inline-block;min-height:3rem;min-width:3rem;height:3rem;width:3rem;-webkit-animation:spin 1s linear infinite;animation:spin 1s linear infinite;margin:0;padding:0;background:url("data:image/svg+xml;charset=utf8,%3Csvg%20id%3D%22Layer_2%22%20data-name%3D%22Layer%202%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2072%2072%22%3E%0A%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3Cstyle%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20.cls-1%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20.cls-2%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20fill%3A%20none%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20stroke-miterlimit%3A%2010%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20stroke-width%3A%205px%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20.cls-1%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20stroke%3A%20%23000%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20stroke-opacity%3A%200.15%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20.cls-2%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20stroke%3A%20%23007cbb%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%3C%2Fstyle%3E%0A%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%3Ctitle%3EPreloader_72x2%3C%2Ftitle%3E%0A%20%20%20%20%3Ccircle%20class%3D%22cls-1%22%20cx%3D%2236%22%20cy%3D%2236%22%20r%3D%2233%22%2F%3E%0A%20%20%20%20%3Cpath%20class%3D%22cls-2%22%20d%3D%22M14.3%2C60.9A33%2C33%2C0%2C0%2C1%2C36%2C3%22%3E%0A%20%20%20%20%3C%2Fpath%3E%0A%3C%2Fsvg%3E%0A");text-indent:100%;overflow:hidden}.spinner.spinner-md{min-height:1.5rem;min-width:1.5rem;height:1.5rem;width:1.5rem}.spinner.spinner-inline,.spinner.spinner-sm{min-height:.75rem;min-width:.75rem;height:.75rem;width:.75rem}.spinner.spinner-inline{vertical-align:text-bottom}.spinner.spinner-inverse{background:url("data:image/svg+xml;charset=utf8,%3Csvg%20id%3D%22Layer_2%22%20data-name%3D%22Layer%202%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2072%2072%22%3E%0A%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3Cstyle%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20.cls-1%2C%0A%20%20%20%20%20%20%20%20%20%20%20%20.cls-2%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20fill%3A%20none%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20stroke-miterlimit%3A%2010%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20stroke-width%3A%205px%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20.cls-1%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20stroke%3A%20%23fff%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20stroke-opacity%3A%200.15%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%20%20%20%20.cls-2%20%7B%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20stroke%3A%20%23007cbb%3B%0A%20%20%20%20%20%20%20%20%20%20%20%20%7D%0A%20%20%20%20%20%20%20%20%3C%2Fstyle%3E%0A%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%3Ctitle%3EPreloader_72x2%3C%2Ftitle%3E%0A%20%20%20%20%3Ccircle%20class%3D%22cls-1%22%20cx%3D%2236%22%20cy%3D%2236%22%20r%3D%2233%22%2F%3E%0A%20%20%20%20%3Cpath%20class%3D%22cls-2%22%20d%3D%22M14.3%2C60.9A33%2C33%2C0%2C0%2C1%2C36%2C3%22%3E%0A%20%20%20%20%3C%2Fpath%3E%0A%3C%2Fsvg%3E%0A")}.alert-app-level .alert-item .btn .spinner,.btn-sm .spinner{min-height:.54167rem;min-width:.54167rem;height:.54167rem;width:.54167rem}@-webkit-keyframes spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}@keyframes spin{0%{-webkit-transform:rotate(0deg);transform:rotate(0deg)}to{-webkit-transform:rotate(1turn);transform:rotate(1turn)}}.table{border-collapse:separate;border:1px solid #ccc;border-radius:.125rem;background-color:#fff;color:#565656;margin:0;margin-top:1rem;max-width:100%;width:100%}.table td,.table th{font-size:.54167rem;line-height:.58333rem;border-top:1px solid #eee;padding:.45833rem .5rem .45833rem;text-align:center;vertical-align:top}.table td.left,.table th.left{text-align:left}.table td.left:first-child,.table th.left:first-child{padding-left:.25rem}.table th{color:#565656;font-size:.45833rem;font-weight:600;letter-spacing:.03em;background-color:#fafafa;vertical-align:bottom;border-bottom:1px solid #ccc}.table tbody tr:first-child td,.table th{border-top:0 none}.table tbody+tbody{border-top:1px solid #ccc}.table thead th:first-child{border-radius:calc(.125rem - 1px) 0 0 0}.table thead th:last-child{border-radius:0 calc(.125rem - 1px) 0 0}.table tbody:last-child tr:last-child td:first-child{border-radius:0 0 0 calc(.125rem - 1px)}.table tbody:last-child tr:last-child td:last-child{border-radius:0 0 calc(.125rem - 1px) 0}.table-compact td,.table-compact th{padding-top:calc(.20833rem + 1px);padding-bottom:0.20833rem}.table.table-vertical thead th{border:0 none;border-radius:0;display:none}.table.table-vertical th{border-bottom:0;border-top:1px solid #ccc;vertical-align:top}.table.table-vertical td,.table.table-vertical th{text-align:left;border-color:#ccc}.table.table-vertical td:first-child,.table.table-vertical th:first-child{border-right:1px solid #ccc;background-color:#fafafa;font-weight:600}.table.table-vertical tbody:first-of-type tr:first-child td,.table.table-vertical tbody:first-of-type tr:first-child th{border-top:0 none}.table.table-vertical tbody:first-of-type tr:first-child td:first-child,.table.table-vertical tbody:first-of-type tr:first-child th:first-child{border-radius:calc(.125rem - 1px) 0 0 0}.table.table-vertical tbody:first-of-type tr:first-child td:last-child,.table.table-vertical tbody:first-of-type tr:first-child th:last-child{border-radius:0 calc(.125rem - 1px) 0 0}.table.table-vertical tbody:last-child tr:last-child td:first-child,.table.table-vertical tbody:last-child tr:last-child th:first-child{border-radius:0 0 0 calc(.125rem - 1px)}.table.table-vertical tbody:last-child tr:last-child td:last-child,.table.table-vertical tbody:last-child tr:last-child th:last-child{border-radius:0 0 calc(.125rem - 1px) 0}.table.table-noborder{border-radius:0;box-shadow:none;background-color:transparent;border:0}.table.table-noborder th{background-color:transparent;border-bottom-color:#ddd;border-top:0 none}.table.table-noborder th:first-child{border-right:0 none}.table.table-noborder td{border-top:0 none;padding-top:calc(.45833rem + 1px)}.table.table-noborder td:first-child{border-right:0 none}.table.table-noborder thead th:first-child,.table.table-noborder thead th:last-child{border-radius:0}.table.table-noborder td,.table.table-noborder th{border-radius:0!important}.table.table-noborder td:first-child,.table.table-noborder th:first-child{padding-left:0}.table.table-compact td,.table.table-compact th{padding-top:calc(.20833rem + 1px);padding-bottom:.20833rem}.table.table-compact.table-noborder td,.table.table-compact.table-noborder th{padding-top:calc(.20833rem + 2px);padding-bottom:calc(.20833rem + 1px)}.tooltip{display:inline-block;position:relative;text-align:left;overflow:visible}.tooltip>.tooltip-content{visibility:hidden;opacity:0;transition:opacity .3s linear;white-space:normal;z-index:1070}.tooltip:hover{background:url("data:image/svg+xml;charset=UTF-8,%3Csvg+xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22+width%3D%221%22+height%3D%221%22+viewBox%3D%220+0+1+1%22%3E%3Ctitle%3Etransparent+bcg%3C%2Ftitle%3E%3C%2Fsvg%3E")}.tooltip:focus>.tooltip-content,.tooltip:hover>.tooltip-content{visibility:visible;opacity:1}.tooltip:focus>.tooltip-content:empty,.tooltip:hover>.tooltip-content:empty{visibility:hidden;opacity:0}.tooltip:focus{outline:0}.tooltip:focus>:first-child{outline-offset:.04167rem;outline-width:.04167rem;outline-color:#3b99fc;outline-style:solid}.tooltip .tooltip-content.tooltip-top-right,.tooltip.tooltip-top-right>.tooltip-content,.tooltip>.tooltip-content{font-size:.54167rem;font-weight:400;letter-spacing:normal;background:#000;border-radius:.125rem;color:#fff;line-height:.75rem;margin:0;padding:.375rem .5rem;width:10rem;position:absolute;top:auto;bottom:100%;left:50%;right:auto;border-bottom-left-radius:0;margin-bottom:.66667rem}.tooltip .tooltip-content.tooltip-top-right:before,.tooltip.tooltip-top-right>.tooltip-content:before,.tooltip>.tooltip-content:before{position:absolute;bottom:-.375rem;left:0;top:auto;right:auto;content:"";border-left:.25rem solid #000;border-top:.20833rem solid #000;border-right:.25rem solid transparent;border-bottom:.20833rem solid transparent}.tooltip .tooltip-content.tooltip-top-left,.tooltip.tooltip-top-left>.tooltip-content{font-size:.54167rem;font-weight:400;letter-spacing:normal;background:#000;border-radius:.125rem;color:#fff;line-height:.75rem;margin:0;padding:.375rem .5rem;width:10rem;position:absolute;top:auto;bottom:100%;right:50%;left:auto;border-bottom-right-radius:0;margin-bottom:.66667rem}.tooltip .tooltip-content.tooltip-top-left:before,.tooltip.tooltip-top-left>.tooltip-content:before{position:absolute;bottom:-.375rem;right:0;top:auto;left:auto;content:"";border-right:.25rem solid #000;border-top:.20833rem solid #000;border-left:.25rem solid transparent;border-bottom:.20833rem solid transparent}.tooltip.tooltip-bottom-right>.tooltip-content,.tooltip .tooltip-content.tooltip-bottom-right{font-size:.54167rem;font-weight:400;letter-spacing:normal;background:#000;border-radius:.125rem;color:#fff;line-height:.75rem;margin:0;padding:.375rem .5rem;width:10rem;position:absolute;bottom:auto;top:100%;left:50%;right:auto;border-top-left-radius:0;margin-top:.66667rem}.tooltip.tooltip-bottom-right>.tooltip-content:before,.tooltip .tooltip-content.tooltip-bottom-right:before{position:absolute;top:-.375rem;left:0;bottom:auto;right:auto;content:"";border-left:.25rem solid #000;border-bottom:.20833rem solid #000;border-right:.25rem solid transparent;border-top:.20833rem solid transparent}.tooltip.tooltip-bottom-left>.tooltip-content,.tooltip .tooltip-content.tooltip-bottom-left{font-size:.54167rem;font-weight:400;letter-spacing:normal;background:#000;border-radius:.125rem;color:#fff;line-height:.75rem;margin:0;padding:.375rem .5rem;width:10rem;position:absolute;bottom:auto;top:100%;right:50%;left:auto;border-top-right-radius:0;margin-top:.66667rem}.tooltip.tooltip-bottom-left>.tooltip-content:before,.tooltip .tooltip-content.tooltip-bottom-left:before{position:absolute;top:-.375rem;right:0;bottom:auto;left:auto;content:"";border-right:.25rem solid #000;border-bottom:.20833rem solid #000;border-left:.25rem solid transparent;border-top:.20833rem solid transparent}.tooltip .tooltip-content.tooltip-right,.tooltip.tooltip-right>.tooltip-content{position:absolute;right:auto;left:100%;top:50%;bottom:auto;font-size:.54167rem;font-weight:400;letter-spacing:normal;background:#000;border-radius:.125rem;color:#fff;line-height:.75rem;margin:0;padding:.375rem .5rem;width:10rem;border-top-left-radius:0;margin-left:.66667rem}.tooltip .tooltip-content.tooltip-right:before,.tooltip.tooltip-right>.tooltip-content:before{position:absolute;top:0;left:-.375rem;bottom:auto;right:auto;content:"";border-top:.25rem solid #000;border-right:.20833rem solid #000;border-bottom:.25rem solid transparent;border-left:.20833rem solid transparent}.tooltip .tooltip-content.tooltip-left,.tooltip.tooltip-left>.tooltip-content{position:absolute;left:auto;right:100%;top:50%;bottom:auto;font-size:.54167rem;font-weight:400;letter-spacing:normal;background:#000;border-radius:.125rem;color:#fff;line-height:.75rem;margin:0;padding:.375rem .5rem;width:10rem;border-top-right-radius:0;margin-right:.66667rem}.tooltip .tooltip-content.tooltip-left:before,.tooltip.tooltip-left>.tooltip-content:before{position:absolute;top:0;right:-.375rem;bottom:auto;left:auto;content:"";border-top:.25rem solid #000;border-left:.20833rem solid #000;border-bottom:.25rem solid transparent;border-right:.20833rem solid transparent}.tooltip .tooltip-content.tooltip-xs,.tooltip.tooltip-xs>.tooltip-content{width:3rem}.tooltip .tooltip-content.tooltip-sm,.tooltip.tooltip-sm>.tooltip-content{width:5rem}.tooltip .tooltip-content.tooltip-md,.tooltip.tooltip-md>.tooltip-content{width:10rem}.tooltip .tooltip-content.tooltip-lg,.tooltip.tooltip-lg>.tooltip-content{width:15rem}.tooltip.tooltip-top-left>.btn+.tooltip-content,.tooltip.tooltip-top-right>.btn+.tooltip-content,.tooltip>.btn+.tooltip-content{margin-bottom:.41667rem}.tooltip.tooltip-bottom-left>.btn+.tooltip-content,.tooltip.tooltip-bottom-right>.btn+.tooltip-content{margin-top:.41667rem}.tooltip.tooltip-right>.btn+.tooltip-content{margin-left:.16667rem}.tooltip>.clr-icon{margin-right:0}.tooltip clr-icon>svg{pointer-events:none}input[type=date],input[type=datetime-local],input[type=email],input[type=number],input[type=password],input[type=tel],input[type=text],input[type=time],input[type=url]{-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;margin:0;padding:0;border:none;border-radius:0;box-shadow:none;background:none;height:1rem;color:#000;display:inline-block;min-width:2.5rem;border-bottom:1px solid #9a9a9a;padding:0 .25rem}input[type=date]:focus,input[type=datetime-local]:focus,input[type=email]:focus,input[type=number]:focus,input[type=password]:focus,input[type=tel]:focus,input[type=text]:focus,input[type=time]:focus,input[type=url]:focus{outline:0}input[type=date]:not([readonly]),input[type=datetime-local]:not([readonly]),input[type=email]:not([readonly]),input[type=number]:not([readonly]),input[type=password]:not([readonly]),input[type=tel]:not([readonly]),input[type=text]:not([readonly]),input[type=time]:not([readonly]),input[type=url]:not([readonly]){background:linear-gradient(180deg,transparent 95%,#0094d2 0) no-repeat;background-size:0 100%;transition:background-size .2s ease}input[type=date]:not([readonly]):focus,input[type=datetime-local]:not([readonly]):focus,input[type=email]:not([readonly]):focus,input[type=number]:not([readonly]):focus,input[type=password]:not([readonly]):focus,input[type=tel]:not([readonly]):focus,input[type=text]:not([readonly]):focus,input[type=time]:not([readonly]):focus,input[type=url]:not([readonly]):focus{border-bottom:1px solid #0094d2;background-size:100% 100%}input[type=date][readonly],input[type=datetime-local][readonly],input[type=email][readonly],input[type=number][readonly],input[type=password][readonly],input[type=tel][readonly],input[type=text][readonly],input[type=time][readonly],input[type=url][readonly]{border:none}input[type=button]:disabled,input[type=date]:disabled,input[type=datetime-local]:disabled,input[type=email]:disabled,input[type=number]:disabled,input[type=password]:disabled,input[type=submit]:disabled,input[type=tel]:disabled,input[type=text]:disabled,input[type=time]:disabled,input[type=url]:disabled,textarea:disabled{opacity:.5;cursor:not-allowed}textarea{resize:vertical;width:100%;background:#fff;border:1px solid #ccc;color:#000;border-radius:.125rem;padding:.25rem .5rem}textarea:focus{outline:0;box-shadow:0 0 2px 2px #6bc1e3}.checkbox{display:block}.checkbox-inline{display:inline-block}.checkbox,.checkbox-inline{position:relative}.checkbox-inline input[type=checkbox],.checkbox input[type=checkbox]{position:absolute;top:.16667rem;left:0;opacity:0;height:.66667rem;width:.66667rem}.checkbox-inline label,.checkbox label{position:relative;display:inline-block;min-height:1rem;padding-left:.91667rem;cursor:pointer;line-height:1rem}.checkbox-inline input[type=checkbox]+label:before,.checkbox input[type=checkbox]+label:before{position:absolute;top:.16667rem;left:0;content:"";display:inline-block;height:.66667rem;width:.66667rem;border:1px solid #9a9a9a;border-radius:.125rem}.checkbox-inline input[type=checkbox]:focus+label:before,.checkbox input[type=checkbox]:focus+label:before{outline:0;box-shadow:0 0 2px 2px #6bc1e3}.checkbox-inline input[type=checkbox]+label:after,.checkbox input[type=checkbox]+label:after{position:absolute;content:"";display:none;height:.20833rem;width:.33333rem;border-left:.08333rem solid #fff;border-bottom:.08333rem solid #fff;top:.16667rem;left:.16667rem;-webkit-transform:translateY(.16667rem) rotate(-45deg);transform:translateY(.16667rem) rotate(-45deg);border-color:#fff}.checkbox-inline input[type=checkbox]:checked+label:before,.checkbox input[type=checkbox]:checked+label:before{background:#0094d2;border:none}.checkbox-inline input[type=checkbox]:checked+label:after,.checkbox input[type=checkbox]:checked+label:after{display:inline-block}.checkbox-inline input[type=checkbox]:indeterminate+label:before,.checkbox input[type=checkbox]:indeterminate+label:before{border:1px solid #0094d2}.checkbox-inline input[type=checkbox]:indeterminate+label:after,.checkbox input[type=checkbox]:indeterminate+label:after{border-left:none;border-bottom-color:#0094d2;display:inline-block;-webkit-transform:translateY(.16667rem);transform:translateY(.16667rem)}.checkbox-inline.disabled label,.checkbox.disabled label{opacity:.5;cursor:not-allowed;color:#565656}.checkbox-inline.disabled input[type=checkbox]:checked+label:before,.checkbox.disabled input[type=checkbox]:checked+label:before{background-color:#ccc}.checkbox-inline.disabled input[type=checkbox]:checked+label:after,.checkbox.disabled input[type=checkbox]:checked+label:after{border-left:.08333rem solid #737373;border-bottom:.08333rem solid #737373}.radio{display:block}.radio-inline{display:inline-block}.radio,.radio-inline{position:relative}.radio-inline input[type=radio],.radio input[type=radio]{position:absolute;top:.16667rem;left:0;opacity:0;height:.66667rem;width:.66667rem}.radio-inline label,.radio label{position:relative;display:inline-block;min-height:1rem;padding-left:.91667rem;cursor:pointer;line-height:1rem}.radio-inline label:empty,.radio label:empty{padding-left:0}.radio-inline input[type=radio]+label:before,.radio input[type=radio]+label:before{position:absolute;top:.16667rem;left:0;content:"";display:inline-block;height:.66667rem;width:.66667rem;border:1px solid #9a9a9a;border-radius:50%}.radio-inline input[type=radio]:checked+label:before,.radio input[type=radio]:checked+label:before{box-shadow:inset 0 0 0 .25rem #0094d2;border:none}.radio-inline input[type=radio]:focus+label:before,.radio input[type=radio]:focus+label:before{outline:0;box-shadow:0 0 2px 2px #6bc1e3}.radio-inline input[type=radio]:focus:checked+label:before,.radio input[type=radio]:focus:checked+label:before{outline:0;box-shadow:inset 0 0 0 .25rem #0094d2,0 0 2px 2px #6bc1e3}.radio-inline.disabled label,.radio.disabled label{opacity:.5;cursor:not-allowed;color:#565656}.radio-inline.disabled input[type=radio]:checked+label:before,.radio.disabled input[type=radio]:checked+label:before{background-color:#ccc;box-shadow:inset 0 0 0 .25rem #0094d2}.select,.select select{position:relative}.select select{-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;margin:0;padding:0;border:none;border-radius:0;box-shadow:none;background:none;height:1rem;color:#000;display:inline-block;min-width:2.5rem;border-bottom:1px solid #9a9a9a;background:linear-gradient(180deg,transparent 95%,#0094d2 0) no-repeat;background-size:0 100%;transition:background-size .2s ease;padding:0 .91667rem 0 .25rem;cursor:pointer;width:100%;z-index:2}.select select:focus{outline:0;border-bottom:1px solid #0094d2;background-size:100% 100%}.select select:active,.select select:hover{border-color:hsla(0,0%,87%,.5);background:hsla(0,0%,87%,.5)}.select select:disabled{opacity:.5;cursor:not-allowed}.select select::-ms-expand{display:none}.select:after{position:absolute;content:"";height:.41667rem;width:.41667rem;top:.29167rem;right:.25rem;background-image:url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2012%22%3E%0A%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3Cstyle%3E.cls-1%7Bfill%3A%239a9a9a%3B%7D%3C%2Fstyle%3E%0A%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%3Ctitle%3ECaret%3C%2Ftitle%3E%0A%20%20%20%20%3Cpath%20class%3D%22cls-1%22%20d%3D%22M6%2C9L1.2%2C4.2a0.68%2C0.68%2C0%2C0%2C1%2C1-1L6%2C7.08%2C9.84%2C3.24a0.68%2C0.68%2C0%2C1%2C1%2C1%2C1Z%22%2F%3E%0A%3C%2Fsvg%3E%0A");background-repeat:no-repeat;background-size:contain;vertical-align:middle;margin:0}.select:hover:after{color:#737373}.select.disabled{opacity:.5;cursor:not-allowed}.select.disabled:hover:after{color:#9a9a9a}.select.disabled>select,.select select:disabled{opacity:.5;cursor:not-allowed}.select.disabled>select:hover,.select select:disabled:hover{background:none;border-color:#9a9a9a}.select.multiple:after{content:none}select[multiple],select[size]{padding:0;background:#fff;border:1px solid #ccc;border-radius:.125rem;height:auto;min-width:5rem}select[multiple]:active,select[multiple]:hover,select[size]:active,select[size]:hover{background:#fff;border-color:#ccc}select[multiple] option,select[size] option{padding:.125rem .25rem}.form,form{padding-top:.5rem}.form label,.form span,form label,form span{display:inline-block}.form .form-block,form .form-block{margin:.5rem 0 1.5rem 0}.form .form-block>label,form .form-block>label{font-size:.66667rem;letter-spacing:.01em;font-weight:400;color:#000;margin-bottom:.25rem}.form .form-group,form .form-group{display:-webkit-box;display:-ms-flexbox;display:flex;-ms-flex-wrap:wrap;flex-wrap:wrap;position:relative;padding-left:9.5rem;margin-bottom:.5rem;font-size:.54167rem;letter-spacing:normal;line-height:1rem}.form .form-group.row,form .form-group.row{padding-left:0;position:static}.form .form-group>label:first-child,.form .form-group>span:first-child,form .form-group>label:first-child,form .form-group>span:first-child{position:absolute;width:8.5rem;left:0;top:.25rem;margin:0}.form .form-group.row>[class*=col-]:first-child>label,.form .form-group.row>[class*=col-]:first-child>span,form .form-group.row>[class*=col-]:first-child>label,form .form-group.row>[class*=col-]:first-child>span{position:static}.form .form-group.row>[class*=col-]>label,.form .form-group.row>[class*=col-]>span,.form .form-group>label:first-child,.form .form-group>span:first-child,form .form-group.row>[class*=col-]>label,form .form-group.row>[class*=col-]>span,form .form-group>label:first-child,form .form-group>span:first-child{color:#000}.form .form-group.row>[class*=col-]>label.required:after,.form .form-group.row>[class*=col-]>span.required:after,.form .form-group>label:first-child.required:after,.form .form-group>span:first-child.required:after,form .form-group.row>[class*=col-]>label.required:after,form .form-group.row>[class*=col-]>span.required:after,form .form-group>label:first-child.required:after,form .form-group>span:first-child.required:after{content:"*";font-size:1.1em;color:#c92100;margin-left:.25rem}.form .form-group .form-control,form .form-group .form-control{width:100%}.form .form-group>.btn,.form .form-group>.checkbox-inline,.form .form-group>.radio-inline,.form .form-group>.select,.form .form-group>.tooltip-validation,.form .form-group>a,.form .form-group>button,.form .form-group>input[type=button],.form .form-group>input[type=text],.form .form-group>label:not(:first-child),.form .form-group>span:not(:first-child),.form .form-group input[type=date],.form .form-group input[type=datetime-local],.form .form-group input[type=email],.form .form-group input[type=number],.form .form-group input[type=password],.form .form-group input[type=submit],.form .form-group input[type=tel],.form .form-group input[type=time],.form .form-group input[type=url],form .form-group>.btn,form .form-group>.checkbox-inline,form .form-group>.radio-inline,form .form-group>.select,form .form-group>.tooltip-validation,form .form-group>a,form .form-group>button,form .form-group>input[type=button],form .form-group>input[type=text],form .form-group>label:not(:first-child),form .form-group>span:not(:first-child),form .form-group input[type=date],form .form-group input[type=datetime-local],form .form-group input[type=email],form .form-group input[type=number],form .form-group input[type=password],form .form-group input[type=submit],form .form-group input[type=tel],form .form-group input[type=time],form .form-group input[type=url]{-webkit-box-flex:0;-ms-flex:0 1 auto;flex:0 1 auto;margin-left:0;margin-right:.5rem}.form .form-group>.btn.btn-link,form .form-group>.btn.btn-link{margin-right:0}.form .form-group>.checkbox,.form .form-group>.radio,form .form-group>.checkbox,form .form-group>.radio{-webkit-box-flex:1;-ms-flex:1 1 100%;flex:1 1 100%;margin-left:0;margin-right:1rem}.form .form-group>.toggle-switch,form .form-group>.toggle-switch{-webkit-box-flex:0;-ms-flex:0 1 auto;flex:0 1 auto;margin-left:0;margin-right:1rem}.form .form-group>textarea,form .form-group>textarea{margin-left:0;margin-right:.5rem}.form .form-group .btn,.form .form-group .checkbox,.form .form-group .checkbox-inline,.form .form-group .radio,.form .form-group .radio-inline,.form .form-group .select,.form .form-group .toggle-switch,.form .form-group .tooltip-validation,.form .form-group a,.form .form-group button,.form .form-group input[type=button],.form .form-group input[type=date],.form .form-group input[type=datetime-local],.form .form-group input[type=email],.form .form-group input[type=number],.form .form-group input[type=password],.form .form-group input[type=submit],.form .form-group input[type=tel],.form .form-group input[type=text],.form .form-group input[type=time],.form .form-group input[type=url],.form .form-group label,.form .form-group span,.form .form-group textarea,form .form-group .btn,form .form-group .checkbox,form .form-group .checkbox-inline,form .form-group .radio,form .form-group .radio-inline,form .form-group .select,form .form-group .toggle-switch,form .form-group .tooltip-validation,form .form-group a,form .form-group button,form .form-group input[type=button],form .form-group input[type=date],form .form-group input[type=datetime-local],form .form-group input[type=email],form .form-group input[type=number],form .form-group input[type=password],form .form-group input[type=submit],form .form-group input[type=tel],form .form-group input[type=text],form .form-group input[type=time],form .form-group input[type=url],form .form-group label,form .form-group span,form .form-group textarea{margin-top:.25rem;margin-bottom:.25rem}.alert-app-level .alert-item .form .form-group .btn,.alert-app-level .alert-item form .form-group .btn,.form .form-group .alert-app-level .alert-item .btn,.form .form-group .btn-sm,form .form-group .alert-app-level .alert-item .btn,form .form-group .btn-sm{margin-top:.5rem;margin-bottom:.5rem}.form .form-group .tooltip-validation,form .form-group .tooltip-validation{height:1rem}.form .form-group .tooltip-validation input,form .form-group .tooltip-validation input{margin:0}.form .form-group .checkbox-inline label,.form .form-group .checkbox label,.form .form-group .radio-inline label,.form .form-group .radio label,.form .form-group .toggle-switch label,form .form-group .checkbox-inline label,form .form-group .checkbox label,form .form-group .radio-inline label,form .form-group .radio label,form .form-group .toggle-switch label{margin-top:0;margin-bottom:0}@media screen and (max-width:544px){.form .form-group,form .form-group{padding-left:0;margin-bottom:1rem}.form .form-group .checkbox,.form .form-group .checkbox-inline,.form .form-group .radio,.form .form-group .radio-inline,.form .form-group .select,.form .form-group .toggle-switch,.form .form-group .tooltip-validation,.form .form-group>label:first-child,.form .form-group>label:not(:first-child),.form .form-group input[type=date],.form .form-group input[type=datetime-local],.form .form-group input[type=email],.form .form-group input[type=number],.form .form-group input[type=password],.form .form-group input[type=tel],.form .form-group input[type=text],.form .form-group input[type=time],.form .form-group input[type=url],form .form-group .checkbox,form .form-group .checkbox-inline,form .form-group .radio,form .form-group .radio-inline,form .form-group .select,form .form-group .toggle-switch,form .form-group .tooltip-validation,form .form-group>label:first-child,form .form-group>label:not(:first-child),form .form-group input[type=date],form .form-group input[type=datetime-local],form .form-group input[type=email],form .form-group input[type=number],form .form-group input[type=password],form .form-group input[type=tel],form .form-group input[type=text],form .form-group input[type=time],form .form-group input[type=url]{-webkit-box-flex:1;-ms-flex:1 1 100%;flex:1 1 100%}.form .form-group>label:first-child,form .form-group>label:first-child{position:relative;margin:0 0 .5rem 0}.form .form-group>label:not(:first-child),.form .form-group span,form .form-group>label:not(:first-child),form .form-group span{margin:.5rem .5rem 0 0}.form .form-group .tooltip-validation input,form .form-group .tooltip-validation input{margin:0;width:100%}}.tooltip.tooltip-validation>input{padding-right:1.16667rem}.tooltip.tooltip-validation:hover>.tooltip-content{visibility:hidden;opacity:0}.tooltip.tooltip-validation.invalid>input{border-bottom:1px solid #c92100;background:linear-gradient(180deg,transparent 95%,#c92100 0);transition:none}.tooltip.tooltip-validation.invalid>input:focus+.tooltip-content{background:#c92100;visibility:visible;opacity:1}.tooltip.tooltip-validation.invalid.tooltip-bottom-right>input:focus+.tooltip-content,.tooltip.tooltip-validation.invalid.tooltip-top-right>input:focus+.tooltip-content,.tooltip.tooltip-validation.invalid>input:focus+.tooltip-content{left:100%;right:auto;margin-left:-.58333rem}.tooltip.tooltip-validation.invalid.tooltip-bottom-left>input:focus+.tooltip-content,.tooltip.tooltip-validation.invalid.tooltip-top-left>input:focus+.tooltip-content{right:0;left:auto;margin-right:.58333rem}.tooltip.tooltip-validation.invalid.tooltip-top-right>.tooltip-content:before,.tooltip.tooltip-validation.invalid>.tooltip-content:before{border-left-color:#c92100;border-top-color:#c92100;border-right-color:transparent;border-bottom-color:transparent}.tooltip.tooltip-validation.invalid.tooltip-top-left>.tooltip-content:before{border-right-color:#c92100;border-top-color:#c92100;border-left-color:transparent;border-bottom-color:transparent}.tooltip.tooltip-validation.invalid.tooltip-bottom-right>.tooltip-content:before{border-left-color:#c92100;border-bottom-color:#c92100;border-right-color:transparent;border-top-color:transparent}.tooltip.tooltip-validation.invalid.tooltip-bottom-left>.tooltip-content:before{border-right-color:#c92100;border-bottom-color:#c92100;border-left-color:transparent;border-top-color:transparent}.tooltip.tooltip-validation.invalid.tooltip-left>input:focus+.tooltip-content{right:100%;left:auto;margin:0 .58333rem 0 0}.tooltip.tooltip-validation.invalid.tooltip-left>input:focus+.tooltip-content:before{border-top-color:#c92100;border-left-color:#c92100;border-bottom-color:transparent;border-right-color:transparent}.tooltip.tooltip-validation.invalid.tooltip-right>input:focus+.tooltip-content{left:100%;right:auto;margin:0 0 0 .58333rem}.tooltip.tooltip-validation.invalid.tooltip-right>input:focus+.tooltip-content:before{border-top-color:#c92100;border-right-color:#c92100;border-bottom-color:transparent;border-left-color:transparent}.tooltip.tooltip-validation.invalid:before{position:absolute;content:"";height:.66667rem;width:.66667rem;top:.125rem;right:.25rem;background-image:url("data:image/svg+xml;charset=utf8,%3Csvg%20version%3D%221.1%22%20viewBox%3D%225%205%2026%2026%22%20preserveAspectRatio%3D%22xMidYMid%20meet%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%3E%3Cdefs%3E%3Cstyle%3E.clr-i-outline%7Bfill%3A%23a32100%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Ctitle%3Eexclamation-circle-line%3C%2Ftitle%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpath%20class%3D%22clr-i-outline%20clr-i-outline-path-1%22%20d%3D%22M18%2C6A12%2C12%2C0%2C1%2C0%2C30%2C18%2C12%2C12%2C0%2C0%2C0%2C18%2C6Zm0%2C22A10%2C10%2C0%2C1%2C1%2C28%2C18%2C10%2C10%2C0%2C0%2C1%2C18%2C28Z%22%3E%3C%2Fpath%3E%3Cpath%20class%3D%22clr-i-outline%20clr-i-outline-path-2%22%20d%3D%22M18%2C20.07a1.3%2C1.3%2C0%2C0%2C1-1.3-1.3v-6a1.3%2C1.3%2C0%2C1%2C1%2C2.6%2C0v6A1.3%2C1.3%2C0%2C0%2C1%2C18%2C20.07Z%22%3E%3C%2Fpath%3E%3Ccircle%20class%3D%22clr-i-outline%20clr-i-outline-path-3%22%20cx%3D%2217.95%22%20cy%3D%2223.02%22%20r%3D%221.5%22%3E%3C%2Fcircle%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3C%2Fsvg%3E");background-repeat:no-repeat;background-size:contain;vertical-align:middle;margin:0}.form.compact .form-block,form.compact .form-block{margin:.5rem 0 1rem 0}.form.compact .form-block>label,.form.compact .form-group,form.compact .form-block>label,form.compact .form-group{margin-bottom:0}:root input[type=date],:root input[type=datetime-local],:root input[type=email],:root input[type=number],:root input[type=password],:root input[type=tel],:root input[type=text],:root input[type=time],:root input[type=url],_:-ms-input-placeholder input[type=date],_:-ms-input-placeholder input[type=datetime-local],_:-ms-input-placeholder input[type=email],_:-ms-input-placeholder input[type=number],_:-ms-input-placeholder input[type=password],_:-ms-input-placeholder input[type=tel],_:-ms-input-placeholder input[type=text],_:-ms-input-placeholder input[type=time],_:-ms-input-placeholder input[type=url]{padding-bottom:.125rem}@supports (-ms-ime-align:auto){input[type=date],input[type=datetime-local],input[type=email],input[type=number],input[type=password],input[type=tel],input[type=text],input[type=time],input[type=url]{padding-bottom:0}}.stack-header{font-weight:clr-getTypePropertyValueForDomElement(stackview_text,font-weight);display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:end;-ms-flex-align:end;align-items:flex-end}.stack-header .stack-title{display:block;-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;padding:.25rem 0}.stack-header .stack-actions{display:block;-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto}.stack-header .stack-actions .stack-action{margin:0 0 .25rem .5rem}.stack-header .stack-actions .stack-action.btn{min-width:0;padding:0 .5rem}.stack-header .stack-actions .stack-action.btn-link{margin-right:-.5rem}.stack-view{color:#565656;font-size:.54167rem;font-weight:400;line-height:1rem;letter-spacing:normal;margin-top:0;border:1px solid #ccc;border-radius:.125rem;overflow-y:auto;background-color:#fafafa;word-wrap:break-word;-webkit-mask-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAAGXRFWHRTb2Z0d2FyZQBBZG9iZSBJbWFnZVJlYWR5ccllPAAAAA5JREFUeNpiYGBgAAgwAAAEAAGbA+oJAAAAAElFTkSuQmCC)}.stack-view dd,.stack-view dt{-webkit-margin-start:0;-moz-margin-start:0;margin-inline-start:0;margin-left:0}.stack-view .stack-block{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row wrap;flex-flow:row wrap;border-bottom:1px solid #ddd}.stack-view>.stack-block:last-child,.stack-view>:last-child .stack-block:last-of-type{border-bottom:none;box-shadow:0 1px 0 #ddd}.stack-view .stack-block-changed>.stack-block-label{margin-left:-.375rem}.stack-view .stack-block-changed:before{content:" ";position:relative;width:0;height:0;border-top:.375rem solid #006a91;border-right:.375rem solid transparent}.stack-view .stack-block-content,.stack-view .stack-block-label{padding:.25rem .5rem;background-color:#fafafa}.stack-view .stack-block-label{font-size:.54167rem;font-weight:500;line-height:1rem;letter-spacing:normal;color:#313131;-webkit-box-flex:0;-ms-flex:0 0 40%;flex:0 0 40%;max-width:40%}.stack-view .stack-block-label:before{display:inline-block;content:"";float:left;height:.41667rem;width:.41667rem;margin:.29167rem .25rem 0 0;text-align:center}.stack-view .stack-block-content{color:inherit;-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;width:60%;margin-bottom:0;font-weight:clr-getTypePropertyValueForDomElement(stackview_text,font-weight)}.stack-view .stack-block-content>:first-child{margin-top:0}.stack-view .stack-block-content>:last-child{margin-bottom:0}.stack-view .stack-children{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;width:100%}.stack-view .stack-children .stack-block{border-bottom-color:#eee}.stack-view .stack-children>.stack-block:last-child,.stack-view .stack-children>:last-child .stack-block:last-of-type{border-bottom:none;box-shadow:0 1px 0 #ddd}.stack-view .stack-children .stack-block-content,.stack-view .stack-children .stack-block-label{background-color:#fff}.stack-view .stack-children .stack-block-label{padding-left:1rem}.stack-view .stack-block-expandable>.stack-block-label{cursor:pointer}.stack-view .stack-block-expandable>.stack-block-label:before{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2012%2012%22%3E%0A%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3Cstyle%3E.cls-1%7Bfill%3A%239a9a9a%3B%7D%3C%2Fstyle%3E%0A%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%3Ctitle%3ECaret%3C%2Ftitle%3E%0A%20%20%20%20%3Cpath%20class%3D%22cls-1%22%20d%3D%22M6%2C9L1.2%2C4.2a0.68%2C0.68%2C0%2C0%2C1%2C1-1L6%2C7.08%2C9.84%2C3.24a0.68%2C0.68%2C0%2C1%2C1%2C1%2C1Z%22%2F%3E%0A%3C%2Fsvg%3E%0A");background-repeat:no-repeat;background-size:contain;vertical-align:middle;-webkit-transform:rotate(-90deg);transform:rotate(-90deg)}.stack-view .stack-block-expandable>.stack-block-content,.stack-view .stack-block-expandable>.stack-block-label{transition:background-color .2s ease-in-out,color .2s ease-in-out}.stack-view .stack-block-expandable:hover:not(.stack-block-expanded)>.stack-block-content,.stack-view .stack-block-expandable:hover:not(.stack-block-expanded)>.stack-block-label{background-color:#eee}.stack-view .stack-block-expanded>.stack-block-label:before{-webkit-transform:rotate(0deg);transform:rotate(0deg)}.stack-view .stack-block-expanded>.stack-block-content,.stack-view .stack-block-expanded>.stack-block-label{background-color:#d9e4ea;color:#000}.stack-view .select,.stack-view input[type=date],.stack-view input[type=datetime-local],.stack-view input[type=email],.stack-view input[type=number],.stack-view input[type=password],.stack-view input[type=tel],.stack-view input[type=text],.stack-view input[type=time],.stack-view input[type=url]{display:inline-block;vertical-align:top;margin-right:.5rem;margin-bottom:0}.stack-view .select select,.stack-view input[type=date],.stack-view input[type=datetime-local],.stack-view input[type=email],.stack-view input[type=number],.stack-view input[type=password],.stack-view input[type=tel],.stack-view input[type=text],.stack-view input[type=time],.stack-view input[type=url]{height:1rem}.stack-view .stack-block-expandable>.stack-block-content input[type=date],.stack-view .stack-block-expandable>.stack-block-content input[type=datetime-local],.stack-view .stack-block-expandable>.stack-block-content input[type=email],.stack-view .stack-block-expandable>.stack-block-content input[type=number],.stack-view .stack-block-expandable>.stack-block-content input[type=password],.stack-view .stack-block-expandable>.stack-block-content input[type=tel],.stack-view .stack-block-expandable>.stack-block-content input[type=text],.stack-view .stack-block-expandable>.stack-block-content input[type=time],.stack-view .stack-block-expandable>.stack-block-content input[type=url]{transition:background-size .2s ease,border-bottom-color .2s ease-in-out}.stack-view .stack-block-expandable>.stack-block-content .select select{transition:border-bottom-color .2s ease-in-out}.stack-view .stack-block-expandable>.stack-block-content .select:after{transition:color .2s ease-in-out}.stack-view .stack-block-expanded>.stack-block-content input[type=date],.stack-view .stack-block-expanded>.stack-block-content input[type=datetime-local],.stack-view .stack-block-expanded>.stack-block-content input[type=email],.stack-view .stack-block-expanded>.stack-block-content input[type=number],.stack-view .stack-block-expanded>.stack-block-content input[type=password],.stack-view .stack-block-expanded>.stack-block-content input[type=tel],.stack-view .stack-block-expanded>.stack-block-content input[type=text],.stack-view .stack-block-expanded>.stack-block-content input[type=time],.stack-view .stack-block-expanded>.stack-block-content input[type=url]{border-bottom-color:#737373;background:linear-gradient(180deg,transparent 95%,#007cbb 0) no-repeat;background-size:0 100%;transition:background-size .2s ease}.stack-view .stack-block-expanded>.stack-block-content input[type=date]:focus,.stack-view .stack-block-expanded>.stack-block-content input[type=datetime-local]:focus,.stack-view .stack-block-expanded>.stack-block-content input[type=email]:focus,.stack-view .stack-block-expanded>.stack-block-content input[type=number]:focus,.stack-view .stack-block-expanded>.stack-block-content input[type=password]:focus,.stack-view .stack-block-expanded>.stack-block-content input[type=tel]:focus,.stack-view .stack-block-expanded>.stack-block-content input[type=text]:focus,.stack-view .stack-block-expanded>.stack-block-content input[type=time]:focus,.stack-view .stack-block-expanded>.stack-block-content input[type=url]:focus{border-bottom:1px solid #007cbb;background-size:100% 100%}.stack-view .stack-block-expanded>.stack-block-content .select select{border-bottom-color:#737373}.stack-view .stack-block-expanded>.stack-block-content .select:after{color:#737373}.modal .stack-view{height:55vh;margin-bottom:0}.clr-tree-node{display:block}@-moz-document url-prefix(){.clr-tree-node{overflow-y:hidden}}.clr-tree-node-content-container{-ms-flex-align:center}.clr-tree-node-content-container,.clr-treenode-content{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;align-items:center}.clr-treenode-content{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;-ms-flex-align:center;border-radius:.125rem 0 0 .125rem;line-height:1.25rem}.clr-treenode-content:first-child{padding-left:1.25rem}.clr-treenode-content .clr-icon,.clr-treenode-content clr-icon{height:.66667rem;width:.66667rem;margin-right:.25rem;vertical-align:middle}.clr-treenode-caret{-webkit-box-flex:0;-ms-flex:0 0 1.25rem;flex:0 0 1.25rem;padding:0;margin:0;height:1.25rem;width:1.25rem;background:none;border:none;color:#9a9a9a;cursor:pointer;outline-offset:-5px}.clr-treenode-caret:hover{color:#000}.clr-tree-node-caret-icon{height:.66667rem;width:.66667rem;vertical-align:middle}.clr-treenode-spinner-container{height:1.25rem;width:1.25rem;padding:.29167rem}.clr-treenode-spinner{min-height:.66667rem;min-width:.66667rem;height:.66667rem;width:.66667rem}.clr-treenode-children{margin-left:.875rem}.clr-treenode-link{display:inline-block;height:100%;margin:0;padding:0 0 0 .25rem;width:100%;background:transparent;border-radius:.125rem 0 0 .125rem;border-color:transparent;color:#565656;cursor:pointer;line-height:inherit;text-align:left}.clr-treenode-link:active,.clr-treenode-link:hover,.clr-treenode-link:link,.clr-treenode-link:visited{color:inherit}.clr-treenode-link:focus,.clr-treenode-link:hover{background:#eee;text-decoration:none}.clr-treenode-link:focus{outline:0}.clr-treenode-link.active{background:#d9e4ea;color:#000}.clr-treenode-checkbox{height:1.25rem;width:1.25rem;padding-top:.125rem;padding-left:.29167rem}.clr-treenode-checkbox:first-child{margin-left:1.25rem}.clr-treenode-content .label{margin-left:.25rem}@supports (-ms-ime-align:auto){.clr-treenode-content .label{margin-left:.125rem}}:root .clr-treenode-content .label,_:-ms-input-placeholder .clr-treenode-content .label{margin-left:.125rem}.datagrid{border-collapse:separate;border:1px solid #ccc;border-radius:.125rem;background-color:#fff;color:#565656;margin:0;margin-top:1rem;max-width:100%;width:100%}.datagrid .datagrid-cell,.datagrid .datagrid-column,.datagrid .datagrid-head .datagrid-row-actions{font-size:.54167rem;line-height:.58333rem;border-top:1px solid #eee;padding:.45833rem .5rem .45833rem;text-align:center;vertical-align:top}.datagrid .datagrid-cell.left,.datagrid .datagrid-column.left,.datagrid .datagrid-head .left.datagrid-row-actions{text-align:left}.datagrid .datagrid-cell.left:first-child,.datagrid .datagrid-column.left:first-child,.datagrid .datagrid-head .left.datagrid-row-actions:first-child{padding-left:.25rem}.datagrid .datagrid-column,.datagrid .datagrid-head .datagrid-row-actions{color:#565656;font-size:.45833rem;font-weight:600;letter-spacing:.03em;background-color:#fafafa;vertical-align:bottom;border-bottom:1px solid #ccc;border-top:0 none}.datagrid .datagrid-body .datagrid-row:first-child .datagrid-cell{border-top:0 none}.datagrid .datagrid-body+.datagrid-body{border-top:1px solid #ccc}.datagrid .datagrid-head .datagrid-column:first-child,.datagrid .datagrid-head .datagrid-row-actions:first-child{border-radius:calc(.125rem - 1px) 0 0 0}.datagrid .datagrid-head .datagrid-column:last-child,.datagrid .datagrid-head .datagrid-row-actions:last-child{border-radius:0 calc(.125rem - 1px) 0 0}.datagrid .datagrid-body:last-child .datagrid-row:last-child .datagrid-cell:first-child{border-radius:0 0 0 calc(.125rem - 1px)}.datagrid .datagrid-body:last-child .datagrid-row:last-child .datagrid-cell:last-child{border-radius:0 0 calc(.125rem - 1px) 0}.datagrid-compact .datagrid-cell,.datagrid-compact .datagrid-column,.datagrid-compact .datagrid .datagrid-head .datagrid-row-actions,.datagrid .datagrid-head .datagrid-compact .datagrid-row-actions{padding-top:calc(.20833rem + 1px);padding-bottom:0.20833rem}.datagrid-host{-webkit-box-orient:vertical;-ms-flex-flow:column nowrap;flex-flow:column nowrap}.datagrid-host,.datagrid-overlay-wrapper{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-direction:normal}.datagrid-overlay-wrapper{-webkit-box-orient:horizontal;-ms-flex-direction:row;flex-direction:row;-webkit-box-flex:0;-ms-flex:0 auto;flex:0 auto;width:100%;min-height:100%;overflow-x:auto;overflow-y:hidden}.datagrid-overlay-wrapper .datagrid-spinner{-webkit-box-flex:0;-ms-flex:0 0 100%;flex:0 0 100%;margin-left:-100%;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;-webkit-box-align:center;-ms-flex-align:center;align-items:center;background:hsla(0,0%,100%,.6)}.datagrid-scroll-wrapper{-webkit-box-orient:horizontal;-ms-flex-direction:row;flex-direction:row;min-width:100%}.datagrid,.datagrid-scroll-wrapper{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-direction:normal}.datagrid{-webkit-box-orient:vertical;-ms-flex-flow:column nowrap;flex-flow:column nowrap;min-height:1px}.datagrid-body,.datagrid-cell,.datagrid-column,.datagrid-fixed-column,.datagrid-head,.datagrid-row,.datagrid .datagrid-head .datagrid-row-actions{display:block}.datagrid-table-wrapper{-ms-flex:1 1 auto;flex:1 1 auto;-ms-flex-flow:column nowrap;flex-flow:column nowrap;min-height:1px}.datagrid-body,.datagrid-table-wrapper{-webkit-box-flex:1;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal}.datagrid-body{-ms-flex:1 1 auto;flex:1 1 auto;overflow-y:auto;-ms-overflow-style:-ms-autohiding-scrollbar;-ms-flex-direction:column;flex-direction:column;min-height:3rem}.datagrid-head{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto}.datagrid-row-flex{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row nowrap;flex-flow:row nowrap}.datagrid-column,.datagrid .datagrid-head .datagrid-row-actions{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto}.datagrid-column.datagrid-fixed-width,.datagrid .datagrid-head .datagrid-fixed-width.datagrid-row-actions{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto}.datagrid-column--hidden{display:none}.datagrid-cell{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto}.datagrid-cell.datagrid-fixed-width{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto}.datagrid-cell--hidden{display:none}.datagrid-no-layout{display:block}.datagrid-no-layout .datagrid-body,.datagrid-no-layout .datagrid-cell,.datagrid-no-layout .datagrid-row{display:none}.datagrid-computing-columns-width,.datagrid-no-layout .datagrid-column,.datagrid-no-layout .datagrid-head,.datagrid-no-layout .datagrid-head .datagrid-row,.datagrid-no-layout .datagrid .datagrid-head .datagrid-row-actions,.datagrid .datagrid-head .datagrid-no-layout .datagrid-row-actions{display:block}.datagrid-computing-columns-width .datagrid-body{display:table;table-layout:auto}.datagrid-computing-columns-width .datagrid-head{display:table-header-group}.datagrid-computing-columns-width .datagrid-row{display:table-row-group}.datagrid-computing-columns-width .datagrid-head .datagrid-row,.datagrid-computing-columns-width .datagrid-row-master{display:table-row}.datagrid-computing-columns-width .datagrid-row-detail{display:none}.datagrid-computing-columns-width .datagrid-cell,.datagrid-computing-columns-width .datagrid-column,.datagrid-computing-columns-width .datagrid .datagrid-head .datagrid-row-actions,.datagrid .datagrid-head .datagrid-computing-columns-width .datagrid-row-actions{display:table-cell}.datagrid-computing-columns-width .datagrid-column-separator,.datagrid-computing-columns-width .datagrid-fixed-column,.datagrid-computing-columns-width .datagrid-placeholder-container{display:none}.datagrid{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;width:auto;max-width:none}.datagrid .datagrid-body .datagrid-row:last-child .datagrid-cell:first-child,.datagrid .datagrid-body .datagrid-row:last-child .datagrid-cell:last-child{border-radius:0}.datagrid .datagrid-cell,.datagrid .datagrid-column,.datagrid .datagrid-head .datagrid-row-actions{text-align:left;min-width:4rem}.datagrid .datagrid-cell.datagrid-fixed-column,.datagrid .datagrid-column.datagrid-fixed-column,.datagrid .datagrid-head .datagrid-fixed-column.datagrid-row-actions{-webkit-box-flex:0;-ms-flex:0 0 1.58333rem;flex:0 0 1.58333rem;max-width:1.58333rem;min-width:1.58333rem}.datagrid .datagrid-head{background-color:#fafafa;border-bottom:2px solid #ccc}.datagrid .datagrid-column,.datagrid .datagrid-head .datagrid-row-actions{vertical-align:top;background:none;border-bottom:0}.datagrid .datagrid-column .datagrid-column-title,.datagrid .datagrid-head .datagrid-row-actions .datagrid-column-title{-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;margin:0;padding:0;border:none;border-radius:0;box-shadow:none;background:none;color:#565656;text-align:left;-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;-ms-flex-item-align:start;align-self:flex-start}.datagrid .datagrid-column button.datagrid-column-title:hover,.datagrid .datagrid-head .datagrid-row-actions button.datagrid-column-title:hover{text-decoration:underline;cursor:pointer}.datagrid .datagrid-column .datagrid-column-separator,.datagrid .datagrid-head .datagrid-row-actions .datagrid-column-separator{position:relative;left:.5rem;-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;width:1px;-webkit-box-ordinal-group:101;-ms-flex-order:100;order:100;margin-left:auto}.datagrid .datagrid-column .datagrid-column-separator:after,.datagrid .datagrid-head .datagrid-row-actions .datagrid-column-separator:after{content:"";position:absolute;height:calc(100% + .5rem - 1px);width:.04167rem;top:calc(-.5 * .5rem + 1px);left:0;background-color:#ddd}.datagrid .datagrid-column .datagrid-column-separator .datagrid-column-handle,.datagrid .datagrid-head .datagrid-row-actions .datagrid-column-separator .datagrid-column-handle{-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;margin:0;padding:0;border:none;border-radius:0;box-shadow:none;background:none;display:block;position:absolute;width:calc(.5rem + 1px);right:-.25rem;top:-.25rem;cursor:col-resize;height:calc(100% + .5rem - 1px);z-index:1000}.datagrid .datagrid-column .datagrid-column-separator .datagrid-column-handle-tracker,.datagrid .datagrid-head .datagrid-row-actions .datagrid-column-separator .datagrid-column-handle-tracker{position:absolute;right:0;top:-.5rem;display:none;width:0;height:100vh;border-right:1px dotted #89cbdf}.datagrid .datagrid-column .datagrid-column-separator .exceeded-max,.datagrid .datagrid-head .datagrid-row-actions .datagrid-column-separator .exceeded-max{border-right:1px dotted rgba(230,39,0,.3)}.datagrid .datagrid-column:last-child .datagrid-column-separator,.datagrid .datagrid-head .datagrid-row-actions:last-child .datagrid-column-separator{display:none}.datagrid .datagrid-column .datagrid-column-flex,.datagrid .datagrid-head .datagrid-row-actions .datagrid-column-flex{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto}.datagrid .datagrid-column clr-dg-filter,.datagrid .datagrid-column clr-dg-string-filter,.datagrid .datagrid-head .datagrid-row-actions clr-dg-filter,.datagrid .datagrid-head .datagrid-row-actions clr-dg-string-filter{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-ordinal-group:100;-ms-flex-order:99;order:99;margin-left:auto}.datagrid .datagrid-column.asc,.datagrid .datagrid-column.desc,.datagrid .datagrid-head .asc.datagrid-row-actions,.datagrid .datagrid-head .desc.datagrid-row-actions{font-weight:600}.datagrid .datagrid-column.asc .datagrid-column-flex:after,.datagrid .datagrid-column.desc .datagrid-column-flex:after,.datagrid .datagrid-head .asc.datagrid-row-actions .datagrid-column-flex:after,.datagrid .datagrid-head .desc.datagrid-row-actions .datagrid-column-flex:after{content:"";display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;vertical-align:middle;width:.58333rem;height:.58333rem;margin-left:.25rem;background-repeat:no-repeat;background-size:contain}.datagrid .datagrid-column.asc .datagrid-column-flex:after,.datagrid .datagrid-head .asc.datagrid-row-actions .datagrid-column-flex:after{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg%20id%3D%22Layer_1%22%20data-name%3D%22Layer%201%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%23007cbb%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ctitle%3Eicon%20artboards%20patch%202%20strokecenter%3C%2Ftitle%3E%3Cpath%20class%3D%22cls-1%22%20d%3D%22M8.5%2C3a0.5%2C0.5%2C0%2C0%2C0-.35.15l-3.5%2C3.5a0.5%2C0.5%2C0%2C0%2C0%2C.71.71L8.5%2C4.21l3.15%2C3.15a0.5%2C0.5%2C0%2C0%2C0%2C.71-0.71l-3.5-3.5A0.5%2C0.5%2C0%2C0%2C0%2C8.5%2C3Z%22%2F%3E%3Crect%20class%3D%22cls-1%22%20x%3D%228%22%20y%3D%224%22%20width%3D%221%22%20height%3D%2210%22%2F%3E%3C%2Fsvg%3E")}.datagrid .datagrid-column.desc .datagrid-column-flex:after,.datagrid .datagrid-head .desc.datagrid-row-actions .datagrid-column-flex:after{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg%20version%3D%221.1%22%20id%3D%22Layer_1%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20x%3D%220px%22%20y%3D%220px%22%0A%09%20viewBox%3D%220%200%2016%2016%22%20style%3D%22enable-background%3Anew%200%200%2016%2016%3B%22%20xml%3Aspace%3D%22preserve%22%3E%0A%3Cstyle%20type%3D%22text%2Fcss%22%3E%0A%09.st0%7Bfill%3A%23007cbb%3B%7D%0A%3C%2Fstyle%3E%0A%3Ctitle%3Eicon%20artboards%20patch%202%20strokecenter%3C%2Ftitle%3E%0A%3Cpath%20class%3D%22st0%22%20d%3D%22M8.5%2C13c-0.1%2C0.1-0.3%2C0-0.4-0.1L4.6%2C9.4c-0.2-0.2-0.2-0.5%2C0-0.7s0.5-0.2%2C0.7%2C0l3.1%2C3.1l3.1-3.2%0A%09c0.2-0.2%2C0.5-0.2%2C0.7%2C0s0.2%2C0.5%2C0%2C0.7C12.2%2C9.3%2C8.6%2C12.9%2C8.5%2C13z%22%2F%3E%0A%3Crect%20x%3D%228%22%20y%3D%223%22%20class%3D%22st0%22%20width%3D%221%22%20height%3D%229.3%22%2F%3E%0A%3C%2Fsvg%3E")}.datagrid .datagrid-column .datagrid-filter-toggle,.datagrid .datagrid-head .datagrid-row-actions .datagrid-filter-toggle{-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;margin:0;padding:0;border:none;border-radius:0;box-shadow:none;background:none;cursor:pointer;float:right;vertical-align:middle;width:.58333rem;height:.58333rem;margin:0 .25rem;background-repeat:no-repeat;background-size:contain;background-image:url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%239a9a9a%3Bfill-rule%3Aevenodd%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%227%209.32%203%205.38%203%205%2013%205%2013%205.38%209%209.32%209%2012.21%207%2013.29%207%209.32%22%2F%3E%3C%2Fsvg%3E")}.datagrid .datagrid-column .datagrid-filter-toggle.datagrid-filter-open,.datagrid .datagrid-column .datagrid-filter-toggle:hover,.datagrid .datagrid-head .datagrid-row-actions .datagrid-filter-toggle.datagrid-filter-open,.datagrid .datagrid-head .datagrid-row-actions .datagrid-filter-toggle:hover{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%23007cbb%3Bfill-rule%3Aevenodd%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%227%209.32%203%205.38%203%205%2013%205%2013%205.38%209%209.32%209%2012.21%207%2013.29%207%209.32%22%2F%3E%3C%2Fsvg%3E")}.datagrid .datagrid-column .datagrid-filter-toggle.datagrid-filtered,.datagrid .datagrid-head .datagrid-row-actions .datagrid-filter-toggle.datagrid-filtered{background-image:url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%23007cbb%3Bfill-rule%3Aevenodd%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%227%209.32%203%205.38%203%205%2013%205%2013%205.38%209%209.32%209%2012.21%207%2013.29%207%209.32%22%2F%3E%3C%2Fsvg%3E"),url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3Anone%3Bstroke%3A%23007cbb%3Bstroke-miterlimit%3A10%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Ccircle%20class%3D%22cls-1%22%20cx%3D%228%22%20cy%3D%228%22%20r%3D%227.5%22%2F%3E%3C%2Fsvg%3E")}.datagrid .datagrid-column .datagrid-filter,.datagrid .datagrid-head .datagrid-row-actions .datagrid-filter{position:absolute;top:100%;right:0;margin-top:.2rem;background:#fff;padding:.75rem;border:1px solid #ccc;box-shadow:0 1px 3px hsla(0,0%,45%,.25);border-radius:.125rem;font-weight:400;z-index:1000}.datagrid .datagrid-column .datagrid-filter .datagrid-filter-close-wrapper,.datagrid .datagrid-head .datagrid-row-actions .datagrid-filter .datagrid-filter-close-wrapper{text-align:right}.datagrid .datagrid-column .datagrid-filter .datagrid-filter-close-wrapper .close,.datagrid .datagrid-head .datagrid-row-actions .datagrid-filter .datagrid-filter-close-wrapper .close{float:none}.datagrid .datagrid-column .datagrid-filter .datagrid-filter-apply,.datagrid .datagrid-head .datagrid-row-actions .datagrid-filter .datagrid-filter-apply{margin-bottom:0}.datagrid .datagrid-select .checkbox label,.datagrid .datagrid-select .radio label{display:block;min-height:.54167rem;padding-left:.58333rem}.datagrid .datagrid-select .checkbox label:after,.datagrid .datagrid-select .checkbox label:before,.datagrid .datagrid-select .radio label:after,.datagrid .datagrid-select .radio label:before{top:0}.datagrid .datagrid-foot-select.checkbox{display:block;line-height:inherit;-webkit-box-flex:1;-ms-flex:1 1 1.41667rem;flex:1 1 1.41667rem;margin-right:.083333rem}.datagrid .datagrid-foot-select.checkbox label{color:unset;cursor:default;opacity:1;line-height:inherit}.datagrid .datagrid-foot-select.checkbox input[type=checkbox]+label:after,.datagrid .datagrid-foot-select.checkbox input[type=checkbox]+label:before{top:.333333rem}.datagrid .datagrid-foot-select.checkbox input[type=checkbox]+label:after{border-left-color:#fff;border-bottom-color:#fff}.datagrid .datagrid-row-actions{padding:0}.datagrid .datagrid-row-actions clr-icon{height:.58333rem;vertical-align:bottom}.datagrid .datagrid-signpost-trigger .signpost{margin:-.3rem 0;height:1.03rem}.datagrid .datagrid-signpost-trigger .signpost .signpost-trigger{height:inherit;line-height:1rem}.datagrid .datagrid-expandable-caret{padding:calc(.125rem - 1px) .16667rem .125rem;text-align:center}.datagrid .datagrid-expandable-caret .datagrid-expandable-caret-button{-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;margin:0;padding:0;border:none;border-radius:0;box-shadow:none;background:none;cursor:pointer;height:1.25rem;width:1.25rem}.datagrid .datagrid-expandable-caret .datagrid-expandable-caret-icon{color:#737373;margin-top:.125rem}.datagrid .datagrid-expandable-caret .datagrid-expandable-caret-icon svg{transition:-webkit-transform .2s ease-in-out;transition:transform .2s ease-in-out;transition:transform .2s ease-in-out,-webkit-transform .2s ease-in-out}.datagrid .datagrid-expandable-caret .spinner{margin-top:.25rem}.datagrid .datagrid-expandable-caret.datagrid-column,.datagrid .datagrid-head .datagrid-expandable-caret.datagrid-row-actions{padding:.45833rem .5rem .45833rem}.datagrid .datagrid-body .datagrid-row{border-top:1px solid #ddd;-ms-flex-negative:0;flex-shrink:0}.datagrid .datagrid-body .datagrid-row:first-child{border-top:0}.datagrid .datagrid-body .datagrid-row:hover{background-color:#eee}.datagrid .datagrid-body .datagrid-row.datagrid-selected{color:#000;background-color:#d9e4ea}.datagrid .datagrid-body .datagrid-row .datagrid-action-overflow{position:absolute;background:#fff;padding:.25rem .25rem;margin-left:.25rem;border:1px solid #ccc;box-shadow:0 1px 3px hsla(0,0%,45%,.25);border-radius:.125rem;font-weight:400;z-index:1000;white-space:nowrap}.datagrid .datagrid-body .datagrid-row .datagrid-action-overflow:before{content:"";position:absolute;top:50%;right:100%;width:0;height:0;margin-top:-.25rem;border-right:.25rem solid #ccc;border-top:.25rem solid transparent;border-bottom:.25rem solid transparent}.datagrid .datagrid-body .datagrid-row .datagrid-action-overflow:after{content:"";position:absolute;top:50%;right:100%;width:0;height:0;margin-top:-.20833rem;border-right:.20833rem solid #fff;border-top:.20833rem solid transparent;border-bottom:.20833rem solid transparent}.datagrid .datagrid-body .datagrid-row .datagrid-action-overflow .action-item{font-size:.58333rem;letter-spacing:normal;background:transparent;border:0;color:#565656;cursor:pointer;display:block;line-height:calc(1rem - 1px);margin:0;padding:1px 1rem 0;text-align:left;width:100%}.datagrid .datagrid-body .datagrid-row .datagrid-action-overflow .action-item:focus,.datagrid .datagrid-body .datagrid-row .datagrid-action-overflow .action-item:hover{text-decoration:none;background-color:#eee}.datagrid .datagrid-body .datagrid-row .datagrid-action-overflow .action-item.active{background:#eee;color:#000}.datagrid .datagrid-body .datagrid-row .datagrid-action-overflow .action-item:focus{outline:0}.datagrid .datagrid-body .datagrid-row .datagrid-action-overflow .action-item.disabled,.datagrid .datagrid-body .datagrid-row .datagrid-action-overflow .action-item:disabled{cursor:not-allowed;opacity:.4;-webkit-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}.datagrid .datagrid-body .datagrid-row .datagrid-action-overflow .action-item.disabled:hover,.datagrid .datagrid-body .datagrid-row .datagrid-action-overflow .action-item:disabled:hover{background:none}.datagrid .datagrid-body .datagrid-row .datagrid-action-overflow .action-item.disabled:active,.datagrid .datagrid-body .datagrid-row .datagrid-action-overflow .action-item.disabled:focus,.datagrid .datagrid-body .datagrid-row .datagrid-action-overflow .action-item:disabled:active,.datagrid .datagrid-body .datagrid-row .datagrid-action-overflow .action-item:disabled:focus{background:none;box-shadow:none}.datagrid .datagrid-body .datagrid-row .datagrid-action-overflow .action-item clr-icon{vertical-align:middle;-webkit-transform:translate3d(0,-1px,0);transform:translate3d(0,-1px,0)}.datagrid .datagrid-body .datagrid-row .datagrid-action-toggle{cursor:pointer;color:#565656;border:none;background:none;padding:calc(.375rem - 1px);margin:calc(.125rem - 1px) .16667rem}.datagrid .datagrid-body .datagrid-row.datagrid-selected .datagrid-action-toggle{color:#000}.datagrid .datagrid-body .datagrid-row .datagrid-row-detail .datagrid-cell,.datagrid .datagrid-body .datagrid-row .datagrid-row-detail.datagrid-container{padding-top:0}.datagrid .datagrid-cell{border-top:0}.datagrid .datagrid-container{font-size:.54167rem;padding:.45833rem .5rem .45833rem}.datagrid-placeholder-container{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center}.datagrid-placeholder{background:#fff;border-top:1px solid #ddd;width:100%}.datagrid-placeholder.datagrid-empty{border-top:0;padding:.5rem;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-flow:column nowrap;flex-flow:column nowrap;-webkit-box-align:center;-ms-flex-align:center;align-items:center;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;font-size:.66667rem;color:#9a9a9a}.datagrid-placeholder.datagrid-empty .datagrid-placeholder-image{width:2.5rem;height:2.5rem;margin-bottom:.5rem;background-repeat:no-repeat;background-size:contain;background-position:50%;background-image:url("data:image/svg+xml;charset=utf8,%3Csvg%20viewBox%3D%220%200%2060%2072%22%20version%3D%221.1%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%3E%0A%20%20%20%20%3Cdefs%3E%0A%20%20%20%20%20%20%20%20%3Cellipse%20id%3D%22path-1%22%20cx%3D%2230%22%20cy%3D%2261.7666667%22%20rx%3D%2215.4512904%22%20ry%3D%224.73333333%22%3E%3C%2Fellipse%3E%0A%20%20%20%20%20%20%20%20%3Cmask%20id%3D%22mask-2%22%20maskContentUnits%3D%22userSpaceOnUse%22%20maskUnits%3D%22objectBoundingBox%22%20x%3D%220%22%20y%3D%220%22%20width%3D%2230.9025808%22%20height%3D%229.46666667%22%20fill%3D%22white%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Cuse%20xlink%3Ahref%3D%22%23path-1%22%3E%3C%2Fuse%3E%0A%20%20%20%20%20%20%20%20%3C%2Fmask%3E%0A%20%20%20%20%3C%2Fdefs%3E%0A%20%20%20%20%3Cg%20id%3D%22Page-1%22%20stroke%3D%22none%22%20stroke-width%3D%221%22%20fill%3D%22none%22%20fill-rule%3D%22evenodd%22%3E%0A%20%20%20%20%20%20%20%20%3Cg%20id%3D%22Artboard%22%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Cuse%20id%3D%22Oval-10%22%20stroke%3D%22%23C1DFEF%22%20mask%3D%22url(%23mask-2)%22%20stroke-width%3D%222.8%22%20stroke-linecap%3D%22square%22%20stroke-dasharray%3D%223%2C6%2C3%2C5%22%20xlink%3Ahref%3D%22%23path-1%22%3E%3C%2Fuse%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Cpath%20d%3D%22M38.4613647%2C18.1642456%20L30.9890137%2C34.9141846%20L31%2C47%20L32.5977783%2C46.5167236%20L32.5977783%2C34.9141846%20L51.0673218%2C15.7560425%20C51.0673218%2C15.7560425%2048.6295166%2C16.6542969%2044.9628906%2C17.3392334%20C41.2962646%2C18.0241699%2038.4613647%2C18.1642456%2038.4613647%2C18.1642456%20Z%22%20id%3D%22Path-195%22%20fill%3D%22%23C1DFEF%22%3E%3C%2Fpath%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Cpath%20d%3D%22M4.74639226%2C12.5661855%20L4.62065726%2C12.1605348%20L5.3515414%2C11.1625044%20L5.77622385%2C11.159939%20L6.20936309%2C12.5573481%20L4.74639226%2C12.5661855%20Z%20M6.20936309%2C12.5573481%20L6.32542632%2C12.9317954%20L28.4963855%2C34.8796718%20L28.4963855%2C47.8096691%20L32.6%2C46.4836513%20L32.6%2C34.8992365%20L53.973494%2C12.7035813%20L53.973494%2C12.2688201%20L6.20936309%2C12.5573481%20Z%20M55.373494%2C10.8603376%20L55.373494%2C13.2680664%20L34%2C35.4637216%20L34%2C47.5025401%20L27.0963855%2C49.7333333%20L27.0963855%2C35.4637219%20L5.09179688%2C13.680542%20L4.31325301%2C11.1687764%20L55.373494%2C10.8603376%20Z%22%20id%3D%22Path-149%22%20fill%3D%22%237FBDDD%22%3E%3C%2Fpath%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Cellipse%20id%3D%22Oval-9%22%20fill%3D%22%23FFFFFF%22%20cx%3D%2230%22%20cy%3D%2211.785654%22%20rx%3D%2226%22%20ry%3D%226.78565401%22%3E%3C%2Fellipse%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Cpath%20d%3D%22M30%2C17.171308%20C36.8772177%2C17.171308%2043.3112282%2C16.4610701%2048.0312371%2C15.2292106%20C50.2777611%2C14.6428977%2052.0507619%2C13.9579677%2053.2216231%2C13.2354973%20C54.1938565%2C12.6355886%2054.6%2C12.1175891%2054.6%2C11.785654%20C54.6%2C11.4537189%2054.1938565%2C10.9357194%2053.2216231%2C10.3358107%20C52.0507619%2C9.61334032%2050.2777611%2C8.92841034%2048.0312371%2C8.34209746%20C43.3112282%2C7.11023795%2036.8772177%2C6.4%2030%2C6.4%20C23.1227823%2C6.4%2016.6887718%2C7.11023795%2011.9687629%2C8.34209746%20C9.72223886%2C8.92841034%207.94923814%2C9.61334032%206.77837689%2C10.3358107%20C5.8061435%2C10.9357194%205.4%2C11.4537189%205.4%2C11.785654%20C5.4%2C12.1175891%205.8061435%2C12.6355886%206.77837689%2C13.2354973%20C7.94923814%2C13.9579677%209.72223886%2C14.6428977%2011.9687629%2C15.2292106%20C16.6887718%2C16.4610701%2023.1227823%2C17.171308%2030%2C17.171308%20Z%20M30%2C18.571308%20C15.6405965%2C18.571308%204%2C15.5332672%204%2C11.785654%20C4%2C8.03804078%2015.6405965%2C5%2030%2C5%20C44.3594035%2C5%2056%2C8.03804078%2056%2C11.785654%20C56%2C15.5332672%2044.3594035%2C18.571308%2030%2C18.571308%20Z%22%20id%3D%22Oval-9-Copy%22%20fill%3D%22%237FBDDD%22%3E%3C%2Fpath%3E%0A%20%20%20%20%20%20%20%20%20%20%20%20%3Cpath%20d%3D%22M18.2608643%2C7.14562988%20L22.727356%2C16.9047241%20C22.727356%2C16.9047241%2015.3006592%2C16.3911743%2010.276001%2C14.7511597%20C5.25134277%2C13.111145%205.38031006%2C11.8284302%205.38031006%2C11.6882935%20C5.38031006%2C10.4832831%208.16633152%2C9.41877716%2011.114563%2C8.57324219%20C14.549319%2C7.58817492%2018.2608643%2C7.14562988%2018.2608643%2C7.14562988%20Z%22%20id%3D%22Path-196%22%20fill%3D%22%23C1DFEF%22%3E%3C%2Fpath%3E%0A%20%20%20%20%20%20%20%20%3C%2Fg%3E%0A%20%20%20%20%3C%2Fg%3E%0A%3C%2Fsvg%3E")}.datagrid-action-bar{-webkit-transform:translateY(.5rem);transform:translateY(.5rem)}.datagrid-action-bar,.datagrid-foot{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row nowrap;flex-flow:row nowrap;-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch}.datagrid-foot{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end;height:1.5rem;padding:0 .5rem;line-height:calc(1.5rem - 3px);font-size:.45833rem;background-color:#fafafa;border-top:2px solid #ccc;border-radius:0 0 .125rem .125rem}.datagrid-foot .pagination{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-align:center;-ms-flex-align:center;align-items:center}.datagrid-foot .pagination-description{white-space:nowrap}.datagrid-foot .pagination-list{margin-left:1.5rem;height:calc(1.5rem - 2px)}.datagrid-foot .column-switch-wrapper{-webkit-box-flex:1;-ms-flex:1 1 100%;flex:1 1 100%}.datagrid-foot .column-switch-wrapper.active .column-toggle--action{color:#007cbb}.datagrid-foot .column-switch-wrapper .column-toggle--action{min-width:.75rem;padding-left:0;padding-right:0;color:#9a9a9a}.datagrid-foot .column-switch-wrapper .column-toggle--action:hover{color:#007cbb}.datagrid-foot .column-switch-wrapper .column-switch{border-radius:.125rem;padding:.75rem;background-color:#fff;border:1px solid #ccc;box-shadow:0 1px 3px hsla(0,0%,45%,.25);width:10.416667rem;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column}.datagrid-foot .column-switch-wrapper .column-switch .switch-header{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;font-weight:400;font-size:.66667rem;padding-bottom:.5rem;line-height:1rem}.datagrid-foot .column-switch-wrapper .column-switch .switch-header button{min-width:.75rem;margin:0;padding:0;color:#9a9a9a}.datagrid-foot .column-switch-wrapper .column-switch .switch-header button:hover{color:#007cbb}.datagrid-foot .column-switch-wrapper .column-switch .switch-content{max-height:12.5rem;overflow-y:auto;min-height:calc(1rem + 1px)}.datagrid-foot .column-switch-wrapper .column-switch .switch-content li{line-height:1rem;padding-left:.08333rem}.datagrid-foot .column-switch-wrapper .column-switch .switch-footer .btn{margin:0;padding:0}.datagrid-foot .column-switch-wrapper .column-switch .switch-footer .action-right{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.datagrid-foot-description{display:block;white-space:nowrap}.pagination-list{list-style:none;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-flow:row nowrap;flex-flow:row nowrap;-webkit-box-pack:center;-ms-flex-pack:center;justify-content:center;-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch}.pagination-list>*{padding:0 .1rem;margin-left:.3rem}.pagination-list>:first-child{margin-left:0}.pagination-list .pagination-current{font-weight:600;border-bottom:2px solid #007cbb}.pagination-list .pagination-next,.pagination-list .pagination-previous{display:inline-block;vertical-align:middle;width:.58333rem;height:.58333rem;background-repeat:no-repeat;background-size:contain}.pagination-list .pagination-previous{margin-right:.25rem;background-image:url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cdefs%3E%3Cstyle%3E.cls-3%7Bfill%3A%23737373%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cg%3E%3Cpolygon%20class%3D%22cls-3%22%20points%3D%2210.15%2014.72%203.44%208%2010.15%201.28%2011%202.13%205.14%208%2011%2013.87%2010.15%2014.72%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")}.pagination-list .pagination-next{margin-left:.25rem;background-image:url("data:image/svg+xml;charset=utf8,%3Csvg%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20xmlns%3Axlink%3D%22http%3A%2F%2Fwww.w3.org%2F1999%2Fxlink%22%20viewBox%3D%220%200%2016%2016%22%3E%3Cdefs%3E%3Cstyle%3E.cls-1%7Bfill%3A%23737373%3B%7D%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cg%3E%3Cpolygon%20class%3D%22cls-1%22%20points%3D%225.85%2014.72%2012.56%208%205.85%201.28%205%202.13%2010.86%208%205%2013.87%205.85%2014.72%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E")}.pagination-list button{-webkit-appearance:none;-moz-appearance:none;-ms-appearance:none;-o-appearance:none;margin:0;padding:0;border:none;border-radius:0;box-shadow:none;background:none;color:#737373;cursor:pointer}.datagrid-select .checkbox,.datagrid-select .radio{margin-top:-.04167rem}.datagrid-compact .datagrid-column .datagrid-column-separator:after,.datagrid-compact .datagrid .datagrid-head .datagrid-row-actions .datagrid-column-separator:after,.datagrid .datagrid-head .datagrid-compact .datagrid-row-actions .datagrid-column-separator:after{height:calc(100% + .5 * .5rem - 1px);top:calc(-.25 * .5rem + 1px)}.datagrid-compact .datagrid-column .datagrid-column-separator .datagrid-column-handle-tracker,.datagrid-compact .datagrid .datagrid-head .datagrid-row-actions .datagrid-column-separator .datagrid-column-handle-tracker,.datagrid .datagrid-head .datagrid-compact .datagrid-row-actions .datagrid-column-separator .datagrid-column-handle-tracker{top:-.25rem}.datagrid-compact .datagrid-cell clr-icon{margin-top:calc(-.125rem - 1px);margin-bottom:-.125rem}.datagrid-compact .datagrid-cell .badge{margin-top:-.125rem;margin-bottom:-1px}.datagrid-compact .datagrid-expandable-caret .spinner{margin-top:.125rem}.datagrid-compact .datagrid-expandable-caret .datagrid-expandable-caret-button{height:1rem;width:1rem;outline-offset:-.16667rem}.datagrid-compact .datagrid-expandable-caret .datagrid-expandable-caret-icon{margin:0}.datagrid-compact .datagrid-expandable-caret.datagrid-cell{padding:0}.datagrid-compact .datagrid-expandable-caret.datagrid-column,.datagrid-compact .datagrid .datagrid-head .datagrid-expandable-caret.datagrid-row-actions,.datagrid .datagrid-head .datagrid-compact .datagrid-expandable-caret.datagrid-row-actions{padding-top:calc(.20833rem + 1px);padding-bottom:0.20833rem}.datagrid-compact .datagrid-signpost-trigger .signpost .signpost-trigger clr-icon:not([shape=info-circle]):not([shape=exclamation-triangle]):not([shape=exclamation-circle]):not([shape=check-circle]):not([shape=info]):not([shape=error]){width:.875rem;height:.875rem}.datagrid-compact .datagrid-body .datagrid-row .datagrid-action-toggle{padding-top:calc(.20833rem + 1px);padding-bottom:0.20833rem;margin-top:0;margin-bottom:0;outline-offset:-.16667rem}.datagrid-compact .datagrid-body .datagrid-row .datagrid-action-toggle clr-icon{margin-bottom:0}.datagrid-compact .datagrid-foot{height:1rem;padding:0 .5rem;line-height:calc(1rem - 1px)}.datagrid-compact .datagrid-foot .pagination-list{height:calc(1rem - 2px)}.datagrid-compact .datagrid-foot .column-switch-wrapper .column-toggle--action{margin:0;outline-offset:-.16667rem}.datagrid-compact .datagrid-foot .datagrid-foot-select.checkbox input[type=checkbox]+label:after,.datagrid-compact .datagrid-foot .datagrid-foot-select.checkbox input[type=checkbox]+label:before{top:.11111rem}.datagrid-cell-width-zero{border:0!important;padding:0!important;width:0;-webkit-box-flex:0!important;-ms-flex:0 0 auto!important;flex:0 0 auto!important;min-width:0!important}@supports (-ms-ime-align:auto){.datagrid .datagrid-column .datagrid-filter-toggle,.datagrid .datagrid-head .datagrid-row-actions .datagrid-filter-toggle{width:.59rem;height:.59rem;margin-top:-1px}}.fade{opacity:0;transition:opacity .2s ease-in-out;will-change:opacity}.fade.in{opacity:1}.fadeDown{opacity:0;-webkit-transform:translateY(-25%);transform:translateY(-25%);transition:opacity .2s ease-in-out,-webkit-transform .2s ease-in-out;transition:opacity .2s ease-in-out,transform .2s ease-in-out;transition:opacity .2s ease-in-out,transform .2s ease-in-out,-webkit-transform .2s ease-in-out;will-change:opacity,transform}.fadeDown.in{opacity:1;-webkit-transform:translate(0);transform:translate(0)}@media screen{section[aria-hidden=true]{display:none}}[data-hidden=true]{display:none}button.nav-link{border-radius:0;text-transform:capitalize;min-width:0}.tabs-overflow{position:relative}.tabs-overflow .nav-item{margin-right:0}.clr-wizard .modal-dialog{-ms-flex-direction:column;flex-direction:column;-ms-flex-pack:justify;-webkit-box-align:center;-ms-flex-align:center;align-items:center;box-shadow:0 .04167rem .08333rem .08333rem rgba(0,0,0,.2);height:50%;max-height:100%}.clr-wizard .modal-content,.clr-wizard .modal-dialog{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:vertical;-webkit-box-direction:normal;-webkit-box-pack:justify;justify-content:space-between}.clr-wizard .modal-content{border-radius:0 .125rem .125rem 0;box-shadow:none;padding:0;-webkit-box-flex:2;-ms-flex:2 2 auto;flex:2 2 auto;width:66%;height:100%;overflow:hidden;-ms-flex-pack:justify;-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start;-ms-flex-direction:column;flex-direction:column}.clr-wizard .modal-header{-webkit-box-flex:0;-ms-flex:0 0 auto;flex:0 0 auto;padding:1rem .79167rem .25rem 1rem;-webkit-box-direction:reverse;-ms-flex-direction:row-reverse;flex-direction:row-reverse;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;-webkit-box-align:start;-ms-flex-align:start;align-items:flex-start}.clr-wizard .modal-header,.clr-wizard .modal-title{width:100%;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal}.clr-wizard .modal-title{color:#313131;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.clr-wizard .modal-body{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;color:#565656;width:100%}.clr-wizard .modal-footer{padding:0;display:block;padding-top:1rem;height:3.5rem;min-height:3.5rem;max-height:3.5rem;width:100%;-webkit-box-flex:0;-ms-flex:0 0 3.5rem;flex:0 0 3.5rem}.clr-wizard .clr-wizard-btn{margin:0;max-width:100%;display:block}.clr-wizard .modal-title-text{display:inline-block;-webkit-box-flex:0;-ms-flex:0 1 auto;flex:0 1 auto;width:100%}.clr-wizard .modal-header-actions-wrapper{-webkit-box-flex:1;-ms-flex:1 0 auto;flex:1 0 auto;padding-left:.5rem;padding-right:.166667rem}.clr-wizard .clr-wizard-header-action{width:1rem;height:1rem;padding:0;margin:0;min-width:1rem;line-height:1rem;font-size:1.08333rem;color:#737373;transition:color .2s linear}.clr-wizard .clr-wizard-header-action a{color:#737373}.clr-wizard .clr-wizard-header-action:active,.clr-wizard .clr-wizard-header-action:focus,.clr-wizard .clr-wizard-header-action:hover{color:#313131}.clr-wizard .clr-wizard-header-action clr-icon{height:.91667rem;width:.91667rem}.clr-wizard .clr-wizard-stepnav-wrapper{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;width:34%;max-width:34%;display:block;-webkit-box-ordinal-group:0;-ms-flex-order:-1;order:-1;overflow:hidden;overflow-y:auto;padding-bottom:1rem;line-height:1rem;border-right:1px solid #e4e4e4;height:100%;background-color:#fafafa;border-radius:.125rem 0 0 .125rem}.clr-wizard .clr-wizard-stepnav{padding-left:1rem;display:block;font-size:.583333rem;color:#565656;width:100%}.clr-wizard .clr-wizard-stepnav-list{display:block;box-shadow:none;counter-reset:a;white-space:nowrap;height:auto;list-style-type:none;margin:0;width:100%}.clr-wizard .clr-wizard-stepnav-item{display:block;box-shadow:inset .16667rem 0 0 #eee;margin:0 0 -1px 0;padding:.25rem 0;padding-left:.333333rem;color:#565656;font-weight:400}.clr-wizard .clr-wizard-stepnav-item.active{color:#313131;font-weight:500}.clr-wizard .clr-wizard-stepnav-item.active .clr-wizard-stepnav-link{background-color:#d9e4ea;border-radius:.125rem 0 0 .125rem}.clr-wizard .clr-wizard-stepnav-item.complete{box-shadow:inset .16667rem 0 0 #60b515;transition:box-shadow .2s ease-in}.clr-wizard .clr-wizard-stepnav-item.no-click button{pointer-events:none}.clr-wizard .clr-wizard-stepnav-link{width:100%;display:inline-block;color:inherit;line-height:.666667rem;padding:.416667rem;padding-right:.125rem;font-size:.583333rem;font-weight:inherit;letter-spacing:normal;text-align:left;text-transform:none;margin:0}.clr-wizard .clr-wizard-stepnav-link:before{content:counter(a);counter-increment:a;padding-right:.29167rem;min-width:.625rem}.clr-wizard .clr-wizard-title{color:#313131;margin-top:0;padding-top:1rem;padding-left:1rem;padding-right:.5rem;padding-bottom:1rem}.clr-wizard .modal-content-wrapper{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row;-webkit-box-flex:1;-ms-flex:1 1 100%;flex:1 1 100%;width:100%;height:100%}.clr-wizard .clr-wizard-footer-buttons{text-align:right;padding-right:1rem;margin:0}.clr-wizard .clr-wizard-footer-buttons-wrapper{display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row;-ms-flex-wrap:nowrap;flex-wrap:nowrap;-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end}.clr-wizard .clr-wizard-btn-wrapper{-webkit-box-flex:0;-ms-flex:0 1 auto;flex:0 1 auto;min-width:3.5rem;padding-left:.5rem}.clr-wizard .clr-wizard-btn-wrapper[aria-hidden=true]{display:none}.clr-wizard .clr-wizard-btn.btn-link{padding:0}.clr-wizard .clr-wizard-content{display:block}.clr-wizard .clr-wizard-page:not([aria-hidden=true]){padding:1rem;padding-top:.75rem;display:block}.clr-wizard .modal-dialog{height:75vh}.clr-wizard .modal-body{max-height:100%}.clr-wizard.wizard-md .modal-dialog{min-height:17.5rem;max-height:21rem}.clr-wizard.wizard-md .clr-wizard-stepnav-wrapper,.clr-wizard.wizard-md .modal-content{max-height:21rem}.clr-wizard.wizard-md .clr-wizard-stepnav-wrapper{min-width:9rem;max-width:10rem}.clr-wizard.wizard-lg .modal-dialog{min-height:17.5rem;max-height:30rem}.clr-wizard.wizard-lg .clr-wizard-stepnav-wrapper,.clr-wizard.wizard-lg .modal-content{max-height:30rem}.clr-wizard.wizard-lg .clr-wizard-stepnav-wrapper,.clr-wizard.wizard-lg .nav-panel{min-width:10rem;max-width:12rem}.clr-wizard.wizard-xl .modal-dialog{height:75vh}.clr-wizard.wizard-xl .clr-wizard-stepnav-wrapper,.clr-wizard.wizard-xl .modal-content{max-height:75vh}.clr-wizard.wizard-xl .clr-wizard-stepnav-wrapper,.clr-wizard.wizard-xl .nav-panel{min-width:10rem;max-width:13rem}.clr-wizard .spinner:not(.spinner-inline):not(.clr-treenode-spinner){left:calc(50% + 4.791667rem);position:absolute;top:40%}.clr-wizard-page>:first-child,.clr-wizard-page>:first-child>:first-child{margin-top:0}.clr-wizard-page>form:first-child{padding-top:0}.clr-wizard-page>form:first-child>.form-block:first-child{margin-top:0}.clr-wizard--ghosted .modal-dialog{display:block;box-shadow:none}.clr-wizard--ghosted .modal-outer-wrapper{position:static;display:-webkit-box;display:-ms-flexbox;display:flex;-webkit-box-pack:justify;-ms-flex-pack:justify;justify-content:space-between;-webkit-box-align:center;-ms-flex-align:center;align-items:center;height:100%;max-height:100%;box-shadow:none;-webkit-box-orient:horizontal;-webkit-box-direction:normal;-ms-flex-direction:row;flex-direction:row}.clr-wizard--ghosted .modal-content-wrapper{box-shadow:0 .04167rem .08333rem .08333rem rgba(0,0,0,.2)}.clr-wizard--ghosted .modal-ghost-wrapper{display:block;-webkit-box-flex:0;-ms-flex:0 0 2rem;flex:0 0 2rem;height:100%;position:relative}.clr-wizard--ghosted .modal-ghost{position:absolute;top:1rem;bottom:1rem;background:#bbb;width:1rem;border-radius:0 .125rem .125rem 0;left:-1rem;box-shadow:0 .04167rem .08333rem .08333rem rgba(0,0,0,.2);z-index:-1}.clr-wizard--ghosted .modal-ghost-2{top:2rem;bottom:2rem;background:#9a9a9a;z-index:-2}.clr-wizard--ghosted .modal-dialog,.clr-wizard--ghosted .modal-outer-wrapper{width:26rem}.clr-wizard--ghosted .modal-content-wrapper{width:24rem}.clr-wizard--ghosted.wizard-md .modal-dialog,.clr-wizard--ghosted.wizard-md .modal-outer-wrapper{width:26rem}.clr-wizard--ghosted.wizard-md .modal-outer-wrapper{min-height:17.5rem}.clr-wizard--ghosted.wizard-md .modal-content-wrapper{width:24rem}.clr-wizard--ghosted.wizard-lg .modal-dialog,.clr-wizard--ghosted.wizard-lg .modal-outer-wrapper{width:38rem}.clr-wizard--ghosted.wizard-lg .modal-outer-wrapper{min-height:17.5rem}.clr-wizard--ghosted.wizard-lg .modal-content-wrapper{width:36rem}.clr-wizard--ghosted.wizard-xl .modal-dialog,.clr-wizard--ghosted.wizard-xl .modal-outer-wrapper{width:50rem}.clr-wizard--ghosted.wizard-xl .modal-outer-wrapper{max-height:75vh}.clr-wizard--ghosted.wizard-xl .modal-content-wrapper{width:48rem}.clr-wizard--inline{display:block;width:100%}.clr-wizard--inline>clr-modal>.modal:focus{outline-style:none;outline-color:transparent}.clr-wizard--inline clr-modal{height:100%;width:100%;display:block}.clr-wizard--inline .modal{padding:0;position:static;height:100%;max-height:100%}.clr-wizard--inline .modal .content-container{height:100%}.clr-wizard--inline .modal .content-container .nav-panel{width:99%;height:99%}.clr-wizard--inline .modal .modal-outer-wrapper{-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch;width:100%}.clr-wizard--inline .modal .modal-content{box-shadow:none}.clr-wizard--inline .modal .modal-dialog{min-height:100%;height:100%;width:100%;z-index:auto}.clr-wizard--inline .modal-body{height:100%}.clr-wizard--inline .modal-header .close{display:none}.clr-wizard--inline .nav.navList{padding-top:0}.clr-wizard--inline .modal-dialog .modal-content .modal-body .content-area{overflow-y:auto}.clr-wizard--inline .modal-backdrop{height:0;width:0;display:none}.clr-wizard--inline .modal-content-wrapper{-webkit-box-align:stretch;-ms-flex-align:stretch;align-items:stretch;height:100%}.clr-wizard--inline .modal-ghost-wrapper{display:none}.clr-wizard--inline .clr-wizard-stepnav-wrapper,.clr-wizard--inline.clr-wizard .modal-content{min-height:100%;height:auto;max-height:100%}.clr-wizard--inline .clr-wizard-stepnav-wrapper .clr-wizard-stepnav,.clr-wizard--inline.clr-wizard .modal-content .clr-wizard-stepnav{height:100%}.clr-wizard--no-shadow .modal-content-wrapper,.clr-wizard--no-shadow .modal-dialog{box-shadow:none}.clr-wizard--no-title .clr-wizard-title{display:none}.clr-wizard--no-title .clr-wizard-stepnav{padding-top:1rem}@media screen{.clr-wizard-page[aria-hidden=true]{display:none}}@supports (-ms-ime-align:auto){.clr-wizard .clr-wizard-header-action{margin-top:-.125rem}}@font-face{font-family:Metropolis;src:url(data:application/font-woff;charset=utf-8;base64,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) format("woff");font-weight:200;font-style:normal}@font-face{font-family:Metropolis;src:url(data:application/font-woff;charset=utf-8;base64,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) format("woff");font-weight:400;font-style:normal}@font-face{font-family:Metropolis;src:url(data:application/font-woff;charset=utf-8;base64,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) format("woff");font-weight:500;font-style:normal}@font-face{font-family:Metropolis;src:url(data:application/font-woff;charset=utf-8;base64,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) format("woff");font-weight:600;font-style:normal}.signpost{display:inline-block}.signpost:hover{cursor:pointer}.signpost .signpost-action{min-width:1rem;margin:0;padding:0;color:#9a9a9a;outline:none}.signpost .signpost-action clr-icon{width:1rem;height:1rem}.signpost .signpost-action.active,.signpost .signpost-action:hover{color:#007cbb}.signpost .signpost-content-header button clr-icon{width:.666667rem;height:.666667rem}.signpost-trigger{margin:0;padding:0;display:inline-block}.signpost-content{background-color:transparent;min-width:9rem;max-width:15rem;min-height:2rem;max-height:21rem;display:inline-block;position:relative;z-index:1070}.signpost-content .popover-pointer{width:0;height:0;position:absolute}.signpost-content .popover-pointer:before{content:"";width:0;height:0;position:absolute}.signpost-content.top-left .popover-pointer,.signpost-content.top-middle .popover-pointer,.signpost-content.top-right .popover-pointer{border-top:.5rem solid #9a9a9a;bottom:-.5rem}.signpost-content.top-left .popover-pointer:before,.signpost-content.top-middle .popover-pointer:before,.signpost-content.top-right .popover-pointer:before{border-top:.5rem solid #fff;bottom:2px}.signpost-content.top-left .signpost-flex-wrap{border-bottom-right-radius:0}.signpost-content.top-left .popover-pointer{border-left:.5rem solid transparent;right:-1px}.signpost-content.top-left .popover-pointer:before{border-left:.5rem solid transparent;right:1px}.signpost-content.top-middle .popover-pointer{border-right:.5rem solid transparent;left:50%}.signpost-content.top-middle .popover-pointer:before{border-right:.5rem solid transparent;left:1px}.signpost-content.top-right .signpost-flex-wrap{border-bottom-left-radius:0}.signpost-content.top-right .popover-pointer{border-right:.5rem solid transparent;left:0}.signpost-content.top-right .popover-pointer:before{border-right:.5rem solid transparent;left:1px}.signpost-content.bottom-left .popover-pointer,.signpost-content.bottom-middle .popover-pointer,.signpost-content.bottom-right .popover-pointer{border-bottom:.5rem solid #9a9a9a;top:calc(-.5rem + 1px)}.signpost-content.bottom-left .popover-pointer:before,.signpost-content.bottom-middle .popover-pointer:before,.signpost-content.bottom-right .popover-pointer:before{border-bottom:.5rem solid #fff;top:2px}.signpost-content.bottom-left .signpost-flex-wrap{border-top-right-radius:0}.signpost-content.bottom-left .popover-pointer{border-left:.5rem solid transparent;right:0}.signpost-content.bottom-left .popover-pointer:before{border-left:.5rem solid transparent;right:1px}.signpost-content.bottom-middle .popover-pointer{border-right:.5rem solid transparent;right:50%}.signpost-content.bottom-middle .popover-pointer:before{border-right:.5rem solid transparent;right:calc(-.5rem - 1px)}.signpost-content.bottom-right .signpost-flex-wrap{border-top-left-radius:0}.signpost-content.bottom-right .popover-pointer{border-right:.5rem solid transparent;left:0}.signpost-content.bottom-right .popover-pointer:before{border-right:.5rem solid transparent;left:1px}.signpost-content.left-bottom .popover-pointer,.signpost-content.left-middle .popover-pointer,.signpost-content.left-top .popover-pointer{border-left:.5rem solid #9a9a9a;right:-.5rem}.signpost-content.left-bottom .popover-pointer:before,.signpost-content.left-middle .popover-pointer:before,.signpost-content.left-top .popover-pointer:before{border-left:.5rem solid #fff}.signpost-content.left-top .signpost-flex-wrap{border-bottom-right-radius:0}.signpost-content.left-top .popover-pointer{border-top:.5rem solid transparent;bottom:0}.signpost-content.left-top .popover-pointer:before{border-top:.5rem solid transparent;top:calc(-.5rem - 1px);right:2px}.signpost-content.left-middle .popover-pointer{border-bottom:.5rem solid transparent;top:50%;-webkit-transform:translateY(-50%);transform:translateY(-50%)}.signpost-content.left-middle .popover-pointer:before{border-bottom:.5rem solid transparent;top:1px;left:calc(-.5rem - 2px)}.signpost-content.left-bottom .signpost-flex-wrap{border-top-right-radius:0}.signpost-content.left-bottom .popover-pointer{border-bottom:.5rem solid transparent;top:0}.signpost-content.left-bottom .popover-pointer:before{border-bottom:.5rem solid transparent;top:1px;left:calc(-.5rem - 2px)}.signpost-content.right-bottom .popover-pointer,.signpost-content.right-middle .popover-pointer,.signpost-content.right-top .popover-pointer{border-right:.5rem solid #9a9a9a;left:-.5rem}.signpost-content.right-bottom .popover-pointer:before,.signpost-content.right-middle .popover-pointer:before,.signpost-content.right-top .popover-pointer:before{border-right:.5rem solid #fff;left:2px}.signpost-content.right-top .signpost-flex-wrap{border-bottom-left-radius:0}.signpost-content.right-top .popover-pointer{border-top:.5rem solid transparent;bottom:0}.signpost-content.right-top .popover-pointer:before{border-top:.5rem solid transparent;top:calc(-.5rem - 1px)}.signpost-content.right-middle .popover-pointer{border-bottom:.5rem solid transparent;top:50%;-webkit-transform:translateY(-50%);transform:translateY(-50%)}.signpost-content.right-middle .popover-pointer:before{border-bottom:.5rem solid transparent;top:1px}.signpost-content.right-bottom .signpost-flex-wrap{border-top-left-radius:0}.signpost-content.right-bottom .popover-pointer{border-bottom:.5rem solid transparent;top:0}.signpost-content.right-bottom .popover-pointer:before{border-bottom:.5rem solid transparent;top:1px}.signpost-content-header{-webkit-box-pack:end;-ms-flex-pack:end;justify-content:flex-end;-webkit-box-flex:0;-ms-flex:0 0 1rem;flex:0 0 1rem}.signpost-content-header,.signpost-flex-wrap{display:-webkit-box;display:-ms-flexbox;display:flex}.signpost-flex-wrap{border:1px solid #9a9a9a;border-radius:.125rem;background-color:#fff;-webkit-box-orient:vertical;-webkit-box-direction:normal;-ms-flex-direction:column;flex-direction:column;z-index:1070}.signpost-content-body{-webkit-box-flex:1;-ms-flex:1 1 auto;flex:1 1 auto;padding:0 1rem 1rem;max-height:20rem;overflow-y:auto} + +.disabled { pointer-events: none; opacity: 0.6; } .hide { display: none; } diff --git a/modules/service_template/client/css/index.css b/modules/service_template/client/css/index.css new file mode 100644 index 0000000..17ec535 --- /dev/null +++ b/modules/service_template/client/css/index.css @@ -0,0 +1,6 @@ +.header-icon { + width: 32px; + height: 32px; + margin-top: 12px; + margin-right: 5px; +} diff --git a/modules/service_template/client/icon.svg b/modules/service_template/client/icon.svg new file mode 100644 index 0000000..29f217c --- /dev/null +++ b/modules/service_template/client/icon.svg @@ -0,0 +1,17 @@ + + + + logo + Created with Sketch. + + + + + diff --git a/modules/service_template/client/index.html b/modules/service_template/client/index.html new file mode 100644 index 0000000..e0c8467 --- /dev/null +++ b/modules/service_template/client/index.html @@ -0,0 +1,26 @@ + + + + Example Service + + + +
+
+   + Example Service +
+
+
+
+
+ Loading ... +
+
+
+ It works! +
+ + + + diff --git a/modules/service_template/client/js/app.js b/modules/service_template/client/js/app.js new file mode 100644 index 0000000..3bca428 --- /dev/null +++ b/modules/service_template/client/js/app.js @@ -0,0 +1,48 @@ +'use strict'; +//@include common.js + +var ui = { + loading: dom('#p_loading'), + app: { + self: dom('#p_app') + } +}; + +var utils = { + loading: { + show: function () { ui.loading.classList.remove('hide'); }, + hide: function () { ui.loading.classList.add('hide'); } + } +}; + +var env = {}; + +(function init () { + utils.loading.show(); + ui.app.self.classList.add('hide'); + var cookie = get_cookie(); + env.user = { + username: cookie.service_username, + uuid: cookie.service_uuid + }; + if (!env.user.username || !env.user.uuid) { + window.location = '/login.html'; + return; + } + ajax({ + url: '/api/auth/check', + json: { + username: env.user.username, + uuid: env.user.uuid + } + }, function () { + utils.loading.hide(); + ui.app.self.classList.remove('hide');; + init_app(); + }, function () { + window.location = '/login.html'; + }); +})(); + +function init_app() { +} diff --git a/modules/service_template/client/js/common.js b/modules/service_template/client/js/common.js new file mode 100644 index 0000000..f5760c4 --- /dev/null +++ b/modules/service_template/client/js/common.js @@ -0,0 +1,57 @@ +'use strict'; + +function dom(selector) { + return document.querySelector(selector); +} + +function ajax (options, done_fn, fail_fn) { + var xhr = new XMLHttpRequest(), + payload = null; + xhr.open(options.method || 'POST', options.url + (options.data?uriencode(options.data):''), true); + xhr.addEventListener('readystatechange', function (evt) { + if (evt.target.readyState === 4 /*XMLHttpRequest.DONE*/) { + if (~~(evt.target.status/100) === 2) { + done_fn && done_fn(evt.target.response); + } else { + fail_fn && fail_fn(evt.target.status); + } + } + }); + if (options.json) { + xhr.setRequestHeader("Content-Type", "application/json;charset=UTF-8"); + payload = JSON.stringify(options.json); + } + xhr.send(payload); +} + +function html (url, done_fn, fail_fn) { + var xhr = new XMLHttpRequest(), + payload = null; + xhr.open('GET', url, true); + xhr.addEventListener('readystatechange', function (evt) { + if (evt.target.readyState === 4 /*XMLHttpRequest.DONE*/) { + if (~~(evt.target.status/100) === 2) { + done_fn && done_fn(evt.target.response || ''); + } else { + fail_fn && fail_fn(evt.target.status); + } + } + }); + xhr.send(null); +} + +function get_cookie() { + var items = document.cookie; + var r = {}; + if (!items) return r; + items.split(';').forEach(function (one) { + var p = one.indexOf('='); + if (p < 0) r[one.trim()] = null; + else r[one.substring(0, p).trim()] = one.substring(p+1).trim(); + }); + return r; +} + +function set_cookie(key, value) { + document.cookie = key + '=' + escape(value) + ';domain=example.localhost'; +} diff --git a/modules/service_template/client/js/login.js b/modules/service_template/client/js/login.js new file mode 100644 index 0000000..ea2b9b8 --- /dev/null +++ b/modules/service_template/client/js/login.js @@ -0,0 +1,44 @@ +'use strict'; +//@include common.js + +var ui = { + login: { + type: dom('#login-auth-source-1'), + username: dom('#login_username'), + password: dom('#login_password'), + error: dom('#login_error'), + signin: dom('#btn_signin'), + } +}; + +(function init(){ + var cookie = get_cookie(); + if (cookie.service_username) { + ui.login.username.value = cookie.service_username; + ui.login.password.focus(); + } else { + ui.login.username.focus(); + } + ui.login.error.style.display = 'none'; +})(); + +ui.login.signin.addEventListener('click', function (evt) { + var username = ui.login.username.value; + var password = ui.login.password.value; + ui.login.error.style.display = 'none'; + ajax({ + url: '/api/auth/login', + json: { + username: username, + password: password + } + }, function (response) { + response = JSON.parse(response); + set_cookie('service_username', username); + set_cookie('service_uuid', response.uuid); + window.location = '/'; + }, function (status) { + ui.login.error.style.display = undefined; + ui.login.password.focus(); + }); +}); diff --git a/modules/service_template/client/login.html b/modules/service_template/client/login.html new file mode 100644 index 0000000..8c39cf4 --- /dev/null +++ b/modules/service_template/client/login.html @@ -0,0 +1,34 @@ + + + Example Service + + + + + + + + diff --git a/modules/service_template/config b/modules/service_template/config new file mode 100644 index 0000000..a025fa7 --- /dev/null +++ b/modules/service_template/config @@ -0,0 +1,2 @@ +name=Example Service without Express +port=20180 diff --git a/modules/service_template/package.json b/modules/service_template/package.json new file mode 100644 index 0000000..2fd4888 --- /dev/null +++ b/modules/service_template/package.json @@ -0,0 +1,26 @@ +{ + "name": "bubble", + "version": "1.0.0", + "description": "VMware General Search Engine", + "main": "index.js", + "scripts": { + "test": "echo \"No Test\"" + }, + "repository": { + "type": "git", + "url": "ssh://git@gitlab.eng.vmware.com/jiayil/bubble" + }, + "keywords": [ + "niumbus", + "service" + ], + "author": "Seven Lju", + "license": "MIT", + "dependencies": { + "ldapjs": "^1.0.2", + "ssh2": "^0.6.1", + "uuid": "^3.3.2", + "ws": "^5.2.1" + }, + "devDependencies": {} +} diff --git a/modules/service_template/readme b/modules/service_template/readme new file mode 100644 index 0000000..a8e4045 --- /dev/null +++ b/modules/service_template/readme @@ -0,0 +1,3 @@ +# Example Service without Express +running: 20180 +params: (no params) diff --git a/modules/service_template/server/api.js b/modules/service_template/server/api.js new file mode 100644 index 0000000..1c87e97 --- /dev/null +++ b/modules/service_template/server/api.js @@ -0,0 +1,137 @@ +const i_auth = require('./auth'); +const i_keyval = require('./keyval'); +const i_utils = require('./utils'); + +const env = { + base: __dirname, + debug: !!process.env.EXAMPLE_SERVICE_DEBUG, + auth_internal: false, + admins: process.env.EXAMPLE_SERVICE_ADMINS?process.env.EXAMPLE_SERVICE_ADMINS.split(','):[] +}; + + +const utils = { + check_admin: (username) => { + return env.admins.indexOf(username) >= 0; + }, + require_login: (fn /*req, res, options{json}*/) => { + return (req, res, options) => { + i_utils.Web.read_request_json(req).then( + (json) => { + if (!i_auth.check_login(json.username, json.uuid)) return i_utils.Web.e401(res); + options.json = json; + return fn(req, res, options); + }, + (error) => i_utils.Web.e400(res) + ); + }; + }, + require_admin_login: (fn /*req, res, options{json}*/) => { + return (req, res, options) => { + i_utils.Web.read_request_json(req).then( + (json) => { + if (!i_auth.check_login(json.username, json.uuid)) return i_utils.Web.e401(res); + if (!utils.check_admin(json.username)) return i_utils.Web.e401(res); + options.json = json; + return fn(req, res, options); + }, + (error) => i_utils.Web.e400(res) + ); + }; + } +} + +const api = { + internal: { + keyval: { + set: (req, res, options) => { + let key = options.path[0]; + let val = options.path[1]; + i_keyval.set(key, val); + res.end('ok'); + }, + get: (req, res, options) => { + let key_query = options.path[0]; + let r = {}; + i_keyval.keys(key_query).forEach((key) => { + r[key] = i_keyval.get(key); + }); + res.setHeader('Content-Type', 'application/json'); + res.end(JSON.stringify(r)); + }, + save: (req, res, options) => { + if (!i_keyval.filename) { + return res.end('not supported'); + } + i_keyval.save(i_keyval.filename); + res.end('ok'); + }, + load: (req, res, options) => { + if (!i_keyval.filename) { + return res.end('not supported'); + } + i_keyval.load(i_keyval.filename); + res.end('ok'); + } + }, // keyval + auth: (req, res, options) => { + i_utils.Web.read_request_json(req).then( + (json) => { + i_auth.authenticate(json.username, json.password).then( + (ctx) => res.end(ctx.uuid), + (ctx) => i_utils.Web.e401(res) + ); + }, + (error) => i_utils.Web.e400(res) + ); + } // auth + }, // internal + auth: { + check: (req, res, options) => { + if (req.method !== 'POST') return i_utils.Web.e405(res); + i_utils.Web.read_request_json(req).then( + (json) => { + if (i_auth.check_login(json.username, json.uuid)) { + i_utils.Web.r200(res); + } else { + i_utils.Web.e401(res); + } + }, + (error) => i_utils.Web.e400(res) + ); + }, + login: (req, res, options) => { + if (req.method !== 'POST') return i_utils.Web.e405(res); + i_utils.Web.read_request_json(req).then( + (json) => { + i_auth.authenticate(json.username, json.password).then( + (ctx) => i_utils.Web.rjson(res, { uuid: ctx.uuid }), + (ctx) => i_utils.Web.e401(res) + ); + }, + (error) => i_utils.Web.e400(res) + ); + }, + logout: (req, res, options) => { + if (req.method !== 'POST') return i_utils.Web.e405(res); + i_utils.Web.read_request_json(req).then( + (json) => { + i_auth.clear(json.username, json.uuid); + i_utils.Web.r200(res); + }, + (error) => i_utils.Web.e400(res) + ); + } + } // auth +}; + +if (!env.debug && !env.auth_internal) { + api.internal.keyval.set = utils.require_admin_login(api.internal.keyval.set); + api.internal.keyval.get = utils.require_admin_login(api.internal.keyval.get); + api.internal.keyval.save = utils.require_admin_login(api.internal.keyval.save); + api.internal.keyval.load = utils.require_admin_login(api.internal.keyval.load); + api.internal.auth = utils.require_admin_login(api.internal.auth); + env.auth_internal = true; +} + +module.exports = api; diff --git a/modules/service_template/server/auth.js b/modules/service_template/server/auth.js new file mode 100644 index 0000000..8ae9db3 --- /dev/null +++ b/modules/service_template/server/auth.js @@ -0,0 +1,46 @@ +const i_ldap = require('ldapjs'); +const i_uuid = require('uuid'); +const i_keyval = require('./keyval'); + +const LDAP_SERVER = process.env.EXAMPLE_SERVICE_LDAP_SERVER || 'ldap://example.localhost:3268' + +const api = { + authenticate: (username, password) => { + return new Promise((resolve, reject) => { + let client = i_ldap.createClient({ + url: LDAP_SERVER + }); + client.bind(username + '@example.localhost', password, (error) => { + client.unbind(); + if (error) { + reject({username, error}); + } else { + let keys = i_keyval.keys(`auth.${username}.*`); + Object.keys(keys).forEach((key) => { + i_keyval.set(key, null); + }); + let meta = { + login: new Date().getTime() + }; + let uuid = i_uuid.v4(); + i_keyval.set(keyval_authkey(username, uuid), meta); + resolve({username, uuid}); + } + }); + }); + }, + check_login: (username, uuid) => { + let meta = i_keyval.get(keyval_authkey(username, uuid)); + if (!meta) return null; + return meta; + }, + clear: (username, uuid) => { + return i_keyval.set(keyval_authkey(username, uuid)); + } +}; + +function keyval_authkey(username, uuid) { + return `auth.${username}.${uuid}`; +} + +module.exports = api; diff --git a/modules/service_template/server/index.js b/modules/service_template/server/index.js new file mode 100644 index 0000000..a3804a7 --- /dev/null +++ b/modules/service_template/server/index.js @@ -0,0 +1,85 @@ +const http = require('http'); +const url = require('url'); +const path = require('path'); +const fs = require('fs'); + +const i_ws = require('./websocket'); +const i_utils = require('./utils'); +const i_api = require('./api'); + +function get_ip (req) { + let ip = null; + if (req.headers['x-forwarded-for']) { + ip = req.headers['x-forwarded-for'].split(",")[0]; + } else if (req.connection && req.connection.remoteAddress) { + ip = req.connection.remoteAddress; + } else { + ip = req.ip; + } + return ip; +} + +function route(req, res) { + let r = url.parse(req.url); + let f = router; + let path = r.pathname.split('/'); + let query = {}; + r.query && r.query.split('&').forEach((one) => { + let key, val; + let i = one.indexOf('='); + if (i < 0) { + key = one; + val = ''; + } else { + key = one.substring(0, i); + val = one.substring(i+1); + } + if (key in query) { + if(Array.isArray(query[key])) { + query[key].push(val); + } else { + query[key] = [query[key], val]; + } + } else { + query[key] = val; + } + }); + path.shift(); + while (path.length > 0) { + let key = path.shift(); + f = f[key]; + if (!f) break; + if (typeof(f) === 'function') { + return f(req, res, { + path: path, + query: query + }); + } + } + router.code(req, res, 404, 'Not Found'); +} + +const static_cache = {}; +const router = { + api: i_api, + test: (req, res, options) => { + res.end('hello'); + }, + code: (req, res, code, text) => { + res.writeHead(code || 404, text || ''); + res.end(); + } +}; + +const server = http.createServer((req, res) => { + route(req, res); +}); + +i_ws.init(server, '/ws'); + +const server_port = 20180; +const server_host = '127.0.0.1'; + +const instance = server.listen(server_port, server_host, () => { + console.log(`Matchbox is listening at ${server_host}:${server_port}`); +}); diff --git a/modules/service_template/server/keyval.js b/modules/service_template/server/keyval.js new file mode 100644 index 0000000..8583097 --- /dev/null +++ b/modules/service_template/server/keyval.js @@ -0,0 +1,60 @@ +const i_fs = require('fs'); + +const in_memory = {}; + +const KeyVal = { + filename: process.env.EXAMPLE_SERVICE_KEYVAL_FILENAME || null, + set: (key, value) => { + if (!value) { + delete in_memory[key]; + return null; + } + in_memory[key] = value; + return value; + }, + get: (key) => { + return in_memory[key]; + }, + keys: (query) => { + let regex = keyval_compile(query); + return Object.keys(in_memory).filter((x) => regex.test(x)); + }, + save: (filename) => { + i_fs.writeFileSync(filename, JSON.stringify(in_memory)); + }, + load: (filename) => { + Object.assign(in_memory, JSON.parse(i_fs.readFileSync(filename))); + } +}; + +function keyval_compile(query) { + let last = null; + let regex = []; + for(let i = 0, n = query.length; i < n; i++) { + let ch = query[i]; + if (ch === '?') { + regex.push('.'); + } else if (ch === '*') { + regex.push('.*'); + } else if (ch === '.') { + regex.push('[.]'); + } else if (ch === '\\') { + // only support \* \? \\ + ch = query[i]; + if (ch === '*') { + regex.push('[*]'); + } else if (ch === '?') { + regex.push('[?]'); + } else if (ch === '\\') { + regex.push('\\\\'); + } // else skip + i ++; + } else { + regex.push(ch); + } + } + regex = '^' + regex.join('') + '$'; + return new RegExp(regex); +} + +module.exports = KeyVal; diff --git a/modules/service_template/server/utils.js b/modules/service_template/server/utils.js new file mode 100644 index 0000000..a8f737d --- /dev/null +++ b/modules/service_template/server/utils.js @@ -0,0 +1,164 @@ +const i_fs = require('fs'); +const i_path = require('path'); + +const Storage = { + list_directories: (dir) => { + dir = i_path.resolve(dir); + return i_fs.readdirSync(dir).filter((name) => { + let subdir = path.join(dir, name); + let state = i_fs.lstatSync(subdir); + return state.isDirectory(); + }); + }, + list_files: (dir) => { + dir = i_path.resolve(dir); + let queue = [dir], list = []; + while (queue.length > 0) { + list_dir(queue.shift(), queue, list); + } + return list; + + function list_dir(dir, queue, list) { + i_fs.readdirSync(dir).forEach((name) => { + let filename = i_path.join(dir, name); + let state = i_fs.lstatSync(filename); + if (state.isDirectory()) { + queue.push(filename); + } else { + list.push(filename); + } + }); + } + }, + make_directory: (dir) => { + dir = i_path.resolve(dir); + let parent_dir = i_path.dirname(dir); + let state = true; + if (dir !== parent_dir) { + if (!i_fs.existsSync(parent_dir)) { + state = Storage.make_directory(parent_dir); + } else { + if (!i_fs.lstatSync(parent_dir).isDirectory()) { + state = false; + } + } + if (!state) { + return null; + } + } + if (!i_fs.existsSync(dir)) { + i_fs.mkdirSync(dir); + return dir; + } else if (!i_fs.lstatSync(dir).isDirectory()) { + return null; + } else { + return dir; + } + }, + remove_directory: (dir) => { + if (dir.length < Storage.work_dir.length) { + return false; + } + if (dir.indexOf(Storage.work_dir) !== 0) { + return false; + } + if (!fs.existsSync(dir)) { + return false; + } + fs.readdirSync(dir).forEach(function(file, index){ + var curPath = i_path.join(dir, file); + if (i_fs.lstatSync(curPath).isDirectory()) { + // recurse + Storage.rmtree(curPath); + } else { // delete file + i_fs.unlinkSync(curPath); + } + }); + i_fs.rmdirSync(dir); + return true; + }, + read_file: (filename) => { + return i_fs.readFileSync(filename); + } +}; + +const Mime = { + '.html': 'text/html', + '.css': 'text/css', + '.js': 'text/javascript', + '.svg': 'image/svg+xml', + '.json': 'application/json', + _default: 'text/plain', + lookup: (filename) => { + let ext = i_path.extname(filename); + if (!ext) return Mime._default; + let content_type = Mime[ext]; + if (!content_type) content_type = Mime._default; + return content_type; + } +}; + +const Database = {}; + +const Web = { + get_request_ip: (req) => { + let ip = null; + if (req.headers['x-forwarded-for']) { + ip = req.headers['x-forwarded-for'].split(",")[0]; + } else if (req.connection && req.connection.remoteAddress) { + ip = req.connection.remoteAddress; + } else { + ip = req.ip; + } + return ip; + }, + read_request_json: (req) => { + return new Promise((resolve, reject) => { + let body = []; + req.on('data', (chunk) => { body.push(chunk); }); + req.on('end', () => { + try { + body = JSON.parse(Buffer.concat(body).toString()); + resolve(body); + } catch(e) { + reject(e); + } + }) + }); + }, + rjson: (res, json) => { + res.setHeader('Content-Type', 'application/json'); + res.end(json?JSON.stringify(json):'{}'); + }, + r200: (res, text) => { + res.writeHead(200, text || null); + res.end(); + }, + e400: (res, text) => { + res.writeHead(400, text || 'Bad Request'); + res.end(); + }, + e401: (res, text) => { + res.writeHead(401, text || 'Not Authenticated'); + res.end(); + }, + e403: (res, text) => { + res.writeHead(403, text || 'Forbbiden'); + res.end(); + }, + e404: (res, text) => { + res.writeHead(404, text || 'Not Found'); + res.end(); + }, + e405: (res, text) => { + res.writeHead(405, text || 'Not Allowed'); + res.end(); + } +}; + +module.exports = { + Storage, + Mime, + Database, + Web, +}; diff --git a/modules/service_template/server/websocket.js b/modules/service_template/server/websocket.js new file mode 100644 index 0000000..100fc45 --- /dev/null +++ b/modules/service_template/server/websocket.js @@ -0,0 +1,66 @@ +const i_ws = require('ws'); +const i_auth = require('./auth'); + +const api = { + send_error: (ws, code, text) => { + ws.send(JSON.stringify({error: text, code: code})); + }, + send: (ws, json) => { + ws.send(JSON.stringify(json)); + }, + start_query: (ws, query, env) => {}, + stop_query: (ws, query, env) => {} +}; + +const service = { + server: null, + init: (server, path) => { + service.server = new i_ws.Server({ server, path }); + service.server.on('connection', service.client); + }, + client: (ws, req) => { + let env = { + authenticated: false, + username: null, + uuid: null, + query: null, + query_tasks: [] + }; + setTimeout(() => { + // if no login in 5s, close connection + if (!env.authenticated) { + ws.close(); + } + }, 5000); + ws.on('message', (m) => { + try { + m = JSON.parse(m); + } catch(e) { + api.send_error(400, 'Bad Request'); + return; + } + if (m.cmd === 'auth') { + if (!i_auth.check_login(m.username, m.uuid)) { + api.send_error(401, 'Not Authenticated'); + return; + } + env.authenticated = true; + env.username = m.username; + env.uuid = m.uuid; + return; + } + if (!env.authenticated) { + api.send_error(401, 'Not Authenticated'); + return; + } + switch (m.cmd) { + }; + }); + ws.on('close', () => { + }); + ws.on('error', (error) => { + }); + } +}; + +module.exports = service; diff --git a/modules/template.js b/modules/template.js new file mode 100644 index 0000000..e634994 --- /dev/null +++ b/modules/template.js @@ -0,0 +1,35 @@ +const path = require('path'); +const express = require('express'); +const app = express(); + +const static_dir = path.join(__dirname, 'static'); + +const addr = '0.0.0.0'; +const port = 9090; + +function send_json(res, obj) { + res.setHeader('Content-Type', 'application/json'); + res.send(JSON.stringify(obj)); +} + +function get_ip (req) { + let ip = null; + if (req.headers['x-forwarded-for']) { + ip = req.headers['x-forwarded-for'].split(",")[0]; + } else if (req.connection && req.connection.remoteAddress) { + ip = req.connection.remoteAddress; + } else { + ip = req.ip; + } + return ip; +} + +app.get('/test', (req, res) => { + send_json(res, { ip: get_ip(req), message: 'hello world!' }); +}); + +app.use('/', express.static(static_dir)); + +app.listen(port, addr, () => { + console.log(` is listening at ${addr}:${port}`); +}); diff --git a/modules/tinychat/config b/modules/tinychat/config new file mode 100644 index 0000000..3b6b538 --- /dev/null +++ b/modules/tinychat/config @@ -0,0 +1,2 @@ +name=NodeBase Tiny Chat +port=9091 diff --git a/modules/tinychat/index.js b/modules/tinychat/index.js new file mode 100644 index 0000000..ca24d1d --- /dev/null +++ b/modules/tinychat/index.js @@ -0,0 +1,169 @@ +const http = require('http'); +const url = require('url'); +const mime = require('mime'); +const path = require('path'); +const fs = require('fs'); +const ws = require('ws'); + + +function get_ip (req) { + let ip = null; + if (req.headers['x-forwarded-for']) { + ip = req.headers['x-forwarded-for'].split(",")[0]; + } else if (req.connection && req.connection.remoteAddress) { + ip = req.connection.remoteAddress; + } else { + ip = req.ip; + } + return ip; +} + +function route(req, res) { + let r = url.parse(req.url); + let f = router; + let path = r.pathname.split('/'); + let query = {}; + r.query && r.query.split('&').forEach((one) => { + let key, val; + let i = one.indexOf('='); + if (i < 0) { + key = one; + val = ''; + } else { + key = one.substring(0, i); + val = one.substring(i+1); + } + if (key in query) { + if(Array.isArray(query[key])) { + query[key].push(val); + } else { + query[key] = [query[key], val]; + } + } else { + query[key] = val; + } + }); + path.shift(); + while (path.length > 0) { + let key = path.shift(); + f = f[key]; + if (!f) break; + if (typeof(f) === 'function') { + return f(req, res, { + path: path, + query: query + }); + } + } + router.static(req, res, r.pathname); + // router.code(req, res, 404, 'Not Found'); +} + +const router = { + test: (req, res, options) => { + res.end('hello'); + }, + static: (req, res, filename) => { + if (!filename || filename === '/') { + filename = 'index.html'; + } + filename = filename.split('/'); + if (!filename[0]) filename.shift(); + if (filename.length === 0 || filename.indexOf('..') >= 0) { + return router.code(req, res, 404, 'Not Found'); + } + filename = path.join(__dirname, 'static', ...filename); + if (!fs.existsSync(filename)) { + return router.code(req, res, 404, 'Not Found'); + } + res.setHeader('Content-Type', mime.lookup(filename)); + let buf = fs.readFileSync(filename); + res.end(buf, 'binary'); + }, + code: (req, res, code, text) => { + res.writeHead(code || 404, text || ''); + res.end(); + } +}; + +const server = http.createServer((req, res) => { + route(req, res); +}); +const wssrv = new ws.Server({ + server: server, + path: '/ws' +}); + +function process_message(ws, env, obj) { + let tmp; + switch(obj.cmd) { + case 'name': + if (obj.value in clients) { + ws.send(JSON.stringify({ + error: 'name' + })); + return; + } + clients[obj.value] = { + name: obj.value, + ws: ws + }; + env.name = obj.value; + ws.send(JSON.stringify({ + ack: 'name' + })); + break; + case 'talk': + tmp = { + name: env.name, + message: obj.value + }; + Object.keys(clients).forEach((name) => { + if (name === env.name) return; + clients[name].ws.send(JSON.stringify(tmp)); + }); + ws.send(JSON.stringify({ + ack: 'talk' + })); + break; + } +} + +const clients = {}; +wssrv.on('connection', (client) => { + let env = { + name: null, + timer: null, + }; + client.on('open', () => { + console.log('[debug]', 'connected ...'); + env.timer = setInterval(() => { + client.ping(); + }, 20*1000); + }); + client.on('message', (message) => { + console.log('[debug]', message); + try { + process_message(client, env, JSON.parse(message)); + } catch(e) {} + }); + client.on('close', () => { + if (!env.name) { + console.log('[debug]', env.name + ' closed ...'); + delete clients[env.name]; + clearInterval(env.timer); + } + }); + client.on('error', () => { + if (!env.name) { + console.log('[debug]', env.name + ' error ...'); + delete clients[env.name]; + clearInterval(env.timer); + } + }) +}); + +const instance = server.listen(9091, '0.0.0.0', () => { + console.log(instance.address()); + console.log(`NodeBase TinyBot is listening at 0.0.0.0:9091`); +}); diff --git a/modules/tinychat/package-lock.json b/modules/tinychat/package-lock.json new file mode 100644 index 0000000..190610c --- /dev/null +++ b/modules/tinychat/package-lock.json @@ -0,0 +1,27 @@ +{ + "name": "tinychat", + "version": "0.1.0", + "lockfileVersion": 1, + "requires": true, + "dependencies": { + "async-limiter": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-limiter/-/async-limiter-1.0.0.tgz", + "integrity": "sha512-jp/uFnooOiO+L211eZOoSyzpOITMXx1rBITauYykG3BRYPu8h0UcxsPNB04RR5vo4Tyz3+ay17tR6JVf9qzYWg==" + }, + "safe-buffer": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.1.tgz", + "integrity": "sha512-kKvNJn6Mm93gAczWVJg7wH+wGYWNrDHdWvpUmHyEsgCtIwwo3bqPtV4tR5tuPaUhTOo/kvhVwd8XwwOllGYkbg==" + }, + "ws": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-4.1.0.tgz", + "integrity": "sha512-ZGh/8kF9rrRNffkLFV4AzhvooEclrOH0xaugmqGsIfFgOE/pIz4fMc4Ef+5HSQqTEug2S9JZIWDR47duDSLfaA==", + "requires": { + "async-limiter": "1.0.0", + "safe-buffer": "5.1.1" + } + } + } +} diff --git a/modules/tinychat/package.json b/modules/tinychat/package.json new file mode 100644 index 0000000..cd54ce9 --- /dev/null +++ b/modules/tinychat/package.json @@ -0,0 +1,22 @@ +{ + "name": "tinychat", + "version": "0.1.0", + "description": "Simple Chat Room for Android NodeBase", + "main": "index.js", + "dependencies": { + "ws": "^4.1.0" + }, + "devDependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "module", + "nodebase", + "android", + "communication", + "tinychat" + ], + "author": "Seven Lju", + "license": "MIT" +} diff --git a/modules/tinychat/readme b/modules/tinychat/readme new file mode 100644 index 0000000..ca59201 --- /dev/null +++ b/modules/tinychat/readme @@ -0,0 +1,3 @@ +# NodeBase Tiny Chat +running: 9091 +params: (no params) diff --git a/modules/tinychat/static/index.html b/modules/tinychat/static/index.html new file mode 100644 index 0000000..92c1a91 --- /dev/null +++ b/modules/tinychat/static/index.html @@ -0,0 +1,243 @@ + + + + + + NodeBase Tiny Chat + + + +
NodeBase Tiny Chat
+ +
A tiny chat room for temporary team talking. For example between mobile and laptop.
+
+
Loading ...
+
+ +
+
Welcome
+
+ +
+ +
+
+ +
+
TinyBot
+
+ +
+ +
Message
+
+
+ + + + diff --git a/modules/werewolf/config b/modules/werewolf/config new file mode 100644 index 0000000..f2c8093 --- /dev/null +++ b/modules/werewolf/config @@ -0,0 +1,2 @@ +name=Werewolf First Night Helper +port=9090 diff --git a/modules/werewolf/index.js b/modules/werewolf/index.js new file mode 100644 index 0000000..a989a1f --- /dev/null +++ b/modules/werewolf/index.js @@ -0,0 +1,225 @@ +const path = require('path'); +const express = require('express'); +const app = express(); + +const werewolf = require('./werewolf/werewolf'); + +const static_dir = path.join(__dirname, 'static'); + +let start_one_role = 0, + werewolf_role = []; + +function ip_decode(ip) { + return ip?ip.split('-').join('.'):ip; +} + +function fake_act(delay) { + setTimeout(() => { + start_one_role = 0; + }, delay); +} + +function send_json(res, obj) { + res.setHeader('Content-Type', 'application/json'); + res.send(JSON.stringify(obj)); +} + +function get_ip (req) { + let ip = null; + if (req.headers['x-forwarded-for']) { + ip = req.headers['x-forwarded-for'].split(",")[0]; + } else if (req.connection && req.connection.remoteAddress) { + ip = req.connection.remoteAddress; + } else { + ip = req.ip; + } + return ip; +} + +app.get('/test', (req, res) => { + res.send('hello world! ' + get_ip(req)); +}); + +app.post('/api/info', (req, res) => { + let ip = get_ip(req), + p = werewolf.player_get_obj(ip), + s = werewolf.state_get(), + m = ''; + if (!p) { + if (s.cur === ' ') { + p = werewolf.player_register(ip, ''); + } else { + p = {name: '本局已经开始'}; + } + } else { + m = werewolf.player_get_info(ip); + } + send_json(res, {name: p?p.name:'', role: p?p.role:null, info: m}); +}); + +app.post('/api/player/register', (req, res) => { + let ip = get_ip(req), + p = werewolf.player_get_obj(ip), + s = werewolf.state_get(); + if (p) { + if (s.cur !== ' ') { + // should not change role after game starting + req.query.role = p.role; + } + werewolf.player_register(ip, req.query.name, req.query.role); + send_json(res, {}); + } else { + res.sendStatus(400); + } +}); + +app.post('/api/player/unregister', (req, res) => { + let ip = ip_decode(req.query.ip) || get_ip(req); + werewolf.player_unregister(ip); + send_json(res, {}); +}); + +app.post('/api/player/alive', (req, res) => { + let ip = ip_decode(req.query.ip) || get_ip(req), + p = werewolf.player_get_obj(ip); + if (p) { + if (parseInt(req.query.alive, 10) === 1) p.alive = true; + else p.alive = false; + } + send_json(res, {}); +}); + +app.post('/api/werewolf/config', (req, res) => { + let config = req.query; + for (let role in config) { + config[role] = parseInt(config[role], 10); + } + werewolf.config_set(Object.assign({}, config)); + send_json(res, {}); +}); + +app.post('/api/werewolf/state', (req, res) => { + let state = req.query.state || null, + s = null; + if (state) { + werewolf.state_set(state); + if (!werewolf.player_find('role', state)) { + fake_act(~~(Math.random()*7000+3000)); // 3~10s + } + start_one_role = 1; + s = Object.assign({}, werewolf.state_get()); + if (state === ' ') { + werewolf_role = []; + } + } else { + let ip = get_ip(req), + p = werewolf.player_get_obj(ip), + info = werewolf.player_get_info(ip), + state = werewolf.state_get(); + if (!p) { + s = {info: '____', cur: '-'}; + } else if (p.role !== state.cur && state.cur !== ' ') { + s = {info: '你想干嘛 :)', cur: '-'}; + } else if (!p.alive) { + s = {info: '僵尸再见 :)', cur: '-'}; + } else { + s = Object.assign({info}, state); + if (['x', 'i', 'g', 's'].indexOf(s.cur) >= 0) { + if (werewolf_role.indexOf(ip) < 0) werewolf_role.push(ip); + } + } + } + send_json(res, s); +}); + +app.post('/api/werewolf/acting', (req, res) => { + let s = Object.assign({start_one_role}, werewolf.state_get()); + // should not exist here when has T + s.werewolf_count = werewolf_role.length; + send_json(res, s); +}); + +app.post('/api/werewolf/act', (req, res) => { + let id = req.query.id; + ip = get_ip(req), + pact = werewolf.player_get_obj(ip), + state = werewolf.state_get(); + delete req.query.id; + if (!id) { + res.sendStatus(400); + } else if (id !== state.id) { + res.sendStatus(400); + } else if (state.cur !== pact.role) { + res.sendStatus(403); + } else { + let s = Object.assign({}, state), p; + Object.keys(req.query).forEach((x) => { + req.query[x] = ip_decode(req.query[x]); + }); + Object.keys(req.query).forEach((x) => { + werewolf.actions_set(x, req.query[x]); + switch(x) { + case 'see': + if (req.query[x]) { + p = werewolf.player_get_obj(req.query[x]); + // first night, wild child (U) should not be werewolf + s.info = p.name + ' 是' + (['x', 'g', 'i', 's'].indexOf(p.role)>=0?'狼人':'好人'); + } + break; + case 'lover_1': + if (req.query.lover_1 && req.query.lover_2) { + werewolf.actions_set('lovers', [req.query.lover_1, req.query.lover_2].join(',')); + } else { + werewolf.actions_set('lovers', null); + } + break; + } + }); + werewolf.state_set('-'); + start_one_role = 0; + send_json(res, s); + } +}); + +app.post('/api/werewolf/info', (req, res) => { + let ps = werewolf.player_all(), + order = werewolf.player_get_order(), + ips = Object.keys(ps); + if (order.length === ips.length) { + ips = order; + } + ps = ips.map((x) => ps[x]); + send_json(res, {players: ps, config: werewolf.config_get()}); +}); + +app.post('/api/werewolf/reorder', (req, res) => { + let seq = req.query.seq; + if (!seq) { + res.sendStatus(400); + return; + } + seq = seq.split(',').map(ip_decode); + werewolf.player_reorder(seq); + send_json(res, {}); +}); + +app.post('/api/werewolf/bigvote', (req, res) => { + let actions = werewolf.actions_get(), + bigvote = actions.bigvote?actions.bigvote[0]:null; + send_json(res, {bigvote}); +}); + +app.post('/api/werewolf/night', (req, res) => { + let actions = werewolf.actions_get(), + m = werewolf.night_result(); + m += werewolf.info_hunter(); + m += werewolf.info_bear(); + werewolf.state_set('-'); + send_json(res, {info: m}); +}); + +app.use('/', express.static(static_dir)); + +app.listen(9090, '0.0.0.0', () => { + console.log(`Werewolf is listening at 0.0.0.0:9090`); +}); diff --git a/modules/werewolf/package.json b/modules/werewolf/package.json new file mode 100644 index 0000000..07be8bc --- /dev/null +++ b/modules/werewolf/package.json @@ -0,0 +1,25 @@ +{ + "name": "werewolf", + "version": "0.1.0", + "description": "Werewolf app for Android NodeBase", + "main": "index.js", + "dependencies": { + "body-parser": "^1.16.0", + "bootstrap": "^3.3.7", + "express": "^4.14.0", + "uuid": "^3.0.1" + }, + "devDependencies": {}, + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [ + "module", + "nodebase", + "android", + "boardgame", + "werewolf" + ], + "author": "Seven Lju", + "license": "MIT" +} diff --git a/modules/werewolf/readme b/modules/werewolf/readme new file mode 100644 index 0000000..d665d8a --- /dev/null +++ b/modules/werewolf/readme @@ -0,0 +1,4 @@ +# Werewolf Service +- Board Game: Werewolf Judge Helper +running: 9090 +params: (no params) diff --git a/modules/werewolf/static/common.js b/modules/werewolf/static/common.js new file mode 100644 index 0000000..b6b2d19 --- /dev/null +++ b/modules/werewolf/static/common.js @@ -0,0 +1,175 @@ +'use strict'; +function $(id){ + var el = 'string' == typeof id + ? document.getElementById(id) + : id; + + el.on = function(event, fn){ + if ('content loaded' == event) { + event = window.attachEvent ? "load" : "DOMContentLoaded"; + } + el.addEventListener + ? el.addEventListener(event, fn, false) + : el.attachEvent("on" + event, fn); + }; + + el.all = function(selector){ + return $(el.querySelectorAll(selector)); + }; + + el.each = function(fn){ + for (var i = 0, len = el.length; i < len; ++i) { + fn($(el[i]), i); + } + }; + + el.getClasses = function(){ + return this.getAttribute('class').split(/\s+/); + }; + + el.addClass = function(name){ + var classes = this.getAttribute('class'); + el.setAttribute('class', classes + ? classes + ' ' + name + : name); + }; + + el.removeClass = function(name){ + var classes = this.getClasses().filter(function(curr){ + return curr != name; + }); + this.setAttribute('class', classes.join(' ')); + }; + + el.prepend = function (child) { + this.insertBefore(child, this.firstChild); + }; + + el.append = function (child) { + this.appendChild(child); + }; + + el.css = function (name, value) { + this.style[name] = value; + } + + el.click = function () { + var event = new MouseEvent('click', { + view: window, + bubbles: false, + cancelable: true + }); + this.dispatchEvent(event); + }; + + return el; +} + +function uriencode(data) { + if (!data) return data; + return '?' + Object.keys(data).map(function (x) { + return (encodeURIComponent(x) + '=' + encodeURIComponent(data[x]))}).join('&'); +} + +function ajax (options, done_fn, fail_fn) { + var xhr = new XMLHttpRequest(); + xhr.open(options.method || 'POST', options.url + (options.data?uriencode(options.data):''), true); + xhr.addEventListener('readystatechange', function (evt) { + if (evt.target.readyState === 4 /*XMLHttpRequest.DONE*/) { + if (~~(evt.target.status/100) === 2) { + done_fn && done_fn(JSON.parse(evt.target.response || 'null')); + } else { + fail_fn && fail_fn(evt.target.status); + } + } + }); + xhr.send(null); +} + +function green_border(element) { + element.style.border = '1px solid green'; +} +function red_border(element) { + element.style.border = '1px solid red'; +} +function clear_border(element) { + element.style.border = null; +} + +function clear_element(element) { + while (element.hasChildNodes()) { + element.removeChild(element.lastChild); + } +} + +function generate_players(allow_none, players, sel_element) { + clear_element(sel_element); + if (allow_none) { + var c = document.createElement('option'); + c.value = ''; + c.appendChild(document.createTextNode('<弃权>')); + sel_element.appendChild(c); + } + if (players) { + players.forEach(function (x) { + if (!x) return; + var c = document.createElement('option'); + c.value = x.ip; + c.appendChild(document.createTextNode(x.name)); + sel_element.appendChild(c); + }); + } +} + +function play_sound(element, soundfile) { + // element.innerHTML = '