diff --git a/.gitignore b/.gitignore old mode 100755 new mode 100644 index 77d7b33..8a85989 --- a/.gitignore +++ b/.gitignore @@ -1,47 +1,48 @@ -# 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 +# Miscellaneous +*.class +*.log +*.pyc +*.swp +.DS_Store +.atom/ +.buildlog/ +.history +.svn/ + +# 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 +.packages +.pub-cache/ +.pub/ +/build/ + +# Web related +lib/generated_plugin_registrant.dart + +# Symbolication related +app.*.symbols + +# Obfuscation related +app.*.map.json + +# Exceptions to above rules. +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages + +android/gradle* +.gradle +local diff --git a/.metadata b/.metadata old mode 100755 new mode 100644 index 417ad2f..d7031d1 --- a/.metadata +++ b/.metadata @@ -4,42 +4,7 @@ # This file should be version controlled and should not be manually edited. version: - revision: "ef1af02aead6fe2414f3aafa5a61087b610e1332" - channel: "stable" + revision: 216dee60c0cc9449f0b29bcf922974d612263e24 + 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..07c609d 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 2020 Seven Lju 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..9be0a51 --- a/README.md +++ b/README.md @@ -1,18 +1,133 @@ # NodeBase -Platform to Build Sharable Application for Android +Android NodeJS Platform to Build Sharable Application -Running Websocket application over Wifi and share with your friends. +Running Node.js application over Wifi and share with your friends. -> WSTun: it is a base server on http/websocket with netty; and project name is "WSTun" +For previous mature version, please explore source code on kotlin branch. -The design is changed, we never use a pre-built binary of NodeJS to run server anymore. +Currently we are redesigning whole NodeBase based on Flutter. -We run a native http/websocket with netty. +## How to use -Any JS client can register itself as a service so that it can be a control center of data. +- Platform/App market is online + - click into platform or application page + - click on the top-right cart icon button + - select what you want to download + - e.g. download platform `node-10.10.0` + - download app `file-transfer` + - edit app `file-transfer` platform value to `node-10.10.0` + - start `file-transfer` by clicking play icon button +- Create a new platform, for example named `node` + - fill node url like `file:///sdcard/bin-node-v10.10.0` or `https://example.com/latest/arm/node` + - click download button and wait for task complete + - (NodeBase will copy the binary to its app zone and make it executable) + - Wow; now we not only support node binary but also customized exectuables. +- Create a new app, for example named `test` and its platform is `node` + - click into the new app + - download an app zip into for example `/sdcard/test.zip` + - fill `Import / Export` text field with `/sdcard/test.zip` + - click upload button and wait for task complete + - (NodeBase will extract zip app as a app folder into app zone) + - fill `Params` text field (for example, file manager need to config target folder as first param) + - click `play` button to start node app + - click `open in browser` button to open the app in a webview / `pop-out` button to open in external browser + - click `stop` button to stop node app -Others can connect as clients of the service and do more flexible things like file sharing, board gaming! +### App folder structure -> It is also a project enjoy vibe coding! (save lots of time) +``` +//config.json +{ + "host": "http://127.0.0.1" + "port": 0, + "home": "/index.html", + "entry": "index.js" +} + +//static/index.html +[...] source code frontend client + +//index.js +ref: https://github.com/stallpool/halfbase/tree/master/nodejs/tinyserver/index.js +[...] source code for backend server +[...] hook `/index.html` to load `/app/static/index.html` +``` + +App examples: [https://github.com/nodebase0](https://github.com/nodebase0), includes file-viewer-uploader, nodepad, ... + + +## Development + +This project is a starting point for a Flutter application. + +A few resources to get you started if this is your first Flutter project: + +- [Lab: Write your first Flutter app](https://flutter.dev/docs/get-started/codelab) +- [Cookbook: Useful Flutter samples](https://flutter.dev/docs/cookbook) + +For help getting started with Flutter, view our +[online documentation](https://flutter.dev/docs), which offers tutorials, +samples, guidance on mobile development, and a full API reference. + +##### NodeJS/Python binary for ARM + +ref: https://github.com/dna2github/dna2oslab/releases/tag/0.2.0-android-gt6-arm + +##### Java binary + +write a shell script `java` and `adb push java /sdcard` +``` +#!/system/bin/sh + +exec dalvikvm $@ +``` + +create a new platform in NodeBase and download java wrapper from `file:///sdcard/java` + +then write a command line tool to have a try. ref: https://github.com/dna2github/dna2sevord/tree/master/past/others/walkserver/javacmd + +##### Golang binary + +``` +# download go source package and extract +cd src +GOOS=android GOARCH=arm64 ./bootstrap.bash + +tar zcf go-android-arm64-bootstrap.tar.gz go-android-arm64-bootstrap +adb push go-android-arm64-bootstrap.tar.gz /sdcard/ +# we suggest write a javascript script to set up golang environment on your Android +# to extract tar package to NodeBase app zone /data/user/0/net.seven.nodebase/ +# e.g. /data/user/0/net.seven.nodebase/go-android-arm64-bootstrap +``` + +write a shell script `go` and `adb push go /sdcard` + +``` +#!/system/bin/sh + +SELF=$(cd `dirname $0`; pwd) +BASE=/data/user/0/net.seven.nodebase/go-android-arm64-bootstrap +CACHEBASE=${BASE}/cache +mkdir -p ${CACHEBASE}/{cache,tmp,local} +export GOROOT=${BASE} +export GOPATH=${CACHEBASE}/golang/local +export GOCACHE=${CACHEBASE}/golang/cache +export GOTMPDIR=${CACHEBASE}/golang/tmp +export CGO_ENABLED=0 +exec ${BASE}/bin/go run $@ +``` + +create a new platform in NodeBase and download go wrapper from `file:///sdcard/go`; + +then write a tiny server to have a try. ref: https://github.com/stallpool/halfbase/blob/master/golang/tinyserver/main.go + +##### Notice + +currently NodeBase support kill a program with 1-level children, for example `go run main.go` will spawn a child process `main`; +if click on `stop` button, NodeBase can kill the `go run` and its child `main`. + +if remove `exec` in the `go` wrapper shell script, the shell script will run in `sh`, it spawn `go run` and the `go run` spawn `main`; +when `stop` the application, NodeBase will merely kill `sh` and its child `go run`; but `main` will still be running there, +which may cause next `start` failure (like port has already been used) and need to kill whole NodeBase for cleanup. diff --git a/android/.gitignore b/android/.gitignore new file mode 100644 index 0000000..0a741cb --- /dev/null +++ b/android/.gitignore @@ -0,0 +1,11 @@ +gradle-wrapper.jar +/.gradle +/captures/ +/gradlew +/gradlew.bat +/local.properties +GeneratedPluginRegistrant.java + +# Remember to never publicly share your keystore. +# See https://flutter.dev/docs/deployment/android#reference-the-keystore-from-the-app +key.properties 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 index 334f5b2..5da3833 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,48 +1,71 @@ -plugins { - id 'com.android.application' +def localProperties = new Properties() +def localPropertiesFile = rootProject.file('local.properties') +if (localPropertiesFile.exists()) { + localPropertiesFile.withReader('UTF-8') { reader -> + localProperties.load(reader) + } +} + +def flutterRoot = localProperties.getProperty('flutter.sdk') +if (flutterRoot == null) { + throw new GradleException("Flutter SDK not found. Define location with flutter.sdk in the local.properties file.") } +def flutterVersionCode = localProperties.getProperty('flutter.versionCode') +if (flutterVersionCode == null) { + flutterVersionCode = '1' +} + +def flutterVersionName = localProperties.getProperty('flutter.versionName') +if (flutterVersionName == null) { + flutterVersionName = '1.0' +} + +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" + android { - namespace 'seven.lab.wstun' - compileSdk 34 + namespace "net.seven.nodebase" + compileSdkVersion 33 + + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + kotlinOptions { + jvmTarget = '17' + } defaultConfig { - applicationId "seven.lab.wstun" - minSdk 24 - targetSdk 34 - versionCode 1 - versionName "1.0" + applicationId "net.seven.nodebase" + targetSdkVersion 33 + minSdkVersion flutter.minSdkVersion + versionCode flutterVersionCode.toInteger() + versionName flutterVersionName } - buildTypes { - release { - minifyEnabled false - proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - } + sourceSets { + main.java.srcDirs += 'src/main/kotlin' } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + lintOptions { + disable 'InvalidPackage' } - packagingOptions { - exclude 'META-INF/INDEX.LIST' - exclude 'META-INF/io.netty.versions.properties' + buildTypes { + release { + // TODO: Add your own signing config for the release build. + // Signing with the debug keys for now, so `flutter run --release` works. + signingConfig signingConfigs.debug + } } } +flutter { + source '../..' +} + 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' + implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version" } 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/debug/AndroidManifest.xml b/android/app/src/debug/AndroidManifest.xml new file mode 100644 index 0000000..e6181ef --- /dev/null +++ b/android/app/src/debug/AndroidManifest.xml @@ -0,0 +1,7 @@ + + + + diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 9d0275f..e2b3070 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,43 +1,55 @@ - - + - - - - - + + + + - - + android:label="NodeBase" + android:icon="@mipmap/ic_launcher"> + android:name=".MainActivity" + android:launchMode="singleTop" + android:theme="@style/LaunchTheme" + android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode" + android:hardwareAccelerated="true" + android:windowSoftInputMode="adjustResize"> + + + + + - - + + - - - - - + + + - diff --git a/android/app/src/main/assets/libwstun.js b/android/app/src/main/assets/libwstun.js deleted file mode 100644 index a014927..0000000 --- a/android/app/src/main/assets/libwstun.js +++ /dev/null @@ -1,274 +0,0 @@ -/** - * WSTun Client Library v2.0 - * Supports: Server auth, Service instances, User management - */ -(function(global) { -'use strict'; - -const WSTun = { - version: '1.0.0', - - /** Create instance host (creates and manages an instance) */ - createInstanceHost: function(options) { return new InstanceHost(options); }, - - /** Create instance client (joins an existing instance) */ - createInstanceClient: function(options) { return new InstanceClient(options); }, - - /** Generate unique ID */ - generateId: function() { return Date.now().toString(36) + Math.random().toString(36).substr(2, 6); }, - - /** Build WebSocket URL */ - buildWsUrl: function(serverToken) { - const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'; - let url = protocol + '//' + location.host + '/ws'; - if (serverToken) url += '?token=' + encodeURIComponent(serverToken); - return url; - } -}; - -/** Base WebSocket client */ -class BaseClient { - constructor(options) { - this.service = options.service; - this.serverToken = options.serverToken || null; - this.onOpen = options.onOpen || (() => {}); - this.onMessage = options.onMessage || (() => {}); - this.onClose = options.onClose || (() => {}); - this.onError = options.onError || (() => {}); - this.ws = null; - this.connected = false; - } - - connect() { - try { this.ws = new WebSocket(WSTun.buildWsUrl(this.serverToken)); } - catch (err) { this.onError(err); return; } - this.ws.onopen = () => { this.connected = true; this.onOpen(); this._onConnected(); }; - this.ws.onmessage = (e) => { try { this._handleMessage(JSON.parse(e.data)); } catch(err) { console.error(err); } }; - this.ws.onclose = () => { this.connected = false; this.onClose(); }; - this.ws.onerror = (err) => { this.onError(err); }; - } - - disconnect() { if (this.ws) { this.ws.close(); this.ws = null; } } - - send(type, payload) { - if (this.ws && this.ws.readyState === WebSocket.OPEN) { - this.ws.send(JSON.stringify({ type, service: this.service, payload })); - } - } - - _onConnected() {} - _handleMessage(msg) { this.onMessage(msg); } -} - -/** - * Instance Host - Creates and manages a service instance (room) - * @param {Object} options - * @param {string} options.service - Service type (e.g., 'fileshare', 'chat') - * @param {string} options.name - Instance name (room name) - * @param {string} [options.serverToken] - Server auth token - * @param {string} [options.instanceToken] - Token users need to join - * @param {string} [options.reclaimUuid] - UUID of instance to reclaim (for reconnection) - * @param {function} [options.onInstanceCreated] - Called when instance is created - * @param {function} [options.onInstanceReclaimed] - Called when instance is reclaimed - */ -class InstanceHost extends BaseClient { - constructor(options) { - super(options); - this.instanceName = options.name || 'Room'; - this.instanceToken = options.instanceToken || null; - this.reclaimUuid = options.reclaimUuid || null; - this.onInstanceCreated = options.onInstanceCreated || (() => {}); - this.onInstanceReclaimed = options.onInstanceReclaimed || options.onInstanceCreated || (() => {}); - this.instanceUuid = null; - } - - _onConnected() { - if (this.reclaimUuid) { - // Try to reclaim an existing instance - this.send('reclaim_instance', { - uuid: this.reclaimUuid, - token: this.instanceToken, - server_token: this.serverToken - }); - } else { - // Create new instance - this.send('create_instance', { - name: this.instanceName, - token: this.instanceToken, - server_token: this.serverToken - }); - } - } - - _handleMessage(msg) { - if (msg.type === 'instance_created' && msg.payload?.success) { - this.instanceUuid = msg.payload.uuid; - this.onInstanceCreated(msg.payload); - } else if (msg.type === 'instance_reclaimed' && msg.payload?.success) { - this.instanceUuid = msg.payload.uuid; - this.onInstanceReclaimed(msg.payload); - } else if (msg.type === 'instance_reclaimed' && !msg.payload?.success) { - // Reclaim failed, create new instance instead - this.reclaimUuid = null; - this.send('create_instance', { - name: this.instanceName, - token: this.instanceToken, - server_token: this.serverToken - }); - } else if (msg.type === 'error') { - this.onError(new Error(msg.payload?.message || 'Unknown error')); - } else { - this.onMessage(msg); - } - } - - /** Broadcast message to all users in instance */ - broadcast(type, payload) { - if (this.instanceUuid) { - payload = payload || {}; - payload.instanceUuid = this.instanceUuid; - this.send(type, payload); - } - } - - /** Kick a user from instance */ - kickUser(userId) { - this.send('kick_client', { userId, instanceUuid: this.instanceUuid }); - } -} - -/** - * Instance Client - Joins an existing instance - * @param {Object} options - * @param {string} options.service - Service type - * @param {string} options.instanceUuid - Instance UUID to join - * @param {string} [options.serverToken] - Server auth token - * @param {string} [options.instanceToken] - Instance access token - * @param {string} [options.userId] - User ID (auto-generated if not provided) - * @param {function} [options.onJoined] - Called when joined instance - * @param {function} [options.onKicked] - Called when kicked from instance - */ -class InstanceClient extends BaseClient { - constructor(options) { - super(options); - this.instanceUuid = options.instanceUuid; - this.instanceToken = options.instanceToken || null; - this.userId = options.userId || WSTun.generateId(); - this.onJoined = options.onJoined || (() => {}); - this.onKicked = options.onKicked || (() => {}); - this.instanceName = null; - } - - _onConnected() { - this.send('join_instance', { - uuid: this.instanceUuid, - userId: this.userId, - token: this.instanceToken - }); - } - - _handleMessage(msg) { - if (msg.type === 'ack' && msg.payload?.success && msg.payload?.instanceUuid) { - this.instanceName = msg.payload.instanceName; - this.onJoined(msg.payload); - } else if (msg.type === 'kick') { - this.onKicked(msg.payload); - this.disconnect(); - } else if (msg.type === 'error') { - this.onError(new Error(msg.payload?.message || 'Unknown error')); - } else { - this.onMessage(msg); - } - } -} - -/** - * List available instances for a service - * @param {string} service - Service type - * @param {string} [serverToken] - Server auth token - * @returns {Promise} List of instances - */ -WSTun.listInstances = function(service, serverToken) { - return new Promise((resolve, reject) => { - let resolved = false; - let ws; - - try { - ws = new WebSocket(WSTun.buildWsUrl(serverToken)); - } catch(err) { - reject(err); - return; - } - - const cleanup = () => { - resolved = true; - if (ws && ws.readyState !== WebSocket.CLOSED) { - try { ws.close(); } catch(e) {} - } - }; - - ws.onopen = () => { - if (resolved) return; - ws.send(JSON.stringify({ type: 'list_instances', service, payload: {} })); - }; - ws.onmessage = (e) => { - if (resolved) return; - try { - const msg = JSON.parse(e.data); - if (msg.type === 'instance_list') { - cleanup(); - resolve(msg.payload?.instances || []); - } else if (msg.type === 'error') { - cleanup(); - reject(new Error(msg.payload?.message || msg.payload?.error || 'Failed to list instances')); - } - } catch(err) { - cleanup(); - reject(err); - } - }; - ws.onerror = (err) => { - if (resolved) return; - cleanup(); - reject(new Error('WebSocket connection failed')); - }; - ws.onclose = () => { - if (resolved) return; - cleanup(); - resolve([]); // Return empty list if connection closes without response - }; - - // Shorter timeout (3 seconds) to avoid long waits - setTimeout(() => { - if (resolved) return; - cleanup(); - resolve([]); // Return empty list on timeout instead of rejecting - }, 3000); - }); -}; - -// Legacy support - createService and createClient still work for simple cases -WSTun.createService = function(options) { return new InstanceHost(options); }; -WSTun.createClient = function(options) { - // If instanceUuid provided, use InstanceClient, otherwise fallback - if (options.instanceUuid) return new InstanceClient(options); - // Simple client without instance support (legacy) - const client = new BaseClient(options); - client.userId = options.userId || WSTun.generateId(); - client.onRegistered = options.onRegistered || (() => {}); - client.onKicked = options.onKicked || (() => {}); - client._onConnected = function() { - this.send('client_register', { clientType: this.service, userId: this.userId, auth_token: options.serviceToken }); - }; - client._handleMessage = function(msg) { - if (msg.type === 'ack' && msg.payload?.success) { this.onRegistered(msg.payload); } - else if (msg.type === 'kick') { this.onKicked(msg.payload); this.disconnect(); } - else if (msg.type === 'error') { this.onError(new Error(msg.payload?.message || 'Error')); } - else { this.onMessage(msg); } - }; - return client; -}; -WSTun.generateUserId = WSTun.generateId; - -global.WSTun = WSTun; -})(typeof window !== 'undefined' ? window : this); diff --git a/android/app/src/main/assets/services/chat/index.html b/android/app/src/main/assets/services/chat/index.html deleted file mode 100644 index 86aa902..0000000 --- a/android/app/src/main/assets/services/chat/index.html +++ /dev/null @@ -1,274 +0,0 @@ - - - - - - Chat Instance Manager - WSTun - - - -
-
-

