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
-
-
- Server Auth Token (if required)
-
-
-
- Room Name
-
-
-
- Room Token (optional, for private rooms)
-
-
-
-
Create Room
-
-
-
-
-
-
Existing Rooms
-
Click to open the chat page
-
-
Refresh
-
-
-
-
-
-
-
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...
-
-
-
-
-
-
-
-
-
-
-
-
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
-
-
- Server Auth Token (if required)
-
-
-
- Room Name
-
-
-
- Room Token (optional, for private rooms)
-
-
-
-
Create Room
-
-
-
-
-
-
Existing Rooms
-
Click to open the file sharing page
-
-
Refresh
-
-
-
-
-
-
-
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
-
- Room UUID
-
-
-
- Room Token (if required)
-
-
-
Join Room
-
-
-
-
-
-
-
Share a File
-
-
-
Click to select a file
-
File stays on your device until downloaded
-
-
-
-
Connected Users
-
No other users yet
-
-
-
-
All Available Files
-
-
-
-
-
-
-
-
-
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 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/app/src/main/res/layout/fragment_installed_services.xml b/android/app/src/main/res/layout/fragment_installed_services.xml
deleted file mode 100644
index 8812edb..0000000
--- a/android/app/src/main/res/layout/fragment_installed_services.xml
+++ /dev/null
@@ -1,53 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/app/src/main/res/layout/fragment_marketplace.xml b/android/app/src/main/res/layout/fragment_marketplace.xml
deleted file mode 100644
index 14a7df8..0000000
--- a/android/app/src/main/res/layout/fragment_marketplace.xml
+++ /dev/null
@@ -1,76 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/app/src/main/res/layout/fragment_running_instances.xml b/android/app/src/main/res/layout/fragment_running_instances.xml
deleted file mode 100644
index 558e86e..0000000
--- a/android/app/src/main/res/layout/fragment_running_instances.xml
+++ /dev/null
@@ -1,40 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/app/src/main/res/layout/fragment_status.xml b/android/app/src/main/res/layout/fragment_status.xml
deleted file mode 100644
index cfe79e3..0000000
--- a/android/app/src/main/res/layout/fragment_status.xml
+++ /dev/null
@@ -1,64 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/app/src/main/res/layout/item_installed_service.xml b/android/app/src/main/res/layout/item_installed_service.xml
deleted file mode 100644
index c49a176..0000000
--- a/android/app/src/main/res/layout/item_installed_service.xml
+++ /dev/null
@@ -1,78 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/app/src/main/res/layout/item_instance.xml b/android/app/src/main/res/layout/item_instance.xml
deleted file mode 100644
index 3568f3a..0000000
--- a/android/app/src/main/res/layout/item_instance.xml
+++ /dev/null
@@ -1,77 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/app/src/main/res/layout/item_marketplace_service.xml b/android/app/src/main/res/layout/item_marketplace_service.xml
deleted file mode 100644
index 8b28d6f..0000000
--- a/android/app/src/main/res/layout/item_marketplace_service.xml
+++ /dev/null
@@ -1,54 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/app/src/main/res/layout/item_service.xml b/android/app/src/main/res/layout/item_service.xml
deleted file mode 100644
index 3d161d9..0000000
--- a/android/app/src/main/res/layout/item_service.xml
+++ /dev/null
@@ -1,53 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/android/app/src/main/res/mipmap/ic_launcher.png b/android/app/src/main/res/mipmap/ic_launcher.png
deleted file mode 100644
index 3a6aa2a..0000000
Binary files a/android/app/src/main/res/mipmap/ic_launcher.png and /dev/null differ
diff --git a/android/app/src/main/res/values/colors.xml b/android/app/src/main/res/values/colors.xml
deleted file mode 100644
index ca1931b..0000000
--- a/android/app/src/main/res/values/colors.xml
+++ /dev/null
@@ -1,10 +0,0 @@
-
-
- #FFBB86FC
- #FF6200EE
- #FF3700B3
- #FF03DAC5
- #FF018786
- #FF000000
- #FFFFFFFF
-
diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml
deleted file mode 100644
index ad7e3ef..0000000
--- a/android/app/src/main/res/values/strings.xml
+++ /dev/null
@@ -1,45 +0,0 @@
-
-
- NodeBase
- Status
- Config
- Instances
- Services
- Marketplace
- Server Running
- Server Stopped
- Start Server
- Stop Server
- Port
- Enable HTTPS
- Running service instances (registered via WebSocket)
- No services registered
- Running Instances
- Active rooms and sessions across all services
- No running instances
- Kick
- Save
- Server Address
- NodeBase Server
- NodeBase HTTP/WebSocket server
- NodeBase Server
- Server is running on port %d
-
-
- Installed Services
- FileShare and Chat are built-in and cannot be uninstalled
- No services installed
- Filter services...
- Enable
- Disable
- Uninstall
- Destroy
-
-
- Marketplace
- Marketplace URL
- http://example.com/wstun/marketplace
- Fetch
- Install
- Enter a marketplace URL and click Fetch
-
diff --git a/android/app/src/main/res/values/themes.xml b/android/app/src/main/res/values/themes.xml
deleted file mode 100644
index cda327a..0000000
--- a/android/app/src/main/res/values/themes.xml
+++ /dev/null
@@ -1,12 +0,0 @@
-
-
-
-
diff --git a/android/app/src/main/res/xml/network_security_config.xml b/android/app/src/main/res/xml/network_security_config.xml
deleted file mode 100644
index 8a76775..0000000
--- a/android/app/src/main/res/xml/network_security_config.xml
+++ /dev/null
@@ -1,9 +0,0 @@
-
-
-
-
-
-
-
-
-
diff --git a/android/build.gradle b/android/build.gradle
deleted file mode 100644
index 3d55f50..0000000
--- a/android/build.gradle
+++ /dev/null
@@ -1,3 +0,0 @@
-plugins {
- id 'com.android.application' version '8.2.0' apply false
-}
diff --git a/android/docs/architecture.md b/android/docs/architecture.md
deleted file mode 100644
index ce59a4f..0000000
--- a/android/docs/architecture.md
+++ /dev/null
@@ -1,178 +0,0 @@
-# WSTun Architecture
-
-## Overview
-
-WSTun is a WebSocket-based relay server that enables real-time communication between services and users without requiring direct connections. The server acts as a central hub that routes messages between different types of clients.
-
-## Core Concepts
-
-### 1. Server
-
-The WSTun server is an HTTP/WebSocket server that:
-- Accepts WebSocket connections from service clients and user clients
-- Routes messages between connected clients
-- Serves static content (HTML, JS) for built-in services
-- Provides REST API for management operations
-
-### 2. Services
-
-A **Service** is a type of application (e.g., `fileshare`, `chat`) that can be hosted on the WSTun server. Services are defined by:
-- A unique name (e.g., `fileshare`)
-- HTML/JS files that implement the service logic
-- Endpoints (e.g., `/service`, `/main`)
-
-Services can be:
-- **Built-in**: Pre-installed with the server (fileshare, chat)
-- **Marketplace**: Installed from external marketplace URLs
-
-### 3. Service Instances (Rooms)
-
-A **Service Instance** is a specific running session of a service. For example, a `fileshare` service can have multiple file-sharing rooms, each being an instance.
-
-Each instance has:
-- **UUID**: Unique identifier (e.g., `a1b2c3d4`)
-- **Name**: Human-readable name (e.g., "Team Files")
-- **Token** (optional): Password for joining
-- **Owner**: The service client that created the instance
-
-### 4. Client Types
-
-#### Service Client (Instance Host)
-- Creates and manages service instances
-- Receives notifications when users join/leave
-- Can kick users from the instance
-- Coordinates data between user clients
-
-#### User Client (Instance Member)
-- Joins an existing service instance
-- Sends/receives data through the instance
-- Can be kicked by the instance host
-
-## Architecture Diagram
-
-```
-┌─────────────────────────────────────────────────────────────────┐
-│ WSTun Server │
-│ ┌───────────────────────────────────────────────────────────┐ │
-│ │ HTTP Handler │ │
-│ │ - Serves static files (HTML, JS) │ │
-│ │ - REST API (/_api/...) │ │
-│ │ - WebSocket upgrade │ │
-│ └───────────────────────────────────────────────────────────┘ │
-│ ┌───────────────────────────────────────────────────────────┐ │
-│ │ WebSocket Handler │ │
-│ │ - Message routing │ │
-│ │ - Instance management │ │
-│ │ - Client registration │ │
-│ └───────────────────────────────────────────────────────────┘ │
-│ ┌───────────────────────────────────────────────────────────┐ │
-│ │ Service Manager │ │
-│ │ - Tracks registered services │ │
-│ │ - Manages service instances │ │
-│ │ - Handles client connections │ │
-│ └───────────────────────────────────────────────────────────┘ │
-└─────────────────────────────────────────────────────────────────┘
- │ │ │
- │ WebSocket │ WebSocket │ WebSocket
- ▼ ▼ ▼
-┌───────────────┐ ┌───────────────┐ ┌───────────────┐
-│ Service Client│ │ User Client │ │ User Client │
-│ (Instance Host) │ (Member) │ │ (Member) │
-│ │ │ │ │ │
-│ /fileshare/ │ │ /fileshare/ │ │ /fileshare/ │
-│ service │ │ main │ │ main │
-└───────────────┘ └───────────────┘ └───────────────┘
-```
-
-## Message Flow
-
-### 1. Instance Creation
-
-```
-Service Client Server
- │ │
- │──── create_instance ─────>│ (name, token, server_token)
- │ │
- │<─── instance_created ─────│ (uuid, name)
- │ │
-```
-
-### 2. User Joining
-
-```
-User Client Server Service Client
- │ │ │
- │──── join_instance ───────>│ (uuid, userId, token) │
- │ │ │
- │ │──── client_connected ───────>│
- │ │ │
- │<──────── ack ─────────────│ (success, instanceName) │
- │ │ │
-```
-
-### 3. Data Exchange
-
-All data exchange happens through the server. The service client acts as a coordinator:
-
-```
-User Client A Server User Client B
- │ │ │
- │──── file_register ───────>│ │
- │ │───(to service client)───────>│
- │ │<──── file_list ──────────────│
- │<──── file_list ───────────│───(broadcast to all)────────>│
- │ │ │
-```
-
-## Authentication
-
-### Server Authentication
-- Optional server-wide token
-- Required for all requests when enabled (except `/libwstun.js`)
-- Passed via `Authorization: Bearer ` header or `?token=` query param
-
-### Instance Authentication
-- Optional per-instance token
-- Set when creating an instance
-- Required for users to join that instance
-
-## API Endpoints
-
-### HTTP Endpoints
-
-| Endpoint | Description |
-|----------|-------------|
-| `/` | Server info page |
-| `/admin` | Service management UI |
-| `/libwstun.js` | Client library |
-| `/{service}/service` | Service controller page |
-| `/{service}/main` | User client page |
-| `/_api/services` | List installed services |
-| `/_api/instances` | List running instances |
-| `/_api/marketplace/*` | Marketplace operations |
-
-### WebSocket Messages
-
-| Message Type | Direction | Description |
-|--------------|-----------|-------------|
-| `create_instance` | Client → Server | Create a new instance |
-| `instance_created` | Server → Client | Instance creation result |
-| `join_instance` | Client → Server | Join an existing instance |
-| `list_instances` | Client → Server | List available instances |
-| `instance_list` | Server → Client | List of instances |
-| `kick_client` | Host → Server | Kick a user from instance |
-| `client_connected` | Server → Host | User joined notification |
-| `client_disconnected` | Server → Host | User left notification |
-
-## Data Storage
-
-- **Server never stores user data**: All files, messages, etc. remain on client devices
-- **Service Manager**: Tracks active connections and instances in memory
-- **Marketplace Service**: Stores installed service manifests and files locally
-
-## Best Practices
-
-1. **Use libwstun.js**: The client library handles all WebSocket communication and authentication
-2. **Instance-scoped data**: Keep data scoped to instances, not globally
-3. **Handle disconnections**: Clean up resources when users disconnect
-4. **Broadcast updates**: Notify all instance members when data changes
diff --git a/android/docs/libwstun.md b/android/docs/libwstun.md
deleted file mode 100644
index a72d5f4..0000000
--- a/android/docs/libwstun.md
+++ /dev/null
@@ -1,366 +0,0 @@
-# libwstun.js - WSTun Client Library
-
-## Overview
-
-`libwstun.js` is the official JavaScript client library for WSTun. It provides a simple API for creating service instances (rooms) and joining them as users.
-
-## Loading the Library
-
-```html
-
-```
-
-The library is served directly by the WSTun server at `/libwstun.js`.
-
-## API Reference
-
-### WSTun Object
-
-The main `WSTun` object provides factory methods and utilities:
-
-```javascript
-WSTun.version // Library version string
-WSTun.generateId() // Generate a unique ID
-WSTun.buildWsUrl(token) // Build WebSocket URL with optional token
-```
-
-### Creating an Instance Host (Service Controller)
-
-Use `WSTun.createInstanceHost()` to create and manage a service instance (room).
-
-```javascript
-const host = WSTun.createInstanceHost({
- service: 'fileshare', // Service name
- name: 'My Room', // Room display name
- serverToken: 'secret123', // Optional: server auth token
- instanceToken: 'roompass', // Optional: token users need to join
-
- // Callbacks
- onOpen: () => { }, // WebSocket connected
- onInstanceCreated: (payload) => { }, // Instance created successfully
- onMessage: (msg) => { }, // Received a message
- onClose: () => { }, // Connection closed
- onError: (err) => { } // Error occurred
-});
-
-host.connect(); // Start connection
-```
-
-#### Instance Host Methods
-
-```javascript
-host.connect() // Connect to server and create instance
-host.disconnect() // Close connection
-host.send(type, payload) // Send a message
-host.broadcast(type, payload) // Broadcast to all users in instance
-host.kickUser(userId) // Kick a user from the instance
-```
-
-#### Instance Created Payload
-
-```javascript
-{
- success: true,
- uuid: 'a1b2c3d4', // Instance UUID
- name: 'My Room' // Instance name
-}
-```
-
-### Creating an Instance Client (User)
-
-Use `WSTun.createInstanceClient()` to join an existing instance.
-
-```javascript
-const client = WSTun.createInstanceClient({
- service: 'fileshare', // Service name
- instanceUuid: 'a1b2c3d4', // Instance UUID to join
- serverToken: 'secret123', // Optional: server auth token
- instanceToken: 'roompass', // Optional: instance access token
- userId: 'user123', // Optional: user ID (auto-generated if not set)
-
- // Callbacks
- onOpen: () => { }, // WebSocket connected
- onJoined: (payload) => { }, // Successfully joined instance
- onMessage: (msg) => { }, // Received a message
- onClose: () => { }, // Connection closed
- onError: (err) => { }, // Error occurred
- onKicked: (payload) => { } // Kicked from instance
-});
-
-client.connect(); // Start connection
-```
-
-#### Instance Client Methods
-
-```javascript
-client.connect() // Connect to server and join instance
-client.disconnect() // Leave instance and close connection
-client.send(type, payload) // Send a message to the instance
-```
-
-#### Joined Payload
-
-```javascript
-{
- success: true,
- instanceUuid: 'a1b2c3d4',
- instanceName: 'My Room'
-}
-```
-
-### Listing Available Instances
-
-Use `WSTun.listInstances()` to get a list of available instances for a service.
-
-```javascript
-try {
- const instances = await WSTun.listInstances('fileshare', serverToken);
- // instances is an array of instance objects
- instances.forEach(inst => {
- console.log(inst.uuid, inst.name, inst.userCount, inst.hasToken);
- });
-} catch (err) {
- console.error('Failed to list instances:', err);
-}
-```
-
-#### Instance Object
-
-```javascript
-{
- uuid: 'a1b2c3d4', // Instance UUID
- name: 'My Room', // Display name
- service: 'fileshare', // Service name
- userCount: 3, // Number of connected users
- hasToken: true, // Whether token is required to join
- createdAt: 1699000000 // Creation timestamp
-}
-```
-
-## Complete Example: File Sharing Service
-
-### Service Controller (index.html)
-
-```html
-
-
-
- FileShare Controller
-
-
- FileShare Room
- Not connected
- Create Room
-
-
-
-
-
-
-```
-
-### 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
- Create Room
-
-
-
-
-
-
-
-```
-
-### 2. Create the User Client (`main.html`)
-
-```html
-
-
-
-
- My Service
-
-
- My Service
- Loading...
-
- Send
-
-
-
-
-
-
-```
-
-### 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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ generateDebugSources
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ 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.
+
+
+
+
+
Local Applications
+
+
+
+
Shared Applications
+
+
+
+
+
+
+
+
+
+
+
+
+
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.
+
+
+
+
+
Local Applications
+
+
+
+
Shared Application
+
+
+
+
+
+
+
+
+
+
+
+
+
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}
+
+ uploading ...
+
+
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
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
+
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
+
+
+
+
+
+
+
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,d09GRgABAAAAAFXwABMAAAAAoOAAAQAAAAAAAAAAAAAAAAAAAAAAAAAAAABGRlRNAAABqAAAABwAAAAcfKTbLEdERUYAAAHEAAAATQAAAGIH1Qf8R1BPUwAAAhQAAAcfAAAOdj+hfXRHU1VCAAAJNAAAACAAAAAgRHZMdU9TLzIAAAlUAAAATgAAAGBoqa3+Y21hcAAACaQAAAJsAAADnndDD7FjdnQgAAAMEAAAADAAAAA8Ed8By2ZwZ20AAAxAAAAGOgAADRZ2ZH12Z2FzcAAAEnwAAAAIAAAACAAAABBnbHlmAAAShAAAODkAAG08sNGyNWhlYWQAAErAAAAANgAAADYLa4YHaGhlYQAASvgAAAAhAAAAJAeEBCBobXR4AABLHAAAAosAAATasng5PmxvY2EAAE2oAAACbwAAAnpyVVfabWF4cAAAUBgAAAAgAAAAIAKRAh5uYW1lAABQOAAAAYUAAANkL+aGSnBvc3QAAFHAAAADoQAABiGXFj2KcHJlcAAAVWQAAACBAAAAjRlQAhB3ZWJmAABV6AAAAAYAAAAG9G1YmAAAAAEAAAAA1CSYugAAAADTwZ2GAAAAANS+pOt42g2MQQqEQBDEEkf0MLPof7ypL/DofXfV/z/AIgRC0TQCLR6cdFRkjVso7HzTv1D4B7m4048DOlopNlv645SeXXLT51sXzSa+W3AF3AAAAHjajVcLbFVVFl37fO5r3wMspXyEUgkhUAhWhjCiCKNxmlpIRxmsBA0YNY4fkM9UZKbGyGcUzUjqxJGKZELQMtpgBUTFqkheCP6IIfgJEiwEK2L9ASoaI8p13X0fcEv7ZuxKV3f3Offcs/de5+3zIADSGIopkMqqmloUwNGDMITlH4GBnfOXunkomXtj3RyUzp0zdw5ng/5oNJ4RseVzafRDGYarx2IYamxLPBos0FUlaEDAh6TnQj4xUH0eJachqAobyB4TsQK/IJSBMNwG58kAaUMGPbizI2E2bA73hvuR5yf8Ju/I7m69n2BAp/8/C/+dd4WOvCNteUf2htk8I83hwXBrhLOf4O/OCF12ivDzsIFZMhjCTA9ntkYQBhWExQWEw1jC4/dEgPFEChcTBZhAFDK3E1mpRYRgKe7nzAcIz4yvoP9FQvASIXiZMHiXcHifcNhPeHxCBPiUCPAZEeAokcI3RAoniEJW7xeuFhJpKZIiFEqxFJNLpIQ8kJVNc+0xVMkwPjOKEN13vGOjO7a6Y6c79qgkClBFFKKaSGMakcF83MUVokgCjSTQSDwexkrObyQK8RhWc/4T+C/nP01ksJFIYRNRgOeIFDYTBXieSOEFogBbiEK0EoXYRqSRJdLYTqSxg0jjdULwJiGanQAHiAw+JuK8GM2L0bw4zYvXvHjNi9O8OM2Lk/7Sn/k6V84lRznyXLWCGRrOGlewtmNZ0/HMzARmZD4W4K+ow51YyFouxTL8A/cxCw8w+o2M6HlW8iVW8F1Wbj8r9ikrdZQ7OaEnq4jvLYnOl7lXz+EbUse4e1JfW6m7hvD7btSqI9H5yDvyJXGsmyeP5T0bX+b+7gu/Dg+ED4UPnT2SO5FNYdPp/75IjHyuzxiUqiZENWFwDWFxLeFwHeExkwioiJWcE6lBVA1G1WDQQgTYQARaadFKi1ZatNKidTU4RFgcJiw6CIufCY+TRCC9pTfr2kf6kPtKX3JUUdGKigySQVzfYDTOITLoTfRQpYsq3ajSbU7pVxBJpTuNJ0jEk1Kl25zSz2jcaVROo0ppVKeUHms8VvcrRH5dR9E61azJRRgp12qcKdWvVf2aXMyRik0u8kjLJhF/UtFWs5CSMXIx3x59elUxwmms17WMayYjaWQkq/A4o3kCT6IJ6xjR04xkA09jK3eb5S53cHeHWIMO7uwkd9CHb+vPtwziimWqZ4Ne2lGKw6W0i9nfLqUlal/KTtQWlJHbmf/tslxWyCOyStbIOlkvm2SLbJXt8pbskg9knxyUw/KVfCs/yknjTNoUmX6m1Aw15abCjDMTzGWmytSYaWaGud7cbGabBWaRuccsMw+aBvOoWW3WmqdMi9lsWs02s8PsNLvNHtNm2k2HOWKOm58sbGB72GI7wJbZYXaUHWMvtBPt5bbaXmlr7XX2BnuLvcPW2b/be+199p/2X7bR/sc+aZvtBvuCfcVm7Rv2Hfue3WsP2EP2C3vM/mB/dsYVuF6uxA10Q9xwN9qNdRe5P7hKN8VNddPdTHeTu83Ncwvd3W6JW+5WuEfcKrfGrXPr3Sa3xW11291bbpf7wO1zB91h95X71v3oTnrn077I9/Olfqgv9xV+nJ/gL/NVvsZP8zP89f5mP9sv8Iv8PX6Zf9A3+Ef9ar/WP+Vb/Gbf6rf5HX6n3+33+Dbf7jv8EX/c/xQgCIIePB31ZiN5iXKlcrVyY8RYrNysnmXKKxM8VblCeZLy3yI2g9WuVa5SHmH4mSrlyhXKkyNGvfKz5tVTtlyiPEn97con1DNKuUl5pHJG+c92NvkZ5frumVU/E2O1+rswpitvVl58hmVWHLva85TfVF7ZleMMqN2Va5VHmOz/Y2o3matsd4ypyi3K9WeY2ctq9v43x/nMdssjE1yf2HMnO1HTJeovV3/SrtQcPqb2rETm4yg62TkV+bP8jWpPjm2dE2smrk4cdazPnJ3zR3Oa1X7WLjql3pzGPlT7/cjO1TTOWKztWHtJf1zZOG/vqb1Ya/SR6vxrnT9V39Wuc/RExLrt5K9Q+0SughtPqzrpj0/QpIRm4ook7efUvjX263w9F2aw+pvUHyttZMKuUs6oJ599XO27Nbqr1N6jdnLl6nAL+XzlZKWSb5ycO93Z38CdZxqcp/dS8N5VzqxEt1PHrj2aXTC6YQf4HW9iKYzDRexhUefuxb49iT096ty99Y5arJ27D/vOFPahGqIvrmKf68dON53ffWYQpdrLB7PrzWLXms+72xC9vY1nR1/D9day8/1Je9/V7H6vskO+hl24lTe6o6jXW+UqfCcej7MTD0KL9tRW7lfkHP1uFkDCWIVLcAt5Obtdht91yhjbKN69L+SuL+cur0QtR99W7e5VPqisZwa7E6yVwF3K65U/1qz1VTuDP/I9t+F2SUmBFEpaMtJTenXd0a99l590AAABAAAACgAcAB4AAURGTFQACAAEAAAAAP//AAAAAAAAeNpjYGZyZ/zCwMrAwtTFFMHAwOANoRnjGEQYzYB8BjYGOGBnQAKh3uF+DA4MCqp/mKX/GzMwMJ9k1FFgYJgMkmNiZVoPpBQYmADwbQq1AAB42rWTWVCOURzGf/+3fREqFPX29mnTRqIURfalyL6UrNmyr9ka6xBDRVLIniSjGRNTU7Yb7rg1Y4y+z5Vb7gwdx1dMM8y4cmbec95zzpznnHme3x9woesLQXSPVOqZOOeuUqzHJYzDjYGUcIs67tJIE8200CYeEiCDJEwGS5wkSaqkS6ZMlRzJk0IpkhIj1XhlvHeJMo+breYT84vlbgVawVaoZbOirGFWunXf5h/5TSl9h8WNHtqPaeOZ+Ep/McUmsZIoKZImGZIl2ZIrBbJBNmvtl8ZbrX3IbDHbzc+WYQVYQVaIU3uolfZLW31UL9Rz9VS1q1b1SDWrh6pJNaoGVa/q1DVVq2pUtapSlapClakzqlSd6HzTmdWZ9P2To9xR4Mh3xNgH2v3sPnYvu5vd6Pja8bnj8IeQd8ldXv2n5m54O5Pgj1sEo/vP+IdG10kXXHV27njgiRfe+OBLL/zoTR/64k8AgfSjPwMIIlhnPEinHopJmE4kHBuDiSCSKKKJYQixxBFPAokMZRhJDCeZEYwkhVRGkUY6oxlDBpmM1cxkMZ4JTGQSk5nCVKYxnRlkk8NMZpHLbOYwl3nMZwELWcRiTVoe+SylgGUsZ4V+/w52sptiDnGc05RTRgXnOUclVVRzkRoucYXL1HKV69zUFP1k9DYNmqV7mqafbRWrtR3RbOBstzfrWaP7XZz47VbhXxy8QD2bWdljZS2bJEaPW9jOMew4JFzzGSlRugIiuKN3HqBplgRdD/HdZ4qcYcSyjb1sZR97OMBBXUv7OcJRvXWYUk5xkte6mnqxTrzEW3zYKH6af88fkM2q6HjaY2DAApKBMIwhjGk9AwPTbiZWBob/IczS/42Zdv//wnSJSfD/l/9+ID4A2s8NsnjarVZpd9NGFJW8ZSMbWWhRS8dMnKbRyKQUggEDQYrtQro4WytBaaU4SfcFutF9X/CveXLac+g3flrvG9kmgYSe9tQf9O7MuzNvm3ljMpQgY92vBEIs3TWGlpcot3rNp1MWzQThtmiu+5QqRH/1Gr1GoyE3rHyejIAMTy62DNPwQtchU5EItx1KKbEp6F6dMtPXWjNmv1dpVChX8fOULgQr1/28zFtNX1C9jqmFwBJUYlQKAhEn7GiTZjDVHgmaY/0cM+/VfQFvmpGg/rofYkawrp/RPKP50AqDILDItINAklH3t4LAobQS2CdTiOBZ1qv7lJUu5aSLOAIyQ4cySsIvsRlnN1zBGvbYSjzgL0iVBqVn81B6oimaMBDPZQsIctkP61a0EvgyyAeCFlZ96CwOrW3foayiHs9uGakkUzkMpSuRcelGlNrYJrMBA5SddahHCXZ1wGvczRgbgneghTBgSrioXe1VrZ4Bw6u4s/lu7vvU3lr0J7uYNlzwEHcoKk0ZcV10vgyLc0rCgpMdL1EdGS0mJgYOWE5TWGVY90PbveiQ0gG1BvrTKLYl88Fs3qFBFadSFdqMFh0aUiAKQYe8q7wcQLoBDfJoBaNBjBwaxjYjOiUCGWjALg15oWiGgoaQNIdG1NKaH2c2F4MpGtyStx0aVUvL/tJqMmnlMT+m5w+r2Bj21v14eBgFjFwatvnM4iS78SH+DOJD5iQqkS7U/ZiTh2jdJurLZmfzEss62Er0vARXgWcCRFKD/zXM7i3VAQWMDWNMIlseGRdbpmnqWo0pIzZSlTWfhqUrKjSAw9cPw6ErQpj/c3TUNIYM122G8eGcTXds6zjSNI7YxmyHJlRsspxEnlkeUXGa5WMqzrB8XMVZlkdVnGNpqbiH5RMq7mX5pIr7WD6jZCfvlAuRYSmKZN7gC+LQ7C7lZFd5M1Hau5TTXeWtRHlMGTRo/4f4nkJ8x+CXQHws84iP5XHEx1IiPpZTiI9lAfGxnEZ8LJ9GfCxnEB9LpURZH1NHwexoKDx2wdOlxNVTfFaLihybHNzCE7gANXFAFWVUktwRH8mwOPq5bmnNSToxG2fNiYqPRsYBPrs7Mw+rTypxWvv7HHhm5WEjuJ37Gud5Y/IPg3+LF2UpPmlOcHCnkAB4vL/DuBVRyaHTqnik7ND8P1Fxghugn0FNjMmCKIoa33zk8kqzWZM1tAofTwQ6K9rBvGlOjCOlJbSoSRoBLYOuWdA06vPsrWZRClFuYr+zeymimOxFGcyAKSjkprGw7O+kRFpYO6np9NHA5Ubai54sNVtWcYW9B+9jyM0seTdSXrgpKe1Fm1CnvMgCDrmRPbgmglto77KKYkpYqCI+CG0F++1jRCYtM4MugSJkcbKyD+2KHTmignYC33rSKu/bQu3PdfIgMJudbudBlpGi810V9Wp9VdbYKFev3E0fB9POsLHmF0UZTy57354U7FenBLkCRld2v+5J8fY71u1KST7bF3Z54nVKFfJfgAdD7pT3IhpFkbNYpRHPr1t4MkU5KMZFcxwX9NIe7YpV36Nd2Hfto1ZcVlSyH2XQVXTWbsI3Pl8I6kAqClqkIlZ4OmQ+m52a8LGUuCxF3LNk10X0HTwhHeK/OMS1/+vcchTcosoSXWjXCckHbR8r6K0lu5OHKkZn7bxsZ6IdSTfoGoKeSC44/l7gLo8V6RTu8/MHzF/Bdub4GJ0GvqroDMQS562CBIsq3tJOpl5QfIRpCfBF1UKzAngJwGTwsmqZeqYOoGeWmVMBWGEOg1XmMFhjDoN1tYOudxnoFSBTo1fVjpnM+UDJXMA8k9E15ml0nXkavcY8jW6wTQ/gdbbJ4A22ySBkmwwi5lQBNpjDoMEcBpvMYbCl/XKBtrVfjN7UfjF6S/vF6G3tF6N3tF+M3tV+MXpP+8XofeT4XLeAH+gRXQT8MIGXAD/ipOvRAkY38Yy2ObcSyJyPNcdscz7B4vPdXT/VI73iswTyis8TyPTb2KdN+CKBTPgygUz4Ctxyd7+v9UjTv0kg079NINO/w8o24fsEMuGHBDLhR3AvdPf7SY80/ecEMv2XBDL9V6xsE35LIBN+TyAT7qidvkyq82fVtal3i9JT9dudd9j5G2UzuiwAAAABAAH//wAPeNq1fQl4ZFWZ6DnnVtWtLanUnqSy1Z6lktpSqeyp7Etl6XRn7e4kvSXppqFp6IVFQBAbBkVRnHEbxUEQB1kaBFqUZRxGBZ49LiMOOo7om3FGHbfnG0Z0JDfvP+fcW3WzNTjf96CTVO79z/affz//f4KMaGE9iT8ueJCALMiJSpAf1aAUakEdaAiVZFw9ne2t6aZkbThQWVrsshYZNAQZY3Var9UrOpNOvzOZ8qeSKZH9FOGj8pQ+oz/pG3iSUj7LMKxBOpnCH5f+Dnf83+6eB3p6Hnigx+f19vT0HOnxnnvgiM97xPvAAw94jxw5NzDwwMpA1d8LP+v1Br3w75ajvoGBwEH4NOBt7/Edub7ZHd111VWPXHXVruiqN+qFfwgRNLr+OvoWOc/WFsz4EMZoASFUmEWECEsaLAhuYVSj0Vg0hUFrkVZ019mTgt8VSjU2JRMup0PnnzlT0qcNh8s8oZCHnJd8P6wuKw2HS8uqEVpfR334XnwDebDIh4wIFQnw/SVExw3AtxthXA+qRMOZAYuOIK1AMEF42YQNhsJsYVGBIIr6BbOR6PXWrAYTYiGjCFVWlJdBK09pSbEbxrdbc/+J5XVYTIp+0Z9mX+kk+0qK7EukL/GbsSvtJyK7Iu+NTEQutx+PXWG/Aj69F56csP3Ne2PvxReezz4C/2Wfzz4K/2WfR4CZ0PoF0kbeQBUoiCKoPlMbqfN5PaXFbqfNZNSLhYhoAW1kBJaFCT4I63Oj0UqrVQBsBXSwx6lQOO1ypxpwqrETp2GvXW4xFHZWYCeCx4XYaWtKNcID0nbmsql3HejI7j1yJLmvderKxdb+8RNXSZdHE3VNv860ZK44KWZ6ivZlh2w/Kp3elZxpFjs6zbtHO63/6Jmbwu4a6wuGFr8031Zd3WD9JsxDiyLrvyNr5CnYXTtgOora0P/KPla8ay5TZ8JaI8Y6LV5FOqQ36PRHkUaDlgimu18AeyAsmUUiCBZh1JN9LAxNopdsggCUtROXzFgU3eKoJ5PcpgUhAEuboe1bzc9nQrFYVZXDgVCsLdba1FgVrWqoDjkqHRWlxXabtQgWUxgqFJ11No7RZAIw6SjEfpzEfoZKv0/ndLiSaNP7Tpx/96mRmtqRWAy+Z6P4vbuk8MTpRDIUjidDeO9ILTzkr6rj9GEijPc1jEfjuxoaJmLxiXo8tTaJ/7IjGu3siDV0SvvqJ+JR+g4g6rvp486GaCeitBNcf53cQL6AvKgeNaHOTFs8Gq6qKPeUmIhRIF6MBDJCaRsvAeu5slpgOIYZoKCGBp+voakh5av3RSJ+neiq04bCOr+PLqoprV4avHM3pd060Y3o2tx8nbY0gNOlkqAvse9013JLdqK8NLqnMTZRPzE91FdXORGNnZT+Mllc2tNU73cMXNGxOpo2+hrnYnPtvQf8Nf2hhmwkkq1vHw3sGeiaDCz3nSIj0XBp2lsaDwcSa691Xz2cmmzKIIQpn6MnyYPIjOyZIipF2PbiUWeAwMRteXkh+qetXrfba7XE8H8se90+n9u7PIxoH63rM7iPPIsKkO6JAi2O1TExk3YDg8CixAevvHK1cWrX5J7G2R/d8q7X9jRNP3Xq1FNTada2Fto2Km1FaMuREE5z/NTumYR2q9DDqemnrj715EzTntfedcuP9rC2KXwCHyDPo4OoOZPaOzHUVF3h1ulhESMCxhqMtBit6rBWS5aAeF1aEEIAemDP7s72WIPPWydSSoSZppvSOhH+9/vC/Dfg9QYcDvHfkgm3C/5nv3FUACxANPFX7NdCDBtZgflvtJswtHe5mZh93mrUEbuj2qrTWQ0ac3GDwW02uw0NxWaNsUijt1Y77ERntJoLKJzRZgg5GWABA7SWaqKOAgboDBlsRgpZgE9YTKUJn7nA7jMKFtFgEQstGr1Wq9dYCvUWvVikMfrsBWZfotRk4ZBikegzAyi8ZaCiAVvNHNTsg5ccluG0Gy0TB2lGJhTK+JFGwJppoG2QFFhAy8D+eAEBaseY4C7SiaV12E81YIpqwiRxPLv/mWf2P4u9zz47//zztL+B9WvQt9F1qBC5M8DKaFChMWCUgI3SGHCAT0Vmu2oLPKFwmTccjh2LxEE9hVO1/uE07asR/QS3407gzuKME9GOpumcYUYIZlREO7N7nd5GrJd+jzv3svX0gp58EcY3UY1Pn4DUw9iNKTGYkDEgABGoteJlskY057QhXv/V+gV8gfwUxrVmCmmnt8LYp/mAVHThkcul+y4nP32Tim5QJk0gO24hz4C4q0SRTA0QH1sxlxIYlDObQFERKNbKoopiF5OLoG/qNJvlHkk1NmC/jzJSBYwk3pbN3jY9fS6bPTednonFZtLp2Xh8Nm3e9+nV1Xv37bt3dfXT+xZ6rx0dvaa395rR0Wt7GQ5g3bgcdLYOeTLFsJUC4GuES3GMLXjUameoE8PppNX/hWtqn9q9QtKjw+9Ym0SsfRQWVQzrKUa1mbDJCMsBaiBkhKGTiT9qdCwgpnOs7kBIIxbXdWFFaovhJlncWTBsT/TNvrbRpqaZUN+Bj2eOdYzM3YuHJfeeb7UsRpu6GxPt5xqXOvqvHfqzRTa2H3BZAWPXoK5Me0kxjOPDmMDoMAWmuBHByzCyZgmIEyYCunOJ8rybsXsNqg4EvIGgKJbALlOhkkxQPQOWmjvRtHl6ooz2fzhX2x041Nqza2o8m+2d3NWenAp1LXy4/3j7SGvHTOdVw+ZMqrcukWlMpfFe3JyMdUbrO6+LTTe177YV7ult3ZfkdOCHb1nAuxGkG1gfZjAy8IhOC2vACGzNFUAdU6kWzajJZCowFQBP2YroXIPeME5aqbXpT1kFfKv0kAmPXXv48PJv75nEX5aG5u95A/dLz8Dq44CfGhijDLVnWmwg9grMBGvAnhlB8B1pQIELAlng2nsjcsAc81ntAT9DDhg6SRkjYphykdiUI8O/ONI1t9KzZ0/vZM+gEX9W+qKuZ6zzaEf3qRHz5UP7xtvahhNVeHXxYiR5qKfvWEuOB7pg3zyoGvWD7QNrtwLRBIuJViOM6ECaaLSCZhXlNKgI81IEQ1kZzK66LOz3QvvSQDCkB4sMybOhwoLyiFvkrAHCOGnL0Rplk48e68xcPXjH7dlze66cbwz0RxqnErjqUIu7NzjT3TJVWDjejT/feLCn+7L25z6x+tkDE+Neb/912bqY9NHqbn/HSEdibJbSHggd4mZ8U5kpA32CGddQeYOZnQ12LVg11FrE3pTXCYLgr6SP4B9LPzpNJhdH1z5J7Ygo4CHF8BAG36Mv0+3QEZCqI6IKA7J00AMGMOwPkw7V1RQJ1anqxmh9Wbgs5KuiqIgYqLjazpCQkaOyotz0M+ihQkJtiZ+f7Zvsbu/rvbKr+8qewZbuXf1nh1Jzk+3tu2ZTvXtjwwH/cHxvrzk229qxz1W8q7l5KlI/lW7d5XLva2+di+KPtsfqO9ob4m2C9JWuhK+pAuOKJl+ii+51GtaYye91RRHssQG4VE+1BqxUCzyqJatA6QIsTxBcWZUSUPaaLzAYCLK9dvGt5hwL2pb+Ii/YRpfOZaIrib+4ZZczbPe72DbX0x0nz2zcZb7z0kfoNrP9pmtgeoLoQCe4qcymMprN0ModKi13qMxmhMxus8tWxJSHTrYgFOVBtlEkZfJPfIuiUaRTyifws5h+JDrws0D/WkVkBen8PmR4HOOn8fseS9ZxOpwhhMwDHeqe0BGwkeypoLMAO9P469L78CkcH/vBzEMPzTK+60ZPEAf+BVCeiHyZSkqv1DdD4BCQaVlME4Hq7yIqpSmn0/+78Xekevb1i/nb5mHMERjTqYwpwJjpepzSOrUj+BSM+XUp9chDD838YOwHdEz3+uv4O7D/TrCW05lGu0FPxRum4lmAgW9S6QkqqLVYo3FrRl0ul9dVFSz2B3V0IjIH56QxMLZzg7/6xO6jfc0zA52Lt3cdaorPpQ84FMzequuO1HVNdV4Vn2mMTLaaT/wg78rS+UXW7xT6yX2Amwn0o4y5ykaMhs4OIoKQzD7mBwcpBYaOQdAYbjJjoxYbZ5EWJLWoxUeRaMJ6UX8Y6XSK30P9K1iG1UC9qxponFQaw2INRsGw+pad6MDDasm1QkgnIN3qzq31enEWgUu9xLqZAD+rpKcHoZ6JnvHhQVhUJhQK20OBUKhALNtiQvhCYRXvJFzutFvkEiFBMa6yLhLMYvcrolTmr97HVq/+8snlR5bTM9Foh7FiVzQ2WtdzWWu8z26eLtK4Dd7y8rR/3yf2Lz+6snDPYsdyyt5ystfeF0zEagdrm6OnVx5evvLLVx24b3HiynQkHKqP7GrsPdVX5+/STbjPtJgqqmqmu6Y/OLP66PL+T+wvryoLeLExticRbYxO1Mfb2f6VwrfvgRwWQZPWZaoNWEPwCFARkJUGtChVbWDwU0tGr9cb9UZumhaDFhWZaxk2YRKRPt5+Eofpv8N3wn/k/NrkT/GA9CXA+6wc0yhCJciHOjKtIIlhBB1wjlZDtNS8kIMrIuYWMB3LU2qzeitLfR5fsctaYiuJVOmZAbpRIHsx06p12GlXPszigZb5RDze0d10oEN6BNc39vQ0vvRq6/Bw66vkfGQ83jjsqdjb0jQdwx9M19U1f1V6qSuR6PoXZg/FQNbeDbxWjhoydaUlJqOGGhWKPHVtCP/Ag3JUFgqGaPhHkycBceuu46b7Dx26f2no1sRgYLGx90x//5nexsXAYOLWIfPB+5eW7jvYkuwLR4auHRi4bigS7ku2wL5QvH2Z2TdO5i3k0MQcYxlNZpPDZnKanaEqLUWPQph1OC0TYB22zv7qxCMHDz5y4lf/OXF9f/87xt9Pzs994vDhT87tz5waHr46IxnY2sGAIX0wnglFMxFFslF7RrMAuqVQbYUy697Ew0tg3tidspyzep2P48ulP8cfkkT8BzL5lcVvL5Lzi2hD/wZUkwkp/VNpxXpVVD68MCAD7ZnJLdarX+l3hXb60qL0Xd4p36/7Yb+8KJVJgMuAhEpCNFpmPGs1iBEX90+pJcjEJGh/O2yan7tXXutG/t20eSk//jNcFvuL4aXPHDr0maWRP48N+k809dEN7Ivs8z+Nr5Z+HW/jW9ia6AvV8y2s8Hwgv+Y7GU7DmYBsMi6DjUrXS+NDFrQZl5iuFr6S8J3cuSzdsbyMr6HshKPSt8l56TXsg15oq8dZfJLGFVS+BnfTwNEYXV6mrQDWCfrjG4z/gIqYac8omhmsZEkAYnIztBchiz2ogeZ5Ux5mQbnsseu9g/Zddc2Dyy3L3eZMor86uacbtFS87/J2vs4Z6OpDbJ1VmXKDXkuYhsIsAibwGKXdZuM6MWnFSQP2g2von1nGfbdI/47r3/lfR2C60sfxivR16TbcePxl3i8IYvCXzyMttRHZpGlviG4+R50Waa1WOukg0EnSShxSw8oi0NzaedYeeAgIIDcvcZt5OazKvDzgFPitJtiB2eXf/naZfsGcuvDfAvJ/Tn9KbrlP8jCj44qMRyRkS4+2XI8YpuRn/eH3Li9LZ+iWvEEMa5Pw9QaVvZR+f///w5flAgeo8uB9S72n+/tP93KpI4sbeLp0/8GFoesGBq4d4jTLdAGl1+OwNjPIHPBGYRbApgKmoYl8AByWWFjgsBU4C53WUJWOBr+9Obnj9Csi2ZrF3iEY9/TQs8vYnt23L/soOd+ymsmstvwGjw20tw9Ib6px4KA2fCZBA9hEK1BHRqBhwLwPo1FFAZ1OZ9gZqg+FqfBFMAdxgyKmMeR0OO1u2oAV8vuQf7h1LHpV+5KCHulXPYdal+tz+MEVVzR09fWGanJ4kv6QmO6b659QISqPp0KYC+hMQgNiiku4jLRaWaTJLA5OstVqp/KGztQfBr/TyjCmTcq4IscPTpw//df3LDOUSf2PMnThW045//Cb3zCsvYsijNF0aP2/SBt5FrwB0FEuJ/VHCVahiQZYVTqqGoWDET9FU94LDTeQbQSeW6alj53qWL6xb3RiYfxwc8cVfaPXp1ojy43Bdl9osOvUmdarJkwnsgc6Ig1xp61htK1lfyoRHa2OlMc8ldUei2dupm1/is6zBnA0yewKHhfJuXY8kCGbyNgPvp0fuMT4v0n1vyyDa7co8+4uoIt7oD3YvZkKOZiOsXppTuRwBByK+qXCivszitzCvtuzy/H55ua5+MrobdPmsTtm8ful0x0HmsA0wHdKV8/eMcbljGID6ehYWuaLgqSUhSSXNGCqc0ljoBF1Dybfk75/Ev7R/QGGPr/2HVLP+gKvQFPCeAj6MlJZDwKCnuEw1c0WDxLCVqSlmpMKHfgn+E3YX3nywYdPPvzgyX9ZfuoLVFK8Tszsa5L4136IlL7Jp5g8A3lo0EHXhIZjqY2GOa3ZbDYrxSvMEro0sN7xJ7Dwjq997TosSGvXf+3F6/Cc9NdYlP6AZ+HTH7DI+y6Avj8GfetReaZUR3kvJ9hkb9zGzm7kPmHLqqRfXf/33zoj/eYoLsCflp7HPdKi9J+0ryboa7ei6yk+8yGtnH+fC2nZrGzGae7mg8Jvwu+T3kGKpFP4g2v/NkHI4sSaxPX9+Pq7cT/57tv0vpJAWPA1/sZnPvMG+e7AWhswEF7//foF/Lc7xBsFsGf1l+MFHm7EKArjhXPjKfEwaorR2CgdD+XHc4OwT8FX9IEHfv/7JvLVgTfP0+4T+JP4C5y2Htcd6cu4QKqzCCGMTKOlp+Uox+M61GdP2v1h0f/t4U+OnzkzBtrn19/8Jp2ztH4l2b3+NAxXxfrYIT5LuxABgYRMrT08NMz3tYtksJu8Am3drK0ZWlJpf9oGPiAfNe32dx24doh82fHn3KerB5vhD8QKfBFCN2YcxVjQuEERWMFyryjXCjqtBnw7B7hnVQCs1RHtKvM9qQFfktVj6orJ9OgBu2crCOUqBqeYZPMZp8eDkCfkCXorYdiSQNBvNwBSkcsJWFUHpnKOrA3sM+XcCt9+9cGypfjU8fRSy8juzsHOqcDR/bZ5c/doaqQ7QaxnD0kvDoYje7OJibqyopaxuuFGKZ6s73c01dTE+ZrHQN5Mgx6ygZfy7AWLETQQlldZDsRVlM1FWErkcBpdIugi8FS9AOTdDERNsg2BN9ml9SqHfTsDAozqtWLub4CZB0/VYbcjZPfZvZ4SmDbomE2hvDBlAKsSwVPbB5+7orPzit62pYpDhyoX29wjNTUj0YbhmprhBmIFF2H8hv5UfC95RvptLCW1RKdTqalodCqVmo5ymooAfUhAH+WoMRM3Ah5KTDDJUmByIectlezsLbku6S29ftNI9sbhpkOhXnd3oGEiGp2IBnqKe8OHm80jNw4N3TBSG+osrUhMxxPTiUpPRzjC94/aEc25/bMK6v2jiGR7w5wB9f5Z8vu3GYgRqcjChpxIlf2jgIWXBGT7J7+l0Mx42gizdf9sIb+V7h+WCT6PJCvZtH+k+VDFUlsv38TFyqvZzkXZLpJn1vr3xlP9N4yPv6M/FcOWtbs27x+N2bxOwrB/LtiUTKaD0I1jh65gwxBBQ5ZzM5XnLxOdGyxgd7m7rLQEmjrBj6LzDfBtUxtfXjpHneiFKeOFaXen75rmoeuH+s8Ojp9sk44Z53u65s24yTCaGa0qzgQiAzeMj75jIPue/T278RXZrq4spTEvfFsk34Jh92aMRVintWKkozElulllSKfTLoGlVcyCE2hBg+VoIigqT6YUHAMdSJ3VrS/nM8agn52viaKH6wg/lds8FMbpUef81vXXHxwfH20va3IG9OVFrkqinZIm8ONTnZ0TDmuP3uT3UDxG16dII+CxEtWhazOWiiKi0+Zjs7KIBDkHakNHVqlhuCRqiMzzDKl2JiJzIFQ8wkwpINoEBiKyqgqhqrqq2lAARqwMBYMBKiKxVeZ27rV14I3EkyKqs3/S2HvNaNepULhsNrpnqXKxtfd4R8fx3talCqDH/v65uX6ilRI9q62hyhFPxWhvtj7edXJw8GRnMrJHumbvwMD8/MAAP/ujhrCVxSVPPmVjJhPfGjdibAQmMYtFlihWsMw9bu7sb30Nb/IPWbRDfgOMUsCtPavD76BhASrhYNfkQJpVFnL49vn4wd7GUE/40KHiediW5L5W6Qu4uXXY2+yV/gak2Vq1LOs74PuT5B/AjrKgAT5tVy4oXUwpxSrbqZ6MA+w5TA2NVdXjeWo6WAoLzCxSrd0UqRbBCjjg95cU+/3FicOHyb5AcbE/4C4OzKz9kY6//tL6uDx+KTqSMVpAtxZiDclRt1Y1F0FgpKs5oAPMeDSMuhEACIidPm18CTKFRdFLzSUupxxHF7fE0WVHgJrJutxET8aLCkr8xRWHxzryE37zP436MaPfQwJrr3buYvs+CAugZ4YmtPQFA3PSlJ23cg1I54Q37LmVazz1C9htcHYX2K8b3szPX7BZ7cwixNQYYioMXOmu75799J5Dh9auxR7p374/dQ62swfrOR2iz8F8BNTOp2GiwQw5ICJPwKSKkXjYb0oAbX7+yXzQZOjQISo+kbJOoQ9420/lTynG2iqMWEybMrWLChbtkl7UCVqtNauhp7ZsWSB54Hf6VgdviU5Xkns5z0I1fuTzW+1+u99hAPmjomTdhg9JJ+de+E7+Zjp2sCPePMC+HT7sGIs3z7pt+9oUCm9tGepNSc8pP4l2KFzfHIs1IxWfWsG/HrtgNRPOqHQNduZOaak8sqq5kNHUFv7kTOhAdpj8ZibUicqMZR5s2q07fLh4Ts2DnQ3S40SbDccU/TMKc9oU9yh5y7iH69Jxj9dvzILpMHTjCBgKzHJoiO6Kwj/ZcBi5YQjezYDdEJ9OcANCsf2mwPazMtvh6Gbbj2pyHbgvwPx5A4xiyM41PZMKm21DFczbsNMU9P2P7DTpv8nj89vZadQmmgKbSFnXRpsov64SlWGSVXQSs3Ksm2ymDTCXsF+sm1XQn2C/aNcmsC5vwMxL/VvtT3Db8UlYlwn1XDDpqKcur8rKFCbYMtz8ZMaAU6HovFlK7YALIGWYmLE1JZ0KCb+83LPrmHj4MP7HtpnJPumfiPYwj7m8jl+E8YLU3nVggp3gHcOwIPxykZeSzZGXIAoEagPM3g2pQi9YsTS4TqZxF0LF8stnGqKzc5Gm5o6lPcf2xA/XRcYGqpPuhkTzYPzEjLk6ONgVrKiqspb2dAxMV5UNx7xljmKHxVrZHhuao/YSzHGRfAjspWgm4sY6GuYFs+5WqkvIAj00ACSAZ3aAm3LMBrL7qBEU5O6VVc4zAolLD7Ca0njR2VTWPjo+fvD66ytdReX6EqtjohPHpt7//inpVY/fpOc5SK8DPrVMvthhQJKTLyBaZI2pcgfdylNZ8Oc9QC5fQMX7WexfZQZTRcVmZCUWKlaaFSEDxPJHECkgZPCw9BIVMnhCzskhdUSr5OQokQzFBrTamMdt56EX1/F77zn+Twtg+JzAd1MFjcEqRkI1tN8aw7FeOoZTvnzNzVM3n11+dN8NN+6DHt+Jb6Ffa3/EN0s352NNNuib5YAZRQ3NJ6GdYwx9C0R1KGCz2Sj1eMOi3x5OutNJ0Y7ve9/7T3z1uZPvPnfi2a8+/zzWr33+829Kb9B+S9ZHST30a6XZMCY9gSlTI4HIXVPiPEDn7mHEaUVFNoc6LNiFBbaKQiwer3WV+0p85tLXLnvgk6s/8exuedI1WmRzpYleOoo/unaxL4P5WkB84u/BmDvEeayXjvMksE/6Cb5Heg0HpL0j+ND8iHTPPOu3en0vPkK+CFKrOhMsYcYlaGk85PMSmuMGILMyP2M0YXVR/U1DgGlQRilgszBNKHYXEqebHxWKlMtEbN5fjesbUxFcvTChb2t14lAoHMSuljb9PTUDTbfF63rq4ufSA9X6boO7tvo9DemCwnTDHdW1xYZuGOey9QvoCRY32jk/DmyIy4aHlVw1mn+5F6+ydQB+9CzuQ0+phnwYD7IuZuXTe4QnaO42ER11btBpYZ6BkqYZJmkQDyKQP8goWA9sT21biwumHgphZ2ubfhddFBWu1ft3LRuKa6vvaEgXFqQb3lNd6zZ066sH0ufYwm5L99foae55C67Gt+FHrSIuWF+XfokMjyP8tPRLlg3Bc99n0LeYbLWwUys6QaqQmVHOcp3s9Lxoo/HLDEueaAi2TfJBOXmg5UwJmFBKvsBaP35N8T8vYImMAS94KWYqyktL3C6HvahAS8z8fApGnZbT7kHcl7IcOp41zmPXNF+cbiroTnA/A+Ajg2rBP5yKTnUcbm050jHVsNvbG2htC/VKd/c0NvYEa7WZPvPYVV1dV44V9HRqq72dtSbpL0yRjpsP2PGC7WAbz4FEZAxsyTLUm8nATtFkIiTSXGQRD2sxAaEqEnYcwvwzmlUkity5Rzzpq8xqDwZAs1DjLuj0ptIse1Md7NCVY5p6RsbWfjqUybSsZAau8wwWTMSbh557bm6uofahwXN9l7fLp2y3DD6kjs26UEumyYGRAY+Aiykyt2RFhwmYTWDGLRtZjQDPeXM57TalBqDIRKsAWHmFU0kohS/G8PjRe77yla8cha97WDQXDw5mB0+cgG/4KA3psv3qIpP4avIcy99JsChimCatAv2ywP6SVqCIwhPbZPDQAKPa+0ioPi9WVrpdVZXux9jPKheZpD+9TvqM/wR6rUFJ/H/w3xX5cJ0WFQm4Dr0qxzXH8S3krrcTE6V5OF24R3qe3NX9dmOibtF/4eAHhsgrDlluB9a/AX7Po7DT/gz44aDpELmJqtV3US3GwtrsWCPEtAJWzB96mEvPNwoHLmttXs7g8rGzYzgQn29t3ZtYqyIfXTsKfXeiV9CXQHWZ6JmiSjAQKjImVMm0TSrsHQoESkvh6xX44ffDRzneu96J1qFBMTrD1mYBe0VjN5B8VMJMoxJg3IOq1pykSUQgtFfzUvUAO30BC5CBAGHdtBMM19nFyG0PBAJUZ1PMsQQNFo5Q5TzSWpNXq1sjJTGrs9zv8ABtWmxV7cX6SKg82FBUEHU7LIU2o20szepjYrDnv2N7HtHRPY+g7zE6DK+P4H9lssmLWjPpSqfDrCH86I0gOlFGjFxYgeY5gGR1560qdtmtOZpEanHFU3ESabdOMVlBtuCkLMFim/Oi8uJMumpzZhSbOztLEm4q8tFqHquIetGfoc8h/eMEP/Y5JmS3gTmGHTvAnFVgsIge3AHm8hxMAfroDjCrubFW0L0chmyGeTnXjwl9cyMMzxMSXgMZZAFTfzDTZ6Xyh2l6cMJB8tCsNL1m2QACUbcAdqZ8xqPl1hJz5WxFNmhtkYWSCSQkVjJHUjSZCKwCcFGySgaJtLbnDL6TNEvit3kqyV13kfOL0ofxMekjLPejk+XqpDDJlNbWCEQj6iqANMuxBpfZgG6tmAhamdzr6dGsgE/CN6Q5oQQPZfktO2ug3Q7AnD2ERg2oAdtAGyGsueltt6qk9UAbWgk3vGWzTGRzC5rXR45s15DH/WkcIYUag7XAdl4nVTU8BmvBsmjYzH3bnvcm8cuLgcGG63oK9ZWbWdM9c/fM5oylyUi4L9qs8W7k2GbjkU/MbcxgQoymWL4Po/FqmQ/eyWgKq2hzM8wx9PgOMGcVGOCDa3eAWc31s4JOcpg8ja9fpDkzbKwIn8/6+7b0A6ICJ1Qwx5BnM8z6rwHGxuYT4fNZP78F5t8BpoTNh/ezsv7ZjfMBfqqFb99nOQTlNLt8o3WxoGdnDAaVeWGxwI9ySxmr8XNCswLQNcZ8HIE5TEmrEkMA3kqKoPDx13ny18DAr6Z5+tez+JlcChhuWMT+tUd4ItiLi/8AOGD5LUymxGWZcu8WPLGcDYanhLy3n9kidzbDHMOxHWDOKjCwt1/dAebyHEwBenIHmNXcWCvouU3yi+ZM3YFfJQ5QArondAjH6oJh4IxwmuZsuvGHz707c+627tvOdb773B3n3t3JPmfefQ6xejUlR4VWudajc7J/68IaUldbXlZi1tGUbKQVRjzsqaB6iuHpPG9QzIIp9LjeldVtOEEEg0J9pOTiR4I69XFfphCYvT4QCdnDdlZxoWRxh8A9TKsyUJMicrkxD40jXu6FXUny1eTB+5bSq5GGI/MNjcDaTUcj9UfmJFRZhke7ssDhOHO6v6JMeiKTJcU39C3df9BXmVqJ39gHvO2tbFyRfjztwzdTDpf+MHTdQMO0X7q5nuKe5WmwfW6WaeHRLfSyGeYY+vEOMGcVGKCFz+4As5rrZwV9ajOfc1uZjdUuj/Xcxn425e80ZOqAOrQarF3WYfn4K1eECZrL4nJY3EVua8hbJPKsgqQq2ymYy3ZaeWZLttOZltVM90rL9d8baG/vl9ZysSviJV8DSTX/lBHsdhouqQfqqAAdAEpMoMdE+XRTvKDDcti6TE5qXM0Bql7PZ4z2gDfgrfOzY61cjFRUpppSco1Sil6gceaxliOZzOHmg+00W3Z3e8vwcEt7JNnb05jsXSbmpulodLrpWLpiX3PTdGye5snOdEai7Z1xmjMKuOZ5Aq8ArvtAvxPUu4i2eS7AHnxA9fyiAo/FWTX8C7nnBQPq58/l+l+ZUD0XKnPwphs5n8Jz4UNgH0ZQEmVwN6/pLSsDLHs9YI04sVHfjHVGAyGijnp2uhG5iHdHGBOFyb82bPN6fp6PEwHColWGq0iPdKJet1xgIEpGfkm20GQWqBbPJw3QcZsu2caMcy2yhdhkUgmMzOaGKN+MFg6/ZRdgRlQ1NtbXI9SYaexqa6lP1idiUcBcnT3gDwQDQUvOrFDS3LdkYPDziU0HviBzOnDuvPFlfvqbORn0Xtm7dEKVnjHXvVASWG3bfCIsfaA3TLM2elr42XBLItEyN5ZP24jXRxKqk2LpDn+bP+BoqqmNsbyETpaXkEIPZcqDWKtpDBGdNlVKkK4EC6i4EDa4ALxmnWwWBmh5AAFjQQccpWOsx0rdeNLFAZDCHqzYdUEKC3A3XRo449sCxwrgj+TAtRT3xhCz3hi3BnR/ouFGEyXwD+bilq2GW3H38a7tsicCdZsNtxbD2OmeLdkUnL94fgDl3yHO18vbPad8/RHV84sKPBYX1fDP5fpZmWHP17/GzuxoP9/g/d/O4WlQLKJ6fszJ4X8Gz82s/2/w/h/kz38Mz+2sfw6/ci+PL9GaqU7yTVQGVtbejL2I1QbKlpPDbjPioexjpfQYTwneWzdkl9JDXwyaAJNV9WNwd8vLy2vLawK+UJDm2ysqWNG+7hA1KULcpiBy8qmO29o/n+45kx0929t2rOdAb3jfmfLJqoal/gMVPQVz1cOxgXl69GH51NKeW4Z7z44OXdU1PjrR2Bsorgyn63rL1362lI2O1i8Ox8cjFE/8TI/Ky0kuL/fm8T3K8Leb4/X0ds/pvj2gen5RgcfiFWr4F3LPC/arnz+X639lmcvdbnRErmu2gMXclgHtrFPOzBELTSENkP8skL92SQS+1E4wj9BTVAraVYkk6qlrrj5GB8dQXaOErcGgpyQQKPkFraX6lvwL/lKotCQYLCkNdc0/r3yEOSlnjG5UhWpyNpsDbLYwQLlNYJ3pFZsNngqqp9vabCXb2WzqA7LtbDav11vjrQ7awrYNNpvaZKMWm8Zt5wabixpsYQEEZzg4cuPQZE/Z/h5fCBh6or9sfy8Iu5/UxKK7ov88EfXCJ/zB/YmhG0ciVWOhhSSwcr13/LdxbPIAN38R2Lk9Kr3hYfvGz2jo/s9xujiItnlO6eJDqucXFXgs7lXDP5frZ2U3f87PHmg/++V+7ladq/KcrA/nz1W3TZfbera6Q7rctoew27+WM+foIezbPIW1/s9PYYXVrelyOdw8J+MGcDaJVDGXllxc5ij62Ba/hts/d+Xsn6ODaLuYDjag+3Zom7OdsGGSy8bS9T2CF2xfN/TSkWktwUB9WIfJiB5MbthqssLKfei9M0DPSlmdHY0WFxeXFpcGaOzG4aVBBy2PoaX9YV4KxlV+Im0igtefiHe4C9pzdWHFFeUlZRbpzjvvqmhNBMt4iViFu9hjxZ2sUEzOO91DeoBn6U1CT3MGLPOA6VWKieCAqdVH9IJeFDHW18CUmWW23Wut/Frm4RroWdQTcVWpZC8Bx1qv55FCt3qJnkz9VlhQ3Ba5AdoEP5+x1tbWpmobQwE7GE1hr1Esy6EltYPN5LQqiAqpMlhJD0VZyZYk1pWrOep+qySzUgSGy/o3JrPS/FaGzY/lk1plv/hu5guFZF/o51t8Kp7LSXl3QObdD6ranlXaYj16bIe2F+W2BOvnOH2y2jXWNiy3Pb2tz/5b8gzADDIYi9ABJrkBYJ4WMAUCGDlGApT/zlyO57UgT0rhzWsXbBpi0CunzSGqVjAStGgZNggki14v59DRUxF+MQVPCKLSp3ZHaCbFaRMm1d1EEUMbWxRessUG4DycTifn8aqBaTzP5vF4aj01YXoiH/LLxzX+LWmgykUgaHM2xbWZDekU999/KCP9Up1T0b/2jCon9GOt7e1rX9+QVZGLobXkYmhH4f9t9hvsubty9tzRcbRd/A0b0Xd2aKvYggLAfJHXyAHP97E7FgYzfUWgiK2AO36GTLP7KeOJC0gU5YAyFfKWLDMkgDHtWuXYzeGnVTZWyoBKPaJf4TO3zJGqAsVhzlUVTE5JTyjVioyHmpmI4jWG9KxyheXKhlA9emfGRr2KECZasB2IAWwBmglfBvRRDea+RqvTLNNgrbLj8u04LGOGSQ+mrYIKKFs/PrxdC5qoWhQOh+vDEZfPHmr0B1mYV9y0It2WtFvqGyA59fbH2cLcInW6go0puNXgmUm/kb2uUnehsnKbFavyccEHw2d4Su42sVj9NrHYzb6AXvYFWK0mo5FaWWd9eUtbnk9LaWSE66xTclvpZ7TOk7cF2rGQUsTvfwB4wY8KUQNqRfdkHPWVRC8q5QoCvQ4EDXH5UGeiRxSCXrNqwPl6XlHULoEbrdXy0+0lfrrtoZd6bYU2Yp3OytugTS3AmY5GaYQ22hptSSUtDZaGuhq/t6zUaaeR2mKzYtrS+6i4Nev+E9J2cWVVVdgfMDgrq1z//ZYZvJN1vrK2hpJgTaXLVTX6ttJ5wZd6DYixivE/j1kfXf/Ulrj29wCmlPE/97eOfozHtOLre9DrwMMOnudAsIPngShXefAcIK4zA16Xl5XLyCdwaVokqXDq65RULeZlHWdPquscgQLykGwr0NzZdRjrBXYW7aW5604H0QheJjJ0qo2VTXNLjqNY0kJVRWmx22Up2DYv1q5MyK2e0SH5jHqYzqzCbT8c51PLn1WvVcEsy2MmHX597Y/yNIFmafnNU/kzAJB3xk1xSJZjCn6wNlfHa91Sxzt4+DBNcoL+ZoAHPgS60Ae0zngA/yv3meF5BeONcfn5p9lzVjvLdH9U1v3WLfwGHIrrhfcAzIQM40IXOMwFBYbX4D6j9AP9P8z0NFbpadrPYQYzIcOc3wjD8/lJL9BIAc34NJuMBnC1BD1RqpE3FfYWoAJnrrBXTFEiSTtFJ+mVGnbtWrn11sVFsAEXavCI9PPehd4fyvUCXpaXnM0UmIyijmpgfT4v3QQfi+R6vlxacL7Ez8N+Uw5b5uefdLL7szDNC5THx97JQ2NjhybxwRrpJawLL4TxnPSDmtyZyU3KmQng8Rfb4Rp89ldkn53aWfdzWwn272G2r3FZtvHY6hA8txKrAg84fSFnWz3MZHBclsGf32Gsi7n4gP44Up2TtOTOSY6ip7bYZdxvvivn4x9dQNud5+DCzWcsctupfPwBF14m56GCsH4V9t6Gyum5fe52AT3JXS9goKXUSq4Y89TK7WUuB8sr5Yq9JH9cnEuucGJr3gzq46p97fkD9x88dP8S6ZHEYXp0ebqPK/e5jy8u3bu0KJnxfw2cHRi4kt9FCHMW/h30O11XC5rKTLqxXmcgtACZhQeAhjRavWbZbCA6nRIKMIlGQRUMSCb9foSSLcnmppQ/4Y/X1UB3XnsgFAgWwLw3hlHzMl6ltjWbbDpZhQtlPH7aezoYPNMzzNKbe04HAye7FTUuXaPKc8bXbqir6WxKdfHM547GVDvT50Sd/IyvpJpd1uvH2d6mZPr94ha64Dm3dG+neWwuK9t+QKfHGf2mZDq9yOGln9EcXRmePo/mbIjjjH5TMv3evO1YZxj9TnP6tfGxWO0zm2eTPM+PbKF9nqtL5znD53kEyXXTnaxuugv9d8YVj5WXaXT6EqzBxYVE0BSAuaDZPlcAzM8l0Pku7vdp8dZTf9s2uQJv3WrbXIG3arZjrsA2DXmugDkY8dP0HK/VAF7FJULNobdRHk7arr1im6Cz+32rlyoZP35Stzn2bDx0yRJyeb9YznUHWgfjCou6WAXRiuXAk6W04FC9c6K8c5GNOyeKuiVEKzBQPiK0JcmjftPGvWWjyi27DbvwFq3YEeN227a1Hd81U6C2mm2aHvyZS50P4LfKLMcvHhzfLsEjvhTeMd1814x2c5aHNly5c/65ijfPKrwJfL24A29eVHgT6+s4b9YAb9K7AqrQFzMWNxaQywzKwETvIpS3FgA17KSGnnySG2iQT04zMugJs/O2HNhEaBMNPYh5O20yNZvA5WObLa00NJYIJh5Mtoo67HSbqNfpFXfYI+rI0zsO8D771n3wOOm9B5uy4Hpy1yCwvP5OltffgL6WKa7BeqHWSQx6ByaGXFKfNkf/Wo32ViOGiWLDSVBcgGFaUygImgUdryzkuXvIgxQs1efb6A3kprdqlKndHp6lBqIjqmYCxROV2w2onhUbuAFTNLKh2YGStxYguCnh4/gW2k2052sSAmXVRZuzCJv1o2l1mUKf2xhg9MfupGA0mpZp9IotNOoFG0Zgtuwst2XJxBZ7l+chtOTyEI5uo4d4rPyuXKz86AhS5TC8nMtzMKG/35LDwNqy820WlweYm7fLowAb7BPbtm3Px/px4W4k36exh92nEaDnM04QRwWwrYVA4sxNltNP8nH1vNMELQKBYn/Qwe8nYtkEm8IQm67fIPeM3jatt+ViD44j18n3cJDz7NYNS6kcbPB53PecVF/Kwec6AHOdA7/Ij27iUtpSrIdNETEW/FiH6ZlNEXviZ5Fr4FE8LAd7y+RKF1pZsk18t0p+TWO6ueqTjSFdY4CWuIW9oirEvbUSJZmL4pI5FkRS16UczruvwbLNBSqKkwh7xe4KYfTYynNe8NgWOuK1KFRm7mW0IKLvqPJczip5LvD83Vva8rOHi8rZAxbnkartai5HZgXdsIWO2B0ejNY6ZFtrS56bXP9BaW2B21ryeSNvu6q0Bd/32zu0fU5uS+OBH1a1PZtrq0dnd2h7URkX69sY3bC6WupPF9I4BM1IpzU7LA7BCmDIAWq9exhRF6ICe0C57kpVDmvIl8F+fkvtK80bP0wuvs27PNz0Lo/PzK72j4/1kYt37t69fR+5+iSCbgWeOy1XJ8l9pJOiv6tvbLx/dZZc3L37TrmPEXwFeQ72Uc7GNxKajz9iABU5tF3+eAEyB4VcNr4ccmJOycPjlY2OVGlpytFYMVFLRqoqUg6Xy5GqqGLjTKKHWKwlxMa5ZG765kx/Jbs/HymRawlGsEOoZLUEzaxXem8OzaZf1WIBOHw2VwQqvK2KAvX99Td1dUUinZ0ReoE6/SIj/PdIV4I/cPt4jvweQLobWVErm0G0EBZjodQyomWVAOxWGMDpLJOEAn0zwaumAi6NXFUg38OuCjLf0FHh7s8Flh9hUSG3HFLmd7mTdnw7eQFG7mHjpp3Qs4FVaTkwHtJjYZAemJNZqk6BfleVKzSIMMGMQxfoUK1MHIiafMoVy/SGdK4y/bjAV2x1m+xubbpo2sk+29y6tGWGdNqsBQUVXsM7+E/9DXw/uvB/sFoIEZXyigEtIGeIuran7bZcDYVb9AflogicPfCBQfKK45YPOd7J+K5X+tn6k+tfAqYKsB5K+F3kG+u0Arm+xI2Xkl/ggU1PVVV4nQctWQAzJx9vUuQj6sV1O8jHVxT5iHrR4wht0/YYfvot2x7DXaq2q7m2K7h/i3zkbZ/LtV1BX+f+7fogPc9Qn4W8+TtuP7z5O9l+iABMmPnGQzLM0wyGqGB4rP3yXD8FQA3bx9pfyMXaC8bQtjmQvei9O+iGV3Ln0r0HuO5tRP9GTLjjre+Bf1WqwR1zShuh/W20EfRv/l5p040/gy6SzwLNlDF6GdpcEMfpxa7Ey3ls9oX8Bfbk9g032DPbB38WfZs8CCu2AAZivLaNxHK1bTAm/ms2Zjm90am8EOh8QyGTwIRaoDhgYzdIbRxbu+NUsG6HaVWpPvP54UfIg0VhPj82z3+Gedpy86RkokyYYvFhvAvWZNmpLojWxbO6oE118Y+3+nzwr4X+exg+eenvXvjA8dQLdPNOdB3sPTgyRX74/s+Mhq3AIwjkQGT9d5ozqr+P0gXUOIt+yfMc4xasLeR/vkSPdWZsNOmM6j95Yi+wCSaTsGQ1FIka9V9KSb1FQ/ZXT3hrkbYW5L+X0rJtu23+YsqWtuCBNI2OZjLKX04ZnR2d2b0rk82MDPTFumKdzU3b/hUVx//gr6hUbfo9oIKtavqT/8IKHskqv4xEpfPK31v5K/oh8af84ZX854mG3B9hyf8xFgzWfRKX4L+jN+Y8AQZzrK4LuwF3gft233337jdftD9xwcFrWQGuQoETGFw47RZrP/jB3Z/+9OCFJ+wvvsx0wS/kO3SjaCIzWuYhGnrHgIlZ6RpQcOBbLxswMWJsorUOuRoXM0tO5REQ+B5FDZG62hqrLQjOoNUeLKDBq9xFQ2HwFFnmSSrZSfKVlTRhDJDs9rKQKitvSdw6NPaeFffMINEM7y9Zvm1ELmkZuNaLK6WXYCGt0vcrbspOsmt3u68e7C8uACXee1k7K2gZ6+ovK7Hb+gdmZZnlJQ58Pcg53ROE1hDYc7d7X0/v9WaGDJgyAkhdr9VLhLU1+pXLXWiR72LQP1ViL9CS2DZ3PW29K6Hl8CXuSvjj3h2vSpBj9i1UxuOYfObw+Vwsv4XqLvZcdc7Knl/MPRevQ9vBo5Wr1M/z/ffKfip/flcO/ugx5T6KPeRdgAMvigMOqn02DDjY7GMpiMh7WIHNKHkX87jUaJE9rnWkQg93v9QIkl2vezfnPrD5Un+brwP87U+p1vFCDh8FR3M5/Og8u3NGoYOk1T+m3B6Dad4bvNdufa9dm0D/D5D8EiIAAAAAAQAAAAEAAA8CG+xfDzz1AB8D6AAAAADTwZ2GAAAAANS+pOv/Q/7oBHUDyQAAAAgAAgAAAAAAAHjaY2BkYGA++e8KAwPLov/O/ytYShmAIsiA0RAApfIGqwAAAHjadZQ/aJNRFMXPvV8GRRysWFFsazHWJkSa1thqwcY0xVSTSFtrg0IXcVARsaCp4uJSsQ4u4uRkEF0s6uRW/wzi4K6TOElUWmgoWAr189xnIjGJCYcfefnee/eed74ny5gEP5KlWqkdyMhrDGkBQb2IDi+MiD7EbqxgSC6hnwrLfezXcSSkCUdkCjFZj6Q88xf0JXolj2b+16Wj2KXT1CS6NY9ePYU+PcvxPPrc85yrGY5xHfKYrGKrN8W9Stiuz5HTOUR1lbyGtJ6nivz9EWmsIaOt2MKaJnQfBr3TyHkeFeL/s0g73uPzrF2vI6QLGLE1AwfQpvPUE2zUW6zzCo6z5hWyS76hR0f8XzKBuB7GXp1BVneylhmuNYaInEO73mTtOQxjCYew5L/XTqRQwrB3BykbZ50RN49zZBZZKaFDbnBejn0m0OwNoUUj7G0c23QdeuQuOqUFF8iwvMJB893tOY2Y1SgvWEsbQjyLuKvrNoL4iQEZcONR+rXHedVAgSbS/DPvqoQ1/4P5Ry5SX73NCFe8q5UGkXE0/6pl/tFnnlnWedVA3jxpvYz9K/r2jv6lyCL1Ra/y/Cve1cpyYTT/qmX+mc9G69f2rKX1bvtXaDniuVi/+qDsy5jbpzEta3beZdKrz6z3E73rJn3yqPXhMsgcWA4tC395Bu0yiHbz1vqrY8jVEKkwsAHRwCbuy9xadurILFue6sh8u4xVaOdjHv2H9g64HNoZmn/ld8HyWEvLuBSYPdNTxPGdPEElqDfo1zDH4Ccra9ayztPynrIMeMU/9w1WqceAxhHzLvMeaSrfKYvkIvkIJ/Ut7wreS4EC38MkglRY5/wfLh8e5/JcG3yzyP4Gj5fwtAB42kXCXUgacQAAcLuuM78uMzvP23mfep95nv/z7kEiQiJCIqInieHDiBgxYsSIiIgxxh5GREQPESIRsYc9DAmJESEj9hAjIkRijJAhEhIiMUaIjNjLYPx+Nptt8Z+87WOXrSsLUdA2VITK0H33XvdZdwOG4DA8DL+FD+Faz2jPUk8JYZAUUrRj9hn7nH3VnrcXe+HeusPvWHAUHA0n7kw5t50lZ8WFuIBrxrXmOnDducPudfepx+vRPfOeDc+Jp4GiaBJ9ii6hO+gxWkFbfc/7Hr0T3mq/3v/GZ/NlfXnfn4GxgZcDFT/ln/eXB0cGlwfPMRibwhaxHHYegAJaYCWwHbgJNHEKX8CP8YdgIjgazAZXgh+CF4RGJIkNIk8UiDOiQtwS7SejpJfkyAQ5TmbIBXKN3CT3ySb5GPKGuFAiNB7KhJYpmMIoiUpSk1SWytEQjdIELdCAHqHTTJppMR0WYX0sxSqsxabYKbbEfmOv2RrbYjscwk1z11yNa3EdHuF9/A6/zx/xJf6Kvwkr4XK4Hr6PQBE0wkRAJBlpRDoCIuBCWHgldES3iIuKaIkpsSF2JEQalrakA+mz9FW6kmrSbxmRcXlCfifvyodyQT6VL+UfclNuK04FU+aUW6WtwqpXJVVNTaqTamZIj/ZGiagQLWqz2gttXdvScton7US70L7HtNhBrBj7EivHqrF7HdJRndGH9BE9ra/qOb2k/4qT8Wx8L14HCABgDEyDZ2ARvAYbIA8K4Axcgp+g+Z+BGIyRNmaNPePIqBoto5WoJ9qm21w135u7ZtmsmnfmgwVZqEVY89aStW5tWvm/amzATQAAAQAAATwAYgAKAD8ABAACACgAOQCLAAAAkAFBAAMAAXjahZLNTsJAFIVPCxqIhKAxLrpqXLiTvygYXGrcCGoklp0JSAVisdAWE1/FNzDxQfx5Ajc+g0uXng63CAYlk2a+mXvuuTO3A2ANH4hBiycBHPIbs4ZNrsasI41r4Rj2EAjHUcST8BKMic8yc7+EEyhqhnAShlYVXsGOFnmmYGkPwhmsa5/Cq0jpceFnbOgZ4Rfk9S3hVyR0S/gNaf1yzO8xGLqDA7gY4B4eeuigy5ObeORXRB4FlEgtRk3qukrjk+uc+8zymXuLLGqwmecpJxeOqMLdNnlE7Tm5Q3LQpK7AnLwa+7jAMRo4Ic1z2Z5xWVzH/FXJ4sqjqqdOak5VXlzN4nxFjUtV2IFTOthT9ZrkM8bDWJVz+58ehX0NuKogx3E34+wq3/7ENcuYy3WU40tWh9GAuyP+kUiT4xzV7Kt7/tTMzb3jX3vRrRvkFt9y6BBMOlaT/h2pqMlRUrEyz1ZgvIJdvpjo1ZRxQ53NCgPpv01vn9mRax1D7vQY8xhzvgHFfYVjAAAAeNptk1dsHFUUhr/fsXfdNk7vvVfHXvfEKS5rx7FjJy5x7MRJxrtjZ/F6F8a7cWy6BAIeQPDCM+UJEL0KJHhAolfRewfReaQH79wJXiTuw3z/GZ3znzP33iELd50bYB7/s1SbfpDFDLLJwYefXPLIp4BCAsykiFnMZg5zp+rns4CFLGIxS1jKMpazgpWsYjVrWMs61rOBjWxiM1vYyja2U8wOSiglSBnlVFBJFdXUsJNd1LKbPexlH3XU00AjIZpoZj8tHKCVNg7STgeHOEwnXXTTwxF6OUof/RzjOAOc4CSnsLidq7iam7mBO3if67mWp/mYO7mNu3meZ7mHQcLcSIQXsXmOF3iVl3iZV/iWId7gNV7nXob5hZt4mzd5i9N8z49cxwVEGWGUGHFuIcFFXIjDGCmSnGGc7zjLJBNczKVcwmPcyuVcxhVcyQ/8xOPK0gxlK0c++fmLvzknlKs85UsqUKECmqkizdJszeFXftNczdN8LdBCLeJ33tFiLdFSLdNyreBzvtBKrdJqrdFardN6bdBGbeI+7tdmbdFWbdN2FWuHSviDP/mSr1SqoMpUrgpVqkrVqtFO7VKtdmuP9mofT6hO9WpQI1/zjUK8y2d8wId8xKe8xydqUrP2q0UH1Ko2HVS7OnRIh9WpLnWrR0fUywM8yCM8ykM8zDXcpaM8w5M8pT5+Vr+O6bgGdEIndUqWBhVWRLaG/HWjVthJxP2Woa9u0LHP2D7Lhb8uMZyI2yN+y9DXGLbSSRGDxqkKK+kPeRa2YX4okkha4bAdT+bb/0p/yLOyPauQ8bBdFDaHE6OjlkktHM4I/C2ee9Rji+cTNSxszawcyQh8bVY4lbR9MYM20y9m0G5exl0Utmd6xDM92k163IW/w5shYRjoOJ2KD1tOajRmpZKBRGbk6zQdHNOhM7ODk9mh03RwDLpM1ZgLfyoeLSmtDHos83WbpKSZpsebJmWY0+NE48M5qfQz0POfyVKZkb/H28GUYUFvOOqEU6NDMftswXiG7svQE9Pa129mnHSR3z992pPTp52eOFhW5bIsWOnrHXasqWs1btBrHMZd5PVGorZjj0XH8sbPq3Rdaai+2mONxwaPjb4+YzThIv02WFIS9FjmsdxjhcdKw2BTdijlJNygoqkhxyq2Ysl8y53FSPfup2WRNf3Z6ThgnR/QJLrd07LA+32MNvua1nlW+jRMcjIai7jJudbY1B5FbCcvYnvqH7dltyEAAAB42mPw3sFwIihiIyNjX+QGxp0cDBwMyQUbGdidNkkyMmiBGJt5OBg5ICwxNjCLw2kXswMDIwMnkM3ptIsBymZmcNmowtgRGLHBoSNiI3OKy0Y1EG8XRwMDI4tDR3JIBEhJJBBs5uNg5NHawfi/dQNL70YmBpfNrClsDC4uAP4cJWAAAAAAAViY9GwAAA==) 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
+
+
+
+
+
+
+ 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
+
+
+
+
+
+
+ Welcome to
+ Example Service
+ Use your LDAP account to sign in
+
+
+
+
+
+
+
+
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.
+
+
+
+
+
+
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 = ' ';
+ element.play();
+}
+
+function ip_encode(ip) {
+ return ip.split('.').join('-');
+}
+
+function player_role_name(role) {
+ switch (role) {
+ case 'S':
+ return '预言家';
+ case 'W':
+ return '女巫';
+ case 'H':
+ return '猎人';
+ case 'F':
+ return '白痴';
+ case 'G':
+ return '守卫';
+ case 'B':
+ return '熊';
+ case 'U':
+ return '野孩子';
+ case 'O':
+ return '长老';
+ case 'C':
+ return '乌鸦';
+ case 'P':
+ return '锈剑骑士';
+ case 'Q':
+ return '丘比特';
+ case 'T':
+ return '盗贼';
+ case 'E':
+ return '替罪羊';
+ case 'f':
+ return '吹笛者';
+ case 's':
+ return '贪睡狼';
+ case 'g':
+ return '大野狼';
+ case 'i':
+ return '祖狼';
+ case 'x':
+ return '狼人';
+ case 'o':
+ default:
+ return '村民';
+ }
+}
diff --git a/modules/werewolf/static/host.html b/modules/werewolf/static/host.html
new file mode 100644
index 0000000..5460647
--- /dev/null
+++ b/modules/werewolf/static/host.html
@@ -0,0 +1,513 @@
+
+
+
+
+
+ Werewolf Host
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/werewolf/static/index.html b/modules/werewolf/static/index.html
new file mode 100644
index 0000000..5dba26f
--- /dev/null
+++ b/modules/werewolf/static/index.html
@@ -0,0 +1,392 @@
+
+
+
+
+
+ Werewolf
+
+
+
+ 加载中……
+
+
+
+
+
+
+
+ 丘比特 - 牵红线
+
+
+
+
确定
+
+
+
+
+
+
+
+
+
+
+ 大野狼 - 二次猎杀
+
+
+
确定
+
+
+
+
+
+
+
+
+
+
+ 女巫 - 解药/毒药
+
+
+
确定
+
+
+
+
+
+
+
+
+
+
+ 吹笛者 - 施法
+
+
+
+
确定
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/werewolf/static/play.wav b/modules/werewolf/static/play.wav
new file mode 100644
index 0000000..64e6bb8
Binary files /dev/null and b/modules/werewolf/static/play.wav differ
diff --git a/modules/werewolf/werewolf/werewolf.js b/modules/werewolf/werewolf/werewolf.js
new file mode 100644
index 0000000..7734456
--- /dev/null
+++ b/modules/werewolf/werewolf/werewolf.js
@@ -0,0 +1,347 @@
+const uuid = require('uuid');
+
+let players = { /* ip: obj */ };
+let players_order = [ /* ip */ ];
+let actions = {/*
+ lovers, kill, heal, poison, protect, bigvote, admire, see, deal,
+ infect, theif
+*/};
+let state = {
+ cur: ' ',
+ config: null,
+ id: uuid.v4()
+};
+
+function player_find(key, value) {
+ for (var ip in players) {
+ if (players[ip][key] === value) return ip;
+ }
+ return null;
+}
+
+function player_all() {
+ return players;
+}
+
+function player_get_obj(ip) {
+ return players[ip];
+}
+
+function player_register(ip, name, role) {
+ let p = players[ip];
+ if (!p) {
+ p = { ip, name, alive: true, init_role: role, role: role };
+ players[ip] = p;
+ players_order.push(ip);
+ } else {
+ p.name = name;
+ p.init_role = role;
+ p.role = role;
+ }
+ return p;
+}
+
+function player_unregister(ip) {
+ delete players[ip];
+ if (players_order) {
+ let index = players_order.indexOf(ip);
+ if (index >= 0) players_order.splice(index, 1);
+ }
+}
+
+function player_reorder(seq) {
+ players_order = seq;
+}
+
+function player_get_order() {
+ return players_order;
+}
+
+function player_get_info(ip) {
+ let p = players[ip];
+ let m = '';
+ if (!p) return '请问你哪位?';
+ if (actions.lovers) {
+ if (actions.lovers.indexOf(ip) >= 0) {
+ m += players[actions.lovers[0]].name + ' 和 ' + players[actions.lovers[1]].name +' 是情侣;';
+ }
+ }
+ if (!p.alive) {
+ m += (actions.poison?('女巫毒药使用给了 ' + players[actions.poison[0]].name +';'):'');
+ m += (actions.heal?('女巫解药使用给了 ' + players[actions.heal[0]].name +';'):'');
+ m += (actions.admire?('野孩子崇拜了 ' + players[actions.admire[0]].name + ';'):'');
+ m += ((actions.kill && actions.kill.length > 0)?('狼人猎杀了 ' + players[actions.kill[0]].name + ';'):'');
+ m += (actions.bigvote?('乌鸦把增加票的权利给了 ' + players[actions.kill[0]].name + ';'):'');
+ m += (actions.deal?('两姐妹协商投票给 ' + players[actions.deal[0]].name + ';'):'');
+ m += (actions.lovers?(players[actions.lovers[0]].name + ' 和 ' + players[actions.lovers[1]].name +' 是情侣;'):'');
+ m += '[ ';
+ Object.keys(players).forEach((x) => {
+ m += players[x].name + ' 是 ' + player_role_name(players[x].init_role) + '; ';
+ });
+ m += ']'
+ }
+ return m;
+}
+
+function state_get() {
+ let s = Object.assign({}, state);
+ if (['x', 'S', 'W', 'U', 'Q', 'C', 'G', 'D'].indexOf(s.cur) >= 0) {
+ s.i_alive = Object.keys(players).map((x) => players[x].alive?players[x]:null);
+ }
+ if (s.cur === 'W' && !actions.heal && actions.kill) {
+ s.i_healable = actions.kill.map((x) => x?players[x]:null);
+ }
+ if (s.cur === 'T') {
+ s.i_roles = [];
+ if (state.config) {
+ let roles = Object.assign({}, state.config);
+ for(let ip in players) {
+ roles[players[ip].init_role] --;
+ }
+ for(let role in roles) {
+ if (roles[role] > 0) s.i_roles.push({
+ ip: role,
+ name: player_role_name(role)
+ });
+ }
+ }
+ }
+ return s;
+}
+
+function state_set(val) {
+ switch (val) {
+ case ' ':
+ state.id = uuid.v4();
+ actions = {};
+ Object.keys(players).forEach((p) => {
+ if (!p) return;
+ p = players[p];
+ if (!p) return;
+ p.alive = true;
+ delete p.init_role;
+ delete p.role;
+ });
+ break;
+ default:
+ }
+ state.cur = val;
+}
+
+function config_get() {
+ return state.config;
+}
+
+function config_set(config) {
+ state.config = config;
+}
+
+function actions_get() {
+ return actions;
+}
+
+function actions_set(key, value) {
+ if (!value) {
+ delete actions[key];
+ return;
+ }
+ // e.g. 1,2,3 => [1,2,3]
+ // e.g. +1,2 => [x,y] + [1,2] = [x,y,1,2]
+ value = value.split(',');
+ if (value[0].charAt(0) === '+') {
+ value[0] = value[0].substring(1);
+ if (!actions[key]) actions[key] = [];
+ actions[key] = actions[key].concat(value);
+ } else {
+ actions[key] = value;
+ }
+ if (key === 'role') {
+ let ip = player_find('init_role', 'T');
+ players[ip].role = value[0];
+ }
+}
+
+function night_result() {
+ let died = actions.kill || [],
+ i, heal, protect;
+ if (actions.heal) {
+ heal = actions.heal[0];
+ i = died.indexOf(heal);
+ if (i >= 0) died.splice(i, 1);
+ }
+ if (actions.protect) {
+ protect = actions.protect[0];
+ i = died.indexOf(protect);
+ if (heal === protect) {
+ died.push(heal);
+ } else if (i >= 0) {
+ died.splice(i, 1);
+ }
+ }
+ if (actions.poison) {
+ died = died.concat(actions.poison);
+ }
+ if (actions.lovers) {
+ let love_die = [];
+ died.forEach((x) => {
+ i = actions.lovers.indexOf(x);
+ if (i === 0 && died.indexOf(actions.lovers[1]) < 0) love_die.push(actions.lovers[1]);
+ else if (i === 1 && died.indexOf(actions.lovers[0]) < 0) love_die.push(actions.lovers[0]);
+ });
+ died = died.concat(love_die);
+ }
+ let m;
+ if (died.length) {
+ m = '昨晚死亡的人员有 ' + died.map((x) => players[x].name).join(', ') + ';';
+ died.forEach((x) => {
+ let p = players[x];
+ p.alive = false;
+ });
+ actions.died = died;
+ } else {
+ m = '昨晚平安夜;';
+ actions.died = [];
+ }
+ return m;
+}
+
+function next_player(ip) {
+ var i = players_order.indexOf(ip),
+ j = i + 1;
+ while (j !== i) {
+ if (j >= players_order.length) j = 0;
+ if (players[players_order[j]].alive === true) {
+ return players_order[j];
+ }
+ j ++;
+ }
+ return ip;
+}
+
+function prev_player(ip) {
+ var i = players_order.indexOf(ip),
+ j = i - 1;
+ while (j !== i) {
+ if (j < 0) j = players_order.length - 1;
+ if (players[players_order[j]].alive === true) {
+ return players_order[j];
+ }
+ j --;
+ }
+ return ip;
+}
+
+function is_werewolf(role) {
+ return ['x', 'i', 'g', 's'].indexOf(role) >= 0;
+}
+
+function info_hunter() {
+ let hunter_ip = player_find('init_role', 'H'), m;
+ if (!hunter_ip) return '';
+ // @require: night_result
+ if (actions.died.indexOf(hunter_ip)) {
+ m = '猎人' + ((~~(Math.random()*2))?'可以':'不能') + '发动技能;';
+ } else {
+ if (actions.poison && actions.poison.indexOf(hunter_ip) >= 0) {
+ m = '猎人不能发动技能;';
+ } else if (actions.infect && actions.infect.indexOf(hunter_ip) >= 0) {
+ m = '猎人不能发动技能;';
+ } else if (actions.heal && actions.heal.indexOf(hunter_ip) >= 0) {
+ // heal but die, should heal == protect
+ m = '猎人不能发动技能;';
+ } else if (actions.kill && actions.kill.indexOf(hunter_ip) >= 0) {
+ m = '猎人可以发动技能;';
+ } else if (actions.kill2 && actions.kill2.indexOf(hunter_ip) >= 0) {
+ m = '猎人可以发动技能;';
+ } else {
+ // should die for love
+ m += '猎人不能发动技能;';
+ }
+ }
+ return m;
+}
+
+function info_bear() {
+ let bear_ip = player_find('init_role', 'B');
+ if (!bear_ip) return '';
+ let next_ip = next_player(bear_ip),
+ prev_ip = prev_player(bear_ip),
+ pbear = players[bear_ip],
+ pnext = players[next_ip],
+ pprev = players[prev_ip];
+ if (pbear.alive) {
+ if (pbear.role === 'x') {
+ return '熊在咆哮;';
+ } else if (is_werewolf(pnext.role) || is_werewolf(pprev.role)) {
+ return '熊在咆哮;';
+ } else {
+ return '熊很安静;';
+ }
+ } else {
+ return '熊很安静;';
+ }
+}
+
+function player_role_name(role) {
+ switch (role) {
+ case 'S':
+ return '预言家';
+ case 'W':
+ return '女巫';
+ case 'H':
+ return '猎人';
+ case 'F':
+ return '白痴';
+ case 'G':
+ return '守卫';
+ case 'B':
+ return '熊';
+ case 'U':
+ return '野孩子';
+ case 'O':
+ return '长老';
+ case 'C':
+ return '乌鸦';
+ case 'P':
+ return '锈剑骑士';
+ case 'Q':
+ return '丘比特';
+ case 'T':
+ return '盗贼';
+ case 'E':
+ return '替罪羊';
+ case 'f':
+ return '吹笛者';
+ case 's':
+ return '贪睡狼';
+ case 'g':
+ return '大野狼';
+ case 'i':
+ return '祖狼';
+ case 'x':
+ return '狼人';
+ case 'o':
+ default:
+ return '村民';
+ }
+}
+
+module.exports = {
+ player_all,
+ player_find,
+ player_get_obj,
+ player_register,
+ player_unregister,
+ player_get_info,
+ player_reorder,
+ player_get_order,
+ actions_get,
+ actions_set,
+ state_get,
+ state_set,
+ config_get,
+ config_set,
+ night_result,
+ info_hunter,
+ info_bear
+};
diff --git a/script/dist/ws.js b/script/dist/ws.js
new file mode 100644
index 0000000..bad608f
--- /dev/null
+++ b/script/dist/ws.js
@@ -0,0 +1,3197 @@
+'use strict';
+
+var modules = {};
+
+(function () { // async-limiter
+function Queue(options) {
+ if (!(this instanceof Queue)) {
+ return new Queue(options);
+ }
+
+ options = options || {};
+ this.concurrency = options.concurrency || Infinity;
+ this.pending = 0;
+ this.jobs = [];
+ this.cbs = [];
+ this._done = done.bind(this);
+}
+
+var arrayAddMethods = [
+ 'push',
+ 'unshift',
+ 'splice'
+];
+
+arrayAddMethods.forEach(function(method) {
+ Queue.prototype[method] = function() {
+ var methodResult = Array.prototype[method].apply(this.jobs, arguments);
+ this._run();
+ return methodResult;
+ };
+});
+
+Object.defineProperty(Queue.prototype, 'length', {
+ get: function() {
+ return this.pending + this.jobs.length;
+ }
+});
+
+Queue.prototype._run = function() {
+ if (this.pending === this.concurrency) {
+ return;
+ }
+ if (this.jobs.length) {
+ var job = this.jobs.shift();
+ this.pending++;
+ job(this._done);
+ this._run();
+ }
+
+ if (this.pending === 0) {
+ while (this.cbs.length !== 0) {
+ var cb = this.cbs.pop();
+ process.nextTick(cb);
+ }
+ }
+};
+
+Queue.prototype.onDone = function(cb) {
+ if (typeof cb === 'function') {
+ this.cbs.push(cb);
+ this._run();
+ }
+};
+
+function done() {
+ this.pending--;
+ this._run();
+}
+
+modules['async-limiter'] = Queue;
+})(); // async-limiter
+
+(function() { // ws:buffer-util
+/**
+ * Merges an array of buffers into a new buffer.
+ *
+ * @param {Buffer[]} list The array of buffers to concat
+ * @param {Number} totalLength The total length of buffers in the list
+ * @return {Buffer} The resulting buffer
+ * @public
+ */
+function concat (list, totalLength) {
+ const target = Buffer.allocUnsafe(totalLength);
+ var offset = 0;
+
+ for (var i = 0; i < list.length; i++) {
+ const buf = list[i];
+ buf.copy(target, offset);
+ offset += buf.length;
+ }
+
+ return target;
+}
+
+/**
+ * Masks a buffer using the given mask.
+ *
+ * @param {Buffer} source The buffer to mask
+ * @param {Buffer} mask The mask to use
+ * @param {Buffer} output The buffer where to store the result
+ * @param {Number} offset The offset at which to start writing
+ * @param {Number} length The number of bytes to mask.
+ * @public
+ */
+function _mask (source, mask, output, offset, length) {
+ for (var i = 0; i < length; i++) {
+ output[offset + i] = source[i] ^ mask[i & 3];
+ }
+}
+
+/**
+ * Unmasks a buffer using the given mask.
+ *
+ * @param {Buffer} buffer The buffer to unmask
+ * @param {Buffer} mask The mask to use
+ * @public
+ */
+function _unmask (buffer, mask) {
+ // Required until https://github.com/nodejs/node/issues/9006 is resolved.
+ const length = buffer.length;
+ for (var i = 0; i < length; i++) {
+ buffer[i] ^= mask[i & 3];
+ }
+}
+
+let modulet;
+try {
+ const bufferUtil = require('bufferutil');
+ const bu = bufferUtil.BufferUtil || bufferUtil;
+
+ modulet = {
+ mask (source, mask, output, offset, length) {
+ if (length < 48) _mask(source, mask, output, offset, length);
+ else bu.mask(source, mask, output, offset, length);
+ },
+ unmask (buffer, mask) {
+ if (buffer.length < 32) _unmask(buffer, mask);
+ else bu.unmask(buffer, mask);
+ },
+ concat
+ };
+} catch (e) /* istanbul ignore next */ {
+ modulet = { concat, mask: _mask, unmask: _unmask };
+}
+
+modules['ws:buffer-util'] = modulet;
+})(); // ws:buffer-util
+
+(function () { // ws:constants
+
+modules['ws:constants'] = {
+ BINARY_TYPES: ['nodebuffer', 'arraybuffer', 'fragments'],
+ GUID: '258EAFA5-E914-47DA-95CA-C5AB0DC85B11',
+ kStatusCode: Symbol('status-code'),
+ kWebSocket: Symbol('websocket'),
+ EMPTY_BUFFER: Buffer.alloc(0),
+ NOOP: () => {}
+};
+
+})(); // ws:constants
+
+(function () { // ws:event-target
+
+/**
+ * Class representing an event.
+ *
+ * @private
+ */
+class Event {
+ /**
+ * Create a new `Event`.
+ *
+ * @param {String} type The name of the event
+ * @param {Object} target A reference to the target to which the event was dispatched
+ */
+ constructor (type, target) {
+ this.target = target;
+ this.type = type;
+ }
+}
+
+/**
+ * Class representing a message event.
+ *
+ * @extends Event
+ * @private
+ */
+class MessageEvent extends Event {
+ /**
+ * Create a new `MessageEvent`.
+ *
+ * @param {(String|Buffer|ArrayBuffer|Buffer[])} data The received data
+ * @param {WebSocket} target A reference to the target to which the event was dispatched
+ */
+ constructor (data, target) {
+ super('message', target);
+
+ this.data = data;
+ }
+}
+
+/**
+ * Class representing a close event.
+ *
+ * @extends Event
+ * @private
+ */
+class CloseEvent extends Event {
+ /**
+ * Create a new `CloseEvent`.
+ *
+ * @param {Number} code The status code explaining why the connection is being closed
+ * @param {String} reason A human-readable string explaining why the connection is closing
+ * @param {WebSocket} target A reference to the target to which the event was dispatched
+ */
+ constructor (code, reason, target) {
+ super('close', target);
+
+ this.wasClean = target._closeFrameReceived && target._closeFrameSent;
+ this.reason = reason;
+ this.code = code;
+ }
+}
+
+/**
+ * Class representing an open event.
+ *
+ * @extends Event
+ * @private
+ */
+class OpenEvent extends Event {
+ /**
+ * Create a new `OpenEvent`.
+ *
+ * @param {WebSocket} target A reference to the target to which the event was dispatched
+ */
+ constructor (target) {
+ super('open', target);
+ }
+}
+
+/**
+ * Class representing an error event.
+ *
+ * @extends Event
+ * @private
+ */
+class ErrorEvent extends Event {
+ /**
+ * Create a new `ErrorEvent`.
+ *
+ * @param {Object} error The error that generated this event
+ * @param {WebSocket} target A reference to the target to which the event was dispatched
+ */
+ constructor (error, target) {
+ super('error', target);
+
+ this.message = error.message;
+ this.error = error;
+ }
+}
+
+/**
+ * This provides methods for emulating the `EventTarget` interface. It's not
+ * meant to be used directly.
+ *
+ * @mixin
+ */
+const EventTarget = {
+ /**
+ * Register an event listener.
+ *
+ * @param {String} method A string representing the event type to listen for
+ * @param {Function} listener The listener to add
+ * @public
+ */
+ addEventListener (method, listener) {
+ if (typeof listener !== 'function') return;
+
+ function onMessage (data) {
+ listener.call(this, new MessageEvent(data, this));
+ }
+
+ function onClose (code, message) {
+ listener.call(this, new CloseEvent(code, message, this));
+ }
+
+ function onError (error) {
+ listener.call(this, new ErrorEvent(error, this));
+ }
+
+ function onOpen () {
+ listener.call(this, new OpenEvent(this));
+ }
+
+ if (method === 'message') {
+ onMessage._listener = listener;
+ this.on(method, onMessage);
+ } else if (method === 'close') {
+ onClose._listener = listener;
+ this.on(method, onClose);
+ } else if (method === 'error') {
+ onError._listener = listener;
+ this.on(method, onError);
+ } else if (method === 'open') {
+ onOpen._listener = listener;
+ this.on(method, onOpen);
+ } else {
+ this.on(method, listener);
+ }
+ },
+
+ /**
+ * Remove an event listener.
+ *
+ * @param {String} method A string representing the event type to remove
+ * @param {Function} listener The listener to remove
+ * @public
+ */
+ removeEventListener (method, listener) {
+ const listeners = this.listeners(method);
+
+ for (var i = 0; i < listeners.length; i++) {
+ if (listeners[i] === listener || listeners[i]._listener === listener) {
+ this.removeListener(method, listeners[i]);
+ }
+ }
+ }
+};
+
+modules['ws:event-target'] = EventTarget;
+})(); // ws:event-target
+
+(function(){ // ws:extension
+//
+// Allowed token characters:
+//
+// '!', '#', '$', '%', '&', ''', '*', '+', '-',
+// '.', 0-9, A-Z, '^', '_', '`', a-z, '|', '~'
+//
+// tokenChars[32] === 0 // ' '
+// tokenChars[33] === 1 // '!'
+// tokenChars[34] === 0 // '"'
+// ...
+//
+const tokenChars = [
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 0 - 15
+ 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // 16 - 31
+ 0, 1, 0, 1, 1, 1, 1, 1, 0, 0, 1, 1, 0, 1, 1, 0, // 32 - 47
+ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, // 48 - 63
+ 0, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 64 - 79
+ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, // 80 - 95
+ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, // 96 - 111
+ 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 1, 0, 1, 0 // 112 - 127
+];
+
+/**
+ * Adds an offer to the map of extension offers or a parameter to the map of
+ * parameters.
+ *
+ * @param {Object} dest The map of extension offers or parameters
+ * @param {String} name The extension or parameter name
+ * @param {(Object|Boolean|String)} elem The extension parameters or the
+ * parameter value
+ * @private
+ */
+function push (dest, name, elem) {
+ if (Object.prototype.hasOwnProperty.call(dest, name)) dest[name].push(elem);
+ else dest[name] = [elem];
+}
+
+/**
+ * Parses the `Sec-WebSocket-Extensions` header into an object.
+ *
+ * @param {String} header The field value of the header
+ * @return {Object} The parsed object
+ * @public
+ */
+function parse (header) {
+ const offers = {};
+
+ if (header === undefined || header === '') return offers;
+
+ var params = {};
+ var mustUnescape = false;
+ var isEscaping = false;
+ var inQuotes = false;
+ var extensionName;
+ var paramName;
+ var start = -1;
+ var end = -1;
+
+ for (var i = 0; i < header.length; i++) {
+ const code = header.charCodeAt(i);
+
+ if (extensionName === undefined) {
+ if (end === -1 && tokenChars[code] === 1) {
+ if (start === -1) start = i;
+ } else if (code === 0x20/* ' ' */|| code === 0x09/* '\t' */) {
+ if (end === -1 && start !== -1) end = i;
+ } else if (code === 0x3b/* ';' */ || code === 0x2c/* ',' */) {
+ if (start === -1) {
+ throw new SyntaxError(`Unexpected character at index ${i}`);
+ }
+
+ if (end === -1) end = i;
+ const name = header.slice(start, end);
+ if (code === 0x2c) {
+ push(offers, name, params);
+ params = {};
+ } else {
+ extensionName = name;
+ }
+
+ start = end = -1;
+ } else {
+ throw new SyntaxError(`Unexpected character at index ${i}`);
+ }
+ } else if (paramName === undefined) {
+ if (end === -1 && tokenChars[code] === 1) {
+ if (start === -1) start = i;
+ } else if (code === 0x20 || code === 0x09) {
+ if (end === -1 && start !== -1) end = i;
+ } else if (code === 0x3b || code === 0x2c) {
+ if (start === -1) {
+ throw new SyntaxError(`Unexpected character at index ${i}`);
+ }
+
+ if (end === -1) end = i;
+ push(params, header.slice(start, end), true);
+ if (code === 0x2c) {
+ push(offers, extensionName, params);
+ params = {};
+ extensionName = undefined;
+ }
+
+ start = end = -1;
+ } else if (code === 0x3d/* '=' */&& start !== -1 && end === -1) {
+ paramName = header.slice(start, i);
+ start = end = -1;
+ } else {
+ throw new SyntaxError(`Unexpected character at index ${i}`);
+ }
+ } else {
+ //
+ // The value of a quoted-string after unescaping must conform to the
+ // token ABNF, so only token characters are valid.
+ // Ref: https://tools.ietf.org/html/rfc6455#section-9.1
+ //
+ if (isEscaping) {
+ if (tokenChars[code] !== 1) {
+ throw new SyntaxError(`Unexpected character at index ${i}`);
+ }
+ if (start === -1) start = i;
+ else if (!mustUnescape) mustUnescape = true;
+ isEscaping = false;
+ } else if (inQuotes) {
+ if (tokenChars[code] === 1) {
+ if (start === -1) start = i;
+ } else if (code === 0x22/* '"' */ && start !== -1) {
+ inQuotes = false;
+ end = i;
+ } else if (code === 0x5c/* '\' */) {
+ isEscaping = true;
+ } else {
+ throw new SyntaxError(`Unexpected character at index ${i}`);
+ }
+ } else if (code === 0x22 && header.charCodeAt(i - 1) === 0x3d) {
+ inQuotes = true;
+ } else if (end === -1 && tokenChars[code] === 1) {
+ if (start === -1) start = i;
+ } else if (start !== -1 && (code === 0x20 || code === 0x09)) {
+ if (end === -1) end = i;
+ } else if (code === 0x3b || code === 0x2c) {
+ if (start === -1) {
+ throw new SyntaxError(`Unexpected character at index ${i}`);
+ }
+
+ if (end === -1) end = i;
+ var value = header.slice(start, end);
+ if (mustUnescape) {
+ value = value.replace(/\\/g, '');
+ mustUnescape = false;
+ }
+ push(params, paramName, value);
+ if (code === 0x2c) {
+ push(offers, extensionName, params);
+ params = {};
+ extensionName = undefined;
+ }
+
+ paramName = undefined;
+ start = end = -1;
+ } else {
+ throw new SyntaxError(`Unexpected character at index ${i}`);
+ }
+ }
+ }
+
+ if (start === -1 || inQuotes) {
+ throw new SyntaxError('Unexpected end of input');
+ }
+
+ if (end === -1) end = i;
+ const token = header.slice(start, end);
+ if (extensionName === undefined) {
+ push(offers, token, {});
+ } else {
+ if (paramName === undefined) {
+ push(params, token, true);
+ } else if (mustUnescape) {
+ push(params, paramName, token.replace(/\\/g, ''));
+ } else {
+ push(params, paramName, token);
+ }
+ push(offers, extensionName, params);
+ }
+
+ return offers;
+}
+
+/**
+ * Builds the `Sec-WebSocket-Extensions` header field value.
+ *
+ * @param {Object} extensions The map of extensions and parameters to format
+ * @return {String} A string representing the given object
+ * @public
+ */
+function format (extensions) {
+ return Object.keys(extensions).map((extension) => {
+ var configurations = extensions[extension];
+ if (!Array.isArray(configurations)) configurations = [configurations];
+ return configurations.map((params) => {
+ return [extension].concat(Object.keys(params).map((k) => {
+ var values = params[k];
+ if (!Array.isArray(values)) values = [values];
+ return values.map((v) => v === true ? k : `${k}=${v}`).join('; ');
+ })).join('; ');
+ }).join(', ');
+ }).join(', ');
+}
+
+modules['ws:extension'] = { format, parse };
+
+})(); // ws:extension
+
+(function(){ // ws:permessage-deflate
+const Limiter = modules['async-limiter'];
+const zlib = require('zlib');
+
+const bufferUtil = modules['ws:buffer-util'];
+const constants = modules['ws:constants'];
+
+const TRAILER = Buffer.from([0x00, 0x00, 0xff, 0xff]);
+const EMPTY_BLOCK = Buffer.from([0x00]);
+
+const kPerMessageDeflate = Symbol('permessage-deflate');
+const kWriteInProgress = Symbol('write-in-progress');
+const kPendingClose = Symbol('pending-close');
+const kTotalLength = Symbol('total-length');
+const kCallback = Symbol('callback');
+const kBuffers = Symbol('buffers');
+const kError = Symbol('error');
+
+//
+// We limit zlib concurrency, which prevents severe memory fragmentation
+// as documented in https://github.com/nodejs/node/issues/8871#issuecomment-250915913
+// and https://github.com/websockets/ws/issues/1202
+//
+// Intentionally global; it's the global thread pool that's an issue.
+//
+let zlibLimiter;
+
+/**
+ * permessage-deflate implementation.
+ */
+class PerMessageDeflate {
+ /**
+ * Creates a PerMessageDeflate instance.
+ *
+ * @param {Object} options Configuration options
+ * @param {Boolean} options.serverNoContextTakeover Request/accept disabling
+ * of server context takeover
+ * @param {Boolean} options.clientNoContextTakeover Advertise/acknowledge
+ * disabling of client context takeover
+ * @param {(Boolean|Number)} options.serverMaxWindowBits Request/confirm the
+ * use of a custom server window size
+ * @param {(Boolean|Number)} options.clientMaxWindowBits Advertise support
+ * for, or request, a custom client window size
+ * @param {Object} options.zlibDeflateOptions Options to pass to zlib on deflate
+ * @param {Object} options.zlibInflateOptions Options to pass to zlib on inflate
+ * @param {Number} options.threshold Size (in bytes) below which messages
+ * should not be compressed
+ * @param {Number} options.concurrencyLimit The number of concurrent calls to
+ * zlib
+ * @param {Boolean} isServer Create the instance in either server or client
+ * mode
+ * @param {Number} maxPayload The maximum allowed message length
+ */
+ constructor (options, isServer, maxPayload) {
+ this._maxPayload = maxPayload | 0;
+ this._options = options || {};
+ this._threshold = this._options.threshold !== undefined
+ ? this._options.threshold
+ : 1024;
+ this._isServer = !!isServer;
+ this._deflate = null;
+ this._inflate = null;
+
+ this.params = null;
+
+ if (!zlibLimiter) {
+ const concurrency = this._options.concurrencyLimit !== undefined
+ ? this._options.concurrencyLimit
+ : 10;
+ zlibLimiter = new Limiter({ concurrency });
+ }
+ }
+
+ /**
+ * @type {String}
+ */
+ static get extensionName () {
+ return 'permessage-deflate';
+ }
+
+ /**
+ * Create an extension negotiation offer.
+ *
+ * @return {Object} Extension parameters
+ * @public
+ */
+ offer () {
+ const params = {};
+
+ if (this._options.serverNoContextTakeover) {
+ params.server_no_context_takeover = true;
+ }
+ if (this._options.clientNoContextTakeover) {
+ params.client_no_context_takeover = true;
+ }
+ if (this._options.serverMaxWindowBits) {
+ params.server_max_window_bits = this._options.serverMaxWindowBits;
+ }
+ if (this._options.clientMaxWindowBits) {
+ params.client_max_window_bits = this._options.clientMaxWindowBits;
+ } else if (this._options.clientMaxWindowBits == null) {
+ params.client_max_window_bits = true;
+ }
+
+ return params;
+ }
+
+ /**
+ * Accept an extension negotiation offer/response.
+ *
+ * @param {Array} configurations The extension negotiation offers/reponse
+ * @return {Object} Accepted configuration
+ * @public
+ */
+ accept (configurations) {
+ configurations = this.normalizeParams(configurations);
+
+ this.params = this._isServer
+ ? this.acceptAsServer(configurations)
+ : this.acceptAsClient(configurations);
+
+ return this.params;
+ }
+
+ /**
+ * Releases all resources used by the extension.
+ *
+ * @public
+ */
+ cleanup () {
+ if (this._inflate) {
+ if (this._inflate[kWriteInProgress]) {
+ this._inflate[kPendingClose] = true;
+ } else {
+ this._inflate.close();
+ this._inflate = null;
+ }
+ }
+ if (this._deflate) {
+ if (this._deflate[kWriteInProgress]) {
+ this._deflate[kPendingClose] = true;
+ } else {
+ this._deflate.close();
+ this._deflate = null;
+ }
+ }
+ }
+
+ /**
+ * Accept an extension negotiation offer.
+ *
+ * @param {Array} offers The extension negotiation offers
+ * @return {Object} Accepted configuration
+ * @private
+ */
+ acceptAsServer (offers) {
+ const opts = this._options;
+ const accepted = offers.find((params) => {
+ if (
+ (opts.serverNoContextTakeover === false &&
+ params.server_no_context_takeover) ||
+ (params.server_max_window_bits &&
+ (opts.serverMaxWindowBits === false ||
+ (typeof opts.serverMaxWindowBits === 'number' &&
+ opts.serverMaxWindowBits > params.server_max_window_bits))) ||
+ (typeof opts.clientMaxWindowBits === 'number' &&
+ !params.client_max_window_bits)
+ ) {
+ return false;
+ }
+
+ return true;
+ });
+
+ if (!accepted) {
+ throw new Error('None of the extension offers can be accepted');
+ }
+
+ if (opts.serverNoContextTakeover) {
+ accepted.server_no_context_takeover = true;
+ }
+ if (opts.clientNoContextTakeover) {
+ accepted.client_no_context_takeover = true;
+ }
+ if (typeof opts.serverMaxWindowBits === 'number') {
+ accepted.server_max_window_bits = opts.serverMaxWindowBits;
+ }
+ if (typeof opts.clientMaxWindowBits === 'number') {
+ accepted.client_max_window_bits = opts.clientMaxWindowBits;
+ } else if (
+ accepted.client_max_window_bits === true ||
+ opts.clientMaxWindowBits === false
+ ) {
+ delete accepted.client_max_window_bits;
+ }
+
+ return accepted;
+ }
+
+ /**
+ * Accept the extension negotiation response.
+ *
+ * @param {Array} response The extension negotiation response
+ * @return {Object} Accepted configuration
+ * @private
+ */
+ acceptAsClient (response) {
+ const params = response[0];
+
+ if (
+ this._options.clientNoContextTakeover === false &&
+ params.client_no_context_takeover
+ ) {
+ throw new Error('Unexpected parameter "client_no_context_takeover"');
+ }
+
+ if (!params.client_max_window_bits) {
+ if (typeof this._options.clientMaxWindowBits === 'number') {
+ params.client_max_window_bits = this._options.clientMaxWindowBits;
+ }
+ } else if (
+ this._options.clientMaxWindowBits === false ||
+ (typeof this._options.clientMaxWindowBits === 'number' &&
+ params.client_max_window_bits > this._options.clientMaxWindowBits)
+ ) {
+ throw new Error(
+ 'Unexpected or invalid parameter "client_max_window_bits"'
+ );
+ }
+
+ return params;
+ }
+
+ /**
+ * Normalize parameters.
+ *
+ * @param {Array} configurations The extension negotiation offers/reponse
+ * @return {Array} The offers/response with normalized parameters
+ * @private
+ */
+ normalizeParams (configurations) {
+ configurations.forEach((params) => {
+ Object.keys(params).forEach((key) => {
+ var value = params[key];
+
+ if (value.length > 1) {
+ throw new Error(`Parameter "${key}" must have only a single value`);
+ }
+
+ value = value[0];
+
+ if (key === 'client_max_window_bits') {
+ if (value !== true) {
+ const num = +value;
+ if (!Number.isInteger(num) || num < 8 || num > 15) {
+ throw new TypeError(
+ `Invalid value for parameter "${key}": ${value}`
+ );
+ }
+ value = num;
+ } else if (!this._isServer) {
+ throw new TypeError(
+ `Invalid value for parameter "${key}": ${value}`
+ );
+ }
+ } else if (key === 'server_max_window_bits') {
+ const num = +value;
+ if (!Number.isInteger(num) || num < 8 || num > 15) {
+ throw new TypeError(
+ `Invalid value for parameter "${key}": ${value}`
+ );
+ }
+ value = num;
+ } else if (
+ key === 'client_no_context_takeover' ||
+ key === 'server_no_context_takeover'
+ ) {
+ if (value !== true) {
+ throw new TypeError(
+ `Invalid value for parameter "${key}": ${value}`
+ );
+ }
+ } else {
+ throw new Error(`Unknown parameter "${key}"`);
+ }
+
+ params[key] = value;
+ });
+ });
+
+ return configurations;
+ }
+
+ /**
+ * Decompress data. Concurrency limited by async-limiter.
+ *
+ * @param {Buffer} data Compressed data
+ * @param {Boolean} fin Specifies whether or not this is the last fragment
+ * @param {Function} callback Callback
+ * @public
+ */
+ decompress (data, fin, callback) {
+ zlibLimiter.push((done) => {
+ this._decompress(data, fin, (err, result) => {
+ done();
+ callback(err, result);
+ });
+ });
+ }
+
+ /**
+ * Compress data. Concurrency limited by async-limiter.
+ *
+ * @param {Buffer} data Data to compress
+ * @param {Boolean} fin Specifies whether or not this is the last fragment
+ * @param {Function} callback Callback
+ * @public
+ */
+ compress (data, fin, callback) {
+ zlibLimiter.push((done) => {
+ this._compress(data, fin, (err, result) => {
+ done();
+ callback(err, result);
+ });
+ });
+ }
+
+ /**
+ * Decompress data.
+ *
+ * @param {Buffer} data Compressed data
+ * @param {Boolean} fin Specifies whether or not this is the last fragment
+ * @param {Function} callback Callback
+ * @private
+ */
+ _decompress (data, fin, callback) {
+ const endpoint = this._isServer ? 'client' : 'server';
+
+ if (!this._inflate) {
+ const key = `${endpoint}_max_window_bits`;
+ const windowBits = typeof this.params[key] !== 'number'
+ ? zlib.Z_DEFAULT_WINDOWBITS
+ : this.params[key];
+
+ this._inflate = zlib.createInflateRaw(
+ Object.assign({}, this._options.zlibInflateOptions, { windowBits })
+ );
+ this._inflate[kPerMessageDeflate] = this;
+ this._inflate[kTotalLength] = 0;
+ this._inflate[kBuffers] = [];
+ this._inflate.on('error', inflateOnError);
+ this._inflate.on('data', inflateOnData);
+ }
+
+ this._inflate[kCallback] = callback;
+ this._inflate[kWriteInProgress] = true;
+
+ this._inflate.write(data);
+ if (fin) this._inflate.write(TRAILER);
+
+ this._inflate.flush(() => {
+ const err = this._inflate[kError];
+
+ if (err) {
+ this._inflate.close();
+ this._inflate = null;
+ callback(err);
+ return;
+ }
+
+ const data = bufferUtil.concat(
+ this._inflate[kBuffers],
+ this._inflate[kTotalLength]
+ );
+
+ if (
+ (fin && this.params[`${endpoint}_no_context_takeover`]) ||
+ this._inflate[kPendingClose]
+ ) {
+ this._inflate.close();
+ this._inflate = null;
+ } else {
+ this._inflate[kWriteInProgress] = false;
+ this._inflate[kTotalLength] = 0;
+ this._inflate[kBuffers] = [];
+ }
+
+ callback(null, data);
+ });
+ }
+
+ /**
+ * Compress data.
+ *
+ * @param {Buffer} data Data to compress
+ * @param {Boolean} fin Specifies whether or not this is the last fragment
+ * @param {Function} callback Callback
+ * @private
+ */
+ _compress (data, fin, callback) {
+ if (!data || data.length === 0) {
+ process.nextTick(callback, null, EMPTY_BLOCK);
+ return;
+ }
+
+ const endpoint = this._isServer ? 'server' : 'client';
+
+ if (!this._deflate) {
+ const key = `${endpoint}_max_window_bits`;
+ const windowBits = typeof this.params[key] !== 'number'
+ ? zlib.Z_DEFAULT_WINDOWBITS
+ : this.params[key];
+
+ this._deflate = zlib.createDeflateRaw(
+ Object.assign({}, this._options.zlibDeflateOptions, { windowBits })
+ );
+
+ this._deflate[kTotalLength] = 0;
+ this._deflate[kBuffers] = [];
+
+ //
+ // `zlib.DeflateRaw` emits an `'error'` event only when an attempt to use
+ // it is made after it has already been closed. This cannot happen here,
+ // so we only add a listener for the `'data'` event.
+ //
+ this._deflate.on('data', deflateOnData);
+ }
+
+ this._deflate[kWriteInProgress] = true;
+
+ this._deflate.write(data);
+ this._deflate.flush(zlib.Z_SYNC_FLUSH, () => {
+ var data = bufferUtil.concat(
+ this._deflate[kBuffers],
+ this._deflate[kTotalLength]
+ );
+
+ if (fin) data = data.slice(0, data.length - 4);
+
+ if (
+ (fin && this.params[`${endpoint}_no_context_takeover`]) ||
+ this._deflate[kPendingClose]
+ ) {
+ this._deflate.close();
+ this._deflate = null;
+ } else {
+ this._deflate[kWriteInProgress] = false;
+ this._deflate[kTotalLength] = 0;
+ this._deflate[kBuffers] = [];
+ }
+
+ callback(null, data);
+ });
+ }
+}
+
+modules['ws:permessage-deflate'] = PerMessageDeflate;
+
+/**
+ * The listener of the `zlib.DeflateRaw` stream `'data'` event.
+ *
+ * @param {Buffer} chunk A chunk of data
+ * @private
+ */
+function deflateOnData (chunk) {
+ this[kBuffers].push(chunk);
+ this[kTotalLength] += chunk.length;
+}
+
+/**
+ * The listener of the `zlib.InflateRaw` stream `'data'` event.
+ *
+ * @param {Buffer} chunk A chunk of data
+ * @private
+ */
+function inflateOnData (chunk) {
+ this[kTotalLength] += chunk.length;
+
+ if (
+ this[kPerMessageDeflate]._maxPayload < 1 ||
+ this[kTotalLength] <= this[kPerMessageDeflate]._maxPayload
+ ) {
+ this[kBuffers].push(chunk);
+ return;
+ }
+
+ this[kError] = new RangeError('Max payload size exceeded');
+ this[kError][constants.kStatusCode] = 1009;
+ this.removeListener('data', inflateOnData);
+ this.reset();
+}
+
+/**
+ * The listener of the `zlib.InflateRaw` stream `'error'` event.
+ *
+ * @param {Error} err The emitted error
+ * @private
+ */
+function inflateOnError (err) {
+ //
+ // There is no need to call `Zlib#close()` as the handle is automatically
+ // closed when an error is emitted.
+ //
+ this[kPerMessageDeflate]._inflate = null;
+ err[constants.kStatusCode] = 1007;
+ this[kCallback](err);
+}
+})(); // ws:permessage-deflate
+
+(function(){ // ws:validation
+let modulet = {};
+try {
+ const isValidUTF8 = require('utf-8-validate');
+
+ modulet.isValidUTF8 = typeof isValidUTF8 === 'object'
+ ? isValidUTF8.Validation.isValidUTF8 // utf-8-validate@<3.0.0
+ : isValidUTF8;
+} catch (e) /* istanbul ignore next */ {
+ modulet.isValidUTF8 = () => true;
+}
+
+/**
+ * Checks if a status code is allowed in a close frame.
+ *
+ * @param {Number} code The status code
+ * @return {Boolean} `true` if the status code is valid, else `false`
+ * @public
+ */
+modulet.isValidStatusCode = (code) => {
+ return (
+ (code >= 1000 &&
+ code <= 1013 &&
+ code !== 1004 &&
+ code !== 1005 &&
+ code !== 1006) ||
+ (code >= 3000 && code <= 4999)
+ );
+};
+modules['ws:validation'] = modulet;
+})(); // ws:validation
+
+(function(){ // ws:receiver
+const stream = require('stream');
+
+const PerMessageDeflate = modules['ws:permessage-deflate'];
+const bufferUtil = modules['ws:buffer-util'];
+const validation = modules['ws:validation'];
+const constants = modules['ws:constants'];
+
+const GET_INFO = 0;
+const GET_PAYLOAD_LENGTH_16 = 1;
+const GET_PAYLOAD_LENGTH_64 = 2;
+const GET_MASK = 3;
+const GET_DATA = 4;
+const INFLATING = 5;
+
+/**
+ * HyBi Receiver implementation.
+ *
+ * @extends stream.Writable
+ */
+class Receiver extends stream.Writable {
+ /**
+ * Creates a Receiver instance.
+ *
+ * @param {String} binaryType The type for binary data
+ * @param {Object} extensions An object containing the negotiated extensions
+ * @param {Number} maxPayload The maximum allowed message length
+ */
+ constructor (binaryType, extensions, maxPayload) {
+ super();
+
+ this._binaryType = binaryType || constants.BINARY_TYPES[0];
+ this[constants.kWebSocket] = undefined;
+ this._extensions = extensions || {};
+ this._maxPayload = maxPayload | 0;
+
+ this._bufferedBytes = 0;
+ this._buffers = [];
+
+ this._compressed = false;
+ this._payloadLength = 0;
+ this._mask = undefined;
+ this._fragmented = 0;
+ this._masked = false;
+ this._fin = false;
+ this._opcode = 0;
+
+ this._totalPayloadLength = 0;
+ this._messageLength = 0;
+ this._fragments = [];
+
+ this._state = GET_INFO;
+ this._loop = false;
+ }
+
+ /**
+ * Implements `Writable.prototype._write()`.
+ *
+ * @param {Buffer} chunk The chunk of data to write
+ * @param {String} encoding The character encoding of `chunk`
+ * @param {Function} cb Callback
+ */
+ _write (chunk, encoding, cb) {
+ if (this._opcode === 0x08) return cb();
+
+ this._bufferedBytes += chunk.length;
+ this._buffers.push(chunk);
+ this.startLoop(cb);
+ }
+
+ /**
+ * Consumes `n` bytes from the buffered data.
+ *
+ * @param {Number} n The number of bytes to consume
+ * @return {Buffer} The consumed bytes
+ * @private
+ */
+ consume (n) {
+ this._bufferedBytes -= n;
+
+ if (n === this._buffers[0].length) return this._buffers.shift();
+
+ if (n < this._buffers[0].length) {
+ const buf = this._buffers[0];
+ this._buffers[0] = buf.slice(n);
+ return buf.slice(0, n);
+ }
+
+ const dst = Buffer.allocUnsafe(n);
+
+ do {
+ const buf = this._buffers[0];
+
+ if (n >= buf.length) {
+ this._buffers.shift().copy(dst, dst.length - n);
+ } else {
+ buf.copy(dst, dst.length - n, 0, n);
+ this._buffers[0] = buf.slice(n);
+ }
+
+ n -= buf.length;
+ } while (n > 0);
+
+ return dst;
+ }
+
+ /**
+ * Starts the parsing loop.
+ *
+ * @param {Function} cb Callback
+ * @private
+ */
+ startLoop (cb) {
+ var err;
+ this._loop = true;
+
+ do {
+ switch (this._state) {
+ case GET_INFO:
+ err = this.getInfo();
+ break;
+ case GET_PAYLOAD_LENGTH_16:
+ err = this.getPayloadLength16();
+ break;
+ case GET_PAYLOAD_LENGTH_64:
+ err = this.getPayloadLength64();
+ break;
+ case GET_MASK:
+ this.getMask();
+ break;
+ case GET_DATA:
+ err = this.getData(cb);
+ break;
+ default: // `INFLATING`
+ this._loop = false;
+ return;
+ }
+ } while (this._loop);
+
+ cb(err);
+ }
+
+ /**
+ * Reads the first two bytes of a frame.
+ *
+ * @return {(RangeError|undefined)} A possible error
+ * @private
+ */
+ getInfo () {
+ if (this._bufferedBytes < 2) {
+ this._loop = false;
+ return;
+ }
+
+ const buf = this.consume(2);
+
+ if ((buf[0] & 0x30) !== 0x00) {
+ this._loop = false;
+ return error(RangeError, 'RSV2 and RSV3 must be clear', true, 1002);
+ }
+
+ const compressed = (buf[0] & 0x40) === 0x40;
+
+ if (compressed && !this._extensions[PerMessageDeflate.extensionName]) {
+ this._loop = false;
+ return error(RangeError, 'RSV1 must be clear', true, 1002);
+ }
+
+ this._fin = (buf[0] & 0x80) === 0x80;
+ this._opcode = buf[0] & 0x0f;
+ this._payloadLength = buf[1] & 0x7f;
+
+ if (this._opcode === 0x00) {
+ if (compressed) {
+ this._loop = false;
+ return error(RangeError, 'RSV1 must be clear', true, 1002);
+ }
+
+ if (!this._fragmented) {
+ this._loop = false;
+ return error(RangeError, 'invalid opcode 0', true, 1002);
+ }
+
+ this._opcode = this._fragmented;
+ } else if (this._opcode === 0x01 || this._opcode === 0x02) {
+ if (this._fragmented) {
+ this._loop = false;
+ return error(RangeError, `invalid opcode ${this._opcode}`, true, 1002);
+ }
+
+ this._compressed = compressed;
+ } else if (this._opcode > 0x07 && this._opcode < 0x0b) {
+ if (!this._fin) {
+ this._loop = false;
+ return error(RangeError, 'FIN must be set', true, 1002);
+ }
+
+ if (compressed) {
+ this._loop = false;
+ return error(RangeError, 'RSV1 must be clear', true, 1002);
+ }
+
+ if (this._payloadLength > 0x7d) {
+ this._loop = false;
+ return error(
+ RangeError,
+ `invalid payload length ${this._payloadLength}`,
+ true,
+ 1002
+ );
+ }
+ } else {
+ this._loop = false;
+ return error(RangeError, `invalid opcode ${this._opcode}`, true, 1002);
+ }
+
+ if (!this._fin && !this._fragmented) this._fragmented = this._opcode;
+ this._masked = (buf[1] & 0x80) === 0x80;
+
+ if (this._payloadLength === 126) this._state = GET_PAYLOAD_LENGTH_16;
+ else if (this._payloadLength === 127) this._state = GET_PAYLOAD_LENGTH_64;
+ else return this.haveLength();
+ }
+
+ /**
+ * Gets extended payload length (7+16).
+ *
+ * @return {(RangeError|undefined)} A possible error
+ * @private
+ */
+ getPayloadLength16 () {
+ if (this._bufferedBytes < 2) {
+ this._loop = false;
+ return;
+ }
+
+ this._payloadLength = this.consume(2).readUInt16BE(0);
+ return this.haveLength();
+ }
+
+ /**
+ * Gets extended payload length (7+64).
+ *
+ * @return {(RangeError|undefined)} A possible error
+ * @private
+ */
+ getPayloadLength64 () {
+ if (this._bufferedBytes < 8) {
+ this._loop = false;
+ return;
+ }
+
+ const buf = this.consume(8);
+ const num = buf.readUInt32BE(0);
+
+ //
+ // The maximum safe integer in JavaScript is 2^53 - 1. An error is returned
+ // if payload length is greater than this number.
+ //
+ if (num > Math.pow(2, 53 - 32) - 1) {
+ this._loop = false;
+ return error(
+ RangeError,
+ 'Unsupported WebSocket frame: payload length > 2^53 - 1',
+ false,
+ 1009
+ );
+ }
+
+ this._payloadLength = num * Math.pow(2, 32) + buf.readUInt32BE(4);
+ return this.haveLength();
+ }
+
+ /**
+ * Payload length has been read.
+ *
+ * @return {(RangeError|undefined)} A possible error
+ * @private
+ */
+ haveLength () {
+ if (this._payloadLength && this._opcode < 0x08) {
+ this._totalPayloadLength += this._payloadLength;
+ if (this._totalPayloadLength > this._maxPayload && this._maxPayload > 0) {
+ this._loop = false;
+ return error(RangeError, 'Max payload size exceeded', false, 1009);
+ }
+ }
+
+ if (this._masked) this._state = GET_MASK;
+ else this._state = GET_DATA;
+ }
+
+ /**
+ * Reads mask bytes.
+ *
+ * @private
+ */
+ getMask () {
+ if (this._bufferedBytes < 4) {
+ this._loop = false;
+ return;
+ }
+
+ this._mask = this.consume(4);
+ this._state = GET_DATA;
+ }
+
+ /**
+ * Reads data bytes.
+ *
+ * @param {Function} cb Callback
+ * @return {(Error|RangeError|undefined)} A possible error
+ * @private
+ */
+ getData (cb) {
+ var data = constants.EMPTY_BUFFER;
+
+ if (this._payloadLength) {
+ if (this._bufferedBytes < this._payloadLength) {
+ this._loop = false;
+ return;
+ }
+
+ data = this.consume(this._payloadLength);
+ if (this._masked) bufferUtil.unmask(data, this._mask);
+ }
+
+ if (this._opcode > 0x07) return this.controlMessage(data);
+
+ if (this._compressed) {
+ this._state = INFLATING;
+ this.decompress(data, cb);
+ return;
+ }
+
+ if (data.length) {
+ //
+ // This message is not compressed so its lenght is the sum of the payload
+ // length of all fragments.
+ //
+ this._messageLength = this._totalPayloadLength;
+ this._fragments.push(data);
+ }
+
+ return this.dataMessage();
+ }
+
+ /**
+ * Decompresses data.
+ *
+ * @param {Buffer} data Compressed data
+ * @param {Function} cb Callback
+ * @private
+ */
+ decompress (data, cb) {
+ const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
+
+ perMessageDeflate.decompress(data, this._fin, (err, buf) => {
+ if (err) return cb(err);
+
+ if (buf.length) {
+ this._messageLength += buf.length;
+ if (this._messageLength > this._maxPayload && this._maxPayload > 0) {
+ return cb(error(RangeError, 'Max payload size exceeded', false, 1009));
+ }
+
+ this._fragments.push(buf);
+ }
+
+ const er = this.dataMessage();
+ if (er) return cb(er);
+
+ this.startLoop(cb);
+ });
+ }
+
+ /**
+ * Handles a data message.
+ *
+ * @return {(Error|undefined)} A possible error
+ * @private
+ */
+ dataMessage () {
+ if (this._fin) {
+ const messageLength = this._messageLength;
+ const fragments = this._fragments;
+
+ this._totalPayloadLength = 0;
+ this._messageLength = 0;
+ this._fragmented = 0;
+ this._fragments = [];
+
+ if (this._opcode === 2) {
+ var data;
+
+ if (this._binaryType === 'nodebuffer') {
+ data = toBuffer(fragments, messageLength);
+ } else if (this._binaryType === 'arraybuffer') {
+ data = toArrayBuffer(toBuffer(fragments, messageLength));
+ } else {
+ data = fragments;
+ }
+
+ this.emit('message', data);
+ } else {
+ const buf = toBuffer(fragments, messageLength);
+
+ if (!validation.isValidUTF8(buf)) {
+ this._loop = false;
+ return error(Error, 'invalid UTF-8 sequence', true, 1007);
+ }
+
+ this.emit('message', buf.toString());
+ }
+ }
+
+ this._state = GET_INFO;
+ }
+
+ /**
+ * Handles a control message.
+ *
+ * @param {Buffer} data Data to handle
+ * @return {(Error|RangeError|undefined)} A possible error
+ * @private
+ */
+ controlMessage (data) {
+ if (this._opcode === 0x08) {
+ this._loop = false;
+
+ if (data.length === 0) {
+ this.emit('conclude', 1005, '');
+ this.end();
+ } else if (data.length === 1) {
+ return error(RangeError, 'invalid payload length 1', true, 1002);
+ } else {
+ const code = data.readUInt16BE(0);
+
+ if (!validation.isValidStatusCode(code)) {
+ return error(RangeError, `invalid status code ${code}`, true, 1002);
+ }
+
+ const buf = data.slice(2);
+
+ if (!validation.isValidUTF8(buf)) {
+ return error(Error, 'invalid UTF-8 sequence', true, 1007);
+ }
+
+ this.emit('conclude', code, buf.toString());
+ this.end();
+ }
+
+ return;
+ }
+
+ if (this._opcode === 0x09) this.emit('ping', data);
+ else this.emit('pong', data);
+
+ this._state = GET_INFO;
+ }
+}
+
+modules['ws:receiver'] = Receiver;
+
+/**
+ * Builds an error object.
+ *
+ * @param {(Error|RangeError)} ErrorCtor The error constructor
+ * @param {String} message The error message
+ * @param {Boolean} prefix Specifies whether or not to add a default prefix to
+ * `message`
+ * @param {Number} statusCode The status code
+ * @return {(Error|RangeError)} The error
+ * @private
+ */
+function error (ErrorCtor, message, prefix, statusCode) {
+ const err = new ErrorCtor(
+ prefix ? `Invalid WebSocket frame: ${message}` : message
+ );
+
+ Error.captureStackTrace(err, error);
+ err[constants.kStatusCode] = statusCode;
+ return err;
+}
+
+/**
+ * Makes a buffer from a list of fragments.
+ *
+ * @param {Buffer[]} fragments The list of fragments composing the message
+ * @param {Number} messageLength The length of the message
+ * @return {Buffer}
+ * @private
+ */
+function toBuffer (fragments, messageLength) {
+ if (fragments.length === 1) return fragments[0];
+ if (fragments.length > 1) return bufferUtil.concat(fragments, messageLength);
+ return constants.EMPTY_BUFFER;
+}
+
+/**
+ * Converts a buffer to an `ArrayBuffer`.
+ *
+ * @param {Buffer} The buffer to convert
+ * @return {ArrayBuffer} Converted buffer
+ */
+function toArrayBuffer (buf) {
+ if (buf.byteOffset === 0 && buf.byteLength === buf.buffer.byteLength) {
+ return buf.buffer;
+ }
+
+ return buf.buffer.slice(buf.byteOffset, buf.byteOffset + buf.byteLength);
+}
+})(); // ws:receiver
+
+(function(){ // ws:sender
+const crypto = require('crypto');
+
+const PerMessageDeflate = modules['ws:permessage-deflate'];
+const bufferUtil = modules['ws:buffer-util'];
+const validation = modules['ws:validation'];
+const constants = modules['ws:constants'];
+
+/**
+ * HyBi Sender implementation.
+ */
+class Sender {
+ /**
+ * Creates a Sender instance.
+ *
+ * @param {net.Socket} socket The connection socket
+ * @param {Object} extensions An object containing the negotiated extensions
+ */
+ constructor (socket, extensions) {
+ this._extensions = extensions || {};
+ this._socket = socket;
+
+ this._firstFragment = true;
+ this._compress = false;
+
+ this._bufferedBytes = 0;
+ this._deflating = false;
+ this._queue = [];
+ }
+
+ /**
+ * Frames a piece of data according to the HyBi WebSocket protocol.
+ *
+ * @param {Buffer} data The data to frame
+ * @param {Object} options Options object
+ * @param {Number} options.opcode The opcode
+ * @param {Boolean} options.readOnly Specifies whether `data` can be modified
+ * @param {Boolean} options.fin Specifies whether or not to set the FIN bit
+ * @param {Boolean} options.mask Specifies whether or not to mask `data`
+ * @param {Boolean} options.rsv1 Specifies whether or not to set the RSV1 bit
+ * @return {Buffer[]} The framed data as a list of `Buffer` instances
+ * @public
+ */
+ static frame (data, options) {
+ const merge = data.length < 1024 || (options.mask && options.readOnly);
+ var offset = options.mask ? 6 : 2;
+ var payloadLength = data.length;
+
+ if (data.length >= 65536) {
+ offset += 8;
+ payloadLength = 127;
+ } else if (data.length > 125) {
+ offset += 2;
+ payloadLength = 126;
+ }
+
+ const target = Buffer.allocUnsafe(merge ? data.length + offset : offset);
+
+ target[0] = options.fin ? options.opcode | 0x80 : options.opcode;
+ if (options.rsv1) target[0] |= 0x40;
+
+ if (payloadLength === 126) {
+ target.writeUInt16BE(data.length, 2);
+ } else if (payloadLength === 127) {
+ target.writeUInt32BE(0, 2);
+ target.writeUInt32BE(data.length, 6);
+ }
+
+ if (!options.mask) {
+ target[1] = payloadLength;
+ if (merge) {
+ data.copy(target, offset);
+ return [target];
+ }
+
+ return [target, data];
+ }
+
+ const mask = crypto.randomBytes(4);
+
+ target[1] = payloadLength | 0x80;
+ target[offset - 4] = mask[0];
+ target[offset - 3] = mask[1];
+ target[offset - 2] = mask[2];
+ target[offset - 1] = mask[3];
+
+ if (merge) {
+ bufferUtil.mask(data, mask, target, offset, data.length);
+ return [target];
+ }
+
+ bufferUtil.mask(data, mask, data, 0, data.length);
+ return [target, data];
+ }
+
+ /**
+ * Sends a close message to the other peer.
+ *
+ * @param {(Number|undefined)} code The status code component of the body
+ * @param {String} data The message component of the body
+ * @param {Boolean} mask Specifies whether or not to mask the message
+ * @param {Function} cb Callback
+ * @public
+ */
+ close (code, data, mask, cb) {
+ var buf;
+
+ if (code === undefined) {
+ buf = constants.EMPTY_BUFFER;
+ } else if (typeof code !== 'number' || !validation.isValidStatusCode(code)) {
+ throw new TypeError('First argument must be a valid error code number');
+ } else if (data === undefined || data === '') {
+ buf = Buffer.allocUnsafe(2);
+ buf.writeUInt16BE(code, 0);
+ } else {
+ buf = Buffer.allocUnsafe(2 + Buffer.byteLength(data));
+ buf.writeUInt16BE(code, 0);
+ buf.write(data, 2);
+ }
+
+ if (this._deflating) {
+ this.enqueue([this.doClose, buf, mask, cb]);
+ } else {
+ this.doClose(buf, mask, cb);
+ }
+ }
+
+ /**
+ * Frames and sends a close message.
+ *
+ * @param {Buffer} data The message to send
+ * @param {Boolean} mask Specifies whether or not to mask `data`
+ * @param {Function} cb Callback
+ * @private
+ */
+ doClose (data, mask, cb) {
+ this.sendFrame(Sender.frame(data, {
+ fin: true,
+ rsv1: false,
+ opcode: 0x08,
+ mask,
+ readOnly: false
+ }), cb);
+ }
+
+ /**
+ * Sends a ping message to the other peer.
+ *
+ * @param {*} data The message to send
+ * @param {Boolean} mask Specifies whether or not to mask `data`
+ * @param {Function} cb Callback
+ * @public
+ */
+ ping (data, mask, cb) {
+ var readOnly = true;
+
+ if (!Buffer.isBuffer(data)) {
+ if (data instanceof ArrayBuffer) {
+ data = Buffer.from(data);
+ } else if (ArrayBuffer.isView(data)) {
+ data = viewToBuffer(data);
+ } else {
+ data = Buffer.from(data);
+ readOnly = false;
+ }
+ }
+
+ if (this._deflating) {
+ this.enqueue([this.doPing, data, mask, readOnly, cb]);
+ } else {
+ this.doPing(data, mask, readOnly, cb);
+ }
+ }
+
+ /**
+ * Frames and sends a ping message.
+ *
+ * @param {*} data The message to send
+ * @param {Boolean} mask Specifies whether or not to mask `data`
+ * @param {Boolean} readOnly Specifies whether `data` can be modified
+ * @param {Function} cb Callback
+ * @private
+ */
+ doPing (data, mask, readOnly, cb) {
+ this.sendFrame(Sender.frame(data, {
+ fin: true,
+ rsv1: false,
+ opcode: 0x09,
+ mask,
+ readOnly
+ }), cb);
+ }
+
+ /**
+ * Sends a pong message to the other peer.
+ *
+ * @param {*} data The message to send
+ * @param {Boolean} mask Specifies whether or not to mask `data`
+ * @param {Function} cb Callback
+ * @public
+ */
+ pong (data, mask, cb) {
+ var readOnly = true;
+
+ if (!Buffer.isBuffer(data)) {
+ if (data instanceof ArrayBuffer) {
+ data = Buffer.from(data);
+ } else if (ArrayBuffer.isView(data)) {
+ data = viewToBuffer(data);
+ } else {
+ data = Buffer.from(data);
+ readOnly = false;
+ }
+ }
+
+ if (this._deflating) {
+ this.enqueue([this.doPong, data, mask, readOnly, cb]);
+ } else {
+ this.doPong(data, mask, readOnly, cb);
+ }
+ }
+
+ /**
+ * Frames and sends a pong message.
+ *
+ * @param {*} data The message to send
+ * @param {Boolean} mask Specifies whether or not to mask `data`
+ * @param {Boolean} readOnly Specifies whether `data` can be modified
+ * @param {Function} cb Callback
+ * @private
+ */
+ doPong (data, mask, readOnly, cb) {
+ this.sendFrame(Sender.frame(data, {
+ fin: true,
+ rsv1: false,
+ opcode: 0x0a,
+ mask,
+ readOnly
+ }), cb);
+ }
+
+ /**
+ * Sends a data message to the other peer.
+ *
+ * @param {*} data The message to send
+ * @param {Object} options Options object
+ * @param {Boolean} options.compress Specifies whether or not to compress `data`
+ * @param {Boolean} options.binary Specifies whether `data` is binary or text
+ * @param {Boolean} options.fin Specifies whether the fragment is the last one
+ * @param {Boolean} options.mask Specifies whether or not to mask `data`
+ * @param {Function} cb Callback
+ * @public
+ */
+ send (data, options, cb) {
+ var opcode = options.binary ? 2 : 1;
+ var rsv1 = options.compress;
+ var readOnly = true;
+
+ if (!Buffer.isBuffer(data)) {
+ if (data instanceof ArrayBuffer) {
+ data = Buffer.from(data);
+ } else if (ArrayBuffer.isView(data)) {
+ data = viewToBuffer(data);
+ } else {
+ data = Buffer.from(data);
+ readOnly = false;
+ }
+ }
+
+ const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
+
+ if (this._firstFragment) {
+ this._firstFragment = false;
+ if (rsv1 && perMessageDeflate) {
+ rsv1 = data.length >= perMessageDeflate._threshold;
+ }
+ this._compress = rsv1;
+ } else {
+ rsv1 = false;
+ opcode = 0;
+ }
+
+ if (options.fin) this._firstFragment = true;
+
+ if (perMessageDeflate) {
+ const opts = {
+ fin: options.fin,
+ rsv1,
+ opcode,
+ mask: options.mask,
+ readOnly
+ };
+
+ if (this._deflating) {
+ this.enqueue([this.dispatch, data, this._compress, opts, cb]);
+ } else {
+ this.dispatch(data, this._compress, opts, cb);
+ }
+ } else {
+ this.sendFrame(Sender.frame(data, {
+ fin: options.fin,
+ rsv1: false,
+ opcode,
+ mask: options.mask,
+ readOnly
+ }), cb);
+ }
+ }
+
+ /**
+ * Dispatches a data message.
+ *
+ * @param {Buffer} data The message to send
+ * @param {Boolean} compress Specifies whether or not to compress `data`
+ * @param {Object} options Options object
+ * @param {Number} options.opcode The opcode
+ * @param {Boolean} options.readOnly Specifies whether `data` can be modified
+ * @param {Boolean} options.fin Specifies whether or not to set the FIN bit
+ * @param {Boolean} options.mask Specifies whether or not to mask `data`
+ * @param {Boolean} options.rsv1 Specifies whether or not to set the RSV1 bit
+ * @param {Function} cb Callback
+ * @private
+ */
+ dispatch (data, compress, options, cb) {
+ if (!compress) {
+ this.sendFrame(Sender.frame(data, options), cb);
+ return;
+ }
+
+ const perMessageDeflate = this._extensions[PerMessageDeflate.extensionName];
+
+ this._deflating = true;
+ perMessageDeflate.compress(data, options.fin, (_, buf) => {
+ options.readOnly = false;
+ this.sendFrame(Sender.frame(buf, options), cb);
+ this._deflating = false;
+ this.dequeue();
+ });
+ }
+
+ /**
+ * Executes queued send operations.
+ *
+ * @private
+ */
+ dequeue () {
+ while (!this._deflating && this._queue.length) {
+ const params = this._queue.shift();
+
+ this._bufferedBytes -= params[1].length;
+ params[0].apply(this, params.slice(1));
+ }
+ }
+
+ /**
+ * Enqueues a send operation.
+ *
+ * @param {Array} params Send operation parameters.
+ * @private
+ */
+ enqueue (params) {
+ this._bufferedBytes += params[1].length;
+ this._queue.push(params);
+ }
+
+ /**
+ * Sends a frame.
+ *
+ * @param {Buffer[]} list The frame to send
+ * @param {Function} cb Callback
+ * @private
+ */
+ sendFrame (list, cb) {
+ if (list.length === 2) {
+ this._socket.write(list[0]);
+ this._socket.write(list[1], cb);
+ } else {
+ this._socket.write(list[0], cb);
+ }
+ }
+}
+
+modules['ws:sender'] = Sender;
+
+/**
+ * Converts an `ArrayBuffer` view into a buffer.
+ *
+ * @param {(DataView|TypedArray)} view The view to convert
+ * @return {Buffer} Converted view
+ * @private
+ */
+function viewToBuffer (view) {
+ const buf = Buffer.from(view.buffer);
+
+ if (view.byteLength !== view.buffer.byteLength) {
+ return buf.slice(view.byteOffset, view.byteOffset + view.byteLength);
+ }
+
+ return buf;
+}
+})(); // ws:sender
+
+(function(){ // ws:websocket
+const EventEmitter = require('events');
+const crypto = require('crypto');
+const https = require('https');
+const http = require('http');
+const net = require('net');
+const tls = require('tls');
+const url = require('url');
+
+const PerMessageDeflate = modules['ws:permessage-deflate'];
+const EventTarget = modules['ws:event-target'];
+const extension = modules['ws:extension'];
+const constants = modules['ws:constants'];
+const Receiver = modules['ws:receiver'];
+const Sender = modules['ws:sender'];
+
+const readyStates = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'];
+const kWebSocket = constants.kWebSocket;
+const protocolVersions = [8, 13];
+const closeTimeout = 30 * 1000; // Allow 30 seconds to terminate the connection cleanly.
+
+/**
+ * Class representing a WebSocket.
+ *
+ * @extends EventEmitter
+ */
+class WebSocket extends EventEmitter {
+ /**
+ * Create a new `WebSocket`.
+ *
+ * @param {(String|url.Url|url.URL)} address The URL to which to connect
+ * @param {(String|String[])} protocols The subprotocols
+ * @param {Object} options Connection options
+ */
+ constructor (address, protocols, options) {
+ super();
+
+ this.readyState = WebSocket.CONNECTING;
+ this.protocol = '';
+
+ this._binaryType = constants.BINARY_TYPES[0];
+ this._closeFrameReceived = false;
+ this._closeFrameSent = false;
+ this._closeMessage = '';
+ this._closeTimer = null;
+ this._closeCode = 1006;
+ this._extensions = {};
+ this._isServer = true;
+ this._receiver = null;
+ this._sender = null;
+ this._socket = null;
+
+ if (address !== null) {
+ if (Array.isArray(protocols)) {
+ protocols = protocols.join(', ');
+ } else if (typeof protocols === 'object' && protocols !== null) {
+ options = protocols;
+ protocols = undefined;
+ }
+
+ initAsClient.call(this, address, protocols, options);
+ }
+ }
+
+ get CONNECTING () { return WebSocket.CONNECTING; }
+ get CLOSING () { return WebSocket.CLOSING; }
+ get CLOSED () { return WebSocket.CLOSED; }
+ get OPEN () { return WebSocket.OPEN; }
+
+ /**
+ * This deviates from the WHATWG interface since ws doesn't support the required
+ * default "blob" type (instead we define a custom "nodebuffer" type).
+ *
+ * @type {String}
+ */
+ get binaryType () {
+ return this._binaryType;
+ }
+
+ set binaryType (type) {
+ if (constants.BINARY_TYPES.indexOf(type) < 0) return;
+
+ this._binaryType = type;
+
+ //
+ // Allow to change `binaryType` on the fly.
+ //
+ if (this._receiver) this._receiver._binaryType = type;
+ }
+
+ /**
+ * @type {Number}
+ */
+ get bufferedAmount () {
+ if (!this._socket) return 0;
+
+ //
+ // `socket.bufferSize` is `undefined` if the socket is closed.
+ //
+ return (this._socket.bufferSize || 0) + this._sender._bufferedBytes;
+ }
+
+ /**
+ * @type {String}
+ */
+ get extensions () {
+ return Object.keys(this._extensions).join();
+ }
+
+ /**
+ * Set up the socket and the internal resources.
+ *
+ * @param {net.Socket} socket The network socket between the server and client
+ * @param {Buffer} head The first packet of the upgraded stream
+ * @param {Number} maxPayload The maximum allowed message size
+ * @private
+ */
+ setSocket (socket, head, maxPayload) {
+ const receiver = new Receiver(
+ this._binaryType,
+ this._extensions,
+ maxPayload
+ );
+
+ this._sender = new Sender(socket, this._extensions);
+ this._receiver = receiver;
+ this._socket = socket;
+
+ receiver[kWebSocket] = this;
+ socket[kWebSocket] = this;
+
+ receiver.on('conclude', receiverOnConclude);
+ receiver.on('drain', receiverOnDrain);
+ receiver.on('error', receiverOnError);
+ receiver.on('message', receiverOnMessage);
+ receiver.on('ping', receiverOnPing);
+ receiver.on('pong', receiverOnPong);
+
+ socket.setTimeout(0);
+ socket.setNoDelay();
+
+ if (head.length > 0) socket.unshift(head);
+
+ socket.on('close', socketOnClose);
+ socket.on('data', socketOnData);
+ socket.on('end', socketOnEnd);
+ socket.on('error', socketOnError);
+
+ this.readyState = WebSocket.OPEN;
+ this.emit('open');
+ }
+
+ /**
+ * Emit the `'close'` event.
+ *
+ * @private
+ */
+ emitClose () {
+ this.readyState = WebSocket.CLOSED;
+
+ if (!this._socket) {
+ this.emit('close', this._closeCode, this._closeMessage);
+ return;
+ }
+
+ if (this._extensions[PerMessageDeflate.extensionName]) {
+ this._extensions[PerMessageDeflate.extensionName].cleanup();
+ }
+
+ this._receiver.removeAllListeners();
+ this.emit('close', this._closeCode, this._closeMessage);
+ }
+
+ /**
+ * Start a closing handshake.
+ *
+ * +----------+ +-----------+ +----------+
+ * - - -|ws.close()|-->|close frame|-->|ws.close()|- - -
+ * | +----------+ +-----------+ +----------+ |
+ * +----------+ +-----------+ |
+ * CLOSING |ws.close()|<--|close frame|<--+-----+ CLOSING
+ * +----------+ +-----------+ |
+ * | | | +---+ |
+ * +------------------------+-->|fin| - - - -
+ * | +---+ | +---+
+ * - - - - -|fin|<---------------------+
+ * +---+
+ *
+ * @param {Number} code Status code explaining why the connection is closing
+ * @param {String} data A string explaining why the connection is closing
+ * @public
+ */
+ close (code, data) {
+ if (this.readyState === WebSocket.CLOSED) return;
+ if (this.readyState === WebSocket.CONNECTING) {
+ const msg = 'WebSocket was closed before the connection was established';
+ return abortHandshake(this, this._req, msg);
+ }
+
+ if (this.readyState === WebSocket.CLOSING) {
+ if (this._closeFrameSent && this._closeFrameReceived) this._socket.end();
+ return;
+ }
+
+ this.readyState = WebSocket.CLOSING;
+ this._sender.close(code, data, !this._isServer, (err) => {
+ //
+ // This error is handled by the `'error'` listener on the socket. We only
+ // want to know if the close frame has been sent here.
+ //
+ if (err) return;
+
+ this._closeFrameSent = true;
+
+ if (this._socket.writable) {
+ if (this._closeFrameReceived) this._socket.end();
+
+ //
+ // Ensure that the connection is closed even if the closing handshake
+ // fails.
+ //
+ this._closeTimer = setTimeout(
+ this._socket.destroy.bind(this._socket),
+ closeTimeout
+ );
+ }
+ });
+ }
+
+ /**
+ * Send a ping.
+ *
+ * @param {*} data The data to send
+ * @param {Boolean} mask Indicates whether or not to mask `data`
+ * @param {Function} cb Callback which is executed when the ping is sent
+ * @public
+ */
+ ping (data, mask, cb) {
+ if (typeof data === 'function') {
+ cb = data;
+ data = mask = undefined;
+ } else if (typeof mask === 'function') {
+ cb = mask;
+ mask = undefined;
+ }
+
+ if (this.readyState !== WebSocket.OPEN) {
+ const err = new Error(
+ `WebSocket is not open: readyState ${this.readyState} ` +
+ `(${readyStates[this.readyState]})`
+ );
+
+ if (cb) return cb(err);
+ throw err;
+ }
+
+ if (typeof data === 'number') data = data.toString();
+ if (mask === undefined) mask = !this._isServer;
+ this._sender.ping(data || constants.EMPTY_BUFFER, mask, cb);
+ }
+
+ /**
+ * Send a pong.
+ *
+ * @param {*} data The data to send
+ * @param {Boolean} mask Indicates whether or not to mask `data`
+ * @param {Function} cb Callback which is executed when the pong is sent
+ * @public
+ */
+ pong (data, mask, cb) {
+ if (typeof data === 'function') {
+ cb = data;
+ data = mask = undefined;
+ } else if (typeof mask === 'function') {
+ cb = mask;
+ mask = undefined;
+ }
+
+ if (this.readyState !== WebSocket.OPEN) {
+ const err = new Error(
+ `WebSocket is not open: readyState ${this.readyState} ` +
+ `(${readyStates[this.readyState]})`
+ );
+
+ if (cb) return cb(err);
+ throw err;
+ }
+
+ if (typeof data === 'number') data = data.toString();
+ if (mask === undefined) mask = !this._isServer;
+ this._sender.pong(data || constants.EMPTY_BUFFER, mask, cb);
+ }
+
+ /**
+ * Send a data message.
+ *
+ * @param {*} data The message to send
+ * @param {Object} options Options object
+ * @param {Boolean} options.compress Specifies whether or not to compress `data`
+ * @param {Boolean} options.binary Specifies whether `data` is binary or text
+ * @param {Boolean} options.fin Specifies whether the fragment is the last one
+ * @param {Boolean} options.mask Specifies whether or not to mask `data`
+ * @param {Function} cb Callback which is executed when data is written out
+ * @public
+ */
+ send (data, options, cb) {
+ if (typeof options === 'function') {
+ cb = options;
+ options = {};
+ }
+
+ if (this.readyState !== WebSocket.OPEN) {
+ const err = new Error(
+ `WebSocket is not open: readyState ${this.readyState} ` +
+ `(${readyStates[this.readyState]})`
+ );
+
+ if (cb) return cb(err);
+ throw err;
+ }
+
+ if (typeof data === 'number') data = data.toString();
+
+ const opts = Object.assign({
+ binary: typeof data !== 'string',
+ mask: !this._isServer,
+ compress: true,
+ fin: true
+ }, options);
+
+ if (!this._extensions[PerMessageDeflate.extensionName]) {
+ opts.compress = false;
+ }
+
+ this._sender.send(data || constants.EMPTY_BUFFER, opts, cb);
+ }
+
+ /**
+ * Forcibly close the connection.
+ *
+ * @public
+ */
+ terminate () {
+ if (this.readyState === WebSocket.CLOSED) return;
+ if (this.readyState === WebSocket.CONNECTING) {
+ const msg = 'WebSocket was closed before the connection was established';
+ return abortHandshake(this, this._req, msg);
+ }
+
+ if (this._socket) {
+ this.readyState = WebSocket.CLOSING;
+ this._socket.destroy();
+ }
+ }
+}
+
+readyStates.forEach((readyState, i) => {
+ WebSocket[readyStates[i]] = i;
+});
+
+//
+// Add the `onopen`, `onerror`, `onclose`, and `onmessage` attributes.
+// See https://html.spec.whatwg.org/multipage/comms.html#the-websocket-interface
+//
+['open', 'error', 'close', 'message'].forEach((method) => {
+ Object.defineProperty(WebSocket.prototype, `on${method}`, {
+ /**
+ * Return the listener of the event.
+ *
+ * @return {(Function|undefined)} The event listener or `undefined`
+ * @public
+ */
+ get () {
+ const listeners = this.listeners(method);
+ for (var i = 0; i < listeners.length; i++) {
+ if (listeners[i]._listener) return listeners[i]._listener;
+ }
+ },
+ /**
+ * Add a listener for the event.
+ *
+ * @param {Function} listener The listener to add
+ * @public
+ */
+ set (listener) {
+ const listeners = this.listeners(method);
+ for (var i = 0; i < listeners.length; i++) {
+ //
+ // Remove only the listeners added via `addEventListener`.
+ //
+ if (listeners[i]._listener) this.removeListener(method, listeners[i]);
+ }
+ this.addEventListener(method, listener);
+ }
+ });
+});
+
+WebSocket.prototype.addEventListener = EventTarget.addEventListener;
+WebSocket.prototype.removeEventListener = EventTarget.removeEventListener;
+
+modules['ws:websocket'] = WebSocket;
+
+/**
+ * Initialize a WebSocket client.
+ *
+ * @param {(String|url.Url|url.URL)} address The URL to which to connect
+ * @param {String} protocols The subprotocols
+ * @param {Object} options Connection options
+ * @param {(Boolean|Object)} options.perMessageDeflate Enable/disable permessage-deflate
+ * @param {Number} options.handshakeTimeout Timeout in milliseconds for the handshake request
+ * @param {Number} options.protocolVersion Value of the `Sec-WebSocket-Version` header
+ * @param {String} options.origin Value of the `Origin` or `Sec-WebSocket-Origin` header
+ * @param {Number} options.maxPayload The maximum allowed message size
+ * @private
+ */
+function initAsClient (address, protocols, options) {
+ options = Object.assign({
+ protocolVersion: protocolVersions[1],
+ perMessageDeflate: true,
+ maxPayload: 100 * 1024 * 1024
+ }, options, {
+ createConnection: undefined,
+ socketPath: undefined,
+ hostname: undefined,
+ protocol: undefined,
+ timeout: undefined,
+ method: undefined,
+ auth: undefined,
+ host: undefined,
+ path: undefined,
+ port: undefined
+ });
+
+ if (protocolVersions.indexOf(options.protocolVersion) === -1) {
+ throw new RangeError(
+ `Unsupported protocol version: ${options.protocolVersion} ` +
+ `(supported versions: ${protocolVersions.join(', ')})`
+ );
+ }
+
+ this._isServer = false;
+
+ var parsedUrl;
+
+ if (typeof address === 'object' && address.href !== undefined) {
+ parsedUrl = address;
+ this.url = address.href;
+ } else {
+ parsedUrl = url.parse(address);
+ this.url = address;
+ }
+
+ const isUnixSocket = parsedUrl.protocol === 'ws+unix:';
+
+ if (!parsedUrl.host && (!isUnixSocket || !parsedUrl.pathname)) {
+ throw new Error(`Invalid URL: ${this.url}`);
+ }
+
+ const isSecure = parsedUrl.protocol === 'wss:' || parsedUrl.protocol === 'https:';
+ const key = crypto.randomBytes(16).toString('base64');
+ const httpObj = isSecure ? https : http;
+ const path = parsedUrl.search
+ ? `${parsedUrl.pathname || '/'}${parsedUrl.search}`
+ : parsedUrl.pathname || '/';
+ var perMessageDeflate;
+
+ options.createConnection = isSecure ? tlsConnect : netConnect;
+ options.port = parsedUrl.port || (isSecure ? 443 : 80);
+ options.host = parsedUrl.hostname.startsWith('[')
+ ? parsedUrl.hostname.slice(1, -1)
+ : parsedUrl.hostname;
+ options.headers = Object.assign({
+ 'Sec-WebSocket-Version': options.protocolVersion,
+ 'Sec-WebSocket-Key': key,
+ 'Connection': 'Upgrade',
+ 'Upgrade': 'websocket'
+ }, options.headers);
+ options.path = path;
+
+ if (options.perMessageDeflate) {
+ perMessageDeflate = new PerMessageDeflate(
+ options.perMessageDeflate !== true ? options.perMessageDeflate : {},
+ false,
+ options.maxPayload
+ );
+ options.headers['Sec-WebSocket-Extensions'] = extension.format({
+ [PerMessageDeflate.extensionName]: perMessageDeflate.offer()
+ });
+ }
+ if (protocols) {
+ options.headers['Sec-WebSocket-Protocol'] = protocols;
+ }
+ if (options.origin) {
+ if (options.protocolVersion < 13) {
+ options.headers['Sec-WebSocket-Origin'] = options.origin;
+ } else {
+ options.headers.Origin = options.origin;
+ }
+ }
+ if (parsedUrl.auth) {
+ options.auth = parsedUrl.auth;
+ } else if (parsedUrl.username || parsedUrl.password) {
+ options.auth = `${parsedUrl.username}:${parsedUrl.password}`;
+ }
+
+ if (isUnixSocket) {
+ const parts = path.split(':');
+
+ options.socketPath = parts[0];
+ options.path = parts[1];
+ }
+
+ var req = this._req = httpObj.get(options);
+
+ if (options.handshakeTimeout) {
+ req.setTimeout(
+ options.handshakeTimeout,
+ () => abortHandshake(this, req, 'Opening handshake has timed out')
+ );
+ }
+
+ req.on('error', (err) => {
+ if (this._req.aborted) return;
+
+ req = this._req = null;
+ this.readyState = WebSocket.CLOSING;
+ this.emit('error', err);
+ this.emitClose();
+ });
+
+ req.on('response', (res) => {
+ if (this.emit('unexpected-response', req, res)) return;
+
+ abortHandshake(this, req, `Unexpected server response: ${res.statusCode}`);
+ });
+
+ req.on('upgrade', (res, socket, head) => {
+ this.emit('upgrade', res);
+
+ //
+ // The user may have closed the connection from a listener of the `upgrade`
+ // event.
+ //
+ if (this.readyState !== WebSocket.CONNECTING) return;
+
+ req = this._req = null;
+
+ const digest = crypto.createHash('sha1')
+ .update(key + constants.GUID, 'binary')
+ .digest('base64');
+
+ if (res.headers['sec-websocket-accept'] !== digest) {
+ abortHandshake(this, socket, 'Invalid Sec-WebSocket-Accept header');
+ return;
+ }
+
+ const serverProt = res.headers['sec-websocket-protocol'];
+ const protList = (protocols || '').split(/, */);
+ var protError;
+
+ if (!protocols && serverProt) {
+ protError = 'Server sent a subprotocol but none was requested';
+ } else if (protocols && !serverProt) {
+ protError = 'Server sent no subprotocol';
+ } else if (serverProt && protList.indexOf(serverProt) === -1) {
+ protError = 'Server sent an invalid subprotocol';
+ }
+
+ if (protError) {
+ abortHandshake(this, socket, protError);
+ return;
+ }
+
+ if (serverProt) this.protocol = serverProt;
+
+ if (perMessageDeflate) {
+ try {
+ const extensions = extension.parse(
+ res.headers['sec-websocket-extensions']
+ );
+
+ if (extensions[PerMessageDeflate.extensionName]) {
+ perMessageDeflate.accept(
+ extensions[PerMessageDeflate.extensionName]
+ );
+ this._extensions[PerMessageDeflate.extensionName] = perMessageDeflate;
+ }
+ } catch (err) {
+ abortHandshake(this, socket, 'Invalid Sec-WebSocket-Extensions header');
+ return;
+ }
+ }
+
+ this.setSocket(socket, head, options.maxPayload);
+ });
+}
+
+/**
+ * Create a `net.Socket` and initiate a connection.
+ *
+ * @param {Object} options Connection options
+ * @return {net.Socket} The newly created socket used to start the connection
+ * @private
+ */
+function netConnect (options) {
+ //
+ // Override `options.path` only if `options` is a copy of the original options
+ // object. This is always true on Node.js >= 8 but not on Node.js 6 where
+ // `options.socketPath` might be `undefined` even if the `socketPath` option
+ // was originally set.
+ //
+ if (options.protocolVersion) options.path = options.socketPath;
+ return net.connect(options);
+}
+
+/**
+ * Create a `tls.TLSSocket` and initiate a connection.
+ *
+ * @param {Object} options Connection options
+ * @return {tls.TLSSocket} The newly created socket used to start the connection
+ * @private
+ */
+function tlsConnect (options) {
+ options.path = undefined;
+ options.servername = options.servername || options.host;
+ return tls.connect(options);
+}
+
+/**
+ * Abort the handshake and emit an error.
+ *
+ * @param {WebSocket} websocket The WebSocket instance
+ * @param {(http.ClientRequest|net.Socket)} stream The request to abort or the
+ * socket to destroy
+ * @param {String} message The error message
+ * @private
+ */
+function abortHandshake (websocket, stream, message) {
+ websocket.readyState = WebSocket.CLOSING;
+
+ const err = new Error(message);
+ Error.captureStackTrace(err, abortHandshake);
+
+ if (stream.setHeader) {
+ stream.abort();
+ stream.once('abort', websocket.emitClose.bind(websocket));
+ websocket.emit('error', err);
+ } else {
+ stream.destroy(err);
+ stream.once('error', websocket.emit.bind(websocket, 'error'));
+ stream.once('close', websocket.emitClose.bind(websocket));
+ }
+}
+
+/**
+ * The listener of the `Receiver` `'conclude'` event.
+ *
+ * @param {Number} code The status code
+ * @param {String} reason The reason for closing
+ * @private
+ */
+function receiverOnConclude (code, reason) {
+ const websocket = this[kWebSocket];
+
+ websocket._socket.removeListener('data', socketOnData);
+ websocket._socket.resume();
+
+ websocket._closeFrameReceived = true;
+ websocket._closeMessage = reason;
+ websocket._closeCode = code;
+
+ if (code === 1005) websocket.close();
+ else websocket.close(code, reason);
+}
+
+/**
+ * The listener of the `Receiver` `'drain'` event.
+ *
+ * @private
+ */
+function receiverOnDrain () {
+ this[kWebSocket]._socket.resume();
+}
+
+/**
+ * The listener of the `Receiver` `'error'` event.
+ *
+ * @param {(RangeError|Error)} err The emitted error
+ * @private
+ */
+function receiverOnError (err) {
+ const websocket = this[kWebSocket];
+
+ websocket._socket.removeListener('data', socketOnData);
+
+ websocket.readyState = WebSocket.CLOSING;
+ websocket._closeCode = err[constants.kStatusCode];
+ websocket.emit('error', err);
+ websocket._socket.destroy();
+}
+
+/**
+ * The listener of the `Receiver` `'finish'` event.
+ *
+ * @private
+ */
+function receiverOnFinish () {
+ this[kWebSocket].emitClose();
+}
+
+/**
+ * The listener of the `Receiver` `'message'` event.
+ *
+ * @param {(String|Buffer|ArrayBuffer|Buffer[])} data The message
+ * @private
+ */
+function receiverOnMessage (data) {
+ this[kWebSocket].emit('message', data);
+}
+
+/**
+ * The listener of the `Receiver` `'ping'` event.
+ *
+ * @param {Buffer} data The data included in the ping frame
+ * @private
+ */
+function receiverOnPing (data) {
+ const websocket = this[kWebSocket];
+
+ websocket.pong(data, !websocket._isServer, constants.NOOP);
+ websocket.emit('ping', data);
+}
+
+/**
+ * The listener of the `Receiver` `'pong'` event.
+ *
+ * @param {Buffer} data The data included in the pong frame
+ * @private
+ */
+function receiverOnPong (data) {
+ this[kWebSocket].emit('pong', data);
+}
+
+/**
+ * The listener of the `net.Socket` `'close'` event.
+ *
+ * @private
+ */
+function socketOnClose () {
+ const websocket = this[kWebSocket];
+
+ this.removeListener('close', socketOnClose);
+ this.removeListener('end', socketOnEnd);
+ this[kWebSocket] = undefined;
+
+ websocket.readyState = WebSocket.CLOSING;
+
+ //
+ // The close frame might not have been received or the `'end'` event emitted,
+ // for example, if the socket was destroyed due to an error. Ensure that the
+ // `receiver` stream is closed after writing any remaining buffered data to
+ // it. If the readable side of the socket is in flowing mode then there is no
+ // buffered data as everything has been already written and `readable.read()`
+ // will return `null`. If instead, the socket is paused, any possible buffered
+ // data will be read as a single chunk and emitted synchronously in a single
+ // `'data'` event.
+ //
+ websocket._socket.read();
+ websocket._receiver.end();
+
+ this.removeListener('data', socketOnData);
+ clearTimeout(websocket._closeTimer);
+
+ if (
+ websocket._receiver._writableState.finished ||
+ websocket._receiver._writableState.errorEmitted
+ ) {
+ websocket.emitClose();
+ } else {
+ websocket._receiver.on('error', receiverOnFinish);
+ websocket._receiver.on('finish', receiverOnFinish);
+ }
+}
+
+/**
+ * The listener of the `net.Socket` `'data'` event.
+ *
+ * @param {Buffer} chunk A chunk of data
+ * @private
+ */
+function socketOnData (chunk) {
+ if (!this[kWebSocket]._receiver.write(chunk)) {
+ this.pause();
+ }
+}
+
+/**
+ * The listener of the `net.Socket` `'end'` event.
+ *
+ * @private
+ */
+function socketOnEnd () {
+ const websocket = this[kWebSocket];
+
+ websocket.readyState = WebSocket.CLOSING;
+ websocket._receiver.end();
+ this.end();
+}
+
+/**
+ * The listener of the `net.Socket` `'error'` event.
+ *
+ * @private
+ */
+function socketOnError () {
+ const websocket = this[kWebSocket];
+
+ this.removeListener('error', socketOnError);
+ this.on('error', constants.NOOP);
+
+ if (websocket) {
+ websocket.readyState = WebSocket.CLOSING;
+ this.destroy();
+ }
+}
+})(); // ws:websocket
+
+(function(){ // ws:websocket-server
+const EventEmitter = require('events');
+const crypto = require('crypto');
+const http = require('http');
+const url = require('url');
+
+const PerMessageDeflate = modules['ws:permessage-deflate'];
+const extension = modules['ws:extension'];
+const constants = modules['ws:constants'];
+const WebSocket = modules['ws:websocket'];
+
+/**
+ * Class representing a WebSocket server.
+ *
+ * @extends EventEmitter
+ */
+class WebSocketServer extends EventEmitter {
+ /**
+ * Create a `WebSocketServer` instance.
+ *
+ * @param {Object} options Configuration options
+ * @param {String} options.host The hostname where to bind the server
+ * @param {Number} options.port The port where to bind the server
+ * @param {http.Server} options.server A pre-created HTTP/S server to use
+ * @param {Function} options.verifyClient An hook to reject connections
+ * @param {Function} options.handleProtocols An hook to handle protocols
+ * @param {String} options.path Accept only connections matching this path
+ * @param {Boolean} options.noServer Enable no server mode
+ * @param {Boolean} options.clientTracking Specifies whether or not to track clients
+ * @param {(Boolean|Object)} options.perMessageDeflate Enable/disable permessage-deflate
+ * @param {Number} options.maxPayload The maximum allowed message size
+ * @param {Function} callback A listener for the `listening` event
+ */
+ constructor (options, callback) {
+ super();
+
+ options = Object.assign({
+ maxPayload: 100 * 1024 * 1024,
+ perMessageDeflate: false,
+ handleProtocols: null,
+ clientTracking: true,
+ verifyClient: null,
+ noServer: false,
+ backlog: null, // use default (511 as implemented in net.js)
+ server: null,
+ host: null,
+ path: null,
+ port: null
+ }, options);
+
+ if (options.port == null && !options.server && !options.noServer) {
+ throw new TypeError(
+ 'One of the "port", "server", or "noServer" options must be specified'
+ );
+ }
+
+ if (options.port != null) {
+ this._server = http.createServer((req, res) => {
+ const body = http.STATUS_CODES[426];
+
+ res.writeHead(426, {
+ 'Content-Length': body.length,
+ 'Content-Type': 'text/plain'
+ });
+ res.end(body);
+ });
+ this._server.listen(options.port, options.host, options.backlog, callback);
+ } else if (options.server) {
+ this._server = options.server;
+ }
+
+ if (this._server) {
+ this._removeListeners = addListeners(this._server, {
+ listening: this.emit.bind(this, 'listening'),
+ error: this.emit.bind(this, 'error'),
+ upgrade: (req, socket, head) => {
+ this.handleUpgrade(req, socket, head, (ws) => {
+ this.emit('connection', ws, req);
+ });
+ }
+ });
+ }
+
+ if (options.perMessageDeflate === true) options.perMessageDeflate = {};
+ if (options.clientTracking) this.clients = new Set();
+ this.options = options;
+ }
+
+ /**
+ * Returns the bound address, the address family name, and port of the server
+ * as reported by the operating system if listening on an IP socket.
+ * If the server is listening on a pipe or UNIX domain socket, the name is
+ * returned as a string.
+ *
+ * @return {(Object|String|null)} The address of the server
+ * @public
+ */
+ address () {
+ if (this.options.noServer) {
+ throw new Error('The server is operating in "noServer" mode');
+ }
+
+ if (!this._server) return null;
+ return this._server.address();
+ }
+
+ /**
+ * Close the server.
+ *
+ * @param {Function} cb Callback
+ * @public
+ */
+ close (cb) {
+ //
+ // Terminate all associated clients.
+ //
+ if (this.clients) {
+ for (const client of this.clients) client.terminate();
+ }
+
+ const server = this._server;
+
+ if (server) {
+ this._removeListeners();
+ this._removeListeners = this._server = null;
+
+ //
+ // Close the http server if it was internally created.
+ //
+ if (this.options.port != null) return server.close(cb);
+ }
+
+ if (cb) cb();
+ }
+
+ /**
+ * See if a given request should be handled by this server instance.
+ *
+ * @param {http.IncomingMessage} req Request object to inspect
+ * @return {Boolean} `true` if the request is valid, else `false`
+ * @public
+ */
+ shouldHandle (req) {
+ if (this.options.path && url.parse(req.url).pathname !== this.options.path) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Handle a HTTP Upgrade request.
+ *
+ * @param {http.IncomingMessage} req The request object
+ * @param {net.Socket} socket The network socket between the server and client
+ * @param {Buffer} head The first packet of the upgraded stream
+ * @param {Function} cb Callback
+ * @public
+ */
+ handleUpgrade (req, socket, head, cb) {
+ socket.on('error', socketOnError);
+
+ const version = +req.headers['sec-websocket-version'];
+ const extensions = {};
+
+ if (
+ req.method !== 'GET' || req.headers.upgrade.toLowerCase() !== 'websocket' ||
+ !req.headers['sec-websocket-key'] || (version !== 8 && version !== 13) ||
+ !this.shouldHandle(req)
+ ) {
+ return abortHandshake(socket, 400);
+ }
+
+ if (this.options.perMessageDeflate) {
+ const perMessageDeflate = new PerMessageDeflate(
+ this.options.perMessageDeflate,
+ true,
+ this.options.maxPayload
+ );
+
+ try {
+ const offers = extension.parse(
+ req.headers['sec-websocket-extensions']
+ );
+
+ if (offers[PerMessageDeflate.extensionName]) {
+ perMessageDeflate.accept(offers[PerMessageDeflate.extensionName]);
+ extensions[PerMessageDeflate.extensionName] = perMessageDeflate;
+ }
+ } catch (err) {
+ return abortHandshake(socket, 400);
+ }
+ }
+
+ //
+ // Optionally call external client verification handler.
+ //
+ if (this.options.verifyClient) {
+ const info = {
+ origin: req.headers[`${version === 8 ? 'sec-websocket-origin' : 'origin'}`],
+ secure: !!(req.connection.authorized || req.connection.encrypted),
+ req
+ };
+
+ if (this.options.verifyClient.length === 2) {
+ this.options.verifyClient(info, (verified, code, message, headers) => {
+ if (!verified) {
+ return abortHandshake(socket, code || 401, message, headers);
+ }
+
+ this.completeUpgrade(extensions, req, socket, head, cb);
+ });
+ return;
+ }
+
+ if (!this.options.verifyClient(info)) return abortHandshake(socket, 401);
+ }
+
+ this.completeUpgrade(extensions, req, socket, head, cb);
+ }
+
+ /**
+ * Upgrade the connection to WebSocket.
+ *
+ * @param {Object} extensions The accepted extensions
+ * @param {http.IncomingMessage} req The request object
+ * @param {net.Socket} socket The network socket between the server and client
+ * @param {Buffer} head The first packet of the upgraded stream
+ * @param {Function} cb Callback
+ * @private
+ */
+ completeUpgrade (extensions, req, socket, head, cb) {
+ //
+ // Destroy the socket if the client has already sent a FIN packet.
+ //
+ if (!socket.readable || !socket.writable) return socket.destroy();
+
+ const key = crypto.createHash('sha1')
+ .update(req.headers['sec-websocket-key'] + constants.GUID, 'binary')
+ .digest('base64');
+
+ const headers = [
+ 'HTTP/1.1 101 Switching Protocols',
+ 'Upgrade: websocket',
+ 'Connection: Upgrade',
+ `Sec-WebSocket-Accept: ${key}`
+ ];
+
+ const ws = new WebSocket(null);
+ var protocol = req.headers['sec-websocket-protocol'];
+
+ if (protocol) {
+ protocol = protocol.trim().split(/ *, */);
+
+ //
+ // Optionally call external protocol selection handler.
+ //
+ if (this.options.handleProtocols) {
+ protocol = this.options.handleProtocols(protocol, req);
+ } else {
+ protocol = protocol[0];
+ }
+
+ if (protocol) {
+ headers.push(`Sec-WebSocket-Protocol: ${protocol}`);
+ ws.protocol = protocol;
+ }
+ }
+
+ if (extensions[PerMessageDeflate.extensionName]) {
+ const params = extensions[PerMessageDeflate.extensionName].params;
+ const value = extension.format({
+ [PerMessageDeflate.extensionName]: [params]
+ });
+ headers.push(`Sec-WebSocket-Extensions: ${value}`);
+ ws._extensions = extensions;
+ }
+
+ //
+ // Allow external modification/inspection of handshake headers.
+ //
+ this.emit('headers', headers, req);
+
+ socket.write(headers.concat('\r\n').join('\r\n'));
+ socket.removeListener('error', socketOnError);
+
+ ws.setSocket(socket, head, this.options.maxPayload);
+
+ if (this.clients) {
+ this.clients.add(ws);
+ ws.on('close', () => this.clients.delete(ws));
+ }
+
+ cb(ws);
+ }
+}
+
+modules['ws:websocket-server'] = WebSocketServer;
+
+/**
+ * Add event listeners on an `EventEmitter` using a map of
+ * pairs.
+ *
+ * @param {EventEmitter} server The event emitter
+ * @param {Object.} map The listeners to add
+ * @return {Function} A function that will remove the added listeners when called
+ * @private
+ */
+function addListeners (server, map) {
+ for (const event of Object.keys(map)) server.on(event, map[event]);
+
+ return function removeListeners () {
+ for (const event of Object.keys(map)) {
+ server.removeListener(event, map[event]);
+ }
+ };
+}
+
+/**
+ * Handle premature socket errors.
+ *
+ * @private
+ */
+function socketOnError () {
+ this.destroy();
+}
+
+/**
+ * Close the connection when preconditions are not fulfilled.
+ *
+ * @param {net.Socket} socket The socket of the upgrade request
+ * @param {Number} code The HTTP response status code
+ * @param {String} [message] The HTTP response body
+ * @param {Object} [headers] Additional HTTP response headers
+ * @private
+ */
+function abortHandshake (socket, code, message, headers) {
+ if (socket.writable) {
+ message = message || http.STATUS_CODES[code];
+ headers = Object.assign({
+ 'Connection': 'close',
+ 'Content-type': 'text/html',
+ 'Content-Length': Buffer.byteLength(message)
+ }, headers);
+
+ socket.write(
+ `HTTP/1.1 ${code} ${http.STATUS_CODES[code]}\r\n` +
+ Object.keys(headers).map(h => `${h}: ${headers[h]}`).join('\r\n') +
+ '\r\n\r\n' +
+ message
+ );
+ }
+
+ socket.removeListener('error', socketOnError);
+ socket.destroy();
+}
+})(); // ws:websocket-server
+
+(function(){ // ws
+let modulet = modules['ws:websocket'];
+modulet.Server = modules['ws:websocket-server'];
+modulet.Receiver = modules['ws:receiver'];
+modulet.Sender = modules['ws:sender'];
+modules['ws'] = modulet;
+})(); // ws
+
+module.exports = modules['ws'];
diff --git a/script/single.js b/script/single.js
new file mode 100644
index 0000000..9077e53
--- /dev/null
+++ b/script/single.js
@@ -0,0 +1,86 @@
+const i_fs = require('fs');
+const i_path = require('path');
+
+
+function extract_require(filename) {
+ let base = i_path.resolve(i_path.dirname(filename));
+ let text = i_fs.readFileSync(filename).toString();
+ // TODO: support import {...} from ...;
+ let n = text.length, i = 0;
+ let tokens = [];
+ let state = {
+ current: 0
+ };
+ for (i = 0; i < n; i++) {
+ ch = text[i];
+ if (ch === '"' || ch === '\'' || ch === '`') {
+ // 0 --> 1: " ' `
+ if (state.current === 0) {
+ state.start = i;
+ state.stop = ch;
+ state.current = 1;
+ } else if (state.stop === ch) {
+ tokens.push({
+ start: state.start+1,
+ end: i
+ });
+ state.current = 0;
+ state.stop = null;
+ state.start = null;
+ }
+ } else if (ch === '\\' && state.current === 1) {
+ // 1: \
+ i ++;
+ } else if (ch === '/') {
+ if (state.current === 0 && text[i+1] === '/') {
+ // 0 ---> 2: // line comment
+ state.current = 2;
+ } else if (state.current === 0 && text[i+1] === '*') {
+ // 0 ---> 3: // multiple line comment
+ state.current = 3;
+ } else if (state.current === 3 && text[i-1] === '*') {
+ state.current = 0;
+ }
+ } else if (ch === '\n' && state.current === 2) {
+ state.current = 0;
+ }
+ }
+
+ // tokens now contains strings (start, end)
+ tokens = tokens.filter((x) => 'require(' === text.substring(x.start-9, x.start-1));
+ tokens = tokens.map((x) => text.substring(x.start, x.end));
+ tokens = tokens.map((x) => {
+ if (x[0] === '.') {
+ return i_path.resolve(i_path.join(base, x));
+ }
+ return x;
+ });
+ return tokens;
+}
+
+function extract(filename) {
+ let deps = {};
+ let queue = [i_path.resolve(filename)];
+ while (queue.length > 0) {
+ let one = queue.pop();
+ let tokens = extract_require(one);
+ tokens = tokens.map((x) => {
+ if (x[0] !== '/') return x;
+ let ext = i_path.extname(x);
+ if (ext[0] !== '.') x += '.js';
+ return x;
+ });
+ deps[one] = tokens;
+ tokens.forEach((x) => {
+ if (deps[x]) return;
+ if (x[0] !== '/') {
+ deps[x] = null;
+ return;
+ }
+ queue.push(x);
+ });
+ }
+ return deps
+}
+
+console.log(JSON.stringify(extract(process.argv[2]), null, 3));
\ No newline at end of file