Chat

-

Create a chat room

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

Existing Rooms

-

Click to open the chat page

-
-

Loading...

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

Join Chat Room

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

Chat Room

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

FileShare

-

Create a file sharing room

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

Existing Rooms

-

Click to open the file sharing page

-
-

Loading...

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

FileShare

-

Share files with others

-
Loading...
-
- - -
-

Join a Room

-
-

Loading available rooms...

-
-
-

Or Enter Room Details

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

Service Not Running

\n" + - "

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

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

WSTun Server

"); - html.append("

Service Manager

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

Installed Services

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

Registered Services

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

No services registered

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

WSTun Service Manager

"); - html.append("

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

"); - - html.append("

Installed Services

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

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

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

Service Disabled

\n" + - "

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

\n" + - "

Enable it in the WSTun app service management.

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

FileShare

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

My Service

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

My Service

-
Loading...
- - -
- - - - - -``` - -### 3. Create the Manifest (`myservice.json`) - -```json -{ - "name": "myservice", - "displayName": "My Custom Service", - "description": "A simple example service", - "version": "1.0.0", - "author": "Your Name", - "endpoints": [ - { - "path": "/service", - "file": "index.html", - "type": "service" - }, - { - "path": "/main", - "file": "main.html", - "type": "client" - } - ] -} -``` - -### 4. Update the Service List (`list`) - -```json -[ - { - "name": "myservice", - "displayName": "My Custom Service", - "description": "A simple example service", - "version": "1.0.0", - "author": "Your Name" - } -] -``` - -## Hosting a Marketplace - -### Option 1: Static File Server - -Host files on any static file server (Apache, Nginx, S3, GitHub Pages): - -``` -/marketplace/ - list # Service list JSON - service/ - myservice.json # Manifest - myservice/ - index.html # Controller - main.html # Client -``` - -Make sure CORS headers allow access from the WSTun server. - -### Option 2: Dynamic Server - -Create a dynamic server (Node.js, Python, etc.) that generates the list and serves files: - -```javascript -// Express.js example -const express = require('express'); -const app = express(); - -// Enable CORS -app.use((req, res, next) => { - res.header('Access-Control-Allow-Origin', '*'); - next(); -}); - -// Service list -app.get('/marketplace/list', (req, res) => { - res.json([ - { name: 'myservice', displayName: 'My Service', ... } - ]); -}); - -// Service manifest -app.get('/marketplace/service/:name.json', (req, res) => { - res.sendFile(`./services/${req.params.name}/manifest.json`); -}); - -// Service files -app.get('/marketplace/service/:name/:file', (req, res) => { - res.sendFile(`./services/${req.params.name}/${req.params.file}`); -}); - -app.listen(9090); -``` - -## Installing Services from Marketplace - -### Via Admin UI - -1. Open the WSTun server in a browser -2. Click "Service Manager" (or go to `/admin`) -3. Go to the "Marketplace" tab -4. Enter the marketplace URL -5. Click "Fetch" to see available services -6. Click "Install" on the desired service - -### Via API - -```javascript -// POST /_api/marketplace/install -fetch('/_api/marketplace/install', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - url: 'https://example.com/wstun/marketplace', - name: 'myservice' - }) -}); -``` - -## Best Practices - -1. **Use semantic versioning**: Makes it easy to track updates -2. **Write clear descriptions**: Help users understand what your service does -3. **Test thoroughly**: Ensure your service works with the WSTun server -4. **Include documentation**: Add comments or a README in your service -5. **Handle errors gracefully**: Show user-friendly error messages -6. **Use HTTPS**: Secure your marketplace server -7. **Add CORS headers**: Allow cross-origin requests from WSTun servers - -## Security Considerations - -1. **Validate service names**: Only alphanumeric and lowercase -2. **Sanitize file paths**: Prevent directory traversal attacks -3. **Review code**: Marketplace services can run arbitrary JavaScript -4. **Use HTTPS**: Encrypt data in transit -5. **Implement rate limiting**: Prevent abuse of your marketplace - -## Troubleshooting - -### "Failed to fetch marketplace" -- Check the marketplace URL is correct -- Ensure CORS headers are set -- Verify the `/list` endpoint returns valid JSON - -### "Failed to install service" -- Check the manifest is valid JSON -- Verify all endpoint files exist -- Check file permissions on the server - -### Service doesn't appear after install -- Refresh the service list -- Check the browser console for errors -- Verify the service files were downloaded correctly diff --git a/android/gradle.properties b/android/gradle.properties deleted file mode 100644 index 2e11322..0000000 --- a/android/gradle.properties +++ /dev/null @@ -1,3 +0,0 @@ -org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 -android.useAndroidX=true -android.nonTransitiveRClass=true diff --git a/android/gradle/wrapper/gradle-wrapper.jar b/android/gradle/wrapper/gradle-wrapper.jar deleted file mode 100644 index 1b33c55..0000000 Binary files a/android/gradle/wrapper/gradle-wrapper.jar and /dev/null differ diff --git a/android/gradle/wrapper/gradle-wrapper.properties b/android/gradle/wrapper/gradle-wrapper.properties deleted file mode 100644 index 62f495d..0000000 --- a/android/gradle/wrapper/gradle-wrapper.properties +++ /dev/null @@ -1,7 +0,0 @@ -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 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/gradlew.bat b/android/gradlew.bat deleted file mode 100644 index 93e3f59..0000000 --- a/android/gradlew.bat +++ /dev/null @@ -1,92 +0,0 @@ -@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 diff --git a/android/settings.gradle b/android/settings.gradle index 97edfd1..44e62bc 100644 --- a/android/settings.gradle +++ b/android/settings.gradle @@ -1,17 +1,11 @@ -pluginManagement { - repositories { - google() - mavenCentral() - gradlePluginPortal() - } -} -dependencyResolutionManagement { - repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) - repositories { - google() - mavenCentral() - } -} - -rootProject.name = "NodeBase" include ':app' + +def localPropertiesFile = new File(rootProject.projectDir, "local.properties") +def properties = new Properties() + +assert localPropertiesFile.exists() +localPropertiesFile.withReader("UTF-8") { reader -> properties.load(reader) } + +def flutterSdkPath = properties.getProperty("flutter.sdk") +assert flutterSdkPath != null, "flutter.sdk not set in local.properties" +apply from: "$flutterSdkPath/packages/flutter_tools/gradle/app_plugin_loader.gradle" diff --git a/lib/api.dart b/lib/api.dart new file mode 100644 index 0000000..dc213d3 --- /dev/null +++ b/lib/api.dart @@ -0,0 +1,120 @@ +import 'package:flutter/services.dart'; +import './io.dart'; + +class NodeBaseApi { + static final batteryApi = const MethodChannel('net.seven.nodebase/battery'); + static final appApi = const MethodChannel('net.seven.nodebase/app'); + static final nodebaseApi = const MethodChannel('net.seven.nodebase/nodebase'); + + static final eventApi = const EventChannel('net.seven.nodebase/event'); + + static Future getBatteryLevel() async { + String batteryLevel; + try { + final int lv = await batteryApi.invokeMethod('getBatteryLevel'); + batteryLevel = '${lv}%'; + } on PlatformException catch (e) { + batteryLevel = 'Failed: ${e.message}'; + } + return batteryLevel; + } + + static Future requestExternalStoragePermission() async { + try { + appApi.invokeMethod('RequestExternalStoragePermission'); + } catch (e) {} + } + + static Future fetchExecutable(String url) async { + if (url == "") return ""; + final name = url.split("/").last; + final dst = (await getAppFileReference('/bin/${name}')).path; + // e.g. node -> /path/to/app/bin/node + if (url.indexOf("://") < 0) return dst; + // e.g. file://.../node http://.../node https://.../node -> /path/to/app/bin/node + try { + appApi.invokeMethod( + 'FetchExecutable', {"url": url, "target": dst}); + if (dst.endsWith(".zip")) return dst.substring(0, dst.length - 4); + return dst; + } catch (e) { + return ""; + } + } + + static Future fetchApp(String url) async { + if (url == "") return ""; + var name = url.split("/").last; + if (name.endsWith(".zip")) name = name.substring(0, name.length - 4); + final dst = (await getAppFileReference('/apps/${name}')).path; + // e.g. node -> /path/to/app/apps/node + if (url.indexOf("://") < 0) return dst; + try { + appApi.invokeMethod( + 'FetchApp', {"url": url, "target": dst}); + return dst; + } catch (e) { + return ""; + } + } + + static Future fetchWifiIpv4() async { + try { + return appApi.invokeMethod('FetchWifiIpv4'); + } catch (e) { + return "0.0.0.0"; + } + } + + static Future appStatus(String app) async { + try { + return nodebaseApi + .invokeMethod('GetStatus', {"app": app}); + } catch (e) { + return "error"; + } + } + + static Future appStart(String app, String cmd) async { + try { + nodebaseApi + .invokeMethod('Start', {"app": app, "cmd": cmd}); + } catch (e) {} + } + + static Future appStop(String app) async { + try { + nodebaseApi.invokeMethod('Stop', {"app": app}); + } catch (e) {} + } + + static Future appUnpack(String app, String zipfile) async { + final appBaseDir = await ioGetAppBaseDir(app); + try { + nodebaseApi.invokeMethod('Unpack', { + "app": app, + "path": appBaseDir, + "zipfile": zipfile + }); + } catch (e) {} + } + + static Future appPack(String app, String zipfile) async { + final appBaseDir = await ioGetAppBaseDir(app); + try { + nodebaseApi.invokeMethod('Pack', { + "app": app, + "path": appBaseDir, + "zipfile": zipfile + }); + } catch (e) {} + } + + static Future appBrowser(String url) async { + try { + nodebaseApi.invokeMethod('Browser', { + "url": url, + }); + } catch (e) {} + } +} diff --git a/lib/app_model.dart b/lib/app_model.dart new file mode 100644 index 0000000..eba7d38 --- /dev/null +++ b/lib/app_model.dart @@ -0,0 +1,61 @@ +import 'package:flutter/material.dart'; + +class NodeBaseAppModule { + NodeBaseAppModule({ + required this.id, + required this.icon, + required this.name, + required this.desc, + }) {} + + final int id; + final String name; + final String desc; + final Icon icon; + + static final List list = [ + NodeBaseAppModule( + id: 101, + icon: Icon(Icons.settings), + name: "Environment", + desc: "application configurations."), + NodeBaseAppModule( + id: 102, + icon: Icon(Icons.settings), + name: "Platform", + desc: "platform management, like node, go, python, ..."), + NodeBaseAppModule( + id: 102, + icon: Icon(Icons.apps), + name: "Application", + desc: "application management, like running, developing, sharing, ...") + ]; +} + +class NodeBasePlatform { + NodeBasePlatform({required this.name}) {} + + String name = ""; + String path = ""; + String updateUrl = ""; +} + +class NodeBaseApp { + NodeBaseApp({required this.name}) {} + + String name = ""; + String path = ""; + String platform = ""; +} + +class NodeBaseAppDetails { + String path = ""; + // e.g. 127.0.0.1, 0.0.0.0 + String host = ""; + // e.g. 9090 + int port = 0; + // e.g. index.js + String entry = ""; + // e.g. /index.html + String home = ""; +} diff --git a/lib/homepage.dart b/lib/homepage.dart new file mode 100644 index 0000000..93bacf1 --- /dev/null +++ b/lib/homepage.dart @@ -0,0 +1,93 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import './io.dart'; +import './search.dart'; +import './app_model.dart'; +import './page_environment.dart'; +import './page_platform.dart'; +import './page_apps.dart'; + +class NodeBaseHomePage extends StatefulWidget { + NodeBaseHomePage({required this.title, super.key}); + + final String title; + + @override + _NodeBaseHomePageState createState() => _NodeBaseHomePageState(); +} + +class _NodeBaseHomePageState extends State { + @override + void initState() { + super.initState(); + readAppFileAsString("/config.json").then((config) { + if (config != "") { + onReady(jsonDecode(config)); + } + }); + } + + onReady(config) { + print(config); + if (config == null) return; + // setState(() { _counter = config['counter']; }); + } + + onNavigate(NodeBaseAppModule module) { + var route; + switch (module.name) { + case "Environment": + { + route = MaterialPageRoute( + builder: (context) => NodeBaseEnvironmentSettings()); + } + break; + case "Platform": + { + route = MaterialPageRoute( + builder: (context) => NodeBasePlatformSettings()); + } + break; + case "Application": + { + route = + MaterialPageRoute(builder: (context) => NodeBaseApplications()); + } + break; + } + Navigator.push(context, route); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: Text(widget.title), actions: [ + IconButton( + onPressed: () { + showSearch(context: context, delegate: NodeBaseSearch()); + }, + icon: Icon(Icons.search)) + ]), + body: ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: NodeBaseAppModule.list.length, + itemBuilder: (BuildContext context, int index) { + return Container( + child: Card( + child: ListTile( + onTap: () => + onNavigate(NodeBaseAppModule.list[index]), + title: Text('${NodeBaseAppModule.list[index].name}'), + subtitle: + Text('${NodeBaseAppModule.list[index].desc}'), + leading: IconButton( + icon: NodeBaseAppModule.list[index].icon, + onPressed: () => {}, + ) // IconButton + ) // ListTile + ) // Card + ); + }) // body + ); + } +} diff --git a/lib/io.dart b/lib/io.dart new file mode 100644 index 0000000..8458ebf --- /dev/null +++ b/lib/io.dart @@ -0,0 +1,100 @@ +import 'dart:io'; +import 'package:path_provider/path_provider.dart'; + +// getApplicationDocumentsDirectory -> /data/data/app/... +// getExternalStorageDirectory -> /storage/sdcard-external/android/data/app/... + +Future get _appPath async { + final directory = await getApplicationDocumentsDirectory(); + return directory.path; +} + +Future getAppFileReference(filepath) async { + final path = await _appPath; + return File('$path$filepath'); +} + +Future readAppFileAsString(filepath) async { + try { + final file = await getAppFileReference(filepath); + String contents = await file.readAsString(); + return contents; + } catch (e) { + return ""; + } +} + +Future writeAppFileAsString(filepath, contents) async { + final file = await getAppFileReference(filepath); + file.writeAsString(contents); +} + +Future ioGetEntity(filepath) async { + final path = await _appPath; + final filename = '$path$filepath'; + final T = await FileSystemEntity.type(filename); + if (T == FileSystemEntityType.notFound) { + return Object(); + } + if (T == FileSystemEntityType.link) { + return Link(filepath); + } + if (T == FileSystemEntityType.file) { + return File(filepath); + } + return Directory(filepath); +} + +Future ioMkdir(filepath) async { + final path = await _appPath; + return await Directory('$path$filepath').create(recursive: true); +} + +Future> ioLs(filepath) async { + final path = await _appPath; + final filename = '$path$filepath'; + final list = []; + final T = await FileSystemEntity.type(filename); + if (T == FileSystemEntityType.notFound) { + } else if (T == FileSystemEntityType.link) { + // ignore files under link directory + list.add(Link(filename)); + } else if (T == FileSystemEntityType.file) { + list.add(File(filename)); + } else { + final dir = Directory(filename); + final entities = + await dir.list(recursive: false, followLinks: false).toList(); + entities.forEach((FileSystemEntity entity) { + list.add(entity); + }); + } + return list; +} + +Future ioGetAppBaseDir(String app) async { + final path = await _appPath; + final appBaseDir = '${path}/apps/${app}'; + return appBaseDir; +} + +Future ioRemoveApp(String app) async { + final path = await _appPath; + final appBaseDir = '${path}/apps/${app}'; + final dir = Directory(appBaseDir); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + return true; +} + +Future ioMoveApp(String app, String newname) async { + final path = await _appPath; + final appBaseDir = '${path}/apps/${app}'; + final newBaseDir = '${path}/apps/${newname}'; + final dir = Directory(appBaseDir); + if (await dir.exists()) { + await dir.rename(newBaseDir); + } + return true; +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..a20a069 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,33 @@ +import 'package:flutter/material.dart'; +import './homepage.dart'; + +void main() { + runApp(NodeBaseApp()); +} + +class NodeBaseApp extends StatelessWidget { + // This widget is the root of your application. + @override + Widget build(BuildContext context) { + return MaterialApp( + title: 'NodeBase', + theme: ThemeData( + // This is the theme of your application. + // + // Try running your application with "flutter run". You'll see the + // application has a blue toolbar. Then, without quitting the app, try + // changing the primarySwatch below to Colors.green and then invoke + // "hot reload" (press "r" in the console where you ran "flutter run", + // or simply save your changes to "hot reload" in a Flutter IDE). + // Notice that the counter didn't reset back to zero; the application + // is not restarted. + primarySwatch: Colors.blue, + // This makes the visual density adapt to the platform that you run + // the app on. For desktop platforms, the controls will be smaller and + // closer together (more dense) than on mobile platforms. + visualDensity: VisualDensity.adaptivePlatformDensity, + ), + home: NodeBaseHomePage(title: 'NodeBase'), + ); + } +} diff --git a/lib/page_app_home.dart b/lib/page_app_home.dart new file mode 100644 index 0000000..9d816cd --- /dev/null +++ b/lib/page_app_home.dart @@ -0,0 +1,299 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import './app_model.dart'; +import './io.dart'; +import './api.dart'; +import './page_app_webview.dart'; + +class NodeBaseAppHome extends StatefulWidget { + final NodeBaseApp item; + + NodeBaseAppHome({required this.item, super.key}); + @override + _NodeBaseAppHomeState createState() => _NodeBaseAppHomeState(); +} + +class _NodeBaseAppHomeState extends State { + bool loading = true; + bool isRunning = false; + String wifiIp = "0.0.0.0"; + String appHomeUrl = ""; + String appHomePath = ""; + final ctrlParams = TextEditingController(); + final ctrlDownload = TextEditingController(); + var eventSub = null; + + appStopped() { + setState(() { + isRunning = false; + }); + } + + appStarted() { + setState(() { + isRunning = true; + }); + } + + Future loadPlatform(String name) async { + var config = await readAppFileAsString("/platform.json"); + final List list = []; + if (config != "") { + final data = jsonDecode(config); + data['platforms'].toList().forEach((x) { + if (x['name'] != name) return; + final item = NodeBasePlatform(name: x['name']); + item.path = x['path']; + item.updateUrl = x['url']; + list.add(item); + }); + if (list.length <= 0) return null; + return list[0]; + } + return null; + } + + Future loadAppDetails(String name) async { + var config = await readAppFileAsString("/apps/${name}/config.json"); + if (config != "") { + final data = jsonDecode(config); + final item = NodeBaseAppDetails(); + item.host = data['host']; + item.port = data['port']; + item.entry = data['entry']; + item.home = data['home']; + item.path = await ioGetAppBaseDir(name); + return item; + } + return null; + } + + @override + void initState() { + super.initState(); + NodeBaseApi.fetchWifiIpv4().then((ip) { + setState(() { + wifiIp = ip == null ? "0.0.0.0" : ip; + }); + loadAppDetails(widget.item.name).then((item) { + setState(() { + if (item == null) return; + if (item.host != "") { + final parts = item.host.split("://"); + if (parts.length > 1) { + appHomeUrl = parts[0]; + } else { + appHomeUrl = 'http'; + } + appHomeUrl = '${appHomeUrl}://${ip}'; + if (item.port > 0) { + appHomeUrl = '${appHomeUrl}:${item.port}'; + } + appHomeUrl = '${appHomeUrl}${item.home}'; + } else { + appHomeUrl = ""; + } + appHomePath = item.path; + }); + }); + }); + if (eventSub == null) { + eventSub = NodeBaseApi.eventApi.receiveBroadcastStream().listen((m) { + // \n + if (m == null) return; + final parts = m.split("\n"); + if (parts.length < 1) return; + final appname = parts[0]; + final appstat = parts[1]; + if (appname != widget.item.name) return; + switch (appstat) { + case "start": + { + appStarted(); + } + break; + case "stop": + { + appStopped(); + } + break; + } + }, onError: (err) {}, cancelOnError: true); + } + NodeBaseApi.appStatus(widget.item.name).then((status) { + switch (status) { + case "started": + { + appStarted(); + } + break; + case "stopped": + default: + { + appStopped(); + } + break; + } + setState(() { + loading = false; + }); + }); + } + + @override + void dispose() { + ctrlParams.dispose(); + ctrlDownload.dispose(); + eventSub.cancel(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.item == null || + widget.item.name == null || + widget.item.name == "") { + Navigator.pop(context); + return Scaffold(); + } + if (loading) { + return Scaffold( + body: Center( + child: CircularProgressIndicator(semanticsLabel: "Loading ..."))); + } + return Scaffold( + appBar: AppBar( + title: Text('Application - ${widget.item.name}'), + leading: IconButton( + icon: Icon(Icons.arrow_back), + onPressed: () { + Navigator.pop(context); + })), + body: ListView(children: [ + const ListTile(title: Text('Basic Info'), dense: true), + ListTile(title: Text('Platform: ${widget.item.platform}')), + ListTile( + title: SelectableText( + appHomeUrl == "" + ? 'Network: ${wifiIp}' + : 'Home: ${appHomeUrl}', + maxLines: 1)), + ListTile( + title: SelectableText('Location: ${appHomePath}', maxLines: 1)), + ListTile( + title: TextField( + controller: ctrlParams, + decoration: InputDecoration(labelText: 'Params'))), + ListTile( + title: Row(children: [ + IconButton( + icon: Icon(Icons.play_arrow), + onPressed: isRunning + ? null + : () { + setState(() { + loading = true; + }); + loadPlatform(widget.item.platform).then((p) { + if (p == null || p.path == null || p.path == "") { + setState(() { + loading = false; + }); + return; + } + loadAppDetails(widget.item.name).then((info) { + if (info == null) { + // no config.json + return; + } + final entry = info.entry == null ? "" : info.entry; + final cmd = + "${p.path} ${info.path}/${entry} ${ctrlParams.text}"; + NodeBaseApi.appStart(widget.item.name, cmd); + setState(() { + loading = false; + }); + }); + }); + }), + SizedBox(width: 15), + IconButton( + icon: Icon(Icons.stop), + onPressed: isRunning + ? () { + NodeBaseApi.appStop(widget.item.name); + } + : null), + IconButton( + icon: Icon(Icons.open_in_browser), + onPressed: isRunning + ? () { + // open webview + setState(() { + loading = true; + }); + loadAppDetails(widget.item.name).then((info) { + if (info == null) { + // no config.json + return; + } + setState(() { + loading = false; + }); + var homeUrl = info.host; + if (info.port > 0) homeUrl += ":${info.port}"; + homeUrl += info.home; + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => NodeBaseAppWebview( + name: widget.item.name, home: homeUrl))); + }); + } + : null), + IconButton( + icon: Icon(Icons.open_in_new), + onPressed: (appHomeUrl != "" && isRunning) + ? () { + NodeBaseApi.appBrowser(appHomeUrl); + } + : null) + ])), // Row, ListTile + const Divider(), + const ListTile(title: Text('Import/Export'), dense: true), + ListTile( + leading: IconButton( + icon: Icon(Icons.file_upload), + onPressed: () { + // TODO: if url, download zip to tmp folder and unpack + setState(() { + loading = true; + }); + NodeBaseApi.appUnpack(widget.item.name, ctrlDownload.text) + .then((ok) { + setState(() { + loading = false; + }); + }); + }), + trailing: IconButton( + icon: Icon(Icons.file_download), + onPressed: () { + setState(() { + loading = true; + }); + NodeBaseApi.appPack(widget.item.name, ctrlDownload.text) + .then((ok) { + setState(() { + loading = false; + }); + }); + }), + title: TextField( + controller: ctrlDownload, + decoration: InputDecoration( + labelText: 'ZIP file path'))), // Row, ListTile + ]) // ListView + ); + } +} diff --git a/lib/page_app_market.dart b/lib/page_app_market.dart new file mode 100644 index 0000000..aefd5a0 --- /dev/null +++ b/lib/page_app_market.dart @@ -0,0 +1,163 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import './api.dart'; +import './io.dart'; + +String BASE_HOST = 'raw.githubusercontent.com'; +String BASE_URL = '/wiki/dna2github/NodeBase'; + +class AppItem { + String platform = ""; + String name = ""; + bool zip = false; +} + +class NodeBaseAppMarketItem extends StatefulWidget { + final Function(AppItem item) fnInstall; + final Function(AppItem item) fnUninstall; + final AppItem item; + + NodeBaseAppMarketItem({ + required this.item, + required this.fnInstall, + required this.fnUninstall, + super.key + }); + + @override + _NodeBaseAppMarketItemState createState() => + _NodeBaseAppMarketItemState(); +} + +class _NodeBaseAppMarketItemState extends State { + @override + Widget build(BuildContext context) { + return Card( + child: ListTile( + title: Text(widget.item.name), + subtitle: Text(widget.item.platform), + trailing: PopupMenuButton( + icon: Icon(Icons.more_vert), + onSelected: (int result) { + switch (result) { + case 101: + { + widget.fnInstall(widget.item); + } + break; + } + }, + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem( + value: 101, child: Text('Install')), + // TODO: add Uninstall + ]) // PopupMenuButton + )); + } +} + +class NodeBaseAppMarket extends StatefulWidget { + NodeBaseAppMarket({super.key}); + @override + _NodeBaseAppMarketState createState() => _NodeBaseAppMarketState(); +} + +class _NodeBaseAppMarketState extends State { + List entities = []; + bool loading = true; + + @override + void initState() { + _showAppItems(); + } + + Future fetchAppList() async { + return http.get(Uri.https(BASE_HOST, BASE_URL + '/quick/app/meta.list')); + } + + Future> _getAppItems() async { + final res = await fetchAppList(); + List r = []; + for (String line in res.body.split("\n")) { + if (line.length == 0) continue; + final parts = line.split(" "); + // platform name zip? + final one = AppItem(); + one.platform = parts[0]; + one.name = parts[1]; + if (parts.length == 3 && parts[2] == "zip") { + one.zip = true; + } else { + one.zip = false; + } + r.add(one); + } + return r; + } + + installAppItem(AppItem item) async { + final url = "https://" + + BASE_HOST + + BASE_URL + + "/quick/app/" + + item.platform + + "/" + + item.name + + (item.zip ? ".zip" : ""); + print(url); + try { + var dst = await NodeBaseApi.fetchApp(url); + var config = await readAppFileAsString("/apps.json"); + if (config == "") config = "{\"apps\": []}"; + final data = jsonDecode(config); + final list = data["apps"].toList(); + list.add({"name": item.name, "path": dst, "platform": item.platform}); + await writeAppFileAsString( + "/apps.json", + JsonEncoder((x) { + return x; + }).convert({"apps": list})); + } catch (e) { + // TODO: handle exception + } + } + + uninstallAppItem(AppItem item) {} + + Future _showAppItems() async { + final items = await _getAppItems(); + final List list = []; + for (AppItem item in items) { + final tile = NodeBaseAppMarketItem( + item: item, + fnInstall: installAppItem, + fnUninstall: uninstallAppItem, + ); + list.add(tile); + } + setState(() { + entities.clear(); + entities.addAll(list); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Application Market'), + leading: IconButton( + icon: Icon(Icons.arrow_back), + onPressed: () { + Navigator.pop(context); + })), + body: ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: entities.length, + itemBuilder: (BuildContext context, int index) { + return Container(child: entities[index]); + })); + } +} diff --git a/lib/page_app_webview.dart b/lib/page_app_webview.dart new file mode 100644 index 0000000..d2b7a3f --- /dev/null +++ b/lib/page_app_webview.dart @@ -0,0 +1,309 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:flutter/material.dart'; +import 'package:webview_flutter/webview_flutter.dart'; + +class NodeBaseAppWebview extends StatefulWidget { + String name; + String home; + + NodeBaseAppWebview({ + required this.name, + required this.home, + super.key + }); + + @override + _NodeBaseAppWebviewState createState() => _NodeBaseAppWebviewState(); +} + +class _NodeBaseAppWebviewState extends State { + late WebViewController _controller; + + @override + void initState() { + super.initState(); + _controller = WebViewController() + ..setJavaScriptMode(JavaScriptMode.unrestricted) + ..setNavigationDelegate( + NavigationDelegate( + onProgress: (int progress) {}, + onPageStarted: (String url) { + print('- Page started loading: $url'); + }, + onPageFinished: (String url) { + print('- Page finished loading: $url'); + }, + onWebResourceError: (WebResourceError error) {}, + onNavigationRequest: (NavigationRequest request) { + if (request.url.startsWith('https://www.youtube.com/')) { + print('- blocking navigation to $request}'); + return NavigationDecision.prevent; + } + print('- allowing navigation to $request'); + return NavigationDecision.navigate; + } + ) + ) + ..loadRequest(Uri.parse(widget.home)); + + _controller.addJavaScriptChannel( + "Toaster", + onMessageReceived: (JavaScriptMessage message) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(message.message)), + ); + } + ); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(widget.name), + actions: [ + NavigationControls(Future(() => _controller)), + SampleMenu(Future(() => _controller)), + ], + ), + // We're using a Builder here so we have a context that is below the Scaffold + // to allow calling Scaffold.of(context) so we can show a snackbar. + body: Builder(builder: (BuildContext context) { + return WebViewWidget(controller: _controller); + }), + floatingActionButton: favoriteButton(), + ); + } + + Widget favoriteButton() { + return FutureBuilder( + future: Future(() => _controller), + builder: (BuildContext context, + AsyncSnapshot controller) { + if (controller.hasData) { + return FloatingActionButton( + onPressed: () async { + final String? url = await controller.data?.currentUrl(); + if (url == null) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Favorited $url')), + ); + }, + child: const Icon(Icons.favorite), + ); + } + return Container(); + }); + } +} + +enum MenuOptions { + showUserAgent, + listCookies, + clearCookies, + addToCache, + listCache, + clearCache, + navigationDelegate, +} + +class SampleMenu extends StatelessWidget { + SampleMenu(this.controller); + + final Future controller; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: controller, + builder: + (BuildContext context, AsyncSnapshot controller) { + return PopupMenuButton( + onSelected: (MenuOptions value) { + switch (value) { + case MenuOptions.showUserAgent: + _onShowUserAgent(controller.data, context); + break; + case MenuOptions.listCookies: + _onListCookies(controller.data, context); + break; + case MenuOptions.addToCache: + _onAddToCache(controller.data, context); + break; + case MenuOptions.listCache: + _onListCache(controller.data, context); + break; + case MenuOptions.clearCache: + _onClearCache(controller.data, context); + break; + case MenuOptions.navigationDelegate: + _onNavigationDelegateExample(controller.data, context); + break; + } + }, + itemBuilder: (BuildContext context) => >[ + PopupMenuItem( + value: MenuOptions.showUserAgent, + child: const Text('Show user agent'), + enabled: controller.hasData, + ), + const PopupMenuItem( + value: MenuOptions.listCookies, + child: Text('List cookies'), + ), + const PopupMenuItem( + value: MenuOptions.addToCache, + child: Text('Add to cache'), + ), + const PopupMenuItem( + value: MenuOptions.listCache, + child: Text('List cache'), + ), + const PopupMenuItem( + value: MenuOptions.clearCache, + child: Text('Clear cache'), + ), + const PopupMenuItem( + value: MenuOptions.navigationDelegate, + child: Text('Navigation Delegate example'), + ), + ], + ); + }, + ); + } + + void _onShowUserAgent( + WebViewController? controller, BuildContext context) async { + // Send a message with the user agent string to the Toaster JavaScript channel we registered + // with the WebView. + if (controller == null) return; + await controller.runJavaScript( + 'Toaster.postMessage("User Agent: " + navigator.userAgent);'); + } + + void _onListCookies( + WebViewController? controller, BuildContext context) async { + if (controller == null) return; + final Object cookies = + await controller.runJavaScriptReturningResult('document.cookie'); + ScaffoldMessenger.of(context).showSnackBar(SnackBar( + content: Column( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: [ + const Text('Cookies:'), + _getCookieList(cookies.toString()), + ], + ), + )); + } + + void _onAddToCache(WebViewController? controller, BuildContext context) async { + if (controller == null) return; + await controller.runJavaScript( + 'caches.open("test_caches_entry"); localStorage["test_localStorage"] = "dummy_entry";'); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('Added a test entry to cache.'), + )); + } + + void _onListCache(WebViewController? controller, BuildContext context) async { + if (controller == null) return; + await controller.runJavaScript('caches.keys()' + '.then((cacheKeys) => JSON.stringify({"cacheKeys" : cacheKeys, "localStorage" : localStorage}))' + '.then((caches) => Toaster.postMessage(caches))'); + } + + void _onClearCache(WebViewController? controller, BuildContext context) async { + if (controller == null) return; + await controller.clearCache(); + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text("Cache cleared."), + )); + } + + void _onNavigationDelegateExample( + WebViewController? controller, BuildContext context) async { + if (controller == null) return; + await controller.loadRequest( + Uri.dataFromString( + '', mimeType: 'text/html', encoding: Encoding.getByName('utf-8'))); + } + + Widget _getCookieList(String cookies) { + if (cookies == null || cookies == '""') { + return Container(); + } + final List cookieList = cookies.split(';'); + final Iterable cookieWidgets = + cookieList.map((String cookie) => Text(cookie)); + return Column( + mainAxisAlignment: MainAxisAlignment.end, + mainAxisSize: MainAxisSize.min, + children: cookieWidgets.toList(), + ); + } +} + +class NavigationControls extends StatelessWidget { + const NavigationControls(this._webViewControllerFuture); + final Future _webViewControllerFuture; + + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _webViewControllerFuture, + builder: + (BuildContext context, AsyncSnapshot snapshot) { + final bool webViewReady = + snapshot.connectionState == ConnectionState.done; + final WebViewController? controller = snapshot.data; + return Row( + children: [ + IconButton( + icon: const Icon(Icons.arrow_back_ios), + onPressed: !webViewReady + ? null + : () async { + if (true == await controller?.canGoBack()) { + await controller?.goBack(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text("No back history item")), + ); + return; + } + }, + ), + IconButton( + icon: const Icon(Icons.arrow_forward_ios), + onPressed: !webViewReady + ? null + : () async { + if (true == await controller?.canGoForward()) { + await controller?.goForward(); + } else { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text("No forward history item")), + ); + return; + } + }, + ), + IconButton( + icon: const Icon(Icons.replay), + onPressed: !webViewReady + ? null + : () { + controller?.reload(); + }, + ), + ], + ); + }, + ); + } +} diff --git a/lib/page_apps.dart b/lib/page_apps.dart new file mode 100644 index 0000000..2fe53e9 --- /dev/null +++ b/lib/page_apps.dart @@ -0,0 +1,256 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import './page_app_home.dart'; +import './io.dart'; +import './app_model.dart'; +import './api.dart'; +import './page_app_market.dart'; + +class NodeBaseAppItem extends StatefulWidget { + final Function(NodeBaseAppItem item) fnRemove; + final Function() fnSaveConfig; + NodeBaseApp item; + bool isEdit = false; + bool isCreated = false; + + NodeBaseAppItem({ + required this.item, + required this.fnRemove, + required this.fnSaveConfig, + super.key + }); + + @override + _NodeBaseAppItemState createState() => _NodeBaseAppItemState(); +} + +class _NodeBaseAppItemState extends State { + final ctrlName = TextEditingController(); + final ctrlPlatform = TextEditingController(); + bool _initialized = false; + + @override + void dispose() { + ctrlName.dispose(); + ctrlPlatform.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.isEdit) { + if (!_initialized) { + ctrlName.text = widget.item.name; + ctrlPlatform.text = widget.item.platform; + _initialized = true; + } + final entities = [ + ListTile( + leading: Icon(Icons.bookmark), + title: TextField( + controller: ctrlName, + decoration: InputDecoration(labelText: 'Name'))), + ListTile( + leading: Icon(Icons.cloud_queue), + title: TextField( + controller: ctrlPlatform, + decoration: InputDecoration(labelText: 'Platform'))) // ListTile + ]; + if (widget.item.path != null && widget.item.path != "") { + entities.add(ListTile( + leading: SizedBox(width: 5), title: Text(widget.item.path))); + } + entities.add(Row(children: [ + TextButton.icon( + icon: Icon(Icons.check), + label: Text("Save"), + onPressed: () { + if (ctrlName.text == "") return; + if (widget.item.name != ctrlName.text) { + ioMoveApp(widget.item.name, ctrlName.text); + } + setState(() { + widget.item.name = ctrlName.text; + widget.item.platform = ctrlPlatform.text; + widget.isCreated = false; + widget.isEdit = false; + widget.fnSaveConfig(); + }); + }), + TextButton.icon( + icon: Icon(Icons.close), + label: Text("Cancel"), + onPressed: () { + if (widget.isCreated) { + widget.fnRemove(widget); + } else { + setState(() { + widget.isEdit = false; + }); + } + }) + ]) // Row + ); + return Card(child: Column(children: entities)); + } + var name = widget.item.name == null ? "" : widget.item.name; + var platform = widget.item.platform == null ? "" : widget.item.platform; + return Card( + child: ListTile( + title: Text(name), + subtitle: Text(platform), + trailing: PopupMenuButton( + icon: Icon(Icons.more_vert), + onSelected: (int result) { + switch (result) { + case 101: + { + setState(() { + widget.isEdit = true; + }); + } + break; + case 102: + { + ioRemoveApp(widget.item.name); + widget.fnRemove(widget); + widget.fnSaveConfig(); + } + break; + } + }, + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem(value: 101, child: Text('Edit')), + const PopupMenuItem( + value: 102, child: Text('Delete')) + ]), // PopupMenuButton + onTap: () { + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + NodeBaseAppHome(item: widget.item))); + })); + } +} + +class NodeBaseApplications extends StatefulWidget { + NodeBaseApplications({super.key}); + @override + _NodeBaseApplicationsState createState() => _NodeBaseApplicationsState(); +} + +class _NodeBaseApplicationsState extends State { + List entities = []; + var loading = true; + + @override + void initState() { + super.initState(); + loadConfig(); + } + + loadConfig() async { + setState(() { + loading = true; + }); + var config = await readAppFileAsString("/apps.json"); + final List list = []; + if (config != "") { + final data = jsonDecode(config); + entities.clear(); + data['apps'].toList().forEach((x) { + final item = NodeBaseApp(name: x['name']); + item.path = x['path']; + item.platform = x['platform']; + final NodeBaseAppItem node = makeItem(item); + list.add(node); + }); + } + setState(() { + entities.addAll(list); + loading = false; + }); + } + + saveConfig() async { + setState(() { + loading = true; + }); + await writeAppFileAsString( + "/apps.json", + JsonEncoder((x) { + if (x is NodeBaseAppItem) { + return { + "name": x.item.name, + "path": x.item.path, + "platform": x.item.platform + }; + } + return null; + }).convert({"apps": entities})); + setState(() { + loading = false; + }); + } + + removeItem(NodeBaseAppItem item) { + final index = entities.indexOf(item); + if (index < 0) return; + setState(() { + entities.removeAt(index); + }); + } + + makeItem(NodeBaseApp item) { + return NodeBaseAppItem( + item: item, fnRemove: removeItem, fnSaveConfig: saveConfig); + } + + @override + Widget build(BuildContext context) { + if (loading) { + return Scaffold( + body: Center( + child: CircularProgressIndicator(semanticsLabel: "Loading ..."))); + } + return Scaffold( + appBar: AppBar( + title: Text('Applications'), + leading: IconButton( + icon: Icon(Icons.arrow_back), + onPressed: () { + Navigator.pop(context); + }), + actions: [ + IconButton( + icon: Icon(Icons.add_shopping_cart), + onPressed: () { + var route = MaterialPageRoute( + builder: (context) => NodeBaseAppMarket()); + Navigator.push(context, route); + }) + ], + ), + body: (entities.length == 0) + ? Center(child: Text('No application.')) + : ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: entities.length, + itemBuilder: (BuildContext context, int index) { + return Container(child: entities[index]); + }), // body + floatingActionButton: FloatingActionButton( + tooltip: 'Add Application', + child: Icon(Icons.add), + onPressed: () { + final item = NodeBaseApp(name: ""); + final entity = makeItem(item); + setState(() { + entity.isEdit = true; + entity.isCreated = true; + entities.add(entity); + }); + })); + } +} diff --git a/lib/page_environment.dart b/lib/page_environment.dart new file mode 100644 index 0000000..b357f36 --- /dev/null +++ b/lib/page_environment.dart @@ -0,0 +1,39 @@ +import 'package:flutter/material.dart'; +import './api.dart'; + +class NodeBaseEnvironmentSettings extends StatefulWidget { + NodeBaseEnvironmentSettings({super.key}); + @override + _NodeBaseEnvironmentSettingsState createState() => + _NodeBaseEnvironmentSettingsState(); +} + +class _NodeBaseEnvironmentSettingsState + extends State { + String _batteryLevel = 'Unknown'; + + @override + void initState() { + _getBatteryLevel(); + } + + Future _getBatteryLevel() async { + final String batteryLevel = await NodeBaseApi.getBatteryLevel(); + setState(() { + _batteryLevel = batteryLevel; + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Environment Settings'), + leading: IconButton( + icon: Icon(Icons.arrow_back), + onPressed: () { + Navigator.pop(context); + })), + body: Center(child: Text('Environment Settings $_batteryLevel'))); + } +} diff --git a/lib/page_platform.dart b/lib/page_platform.dart new file mode 100644 index 0000000..c6523ab --- /dev/null +++ b/lib/page_platform.dart @@ -0,0 +1,267 @@ +import 'dart:convert'; +import 'package:flutter/material.dart'; +import './io.dart'; +import './app_model.dart'; +import './api.dart'; +import './page_platform_market.dart'; + +class NodeBasePlatformItem extends StatefulWidget { + final Function(NodeBasePlatformItem item) fnRemove; + final Function() fnSaveConfig; + NodeBasePlatform item; + bool isEdit = false; + bool isCreated = false; + + NodeBasePlatformItem({ + required this.item, + required this.fnRemove, + required this.fnSaveConfig, + super.key + }); + + @override + _NodeBasePlatformItemState createState() => _NodeBasePlatformItemState(); +} + +class _NodeBasePlatformItemState extends State { + final ctrlName = TextEditingController(); + final ctrlDownloadUrl = TextEditingController(); + bool _initialized = false; + + @override + void dispose() { + ctrlName.dispose(); + ctrlDownloadUrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + if (widget.isEdit) { + if (!_initialized) { + ctrlName.text = widget.item.name; + ctrlDownloadUrl.text = widget.item.updateUrl; + _initialized = true; + } + final entities = [ + ListTile( + leading: Icon(Icons.call_to_action), + title: TextField( + controller: ctrlName, + decoration: InputDecoration(labelText: 'Name'))), + ListTile( + leading: Icon(Icons.attachment), + title: TextField( + controller: ctrlDownloadUrl, + decoration: InputDecoration(labelText: 'Download URL')), + trailing: IconButton( + icon: Icon(Icons.file_download), + onPressed: () { + if (ctrlDownloadUrl.text == "") return; + NodeBaseApi.fetchExecutable(ctrlDownloadUrl.text).then((dst) { + setState(() { + widget.item.path = dst; + if (!widget.isCreated) widget.fnSaveConfig(); + }); + }); + }) // trailing + ) // ListTile + ]; + if (widget.item.path != null && widget.item.path != "") { + entities.add(ListTile( + leading: SizedBox(width: 5), title: Text(widget.item.path))); + } + entities.add(Row(children: [ + TextButton.icon( + icon: Icon(Icons.check), + label: Text("Save"), + onPressed: () { + if (ctrlName.text == "") return; + setState(() { + widget.item.name = ctrlName.text; + widget.item.updateUrl = ctrlDownloadUrl.text; + widget.isCreated = false; + widget.isEdit = false; + widget.fnSaveConfig(); + }); + }), + TextButton.icon( + icon: Icon(Icons.close), + label: Text("Cancel"), + onPressed: () { + if (widget.isCreated) { + widget.fnRemove(widget); + } else { + setState(() { + widget.isEdit = false; + }); + } + }) + ]) // Row + ); + return Card(child: Column(children: entities) // ListView + ); + } + var name = widget.item.name == null ? "" : widget.item.name; + var path = widget.item.path == null ? "" : widget.item.path; + if (path == "") { + var url = widget.item.updateUrl == null ? "" : widget.item.updateUrl; + if (url != "") + path = "Remotely available @ ${url}"; + else + path = "Not yet configured."; + } + return Card( + child: ListTile( + title: Text(name), + subtitle: Text(path), + trailing: PopupMenuButton( + icon: Icon(Icons.more_vert), + onSelected: (int result) { + switch (result) { + case 101: + { + setState(() { + widget.isEdit = true; + }); + } + break; + case 102: + { + // TODO: if we remove this item, do we need also remove the file at + // widget.item.path? + widget.fnRemove(widget); + widget.fnSaveConfig(); + } + break; + } + }, + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem(value: 101, child: Text('Edit')), + const PopupMenuItem( + value: 102, child: Text('Delete')) + ]) // PopupMenuButton + )); + } +} + +class NodeBasePlatformSettings extends StatefulWidget { + NodeBasePlatformSettings({super.key}); + @override + _NodeBasePlatformSettingsState createState() => + _NodeBasePlatformSettingsState(); +} + +class _NodeBasePlatformSettingsState extends State { + List entities = []; + var loading = true; + + @override + void initState() { + super.initState(); + loadConfig(); + } + + loadConfig() async { + setState(() { + loading = true; + }); + var config = await readAppFileAsString("/platform.json"); + final List list = []; + if (config != "") { + final data = jsonDecode(config); + entities.clear(); + data['platforms'].toList().forEach((x) { + final item = NodeBasePlatform(name: x['name']); + item.path = x['path']; + item.updateUrl = x['url']; + final NodeBasePlatformItem node = makeItem(item); + list.add(node); + }); + } + setState(() { + entities.addAll(list); + loading = false; + }); + } + + saveConfig() async { + setState(() { + loading = true; + }); + await writeAppFileAsString( + "/platform.json", + JsonEncoder((x) { + if (x is NodeBasePlatformItem) { + return { + "name": x.item.name, + "path": x.item.path, + "url": x.item.updateUrl + }; + } + return null; + }).convert({"platforms": entities})); + setState(() { + loading = false; + }); + } + + removeItem(NodeBasePlatformItem item) { + final index = entities.indexOf(item); + if (index < 0) return; + setState(() { + entities.removeAt(index); + }); + } + + makeItem(NodeBasePlatform item) { + return NodeBasePlatformItem( + item: item, fnRemove: removeItem, fnSaveConfig: saveConfig); + } + + @override + Widget build(BuildContext context) { + if (loading) { + return Scaffold( + body: Center( + child: CircularProgressIndicator(semanticsLabel: "Loading ..."))); + } + return Scaffold( + appBar: AppBar( + title: Text('Platform Settings'), + leading: IconButton( + icon: Icon(Icons.arrow_back), + onPressed: () { + Navigator.pop(context); + }), + actions: [ + IconButton( + icon: Icon(Icons.add_shopping_cart), + onPressed: () { + var route = MaterialPageRoute(builder: (context) => NodeBasePlatformMarket()); + Navigator.push(context, route); + }) + ], + ), + body: (entities.length == 0) + ? Center(child: Text('No platform item.')) + : ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: entities.length, + itemBuilder: (BuildContext context, int index) { + return Container(child: entities[index]); + }), // body + floatingActionButton: FloatingActionButton( + tooltip: 'Add Platform', + child: Icon(Icons.add), + onPressed: () { + final item = NodeBasePlatform(name: ""); + final entity = makeItem(item); + setState(() { + entity.isEdit = true; + entity.isCreated = true; + entities.add(entity); + }); + })); + } +} diff --git a/lib/page_platform_market.dart b/lib/page_platform_market.dart new file mode 100644 index 0000000..f8469aa --- /dev/null +++ b/lib/page_platform_market.dart @@ -0,0 +1,166 @@ +import 'dart:convert'; + +import 'package:flutter/material.dart'; +import 'package:http/http.dart' as http; +import './api.dart'; +import './io.dart'; + +String BASE_HOST = 'raw.githubusercontent.com'; +String BASE_URL = '/wiki/dna2github/NodeBase'; + +class PlatformItem { + String arch = ""; + String name = ""; + bool zip = false; +} + +class NodeBasePlatformMarketItem extends StatefulWidget { + final Function(PlatformItem item) fnInstall; + final Function(PlatformItem item) fnUninstall; + final PlatformItem item; + + NodeBasePlatformMarketItem({ + required this.item, + required this.fnInstall, + required this.fnUninstall, + super.key + }); + + @override + _NodeBasePlatformMarketItemState createState() => + _NodeBasePlatformMarketItemState(); +} + +class _NodeBasePlatformMarketItemState + extends State { + @override + Widget build(BuildContext context) { + return Card( + child: ListTile( + title: Text(widget.item.name), + subtitle: Text(widget.item.arch), + trailing: PopupMenuButton( + icon: Icon(Icons.more_vert), + onSelected: (int result) { + switch (result) { + case 101: + { + widget.fnInstall(widget.item); + } + break; + } + }, + itemBuilder: (BuildContext context) => >[ + const PopupMenuItem( + value: 101, child: Text('Install')), + // TODO: add Uninstall + ]) // PopupMenuButton + )); + } +} + +class NodeBasePlatformMarket extends StatefulWidget { + NodeBasePlatformMarket({super.key}); + @override + _NodeBasePlatformMarketState createState() => _NodeBasePlatformMarketState(); +} + +class _NodeBasePlatformMarketState extends State { + List entities = []; + bool loading = true; + + @override + void initState() { + _showPlatformItems(); + } + + Future fetchPlatformList() async { + return http + .get(Uri.https(BASE_HOST, BASE_URL + '/quick/platform/meta.list')); + } + + Future> _getPlatformItems() async { + final res = await fetchPlatformList(); + List r = []; + for (String line in res.body.split("\n")) { + if (line.length == 0) continue; + final parts = line.split(" "); + // arch name zip? + final one = PlatformItem(); + one.arch = parts[0]; + one.name = parts[1]; + if (parts.length == 3 && parts[2] == "zip") { + one.zip = true; + } else { + one.zip = false; + } + r.add(one); + } + return r; + } + + installPlatformItem (PlatformItem item) async { + final url = "https://" + BASE_HOST + BASE_URL + "/quick/platform/" + item.arch + "/" + item.name + ( item.zip?".zip":"" ); + print(url); + try { + var dst = await NodeBaseApi.fetchExecutable(url); + if (dst.endsWith(".zip")) { + dst = dst.substring(0, dst.length - 4); + } + var config = await readAppFileAsString("/platform.json"); + if (config == "") config = "{\"platforms\": []}"; + final data = jsonDecode(config); + final list = data["platforms"].toList(); + list.add({ + "name": item.name, + "path": dst, + "url": url + }); + await writeAppFileAsString( + "/platform.json", + JsonEncoder((x) { + return x; + }).convert({"platforms": list})); + } catch(e) { + // TODO: handle exception + } + } + + uninstallPlatformItem (PlatformItem item) {} + + Future _showPlatformItems() async { + final items = await _getPlatformItems(); + final List list = []; + for (PlatformItem item in items) { + final tile = NodeBasePlatformMarketItem( + item: item, + fnInstall: installPlatformItem, + fnUninstall: uninstallPlatformItem, + ); + list.add(tile); + } + setState(() { + entities.clear(); + entities.addAll(list); + }); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text('Platform Market'), + leading: IconButton( + icon: Icon(Icons.arrow_back), + onPressed: () { + Navigator.pop(context); + })), + body: ListView.builder( + padding: const EdgeInsets.all(8), + itemCount: entities.length, + itemBuilder: (BuildContext context, int index) { + return Container(child: entities[index]); + }) + ); + } +} diff --git a/lib/search.dart b/lib/search.dart new file mode 100644 index 0000000..2e96473 --- /dev/null +++ b/lib/search.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; + +class NodeBaseSearch extends SearchDelegate { + @override + List buildActions(BuildContext context) { + return [ + IconButton( + icon: Icon(Icons.close), + onPressed: () { + if (query == "") { + Navigator.pop(context); + } else { + query = ""; + } + }) + ]; + } + + @override + Widget buildLeading(BuildContext context) { + return IconButton( + icon: Icon(Icons.arrow_back), + onPressed: () { + Navigator.pop(context); + }); + } + + @override + Widget buildResults(BuildContext context) { + return Container(child: Center(child: Text("hello"))); + } + + @override + Widget buildSuggestions(BuildContext context) { + List candidateList = ["a", "b", "c"]; + List suggestionList = []; + query.isEmpty + ? suggestionList = candidateList + : suggestionList.addAll(candidateList.where((x) => x.contains(query))); + return ListView.builder( + itemCount: suggestionList.length, + itemBuilder: (context, index) { + return ListTile(title: Text(suggestionList[index]), onTap: () {}); + }); + } +} diff --git a/pubspec.lock b/pubspec.lock new file mode 100644 index 0000000..cc31797 --- /dev/null +++ b/pubspec.lock @@ -0,0 +1,325 @@ +# Generated by pub +# See https://dart.dev/tools/pub/glossary#lockfile +packages: + async: + dependency: transitive + description: + name: async + sha256: "947bfcf187f74dbc5e146c9eb9c0f10c9f8b30743e341481c1e2ed3ecc18c20c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.11.0" + boolean_selector: + dependency: transitive + description: + name: boolean_selector + sha256: "6cfb5af12253eaf2b368f07bacc5a80d1301a071c73360d746b7f2e32d762c66" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.1" + characters: + dependency: transitive + description: + name: characters + sha256: "04a925763edad70e8443c99234dc3328f442e811f1d8fd1a72f1c8ad0f69a605" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.0" + clock: + dependency: transitive + description: + name: clock + sha256: cb6d7f03e1de671e34607e909a7213e31d7752be4fb66a86d29fe1eb14bfb5cf + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.1" + collection: + dependency: transitive + description: + name: collection + sha256: ee67cb0715911d28db6bf4af1026078bd6f0128b07a5f66fb2ed94ec6783c09a + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.18.0" + cupertino_icons: + dependency: "direct main" + description: + name: cupertino_icons + sha256: d57953e10f9f8327ce64a508a355f0b1ec902193f66288e8cb5070e7c47eeb2d + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.6" + fake_async: + dependency: transitive + description: + name: fake_async + sha256: "511392330127add0b769b75a987850d136345d9227c6b94c96a04cf4a391bf78" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.1" + ffi: + dependency: transitive + description: + name: ffi + sha256: "7bf0adc28a23d395f19f3f1eb21dd7cfd1dd9f8e1c50051c069122e6853bc878" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.0" + flutter: + dependency: "direct main" + description: flutter + source: sdk + version: "0.0.0" + flutter_archive: + dependency: "direct main" + description: + name: flutter_archive + sha256: "004132780d382df5171589ab793e2efc9c3eef570fe72d78b4ccfbfbe52762ae" + url: "https://pub.flutter-io.cn" + source: hosted + version: "6.0.0" + flutter_test: + dependency: "direct dev" + description: flutter + source: sdk + version: "0.0.0" + http: + dependency: "direct main" + description: + name: http + sha256: d4872660c46d929f6b8a9ef4e7a7eff7e49bbf0c4ec3f385ee32df5119175139 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.1.2" + http_parser: + dependency: transitive + description: + name: http_parser + sha256: "2aa08ce0341cc9b354a498388e30986515406668dbcc4f7c950c3e715496693b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.0.2" + matcher: + dependency: transitive + description: + name: matcher + sha256: "1803e76e6653768d64ed8ff2e1e67bea3ad4b923eb5c56a295c3e634bad5960e" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.12.16" + material_color_utilities: + dependency: transitive + description: + name: material_color_utilities + sha256: "9528f2f296073ff54cb9fee677df673ace1218163c3bc7628093e7eed5203d41" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.5.0" + meta: + dependency: transitive + description: + name: meta + sha256: a6e590c838b18133bb482a2745ad77c5bb7715fb0451209e1a7567d416678b8e + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.10.0" + path: + dependency: transitive + description: + name: path + sha256: "8829d8a55c13fc0e37127c29fedf290c102f4e40ae94ada574091fe0ff96c917" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.8.3" + path_provider: + dependency: "direct main" + description: + name: path_provider + sha256: b27217933eeeba8ff24845c34003b003b2b22151de3c908d0e679e8fe1aa078b + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + path_provider_android: + dependency: transitive + description: + name: path_provider_android + sha256: "477184d672607c0a3bf68fbbf601805f92ef79c82b64b4d6eb318cbca4c48668" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.2" + path_provider_foundation: + dependency: transitive + description: + name: path_provider_foundation + sha256: "5a7999be66e000916500be4f15a3633ebceb8302719b47b9cc49ce924125350f" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.3.2" + path_provider_linux: + dependency: transitive + description: + name: path_provider_linux + sha256: f7a1fe3a634fe7734c8d3f2766ad746ae2a2884abe22e241a8b301bf5cac3279 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.1" + path_provider_platform_interface: + dependency: transitive + description: + name: path_provider_platform_interface + sha256: "88f5779f72ba699763fa3a3b06aa4bf6de76c8e5de842cf6f29e2e06476c2334" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + path_provider_windows: + dependency: transitive + description: + name: path_provider_windows + sha256: "8bc9f22eee8690981c22aa7fc602f5c85b497a6fb2ceb35ee5a5e5ed85ad8170" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.1" + platform: + dependency: transitive + description: + name: platform + sha256: "12220bb4b65720483f8fa9450b4332347737cf8213dd2840d8b2c823e47243ec" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.1.4" + plugin_platform_interface: + dependency: transitive + description: + name: plugin_platform_interface + sha256: "4820fbfdb9478b1ebae27888254d445073732dae3d6ea81f0b7e06d5dedc3f02" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.8" + sky_engine: + dependency: transitive + description: flutter + source: sdk + version: "0.0.99" + source_span: + dependency: transitive + description: + name: source_span + sha256: "53e943d4206a5e30df338fd4c6e7a077e02254531b138a15aec3bd143c1a8b3c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.10.0" + stack_trace: + dependency: transitive + description: + name: stack_trace + sha256: "73713990125a6d93122541237550ee3352a2d84baad52d375a4cad2eb9b7ce0b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.11.1" + stream_channel: + dependency: transitive + description: + name: stream_channel + sha256: ba2aa5d8cc609d96bbb2899c28934f9e1af5cddbd60a827822ea467161eb54e7 + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.2" + string_scanner: + dependency: transitive + description: + name: string_scanner + sha256: "556692adab6cfa87322a115640c11f13cb77b3f076ddcc5d6ae3c20242bedcde" + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.0" + term_glyph: + dependency: transitive + description: + name: term_glyph + sha256: a29248a84fbb7c79282b40b8c72a1209db169a2e0542bce341da992fe1bc7e84 + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.2.1" + test_api: + dependency: transitive + description: + name: test_api + sha256: "5c2f730018264d276c20e4f1503fd1308dfbbae39ec8ee63c5236311ac06954b" + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.6.1" + typed_data: + dependency: transitive + description: + name: typed_data + sha256: facc8d6582f16042dd49f2463ff1bd6e2c9ef9f3d5da3d9b087e244a7b564b3c + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.3.2" + vector_math: + dependency: transitive + description: + name: vector_math + sha256: "80b3257d1492ce4d091729e3a67a60407d227c27241d6927be0130c98e741803" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.1.4" + web: + dependency: transitive + description: + name: web + sha256: afe077240a270dcfd2aafe77602b4113645af95d0ad31128cc02bce5ac5d5152 + url: "https://pub.flutter-io.cn" + source: hosted + version: "0.3.0" + webview_flutter: + dependency: "direct main" + description: + name: webview_flutter + sha256: "71e1bfaef41016c8d5954291df5e9f8c6172f1f6ff3af01b5656456ddb11f94c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "4.4.4" + webview_flutter_android: + dependency: transitive + description: + name: webview_flutter_android + sha256: "161af93c2abaf94ef2192bffb53a3658b2d721a3bf99b69aa1e47814ee18cc96" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.13.2" + webview_flutter_platform_interface: + dependency: transitive + description: + name: webview_flutter_platform_interface + sha256: "80b40ae4fb959957eef9fa8970b6c9accda9f49fc45c2b75154696a8e8996cfe" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.9.1" + webview_flutter_wkwebview: + dependency: transitive + description: + name: webview_flutter_wkwebview + sha256: "4d062ad505390ecef1c4bfb6001cd857a51e00912cc9dfb66edb1886a9ebd80c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "3.10.2" + win32: + dependency: transitive + description: + name: win32 + sha256: "464f5674532865248444b4c3daca12bd9bf2d7c47f759ce2617986e7229494a8" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.2.0" + xdg_directories: + dependency: transitive + description: + name: xdg_directories + sha256: faea9dee56b520b55a566385b84f2e8de55e7496104adada9962e0bd11bcff1d + url: "https://pub.flutter-io.cn" + source: hosted + version: "1.0.4" +sdks: + dart: ">=3.2.0 <4.0.0" + flutter: ">=3.10.0" diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..c493506 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,78 @@ +name: NodeBase +description: Running Node.js application over Wifi and share with your friends. + +# The following line prevents the package from being accidentally published to +# pub.dev using `pub publish`. This is preferred for private packages. +publish_to: 'none' # Remove this line if you wish to publish to pub.dev + +# The following defines the version and build number for your application. +# A version number is three numbers separated by dots, like 1.2.43 +# followed by an optional build number separated by a +. +# Both the version and the builder number may be overridden in flutter +# build by specifying --build-name and --build-number, respectively. +# In Android, build-name is used as versionName while build-number used as versionCode. +# Read more about Android versioning at https://developer.android.com/studio/publish/versioning +# In iOS, build-name is used as CFBundleShortVersionString while build-number used as CFBundleVersion. +# Read more about iOS versioning at +# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html +version: 1.0.0+1 + +environment: + sdk: ">=2.18.0" + +dependencies: + flutter: + sdk: flutter + path_provider: ^2.1.2 + webview_flutter: ^4.4.4 + # The following adds the Cupertino Icons font to your application. + # Use with the CupertinoIcons class for iOS style icons. + cupertino_icons: ^1.0.6 + http: ^1.1.2 + flutter_archive: ^6.0.0 + +dev_dependencies: + flutter_test: + sdk: flutter + +# For information on the generic Dart part of this file, see the +# following page: https://dart.dev/tools/pub/pubspec + +# The following section is specific to Flutter. +flutter: + + # The following line ensures that the Material Icons font is + # included with your application, so that you can use the icons in + # the material Icons class. + uses-material-design: true + + # To add assets to your application, add an assets section, like this: + # assets: + # - images/a_dot_burr.jpeg + # - images/a_dot_ham.jpeg + + # An image asset can refer to one or more resolution-specific "variants", see + # https://flutter.dev/assets-and-images/#resolution-aware. + + # For details regarding adding assets from package dependencies, see + # https://flutter.dev/assets-and-images/#from-packages + + # To add custom fonts to your application, add a fonts section here, + # in this "flutter" section. Each entry in this list should have a + # "family" key with the font family name, and a "fonts" key with a + # list giving the asset and other descriptors for the font. For + # example: + # fonts: + # - family: Schyler + # fonts: + # - asset: fonts/Schyler-Regular.ttf + # - asset: fonts/Schyler-Italic.ttf + # style: italic + # - family: Trajan Pro + # fonts: + # - asset: fonts/TrajanPro.ttf + # - asset: fonts/TrajanPro_Bold.ttf + # weight: 700 + # + # For details regarding fonts from package dependencies, + # see https://flutter.dev/custom-fonts/#from-packages diff --git a/test/widget_test.dart b/test/widget_test.dart new file mode 100644 index 0000000..ac4ec98 --- /dev/null +++ b/test/widget_test.dart @@ -0,0 +1,30 @@ +// This is a basic Flutter widget test. +// +// To perform an interaction with a widget in your test, use the WidgetTester +// utility that Flutter provides. For example, you can send tap and scroll +// gestures. You can also use WidgetTester to find child widgets in the widget +// tree, read text, and verify that the values of widget properties are correct. + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:NodeBase/main.dart'; + +void main() { + testWidgets('Counter increments smoke test', (WidgetTester tester) async { + // Build our app and trigger a frame. + await tester.pumpWidget(MyApp()); + + // Verify that our counter starts at 0. + expect(find.text('0'), findsOneWidget); + expect(find.text('1'), findsNothing); + + // Tap the '+' icon and trigger a frame. + await tester.tap(find.byIcon(Icons.add)); + await tester.pump(); + + // Verify that our counter has incremented. + expect(find.text('0'), findsNothing); + expect(find.text('1'), findsOneWidget); + }); +}