NetDesk 1.5.0 — source release

This commit is contained in:
NetDesk committed 2026-10-18 02:02:46 +00:00
commit 40318aafdd
227 files changed
+80798

No files matched your search

+48
View File
@@ -0,0 +1,48 @@
# Miscellaneous
*.class
*.log
*.pyc
*.swp
.DS_Store
.atom/
.build/
.buildlog/
.history
.svn/
.swiftpm/
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-dependencies
.pub-cache/
.pub/
/build/
/coverage/
# 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
# Widget Preview related
.widget_preview/
+30
View File
@@ -0,0 +1,30 @@
# This file tracks properties of this Flutter project.
# Used by Flutter tool to assess capabilities and perform upgrades etc.
#
# This file should be version controlled and should not be manually edited.
version:
revision: "abaf9c523780a608bd46686fd5e53740a07077f8"
channel: "stable"
project_type: app
# Tracks metadata for the flutter migrate command
migration:
platforms:
- platform: root
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
- platform: android
create_revision: abaf9c523780a608bd46686fd5e53740a07077f8
base_revision: abaf9c523780a608bd46686fd5e53740a07077f8
# 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'
+89
View File
@@ -0,0 +1,89 @@
# NetDesk UI (Flutter)
The premium cross-platform front-end for **NetDesk by Akronlogic** — a
self-hosted remote-desktop client targeting **Windows, macOS, Linux, iOS and
Android** from one Flutter codebase.
This package is the *presentation layer only*. The heavy lifting — signaling,
NAT traversal, the encrypted transport, capture/encode, decode and input
injection — lives in the Rust crate [`netdesk-core`](../core). The two are
joined in **Phase 3** via [`flutter_rust_bridge`](https://pub.dev/packages/flutter_rust_bridge).
---
## Layout
```
ui/
├─ pubspec.yaml # package + deps (flutter_rust_bridge/ffi land in Phase 3)
├─ lib/
│ ├─ main.dart # NetDeskApp root, dark theme, home = ConnectScreen
│ ├─ theme.dart # color tokens (#0B0F15 bg, #22C55E accent) + ThemeData
│ ├─ connect_screen.dart # wordmark, Partner ID + password, Connect, "this device" ID
│ └─ session_screen.dart # AppBar "NetDesk — <id>" + toolbar; hosts remote texture later
└─ README.md
```
## Design language
- **Background** `#0B0F15` (deep near-black), **surface** `#121821`, hairline
borders `#1E2733`.
- **Accent** `#22C55E` green for the primary action, the brand mark and the
"connected" indicator.
- Rounded cards, generous spacing, TeamViewer / RustDesk-grade information
hierarchy. Dark-only for now.
All values are tokens in `lib/theme.dart` (`NetDeskColors`, `NetDeskRadius`,
`NetDeskSpacing`) — no hardcoded hex elsewhere in the app.
---
## How this binds to `netdesk-core` (Phase 3)
1. **Add the bridge deps.** Uncomment `flutter_rust_bridge` and `ffi` in
`pubspec.yaml`, then `flutter pub get`.
2. **Expose a Rust API.** In `../core`, add a `#[frb]`-annotated API module
(e.g. `src/api/session.rs`) with the calls the UI needs:
`local_id()`, `connect(partner_id, password)`, `disconnect()`,
`list_monitors()`, a frame/event stream, etc.
3. **Generate the glue.** Run `flutter_rust_bridge_codegen generate`, which
emits typed Dart bindings under `lib/src/rust/` and the matching Rust
`frb_generated.rs`. The core is compiled as a C-ABI dylib/staticlib and
linked per platform.
4. **Wire the screens.**
- `ConnectScreen` — the Connect button calls `connect(...)`; the
"This device's ID" area shows `local_id()` instead of the current
placeholder.
- `SessionScreen` — the central placeholder becomes a `Texture` whose
frames are pushed from the core's decoder; pointer/keyboard events are
forwarded back for injection; the toolbar actions (disconnect,
fullscreen, monitors, file transfer, settings) call their core
counterparts.
Until then every control is a styled, non-functional placeholder so the UX can
be reviewed and polished independently of the core.
---
## Running (once Flutter is installed)
> Flutter is **not** required to author these files, and was not run to create
> them. Install the Flutter SDK (>= 3.22) to build.
```bash
cd ui
flutter pub get
# Desktop
flutter run -d windows
flutter run -d macos
flutter run -d linux
# Mobile (device/emulator attached)
flutter run -d android
flutter run -d ios
```
Enable the desktop targets once with `flutter config --enable-windows-desktop`
(and the `--enable-macos-desktop` / `--enable-linux-desktop` equivalents) if
they are not already on.
+33
View File
@@ -0,0 +1,33 @@
# This file configures the analyzer, which statically analyzes Dart code to
# check for errors, warnings, and lints.
#
# The issues identified by the analyzer are surfaced in the UI of Dart-enabled
# IDEs (https://dart.dev/tools#ides-and-editors). The analyzer can also be
# invoked from the command line by running `flutter analyze`.
# The following line activates a set of recommended lints for Flutter apps,
# packages, and plugins designed to encourage good coding practices.
include: package:flutter_lints/flutter.yaml
analyzer:
exclude:
- build/**
- android/**
linter:
# The lint rules applied to this project can be customized in the
# section below to disable rules from the `package:flutter_lints/flutter.yaml`
# included above or to enable additional rules. A list of all available lints
# and their documentation is published at https://dart.dev/lints.
#
# Instead of disabling a lint rule for the entire project in the
# section below, it can also be suppressed for a single line of code
# or a specific dart file by using the `// ignore: name_of_lint` and
# `// ignore_for_file: name_of_lint` syntax on the line or in the file
# producing the lint.
rules:
# avoid_print: false # Uncomment to disable the `avoid_print` rule
# prefer_single_quotes: true # Uncomment to enable the `prefer_single_quotes` rule
# Additional information about this file can be found at
# https://dart.dev/guides/language/analysis-options
+18
View File
@@ -0,0 +1,18 @@
gradle-wrapper.jar
/.gradle
/captures/
/gradlew
/gradlew.bat
/local.properties
GeneratedPluginRegistrant.java
.cxx/
# Remember to never publicly share your keystore.
# See https://flutter.dev/to/reference-keystore
key.properties
**/*.keystore
**/*.jks
# NetDesk: prebuilt Rust .so are build artifacts, regenerated by cargo-ndk
# (packaging\build-android.ps1). Do not commit them.
/app/src/main/jniLibs/
+83
View File
@@ -0,0 +1,83 @@
import java.util.Properties
import java.io.FileInputStream
plugins {
id("com.android.application")
// The Flutter Gradle Plugin must be applied after the Android and Kotlin Gradle plugins.
id("dev.flutter.flutter-gradle-plugin")
}
// ---------------------------------------------------------------------------
// NetDesk release signing.
// Credentials live in android/key.properties (git-ignored). Generate the
// keystore + this file with: packaging\build-android.ps1 -GenerateKeystore
// When key.properties is absent the release build falls back to DEBUG signing
// so `flutter run --release` keeps working for local development.
// ---------------------------------------------------------------------------
val keystorePropertiesFile = rootProject.file("key.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
FileInputStream(keystorePropertiesFile).use { keystoreProperties.load(it) }
}
android {
namespace = "space.netdesk"
compileSdk = flutter.compileSdkVersion
// NetDesk ships prebuilt Rust .so under src/main/jniLibs/<abi>/libnetdesk_core.so
// (produced by cargo-ndk). Point AGP at the NDK already on disk via
// ANDROID_NDK_HOME so a Gradle build never triggers an NDK download; fall
// back to Flutter's pinned NDK version when the env var is not set.
val ndkHomeEnv = System.getenv("ANDROID_NDK_HOME")
if (ndkHomeEnv != null && file(ndkHomeEnv).exists()) {
ndkPath = ndkHomeEnv
} else {
ndkVersion = flutter.ndkVersion
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
defaultConfig {
// NetDesk application id (reverse of netdesk.space).
applicationId = "space.netdesk"
minSdk = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion
versionCode = flutter.versionCode
versionName = flutter.versionName
}
signingConfigs {
create("release") {
if (keystorePropertiesFile.exists()) {
keyAlias = keystoreProperties["keyAlias"] as String?
keyPassword = keystoreProperties["keyPassword"] as String?
storeFile = (keystoreProperties["storeFile"] as String?)?.let { file(it) }
storePassword = keystoreProperties["storePassword"] as String?
}
}
}
buildTypes {
release {
// Use the real upload key when key.properties is present, else debug.
signingConfig = if (keystorePropertiesFile.exists()) {
signingConfigs.getByName("release")
} else {
signingConfigs.getByName("debug")
}
}
}
}
kotlin {
compilerOptions {
jvmTarget = org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17
}
}
flutter {
source = "../.."
}
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
@@ -0,0 +1,103 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- Viewer + host networking -->
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<!-- Host role: foreground screen-capture + unattended auto-start -->
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION"/>
<uses-permission android:name="android.permission.SYSTEM_ALERT_WINDOW"/>
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED"/>
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
<application
android:label="@string/app_name"
android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"
android:resizeableActivity="true">
<activity
android:name=".MainActivity"
android:exported="true"
android:launchMode="singleTop"
android:taskAffinity=""
android:theme="@style/LaunchTheme"
android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
android:hardwareAccelerated="true"
android:windowSoftInputMode="adjustResize">
<!-- Specifies an Android theme to apply to this Activity as soon as
the Android process has started. This theme is visible to the user
while the Flutter UI initializes. After that, this theme continues
to determine the Window background behind the Flutter UI. -->
<meta-data
android:name="io.flutter.embedding.android.NormalTheme"
android:resource="@style/NormalTheme"
/>
<!-- The MAIN/LAUNCHER filter lives on the activity-alias below so the
launcher icon can be hidden (stealth/unattended) by disabling the
alias component at runtime. -->
</activity>
<!-- Launcher entry as an alias: disable this component (PackageManager
.setComponentEnabledSetting) to remove NetDesk from the app drawer
while the host keeps running in the background. -->
<activity-alias
android:name=".LauncherAlias"
android:targetActivity=".MainActivity"
android:exported="true"
android:enabled="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
</activity-alias>
<!-- Host role: foreground service that owns the screen-capture session. -->
<service
android:name=".HostService"
android:exported="false"
android:foregroundServiceType="mediaProjection"/>
<!-- Host role: remote input injection via an accessibility service. -->
<service
android:name=".NetDeskAccessibilityService"
android:exported="false"
android:permission="android.permission.BIND_ACCESSIBILITY_SERVICE">
<intent-filter>
<action android:name="android.accessibilityservice.AccessibilityService"/>
</intent-filter>
<meta-data
android:name="android.accessibilityservice"
android:resource="@xml/accessibility_service_config"/>
</service>
<!-- Unattended auto-start after reboot (only acts if the user enabled it). -->
<receiver
android:name=".BootReceiver"
android:exported="true"
android:enabled="true">
<intent-filter>
<action android:name="android.intent.action.BOOT_COMPLETED"/>
<action android:name="android.intent.action.QUICKBOOT_POWERON"/>
</intent-filter>
</receiver>
<!-- Don't delete the meta-data below.
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
<meta-data
android:name="flutterEmbedding"
android:value="2" />
</application>
<!-- Required to query activities that can process text, see:
https://developer.android.com/training/package-visibility and
https://developer.android.com/reference/android/content/Intent#ACTION_PROCESS_TEXT.
In particular, this is used by the Flutter engine in io.flutter.plugin.text.ProcessTextPlugin. -->
<queries>
<intent>
<action android:name="android.intent.action.PROCESS_TEXT"/>
<data android:mimeType="text/plain"/>
</intent>
</queries>
</manifest>
@@ -0,0 +1,42 @@
package space.netdesk
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
/**
* Unattended auto-start: when the device finishes booting, bring the NetDesk host
* back up (if the user enabled unattended access) so the device is reachable
* without anyone opening the app — the Android equivalent of the Windows service.
*
* The receiver only acts when unattended mode is on (stored in SharedPreferences
* by the Flutter settings screen); otherwise it does nothing.
*
* Developed by Akronlogic.
*/
class BootReceiver : BroadcastReceiver() {
override fun onReceive(context: Context, intent: Intent?) {
val action = intent?.action ?: return
if (action != Intent.ACTION_BOOT_COMPLETED &&
action != "android.intent.action.QUICKBOOT_POWERON"
) {
return
}
val prefs = context.getSharedPreferences("netdesk", Context.MODE_PRIVATE)
if (!prefs.getBoolean("unattended", false)) return
val svc = Intent(context, HostService::class.java).apply {
this.action = HostService.ACTION_START
}
try {
if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O) {
context.startForegroundService(svc)
} else {
context.startService(svc)
}
} catch (_: Throwable) {
// Some OEMs block background foreground-service starts at boot; the
// user can still start the host from the app.
}
}
}
@@ -0,0 +1,311 @@
package space.netdesk
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.Service
import android.content.Context
import android.content.Intent
import android.content.pm.ServiceInfo
import android.hardware.display.DisplayManager
import android.hardware.display.VirtualDisplay
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.media.projection.MediaProjection
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.Bundle
import android.os.IBinder
import android.util.DisplayMetrics
import android.view.Surface
import android.view.WindowManager
import kotlin.concurrent.thread
/**
* NetDesk host foreground service: mirrors the screen through MediaProjection ->
* VirtualDisplay -> a MediaCodec H.264 (AVC) encoder and produces Annex-B frames.
*
* Right now the frames are counted (so on-device / BlueStacks testing confirms
* capture works) — the next step hands each Annex-B access unit to the host side
* of the NetDesk protocol (register by Partner ID, challenge/auth, E2E) so a
* remote viewer can connect, exactly like netdesk_host.py on Windows.
*
* Developed by Akronlogic.
*/
class HostService : Service() {
companion object {
const val CHANNEL_ID = "netdesk_host"
const val NOTIF_ID = 1001
const val ACTION_START = "space.netdesk.HOST_START"
const val ACTION_STOP = "space.netdesk.HOST_STOP"
private const val MIME = "video/avc"
/** Screen-capture grant handed over by MainActivity.onActivityResult. */
@Volatile
var projectionResultCode: Int = 0
@Volatile
var projectionData: Intent? = null
@Volatile
var running: Boolean = false
/** Encoded H.264 access units produced so far (capture liveness check). */
@Volatile
var frameCount: Long = 0
/** Capture resolution (advertised to the viewer via host_start). */
@Volatile
var captureW: Int = 0
@Volatile
var captureH: Int = 0
/** Real display resolution — viewer coords (in capture space) scale to it. */
@Volatile
var realW: Int = 0
@Volatile
var realH: Int = 0
/**
* Sink for each outbound access unit: payload[0] = keyframe flag (1/0),
* payload[1..] = Annex-B H.264 (SPS/PPS is prepended to every keyframe so
* the frame is self-contained for mid-stream joins + decoder recovery).
* MainActivity sets this to forward over the `space.netdesk/frames`
* EventChannel into Dart -> host_push_frame.
*/
@Volatile
var frameSink: ((ByteArray) -> Unit)? = null
/** Live instance, so MainActivity can ask for an on-demand keyframe. */
@Volatile
var instance: HostService? = null
}
private var projection: MediaProjection? = null
private var virtualDisplay: VirtualDisplay? = null
private var encoder: MediaCodec? = null
private var inputSurface: Surface? = null
@Volatile
private var draining: Boolean = false
override fun onBind(intent: Intent?): IBinder? = null
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
if (intent?.action == ACTION_STOP) {
stopHost()
return START_NOT_STICKY
}
goForeground()
running = true
instance = this
startCapture()
return START_STICKY
}
private fun goForeground() {
val notif = buildNotification()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
startForeground(
NOTIF_ID,
notif,
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION
)
} else {
startForeground(NOTIF_ID, notif)
}
}
private fun startCapture() {
val data = projectionData ?: return
val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
val proj = mpm.getMediaProjection(projectionResultCode, data) ?: return
projection = proj
projectionData = null
// The system requires a registered callback before createVirtualDisplay.
proj.registerCallback(object : MediaProjection.Callback() {
override fun onStop() {
stopHost()
}
}, null)
val metrics = DisplayMetrics()
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) {
val wm = getSystemService(Context.WINDOW_SERVICE) as WindowManager
val b = wm.currentWindowMetrics.bounds
metrics.widthPixels = b.width()
metrics.heightPixels = b.height()
metrics.densityDpi = resources.configuration.densityDpi
} else {
@Suppress("DEPRECATION")
(getSystemService(Context.WINDOW_SERVICE) as WindowManager)
.defaultDisplay.getRealMetrics(metrics)
}
// Cap the long edge at 1600 px for bandwidth; keep it even.
var w = metrics.widthPixels
var h = metrics.heightPixels
realW = w
realH = h
val longEdge = maxOf(w, h)
if (longEdge > 1600) {
val s = 1600.0 / longEdge
w = (w * s).toInt()
h = (h * s).toInt()
}
w = w and 1.inv()
h = h and 1.inv()
captureW = w
captureH = h
val dpi = if (metrics.densityDpi > 0) metrics.densityDpi else 320
val format = MediaFormat.createVideoFormat(MIME, w, h).apply {
setInteger(
MediaFormat.KEY_COLOR_FORMAT,
MediaCodecInfo.CodecCapabilities.COLOR_FormatSurface
)
setInteger(MediaFormat.KEY_BIT_RATE, 6_000_000)
setInteger(MediaFormat.KEY_FRAME_RATE, 30)
setInteger(MediaFormat.KEY_I_FRAME_INTERVAL, 2)
}
val enc = MediaCodec.createEncoderByType(MIME)
enc.configure(format, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
inputSurface = enc.createInputSurface()
enc.start()
encoder = enc
virtualDisplay = proj.createVirtualDisplay(
"netdesk-host",
w, h, dpi,
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
inputSurface, null, null
)
draining = true
thread(name = "netdesk-encoder", isDaemon = true) { drainLoop() }
}
/** Cached SPS/PPS (BUFFER_FLAG_CODEC_CONFIG), prepended to each keyframe. */
private var csd: ByteArray? = null
/** Ask the encoder for an immediate IDR (viewer keyframe request / recovery). */
fun requestKeyframe() {
try {
encoder?.setParameters(Bundle().apply {
putInt(MediaCodec.PARAMETER_KEY_REQUEST_SYNC_FRAME, 0)
})
} catch (_: Throwable) {
}
}
private fun drainLoop() {
val enc = encoder ?: return
val info = MediaCodec.BufferInfo()
while (draining) {
val idx = try {
enc.dequeueOutputBuffer(info, 20_000)
} catch (_: IllegalStateException) {
break
}
if (idx >= 0) {
val buf = enc.getOutputBuffer(idx)
if (buf != null && info.size > 0) {
buf.position(info.offset)
buf.limit(info.offset + info.size)
val bytes = ByteArray(info.size)
buf.get(bytes)
val isConfig = info.flags and MediaCodec.BUFFER_FLAG_CODEC_CONFIG != 0
if (isConfig) {
// SPS/PPS — cache it, don't emit on its own.
csd = bytes
} else {
frameCount++
val isKey = info.flags and MediaCodec.BUFFER_FLAG_KEY_FRAME != 0
// Self-contained keyframe = SPS/PPS || IDR (like libx264).
val au = if (isKey && csd != null) csd!! + bytes else bytes
val sink = frameSink
if (sink != null) {
val payload = ByteArray(au.size + 1)
payload[0] = if (isKey) 1 else 0
System.arraycopy(au, 0, payload, 1, au.size)
try {
sink(payload)
} catch (_: Throwable) {
}
}
}
}
enc.releaseOutputBuffer(idx, false)
}
}
}
private fun stopHost() {
running = false
draining = false
if (instance === this) instance = null
csd = null
captureW = 0
captureH = 0
try {
virtualDisplay?.release()
} catch (_: Throwable) {
}
virtualDisplay = null
try {
encoder?.stop()
encoder?.release()
} catch (_: Throwable) {
}
encoder = null
try {
inputSurface?.release()
} catch (_: Throwable) {
}
inputSurface = null
try {
projection?.stop()
} catch (_: Throwable) {
}
projection = null
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
stopForeground(STOP_FOREGROUND_REMOVE)
} else {
@Suppress("DEPRECATION")
stopForeground(true)
}
stopSelf()
}
override fun onDestroy() {
stopHost()
super.onDestroy()
}
private fun buildNotification(): Notification {
val nm = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val ch = NotificationChannel(
CHANNEL_ID,
"NetDesk Host",
NotificationManager.IMPORTANCE_LOW
)
ch.description = "Shown while this device can be controlled remotely."
nm.createNotificationChannel(ch)
}
val builder = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Notification.Builder(this, CHANNEL_ID)
} else {
@Suppress("DEPRECATION")
Notification.Builder(this)
}
return builder
.setContentTitle("NetDesk host is active")
.setContentText("This device can be controlled remotely")
.setSmallIcon(android.R.drawable.ic_menu_view)
.setOngoing(true)
.build()
}
}
@@ -0,0 +1,212 @@
package space.netdesk
import android.app.Activity
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.media.projection.MediaProjectionManager
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.provider.Settings
import android.text.TextUtils
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.EventChannel
import io.flutter.plugin.common.MethodChannel
/**
* Hosts the Flutter UI and bridges the Android "host" role to Dart over the
* `space.netdesk/host` MethodChannel: request the screen-capture permission,
* start/stop the host service, toggle the hidden launcher icon + unattended
* mode, and check / open the accessibility settings.
*
* Developed by Akronlogic.
*/
class MainActivity : FlutterActivity() {
private val channelName = "space.netdesk/host"
private val framesChannel = "space.netdesk/frames"
private val reqProjection = 7001
private var pendingResult: MethodChannel.Result? = null
private val mainHandler = Handler(Looper.getMainLooper())
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
// Encoded H.264 access units flow HostService -> here -> Dart, which feeds
// them to the Rust host via host_push_frame. payload[0]=keyframe flag.
EventChannel(flutterEngine.dartExecutor.binaryMessenger, framesChannel)
.setStreamHandler(object : EventChannel.StreamHandler {
override fun onListen(arguments: Any?, events: EventChannel.EventSink?) {
HostService.frameSink = { payload ->
mainHandler.post { events?.success(payload) }
}
}
override fun onCancel(arguments: Any?) {
HostService.frameSink = null
}
})
MethodChannel(flutterEngine.dartExecutor.binaryMessenger, channelName)
.setMethodCallHandler { call, result ->
when (call.method) {
"startHost" -> requestProjection(result)
"stopHost" -> {
stopHost(); result.success(true)
}
"isHostRunning" -> result.success(HostService.running)
"lastFrameCount" -> result.success(HostService.frameCount)
"hostScreenSize" -> result.success(
mapOf("w" to HostService.captureW, "h" to HostService.captureH)
)
"requestKeyframe" -> {
HostService.instance?.requestKeyframe(); result.success(true)
}
"injectTap" -> {
NetDeskAccessibilityService.instance?.tap(
sx(call.argument<Double>("x") ?: 0.0),
sy(call.argument<Double>("y") ?: 0.0)
)
result.success(true)
}
"injectSwipe" -> {
NetDeskAccessibilityService.instance?.swipe(
sx(call.argument<Double>("x1") ?: 0.0),
sy(call.argument<Double>("y1") ?: 0.0),
sx(call.argument<Double>("x2") ?: 0.0),
sy(call.argument<Double>("y2") ?: 0.0),
(call.argument<Int>("dur") ?: 60).toLong()
)
result.success(true)
}
"injectKey" -> {
NetDeskAccessibilityService.instance?.key(
call.argument<String>("code") ?: "",
call.argument<Boolean>("down") ?: false
)
result.success(true)
}
"getHostId" -> result.success(
getSharedPreferences("netdesk", Context.MODE_PRIVATE)
.getString("hostId", "")
)
"getHostPassword" -> result.success(
getSharedPreferences("netdesk", Context.MODE_PRIVATE)
.getString("hostPassword", "")
)
"setHostCreds" -> {
getSharedPreferences("netdesk", Context.MODE_PRIVATE).edit()
.putString("hostId", call.argument<String>("id") ?: "")
.putString("hostPassword", call.argument<String>("password") ?: "")
.apply()
result.success(true)
}
"setHideIcon" -> {
setHideIcon(call.argument<Boolean>("hide") ?: false)
result.success(true)
}
"isIconHidden" -> result.success(isIconHidden())
"hasAccessibility" -> result.success(isAccessibilityEnabled())
"openAccessibilitySettings" -> {
startActivity(
Intent(Settings.ACTION_ACCESSIBILITY_SETTINGS)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
)
result.success(true)
}
"setUnattended" -> {
getSharedPreferences("netdesk", Context.MODE_PRIVATE).edit()
.putBoolean("unattended", call.argument<Boolean>("on") ?: false)
.apply()
result.success(true)
}
"isUnattended" -> result.success(
getSharedPreferences("netdesk", Context.MODE_PRIVATE)
.getBoolean("unattended", false)
)
else -> result.notImplemented()
}
}
}
private fun requestProjection(result: MethodChannel.Result) {
pendingResult = result
val mpm = getSystemService(Context.MEDIA_PROJECTION_SERVICE) as MediaProjectionManager
startActivityForResult(mpm.createScreenCaptureIntent(), reqProjection)
}
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode != reqProjection) return
if (resultCode == Activity.RESULT_OK && data != null) {
// Hand the capture token to the foreground service, which obtains the
// MediaProjection after it has gone foreground (required on API 34+).
HostService.projectionResultCode = resultCode
HostService.projectionData = data
val svc = Intent(this, HostService::class.java).apply {
action = HostService.ACTION_START
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
startForegroundService(svc)
} else {
startService(svc)
}
pendingResult?.success(true)
} else {
pendingResult?.success(false)
}
pendingResult = null
}
private fun stopHost() {
val svc = Intent(this, HostService::class.java).apply {
action = HostService.ACTION_STOP
}
startService(svc)
}
/** Scale a viewer x-coord (capture space) to the real display for injection. */
private fun sx(x: Double): Float {
val cw = HostService.captureW
val rw = HostService.realW
return if (cw > 0 && rw > 0) (x * rw / cw).toFloat() else x.toFloat()
}
private fun sy(y: Double): Float {
val ch = HostService.captureH
val rh = HostService.realH
return if (ch > 0 && rh > 0) (y * rh / ch).toFloat() else y.toFloat()
}
private fun setHideIcon(hide: Boolean) {
val alias = ComponentName(this, "space.netdesk.LauncherAlias")
val state = if (hide) {
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
} else {
PackageManager.COMPONENT_ENABLED_STATE_ENABLED
}
packageManager.setComponentEnabledSetting(alias, state, PackageManager.DONT_KILL_APP)
}
private fun isIconHidden(): Boolean {
val alias = ComponentName(this, "space.netdesk.LauncherAlias")
return packageManager.getComponentEnabledSetting(alias) ==
PackageManager.COMPONENT_ENABLED_STATE_DISABLED
}
private fun isAccessibilityEnabled(): Boolean {
val expected =
ComponentName(this, NetDeskAccessibilityService::class.java).flattenToString()
val enabled = Settings.Secure.getString(
contentResolver, Settings.Secure.ENABLED_ACCESSIBILITY_SERVICES
) ?: return false
val splitter = TextUtils.SimpleStringSplitter(':')
splitter.setString(enabled)
while (splitter.hasNext()) {
if (splitter.next().equals(expected, ignoreCase = true)) return true
}
return false
}
}
@@ -0,0 +1,84 @@
package space.netdesk
import android.accessibilityservice.AccessibilityService
import android.accessibilityservice.GestureDescription
import android.graphics.Path
import android.view.accessibility.AccessibilityEvent
/**
* Remote-input injection for the Android host role.
*
* A normal app can't inject touch events, so NetDesk (like RustDesk) uses an
* AccessibilityService: once the user enables it, [dispatchGesture] can tap,
* swipe and drag anywhere on screen. This is the skeleton; the host session
* calls [tap] / [swipe] with the coordinates a remote viewer sends.
*
* (Full keyboard/text injection and the on-lock-screen limitation are handled in
* later steps — injected input can't reach the secure lock screen, same as
* RustDesk.)
*
* Developed by Akronlogic.
*/
class NetDeskAccessibilityService : AccessibilityService() {
companion object {
@Volatile
var instance: NetDeskAccessibilityService? = null
}
override fun onServiceConnected() {
super.onServiceConnected()
instance = this
}
override fun onAccessibilityEvent(event: AccessibilityEvent?) {
// The host role drives the device OUTward (injects input); it does not
// need to react to UI events, so this is intentionally empty.
}
override fun onInterrupt() {}
override fun onDestroy() {
if (instance === this) instance = null
super.onDestroy()
}
/** Tap (x, y) in screen pixels. */
fun tap(x: Float, y: Float) {
val path = Path().apply { moveTo(x, y) }
val stroke = GestureDescription.StrokeDescription(path, 0, 1)
dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
}
/** Swipe/drag from (x1, y1) to (x2, y2) over [durationMs]. */
fun swipe(x1: Float, y1: Float, x2: Float, y2: Float, durationMs: Long) {
val path = Path().apply {
moveTo(x1, y1)
lineTo(x2, y2)
}
val stroke =
GestureDescription.StrokeDescription(path, 0, durationMs.coerceAtLeast(1))
dispatchGesture(GestureDescription.Builder().addStroke(stroke).build(), null, null)
}
/**
* Map a viewer key (DOM-style code) to an Android navigation action on key
* DOWN. An AccessibilityService can't inject arbitrary key/text events (that
* needs an IME, a later step), but the global navigation actions cover the
* phone's hardware keys — the common remote-control needs.
*/
fun key(code: String, down: Boolean) {
if (!down) return
val action = when (code) {
"Escape", "BrowserBack", "GoBack" -> GLOBAL_ACTION_BACK
"Home", "MetaLeft", "MetaRight", "BrowserHome" -> GLOBAL_ACTION_HOME
"F1", "ContextMenu" -> GLOBAL_ACTION_RECENTS
"AudioVolumeUp", "AudioVolumeDown" -> return // reserved for later
else -> return
}
try {
performGlobalAction(action)
} catch (_: Throwable) {
}
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here -->
<!-- <item>
<bitmap
android:gravity="center"
android:src="@mipmap/launch_image" />
</item> -->
</layer-list>
Binary file not shown.

After

Width:  |  Height:  |  Size: 544 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 442 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 721 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">NetDesk</string>
<string name="accessibility_desc">NetDesk uses this to let a remote viewer tap, swipe and type on this device while you are connected. Developed by Akronlogic.</string>
</resources>
@@ -0,0 +1,18 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is off -->
<style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
</style>
<!-- Theme applied to the Android Window as soon as the process has started.
This theme determines the color of the Android Window while your
Flutter UI initializes, as well as behind your Flutter UI while its
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
</style>
</resources>
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<accessibility-service xmlns:android="http://schemas.android.com/apk/res/android"
android:accessibilityEventTypes="typeAllMask"
android:accessibilityFeedbackType="feedbackGeneric"
android:accessibilityFlags="flagDefault"
android:canPerformGestures="true"
android:notificationTimeout="100"
android:description="@string/accessibility_desc" />
@@ -0,0 +1,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<!-- The INTERNET permission is required for development. Specifically,
the Flutter tool needs it to communicate with the running application
to allow setting breakpoints, to provide hot reload, etc.
-->
<uses-permission android:name="android.permission.INTERNET"/>
</manifest>
+24
View File
@@ -0,0 +1,24 @@
allprojects {
repositories {
google()
mavenCentral()
}
}
val newBuildDir: Directory =
rootProject.layout.buildDirectory
.dir("../../build")
.get()
rootProject.layout.buildDirectory.value(newBuildDir)
subprojects {
val newSubprojectBuildDir: Directory = newBuildDir.dir(project.name)
project.layout.buildDirectory.value(newSubprojectBuildDir)
}
subprojects {
project.evaluationDependsOn(":app")
}
tasks.register<Delete>("clean") {
delete(rootProject.layout.buildDirectory)
}
+6
View File
@@ -0,0 +1,6 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
# This newDsl flag was added by the Flutter template
android.newDsl=false
# This builtInKotlin flag was added by the Flutter template
android.builtInKotlin=false
@@ -0,0 +1,5 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.3.1-all.zip
+14
View File
@@ -0,0 +1,14 @@
# NetDesk release signing - EXAMPLE / TEMPLATE (safe to commit).
#
# Do NOT put real secrets here. Either:
# (a) let the build script create the keystore + the real key.properties:
# $env:NETDESK_KEYSTORE_PASSWORD = '<your-strong-password>'
# powershell -ExecutionPolicy Bypass -File packaging\build-android.ps1 -GenerateKeystore
# (b) or copy this file to key.properties (git-ignored) and fill it in.
#
# The value below is a PLACEHOLDER, not a real password. Replace __SET_ME__.
storePassword=__SET_ME__
keyPassword=__SET_ME__
keyAlias=netdesk
# Path is resolved relative to the app module dir (ui/android/app).
storeFile=netdesk-release.keystore
+26
View File
@@ -0,0 +1,26 @@
pluginManagement {
val flutterSdkPath =
run {
val properties = java.util.Properties()
file("local.properties").inputStream().use { properties.load(it) }
val flutterSdkPath = properties.getProperty("flutter.sdk")
require(flutterSdkPath != null) { "flutter.sdk not set in local.properties" }
flutterSdkPath
}
includeBuild("$flutterSdkPath/packages/flutter_tools/gradle")
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
plugins {
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
id("com.android.application") version "9.1.0" apply false
id("org.jetbrains.kotlin.android") version "2.4.0" apply false
}
include(":app")
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

+613
View File
@@ -0,0 +1,613 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'host_screen.dart';
import 'rust/api.dart' as nd;
import 'session_screen.dart';
import 'theme.dart';
/// Default NetDesk rendezvous / signaling endpoint (the Frankfurt VPS).
/// A LAN host is reachable by overriding this with `ws://<PC-IP>:8901/ws`
/// from the Advanced panel.
const String kDefaultServer = 'wss://netdesk.space/ws';
/// The landing / connect screen.
///
/// Mirrors the mental model of TeamViewer / RustDesk: the brand mark, a big
/// Partner ID field, a session password, and a primary Connect action. Connect
/// calls into the Rust core via flutter_rust_bridge ([nd.connect]) and hands
/// the returned session handle to the [SessionScreen].
class ConnectScreen extends StatefulWidget {
const ConnectScreen({super.key, this.initError});
/// Non-null when the native engine failed to load (shown as a banner).
final String? initError;
@override
State<ConnectScreen> createState() => _ConnectScreenState();
}
class _ConnectScreenState extends State<ConnectScreen> {
final TextEditingController _idController = TextEditingController();
final TextEditingController _passwordController = TextEditingController();
final TextEditingController _serverController =
TextEditingController(text: kDefaultServer);
bool _obscurePassword = true;
bool _showAdvanced = false;
bool _connecting = false;
@override
void dispose() {
_idController.dispose();
_passwordController.dispose();
_serverController.dispose();
super.dispose();
}
String get _digits => _idController.text.replaceAll(RegExp(r'\s'), '');
bool get _canConnect =>
!_connecting && _digits.length >= 6 && widget.initError == null;
Future<void> _connect() async {
if (!_canConnect) return;
FocusScope.of(context).unfocus();
final hostId = _digits;
final server = _serverController.text.trim().isEmpty
? kDefaultServer
: _serverController.text.trim();
setState(() => _connecting = true);
try {
final handle = await nd.connect(
wsUrl: server,
hostId: hostId,
password: _passwordController.text,
totp: '',
);
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => SessionScreen(
partnerId: _idController.text.trim(),
handle: handle,
),
),
);
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
behavior: SnackBarBehavior.floating,
backgroundColor: NetDeskColors.surface,
content: Text('Could not start session: $e'),
),
);
}
} finally {
if (mounted) setState(() => _connecting = false);
}
}
/// The form half of the screen: error banner, connect card, device card and
/// the host-mode button. Shared by the narrow and the wide (two-pane) layouts.
List<Widget> _formChildren(BuildContext context) {
return [
if (widget.initError != null) ...[
_EngineErrorBanner(message: widget.initError!),
const SizedBox(height: NetDeskSpacing.md),
],
_ConnectCard(
idController: _idController,
passwordController: _passwordController,
serverController: _serverController,
obscurePassword: _obscurePassword,
canConnect: _canConnect,
connecting: _connecting,
showAdvanced: _showAdvanced,
onToggleObscure: () =>
setState(() => _obscurePassword = !_obscurePassword),
onToggleAdvanced: () => setState(() => _showAdvanced = !_showAdvanced),
onChanged: () => setState(() {}),
onConnect: _connect,
),
const SizedBox(height: NetDeskSpacing.md),
const _ThisDeviceCard(),
const SizedBox(height: NetDeskSpacing.md),
OutlinedButton.icon(
onPressed: () => Navigator.of(context).push(
MaterialPageRoute(builder: (_) => const HostScreen()),
),
icon: const Icon(Icons.phonelink_setup, size: 18),
label: const Text('Allow remote control of this device'),
style: OutlinedButton.styleFrom(
foregroundColor: NetDeskColors.textSecondary,
side: const BorderSide(color: NetDeskColors.border),
padding: const EdgeInsets.symmetric(vertical: 14),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(NetDeskRadius.md),
),
),
),
];
}
@override
Widget build(BuildContext context) {
// Responsive: the screen must FIT the window on phones, tablets (either
// orientation), split-screen and desktop/DeX windows.
// * wide windows -> brand beside the form (two panes, half the height)
// * short windows -> compact brand + tighter spacing
// * scrolling only remains as a last-resort safety for tiny windows.
return Scaffold(
body: SafeArea(
child: LayoutBuilder(
builder: (context, box) {
final wide = box.maxWidth >= 720;
final compact = box.maxHeight < 700;
if (wide) {
return Row(
children: [
Expanded(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: const [
_Wordmark(),
SizedBox(height: NetDeskSpacing.lg),
_FooterNote(),
],
),
),
Expanded(
child: Center(
child: SingleChildScrollView(
padding: const EdgeInsets.all(NetDeskSpacing.lg),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: _formChildren(context),
),
),
),
),
),
],
);
}
return Center(
child: SingleChildScrollView(
padding: EdgeInsets.all(
compact ? NetDeskSpacing.md : NetDeskSpacing.lg),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 440),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_Wordmark(compact: compact),
SizedBox(
height: compact
? NetDeskSpacing.md
: NetDeskSpacing.xl),
..._formChildren(context),
SizedBox(
height: compact
? NetDeskSpacing.md
: NetDeskSpacing.lg),
const _FooterNote(),
],
),
),
),
);
},
),
),
);
}
}
/// Brand mark: the NetDesk logo, wordmark and the Akronlogic byline.
/// `compact` shrinks it so short windows (landscape phones, small desktop
/// windows) still fit the whole screen without scrolling.
class _Wordmark extends StatelessWidget {
const _Wordmark({this.compact = false});
final bool compact;
@override
Widget build(BuildContext context) {
final logo = compact ? 54.0 : 76.0;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Container(
width: logo,
height: logo,
padding: EdgeInsets.all(compact ? 7 : 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(NetDeskRadius.lg),
color: NetDeskColors.surface,
border: Border.all(color: NetDeskColors.border),
boxShadow: const [
BoxShadow(
color: NetDeskColors.accentGlow,
blurRadius: 36,
spreadRadius: 1,
),
],
),
child: Image.asset(
'assets/icon.png',
fit: BoxFit.contain,
filterQuality: FilterQuality.medium,
),
),
SizedBox(height: compact ? NetDeskSpacing.sm : NetDeskSpacing.md),
Text(
'NetDesk',
style: TextStyle(
fontSize: compact ? 28 : 40,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
color: NetDeskColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
'by Akronlogic',
style: TextStyle(
fontSize: compact ? 12 : 13,
fontWeight: FontWeight.w600,
letterSpacing: 2.4,
color: NetDeskColors.textSecondary.withValues(alpha: 0.9),
),
),
],
);
}
}
/// The main card: Partner ID + password + Connect (+ optional Advanced server).
class _ConnectCard extends StatelessWidget {
const _ConnectCard({
required this.idController,
required this.passwordController,
required this.serverController,
required this.obscurePassword,
required this.canConnect,
required this.connecting,
required this.showAdvanced,
required this.onToggleObscure,
required this.onToggleAdvanced,
required this.onChanged,
required this.onConnect,
});
final TextEditingController idController;
final TextEditingController passwordController;
final TextEditingController serverController;
final bool obscurePassword;
final bool canConnect;
final bool connecting;
final bool showAdvanced;
final VoidCallback onToggleObscure;
final VoidCallback onToggleAdvanced;
final VoidCallback onChanged;
final VoidCallback onConnect;
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(NetDeskSpacing.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _FieldLabel('Partner ID'),
const SizedBox(height: NetDeskSpacing.xs),
TextField(
controller: idController,
onChanged: (_) => onChanged(),
enabled: !connecting,
keyboardType: TextInputType.number,
textInputAction: TextInputAction.next,
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
_PartnerIdFormatter(),
],
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.w700,
letterSpacing: 3,
color: NetDeskColors.textPrimary,
),
decoration: const InputDecoration(
hintText: '000 000 000',
prefixIcon:
Icon(Icons.badge_outlined, color: NetDeskColors.textMuted),
),
),
const SizedBox(height: NetDeskSpacing.md),
const _FieldLabel('Password'),
const SizedBox(height: NetDeskSpacing.xs),
TextField(
controller: passwordController,
onChanged: (_) => onChanged(),
enabled: !connecting,
obscureText: obscurePassword,
onSubmitted: (_) => canConnect ? onConnect() : null,
decoration: InputDecoration(
hintText: 'Session password',
prefixIcon: const Icon(Icons.lock_outline,
color: NetDeskColors.textMuted),
suffixIcon: IconButton(
tooltip: obscurePassword ? 'Show' : 'Hide',
onPressed: onToggleObscure,
icon: Icon(
obscurePassword
? Icons.visibility_outlined
: Icons.visibility_off_outlined,
),
),
),
),
AnimatedCrossFade(
duration: const Duration(milliseconds: 180),
crossFadeState: showAdvanced
? CrossFadeState.showSecond
: CrossFadeState.showFirst,
firstChild: const SizedBox(width: double.infinity),
secondChild: Padding(
padding: const EdgeInsets.only(top: NetDeskSpacing.md),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const _FieldLabel('Server'),
const SizedBox(height: NetDeskSpacing.xs),
TextField(
controller: serverController,
enabled: !connecting,
keyboardType: TextInputType.url,
autocorrect: false,
style: const TextStyle(
fontSize: 14,
color: NetDeskColors.textPrimary,
),
decoration: const InputDecoration(
hintText: 'wss://netdesk.space/ws',
prefixIcon: Icon(Icons.dns_outlined,
color: NetDeskColors.textMuted),
),
),
],
),
),
),
const SizedBox(height: NetDeskSpacing.lg),
FilledButton.icon(
onPressed: canConnect ? onConnect : null,
icon: connecting
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2.2,
color: Color(0xFF04210F),
),
)
: const Icon(Icons.bolt_rounded, size: 20),
label: Text(connecting ? 'Connecting…' : 'Connect'),
),
const SizedBox(height: NetDeskSpacing.xs),
Align(
alignment: Alignment.center,
child: TextButton.icon(
onPressed: onToggleAdvanced,
style: TextButton.styleFrom(
foregroundColor: NetDeskColors.textMuted,
textStyle: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
icon: Icon(
showAdvanced
? Icons.keyboard_arrow_up_rounded
: Icons.tune_rounded,
size: 16,
),
label: Text(showAdvanced ? 'Hide advanced' : 'Advanced'),
),
),
],
),
),
);
}
}
/// Small card standing in for *this* device's own shareable identity.
class _ThisDeviceCard extends StatelessWidget {
const _ThisDeviceCard();
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: NetDeskSpacing.lg,
vertical: NetDeskSpacing.md,
),
child: Row(
children: [
Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: NetDeskColors.accentGlow,
borderRadius: BorderRadius.circular(NetDeskRadius.sm),
),
child: const Icon(Icons.smartphone_rounded,
color: NetDeskColors.accent, size: 20),
),
const SizedBox(width: NetDeskSpacing.md),
const Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'This device',
style: TextStyle(
fontSize: 12,
letterSpacing: 0.4,
color: NetDeskColors.textMuted,
),
),
SizedBox(height: 2),
Text(
'Incoming control — tap “Allow” below',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: NetDeskColors.textSecondary,
),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: NetDeskColors.surfaceAlt,
borderRadius: BorderRadius.circular(999),
border: Border.all(color: NetDeskColors.border),
),
child: const Text(
'BETA',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
letterSpacing: 1,
color: NetDeskColors.textMuted,
),
),
),
],
),
),
);
}
}
class _EngineErrorBanner extends StatelessWidget {
const _EngineErrorBanner({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(NetDeskSpacing.md),
decoration: BoxDecoration(
color: NetDeskColors.danger.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(NetDeskRadius.md),
border: Border.all(color: NetDeskColors.danger.withValues(alpha: 0.5)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.error_outline_rounded,
color: NetDeskColors.danger, size: 20),
const SizedBox(width: NetDeskSpacing.sm),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Engine not loaded',
style: TextStyle(
fontWeight: FontWeight.w700,
color: NetDeskColors.textPrimary,
),
),
const SizedBox(height: 2),
Text(
message,
style: const TextStyle(
fontSize: 12,
color: NetDeskColors.textSecondary,
),
),
],
),
),
],
),
);
}
}
class _FieldLabel extends StatelessWidget {
const _FieldLabel(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Text(
text.toUpperCase(),
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
letterSpacing: 1.2,
color: NetDeskColors.textSecondary,
),
);
}
}
class _FooterNote extends StatelessWidget {
const _FooterNote();
@override
Widget build(BuildContext context) {
return const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.shield_outlined, size: 14, color: NetDeskColors.textMuted),
SizedBox(width: 6),
Text(
'End-to-end encrypted · v0.1.0',
style: TextStyle(fontSize: 12, color: NetDeskColors.textMuted),
),
],
);
}
}
/// Formats up to 12 digits as "123 456 789" while typing.
class _PartnerIdFormatter extends TextInputFormatter {
@override
TextEditingValue formatEditUpdate(
TextEditingValue oldValue,
TextEditingValue newValue,
) {
var digits = newValue.text.replaceAll(RegExp(r'\s'), '');
if (digits.length > 12) digits = digits.substring(0, 12);
final buffer = StringBuffer();
for (var i = 0; i < digits.length; i++) {
if (i != 0 && i % 3 == 0) buffer.write(' ');
buffer.write(digits[i]);
}
final formatted = buffer.toString();
return TextEditingValue(
text: formatted,
selection: TextSelection.collapsed(offset: formatted.length),
);
}
}
+539
View File
@@ -0,0 +1,539 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'rust/api.dart' as nd;
import 'theme.dart';
/// "Allow remote control" — the Android HOST role.
///
/// Turns this device into a NetDesk host (like RustDesk): once the user grants
/// screen capture and enables the accessibility service, a remote viewer can see
/// and control it. Talks to the native side over the `space.netdesk/host`
/// MethodChannel (see MainActivity.kt / HostService.kt).
///
/// Preview: screen capture + the controls are live; the remote-connection wiring
/// (register by Partner ID, send the encrypted H.264 to a viewer) lands next.
class HostScreen extends StatefulWidget {
const HostScreen({super.key});
@override
State<HostScreen> createState() => _HostScreenState();
}
class _HostScreenState extends State<HostScreen> with WidgetsBindingObserver {
static const _host = MethodChannel('space.netdesk/host');
static const _frameChannel = EventChannel('space.netdesk/frames');
static const _wsUrl = 'wss://netdesk.space/ws';
bool _running = false;
bool _accessibility = false;
bool _hideIcon = false;
bool _unattended = false;
int _frames = 0;
bool _busy = false;
// Rust host session.
BigInt? _handle;
String _id = '';
String _password = '';
bool _viewerConnected = false;
StreamSubscription<dynamic>? _frameSub;
StreamSubscription<nd.NdHostEvent>? _eventSub;
// Viewer-mouse -> touch gesture interpretation (coords are in capture space;
// MainActivity scales to the real display before injecting).
int _btns = 0;
double _downX = 0, _downY = 0, _lastX = 0, _lastY = 0;
int _downMs = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_refresh();
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_frameSub?.cancel();
_eventSub?.cancel();
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed) _refresh();
}
Future<T?> _call<T>(String method, [Map<String, dynamic>? args]) async {
try {
return await _host.invokeMethod<T>(method, args);
} catch (_) {
return null;
}
}
Future<void> _refresh() async {
final running = await _call<bool>('isHostRunning') ?? false;
final acc = await _call<bool>('hasAccessibility') ?? false;
final hide = await _call<bool>('isIconHidden') ?? false;
final un = await _call<bool>('isUnattended') ?? false;
final frames = await _call<int>('lastFrameCount') ?? 0;
if (mounted) {
setState(() {
_running = running;
_accessibility = acc;
_hideIcon = hide;
_unattended = un;
_frames = frames;
});
}
}
Future<void> _toggleHost() async {
setState(() => _busy = true);
if (_running) {
await _stopHostFull();
} else {
await _startHostFull();
}
if (mounted) setState(() => _busy = false);
}
/// Load the persisted host id + unattended password, generating them once.
Future<void> _loadCreds() async {
var id = await _call<String>('getHostId') ?? '';
var pw = await _call<String>('getHostPassword') ?? '';
if (id.isEmpty || pw.isEmpty) {
id = nd.hostGenId();
pw = nd.hostGenPassword();
await _call('setHostCreds', {'id': id, 'password': pw});
}
if (mounted) {
setState(() {
_id = id;
_password = pw;
});
}
}
Future<void> _startHostFull() async {
await _loadCreds();
// 1) screen-capture consent -> HostService starts encoding.
final ok = await _call<bool>('startHost') ?? false;
if (!ok) {
await _refresh();
return;
}
// 2) wait for the encoder to report a capture size.
int w = 0, h = 0;
for (var i = 0; i < 30; i++) {
await Future.delayed(const Duration(milliseconds: 200));
final s = await _call<Map<dynamic, dynamic>>('hostScreenSize');
if (s != null && (s['w'] as int? ?? 0) > 0) {
w = s['w'] as int;
h = s['h'] as int;
break;
}
}
if (w == 0) {
await _refresh();
return;
}
// 3) register the Rust host + wire frames out and input in.
final handle = await nd.hostStart(
wsUrl: _wsUrl,
hostId: _id,
name: 'Android',
password: _password,
width: w,
height: h,
);
_handle = handle;
_frameSub = _frameChannel.receiveBroadcastStream().listen(_onFrame);
_eventSub = nd.hostEvents(handle: handle).listen(_onHostEvent);
await _refresh();
}
Future<void> _stopHostFull() async {
await _frameSub?.cancel();
_frameSub = null;
await _eventSub?.cancel();
_eventSub = null;
final h = _handle;
_handle = null;
if (h != null) nd.hostStop(handle: h);
await _call('stopHost');
if (mounted) setState(() => _viewerConnected = false);
await Future.delayed(const Duration(milliseconds: 300));
await _refresh();
}
void _onFrame(dynamic payload) {
final h = _handle;
if (h == null || payload is! Uint8List || payload.isEmpty) return;
final keyframe = payload[0] == 1;
final data = Uint8List.sublistView(payload, 1);
nd.hostPushFrame(handle: h, data: data, keyframe: keyframe);
}
void _onHostEvent(nd.NdHostEvent ev) {
switch (ev) {
case nd.NdHostEvent_Mouse(:final x, :final y, :final buttons, :final wheel):
_onMouse(x, y, buttons, wheel);
case nd.NdHostEvent_Key(:final code, :final down):
_call('injectKey', {'code': code, 'down': down});
case nd.NdHostEvent_KeyframeRequested():
_call('requestKeyframe');
case nd.NdHostEvent_ViewerConnected():
if (mounted) setState(() => _viewerConnected = true);
case nd.NdHostEvent_ViewerLeft():
if (mounted) setState(() => _viewerConnected = false);
default:
break;
}
}
/// Translate the viewer's absolute pointer into Android gestures: a quick
/// press+release = tap, a moved drag = swipe, wheel = scroll.
void _onMouse(int x, int y, int buttons, double wheel) {
if (wheel != 0) {
final dy = (wheel.clamp(-3.0, 3.0) * 140).round();
_call('injectSwipe', {
'x1': x.toDouble(), 'y1': y.toDouble(),
'x2': x.toDouble(), 'y2': (y + dy).toDouble(), 'dur': 120,
});
return;
}
final leftNow = (buttons & 1) != 0;
final leftWas = (_btns & 1) != 0;
_btns = buttons;
if (leftNow && !leftWas) {
_downX = x.toDouble();
_downY = y.toDouble();
_lastX = _downX;
_lastY = _downY;
_downMs = DateTime.now().millisecondsSinceEpoch;
} else if (leftNow && leftWas) {
_lastX = x.toDouble();
_lastY = y.toDouble();
} else if (!leftNow && leftWas) {
_lastX = x.toDouble();
_lastY = y.toDouble();
final dist = (_lastX - _downX).abs() + (_lastY - _downY).abs();
if (dist < 12) {
_call('injectTap', {'x': _downX, 'y': _downY});
} else {
final dur = (DateTime.now().millisecondsSinceEpoch - _downMs)
.clamp(40, 800);
_call('injectSwipe', {
'x1': _downX, 'y1': _downY, 'x2': _lastX, 'y2': _lastY, 'dur': dur,
});
}
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: NetDeskColors.background,
appBar: AppBar(
backgroundColor: NetDeskColors.background,
elevation: 0,
title: const Text('Allow remote control'),
),
body: SafeArea(
child: RefreshIndicator(
color: NetDeskColors.accent,
onRefresh: _refresh,
child: ListView(
padding: const EdgeInsets.all(16),
children: [
_statusCard(),
if (_running && _id.isNotEmpty) ...[
const SizedBox(height: 14),
_credsCard(),
],
const SizedBox(height: 14),
_startStopButton(),
const SizedBox(height: 18),
_tile(
icon: Icons.touch_app_outlined,
title: 'Remote input (accessibility)',
subtitle: _accessibility
? 'Enabled — a viewer can tap and swipe this device.'
: 'Required so a viewer can tap and swipe. Tap to enable.',
trailing: _accessibility
? const Icon(Icons.check_circle, color: NetDeskColors.accent)
: const Icon(Icons.chevron_right,
color: NetDeskColors.textMuted),
onTap: _accessibility
? null
: () async {
await _call('openAccessibilitySettings');
},
),
_switchTile(
icon: Icons.schedule,
title: 'Unattended access',
subtitle: 'Start the host automatically after the device reboots.',
value: _unattended,
onChanged: (v) async {
await _call('setUnattended', {'on': v});
await _refresh();
},
),
_switchTile(
icon: Icons.visibility_off_outlined,
title: 'Hide app icon',
subtitle:
'Remove NetDesk from the app drawer (it keeps running). '
'Reopen it from your browser at netdesk.space.',
value: _hideIcon,
onChanged: (v) async {
await _call('setHideIcon', {'hide': v});
await _refresh();
},
),
const SizedBox(height: 16),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 4),
child: Text(
'Enter the ID and password above in any NetDesk viewer '
'(Windows, web or another phone) to see and control this '
'device. Keep the screen-capture notification on. '
'Developed by Akronlogic.',
style: TextStyle(
color: NetDeskColors.textMuted, fontSize: 12, height: 1.4),
),
),
],
),
),
),
);
}
Widget _statusCard() {
final on = _running;
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: NetDeskColors.surface,
borderRadius: BorderRadius.circular(NetDeskRadius.lg),
border: Border.all(color: NetDeskColors.border),
),
child: Row(
children: [
Container(
width: 44,
height: 44,
decoration: BoxDecoration(
color: on ? NetDeskColors.accentGlow : NetDeskColors.surfaceAlt,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
on ? Icons.cast_connected : Icons.cast,
color: on ? NetDeskColors.accent : NetDeskColors.textSecondary,
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
on ? 'Host is active' : 'Host is off',
style: const TextStyle(
color: NetDeskColors.textPrimary,
fontSize: 17,
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 2),
Text(
on
? 'Capturing the screen · $_frames frames encoded'
: 'This device cannot be controlled yet',
style: const TextStyle(
color: NetDeskColors.textSecondary, fontSize: 13),
),
],
),
),
],
),
);
}
Widget _credsCard() {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: NetDeskColors.surface,
borderRadius: BorderRadius.circular(NetDeskRadius.lg),
border: Border.all(
color: _viewerConnected ? NetDeskColors.accent : NetDeskColors.border,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
_viewerConnected ? Icons.lock_person : Icons.vpn_key_outlined,
size: 18,
color: _viewerConnected
? NetDeskColors.accent
: NetDeskColors.textSecondary,
),
const SizedBox(width: 8),
Text(
_viewerConnected ? 'A viewer is connected' : 'Ready for a viewer',
style: TextStyle(
color: _viewerConnected
? NetDeskColors.accent
: NetDeskColors.textSecondary,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
],
),
const SizedBox(height: 14),
_credRow('Your ID', _id),
const SizedBox(height: 10),
_credRow('Password', _password),
],
),
);
}
Widget _credRow(String label, String value) {
return Row(
children: [
SizedBox(
width: 86,
child: Text(label,
style: const TextStyle(
color: NetDeskColors.textMuted, fontSize: 13)),
),
Expanded(
child: SelectableText(
value,
style: const TextStyle(
color: NetDeskColors.textPrimary,
fontSize: 19,
fontWeight: FontWeight.w700,
letterSpacing: 1.5,
),
),
),
IconButton(
icon: const Icon(Icons.copy_rounded,
size: 18, color: NetDeskColors.textSecondary),
onPressed: () {
Clipboard.setData(ClipboardData(text: value));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('$label copied'),
duration: const Duration(seconds: 1),
),
);
},
),
],
);
}
Widget _startStopButton() {
return SizedBox(
height: 52,
child: ElevatedButton(
onPressed: _busy ? null : _toggleHost,
style: ElevatedButton.styleFrom(
backgroundColor:
_running ? NetDeskColors.danger : NetDeskColors.accent,
foregroundColor: _running ? Colors.white : const Color(0xFF04210F),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(NetDeskRadius.md),
),
),
child: _busy
? const SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2.4),
)
: Text(
_running ? 'Stop hosting' : 'Start hosting this device',
style: const TextStyle(
fontSize: 16, fontWeight: FontWeight.w700),
),
),
);
}
Widget _tile({
required IconData icon,
required String title,
required String subtitle,
Widget? trailing,
VoidCallback? onTap,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: NetDeskColors.surface,
borderRadius: BorderRadius.circular(NetDeskRadius.md),
border: Border.all(color: NetDeskColors.border),
),
child: ListTile(
onTap: onTap,
leading: Icon(icon, color: NetDeskColors.textSecondary),
title: Text(title,
style: const TextStyle(
color: NetDeskColors.textPrimary,
fontWeight: FontWeight.w600)),
subtitle: Text(subtitle,
style: const TextStyle(
color: NetDeskColors.textMuted, fontSize: 12.5)),
trailing: trailing,
),
);
}
Widget _switchTile({
required IconData icon,
required String title,
required String subtitle,
required bool value,
required ValueChanged<bool> onChanged,
}) {
return Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: NetDeskColors.surface,
borderRadius: BorderRadius.circular(NetDeskRadius.md),
border: Border.all(color: NetDeskColors.border),
),
child: SwitchListTile(
value: value,
onChanged: onChanged,
activeThumbColor: NetDeskColors.accent,
secondary: Icon(icon, color: NetDeskColors.textSecondary),
title: Text(title,
style: const TextStyle(
color: NetDeskColors.textPrimary,
fontWeight: FontWeight.w600)),
subtitle: Text(subtitle,
style: const TextStyle(
color: NetDeskColors.textMuted, fontSize: 12.5)),
),
);
}
}
+63
View File
@@ -0,0 +1,63 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'connect_screen.dart';
import 'rust/frb_generated.dart';
import 'theme.dart';
/// Entry point.
///
/// Before the first widget builds we initialise flutter_rust_bridge, which
/// loads the native `netdesk_frb` library (built from the Rust `netdesk-frb`
/// crate over `netdesk-core`). All session work — signaling, the transport,
/// input injection and the H.264 decode — runs behind that FFI layer; the Dart
/// side only paints frames and forwards input.
///
/// If the engine fails to load we still start the app, surfacing the error on
/// the connect screen instead of crashing to a black window.
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
// Behave like a true Android app: allow every orientation (tablets are used
// in landscape), and draw behind the system bars for an edge-to-edge look.
await SystemChrome.setPreferredOrientations(const [
DeviceOrientation.portraitUp,
DeviceOrientation.portraitDown,
DeviceOrientation.landscapeLeft,
DeviceOrientation.landscapeRight,
]);
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
String? initError;
try {
await RustLib.init();
} catch (e) {
initError = '$e';
}
runApp(NetDeskApp(initError: initError));
}
/// Root application widget.
///
/// NetDesk is Akronlogic's premium cross-platform remote-desktop client. This
/// Flutter layer is the touch-native front-end; it binds to the Rust
/// `netdesk-core` crate via flutter_rust_bridge to drive the whole session.
class NetDeskApp extends StatelessWidget {
const NetDeskApp({super.key, this.initError});
/// Non-null when the native engine could not be loaded at startup.
final String? initError;
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'NetDesk',
debugShowCheckedModeBanner: false,
theme: NetDeskTheme.dark,
darkTheme: NetDeskTheme.dark,
themeMode: ThemeMode.dark,
home: ConnectScreen(initError: initError),
);
}
}
+258
View File
@@ -0,0 +1,258 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.13.0.
// ignore_for_file: invalid_use_of_internal_member, unused_import, unnecessary_import
import 'frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated.dart';
import 'package:freezed_annotation/freezed_annotation.dart' hide protected;
part 'api.freezed.dart';
// These functions are ignored because they are not marked as `pub`: `cancels`, `host_cancels`, `host_frames`, `host_pending`, `map_host_event`, `pending`, `rgb_to_rgba`, `runtime`, `sessions`, `with_session`
// These function are ignored because they are on traits that is not defined in current crate (put an empty `#[frb]` on it to unignore): `clone`, `clone`, `clone`, `fmt`, `fmt`, `fmt`
/// Start a session against `ws_url` / `host_id` with `password` + `totp`, and
/// return its **handle**. The actual connect + handshake + H.264 decode run on
/// the background Tokio runtime; subscribe to the resulting [`NdEvent`]s with
/// [`session_events`] (passing this handle), and drive input / control with the
/// other functions here. A failed connect still returns a valid handle and
/// reports [`NdEvent::Ended`] / [`NdEvent::Denied`] on the stream.
Future<BigInt> connect(
{required String wsUrl,
required String hostId,
required String password,
required String totp}) =>
RustLib.instance.api.crateApiConnect(
wsUrl: wsUrl, hostId: hostId, password: password, totp: totp);
/// Subscribe to the [`NdEvent`] stream for a `handle` returned by [`connect`].
/// In Dart this is `Stream<NdEvent> sessionEvents({required BigInt handle})`.
/// Call it once per handle, right after `connect`.
Stream<NdEvent> sessionEvents({required BigInt handle}) =>
RustLib.instance.api.crateApiSessionEvents(handle: handle);
/// Send a pointer event (coordinates in the remote screen's pixel space).
/// `buttons` is the button bitmask, `wheel`/`hwheel` are scroll deltas.
void sendMouse(
{required BigInt handle,
required int x,
required int y,
required int buttons,
required double wheel,
required double hwheel}) =>
RustLib.instance.api.crateApiSendMouse(
handle: handle,
x: x,
y: y,
buttons: buttons,
wheel: wheel,
hwheel: hwheel);
/// Send a key event. `code` is a DOM-style key code (e.g. "KeyA", "Enter").
void sendKey(
{required BigInt handle, required String code, required bool down}) =>
RustLib.instance.api
.crateApiSendKey(handle: handle, code: code, down: down);
/// Reconfigure the host encoder: target `fps`, `bitrate` (kbps), resolution `scale`.
void setQuality(
{required BigInt handle,
required int fps,
required int bitrate,
required double scale}) =>
RustLib.instance.api.crateApiSetQuality(
handle: handle, fps: fps, bitrate: bitrate, scale: scale);
/// Switch the captured monitor to index `i` (as advertised by
/// [`session_monitors`]). Immediately asks the host for a fresh keyframe so the
/// new display paints without waiting for the next natural IDR — the messages
/// travel the same WebSocket in order, so the keyframe is of the *new* monitor.
void monitorSwitch({required BigInt handle, required int i}) =>
RustLib.instance.api.crateApiMonitorSwitch(handle: handle, i: i);
/// The real list of displays the host reported (its `info.monitors`). The UI
/// uses this to show exactly the monitors that exist — never a hardcoded count.
/// Returns empty until the session is accepted, or if the host sent none.
List<NdMonitor> sessionMonitors({required BigInt handle}) =>
RustLib.instance.api.crateApiSessionMonitors(handle: handle);
/// The monitor index the host is currently capturing (as reported at accept).
int sessionCurrentMonitor({required BigInt handle}) =>
RustLib.instance.api.crateApiSessionCurrentMonitor(handle: handle);
/// Send Ctrl+Alt+Del on the host.
void ctrlAltDel({required BigInt handle}) =>
RustLib.instance.api.crateApiCtrlAltDel(handle: handle);
/// Send a chat message to the host.
void sendChat({required BigInt handle, required String text}) =>
RustLib.instance.api.crateApiSendChat(handle: handle, text: text);
/// Push text to the remote clipboard (and announce our local clipboard).
void setClipboard({required BigInt handle, required String text}) =>
RustLib.instance.api.crateApiSetClipboard(handle: handle, text: text);
/// End the session: tell the host, then drop the handle. The event stream
/// receives a final [`NdEvent::Ended`] when the network task unwinds.
void disconnect({required BigInt handle}) =>
RustLib.instance.api.crateApiDisconnect(handle: handle);
/// A fresh 9-digit host id to persist on the device (shown to viewers).
String hostGenId() => RustLib.instance.api.crateApiHostGenId();
/// A fresh friendly unattended password (no look-alike chars).
String hostGenPassword() => RustLib.instance.api.crateApiHostGenPassword();
/// Start hosting: register `host_id` with the server and serve one viewer at a
/// time, authenticating against `password`. Returns a handle; drive input/
/// control via [`host_events`], feed video via [`host_push_frame`], stop with
/// [`host_stop`]. Credentials are derived fresh from `password` each run (the
/// challenge carries the salt, so the viewer only needs the password).
Future<BigInt> hostStart(
{required String wsUrl,
required String hostId,
required String name,
required String password,
required int width,
required int height}) =>
RustLib.instance.api.crateApiHostStart(
wsUrl: wsUrl,
hostId: hostId,
name: name,
password: password,
width: width,
height: height);
/// Subscribe to the [`NdHostEvent`] stream for a `handle` from [`host_start`].
Stream<NdHostEvent> hostEvents({required BigInt handle}) =>
RustLib.instance.api.crateApiHostEvents(handle: handle);
/// Feed one encoded H.264 access unit (Annex-B) from the platform encoder. Cheap
/// + non-blocking; dropped when no viewer is connected.
void hostPushFrame(
{required BigInt handle,
required List<int> data,
required bool keyframe}) =>
RustLib.instance.api
.crateApiHostPushFrame(handle: handle, data: data, keyframe: keyframe);
/// Stop hosting: end the reconnect loop and drop the handle.
void hostStop({required BigInt handle}) =>
RustLib.instance.api.crateApiHostStop(handle: handle);
@freezed
sealed class NdEvent with _$NdEvent {
const NdEvent._();
/// The WebSocket is dialing / the handshake is in flight.
const factory NdEvent.connecting() = NdEvent_Connecting;
/// Session accepted; the remote screen is about to stream.
const factory NdEvent.connected({
required int w,
required int h,
required bool e2E,
required String hostName,
}) = NdEvent_Connected;
/// A decoded, paint-ready frame: tightly-packed RGBA8 (`width*height*4`).
const factory NdEvent.frame({
required int width,
required int height,
required Uint8List rgba,
}) = NdEvent_Frame;
/// The session ended (socket closed, host ended it, connect failed, …).
const factory NdEvent.ended({
required String reason,
}) = NdEvent_Ended;
/// The host refused the session (bad password/TOTP, busy, blocked, …).
const factory NdEvent.denied({
required String reason,
}) = NdEvent_Denied;
/// A chat message from the host.
const factory NdEvent.chatMessage({
required String from,
required String text,
}) = NdEvent_ChatMessage;
/// Remote clipboard text pushed by the host (RustDesk-style sync).
const factory NdEvent.clipboard({
required String text,
}) = NdEvent_Clipboard;
}
@freezed
sealed class NdHostEvent with _$NdHostEvent {
const NdHostEvent._();
/// Registered with the server; the device is reachable at its host id.
const factory NdHostEvent.registered() = NdHostEvent_Registered;
/// A viewer authenticated; the session is live (inject input, feed frames).
const factory NdHostEvent.viewerConnected({
required bool e2E,
}) = NdHostEvent_ViewerConnected;
/// The viewer left; the host stays registered for the next one.
const factory NdHostEvent.viewerLeft() = NdHostEvent_ViewerLeft;
/// Pointer in the captured image's pixel space (buttons = bitmask).
const factory NdHostEvent.mouse({
required int x,
required int y,
required int buttons,
required double wheel,
}) = NdHostEvent_Mouse;
/// Key: DOM-style code + down/up.
const factory NdHostEvent.key({
required String code,
required bool down,
}) = NdHostEvent_Key;
/// The viewer asked for a fresh keyframe — request one from the encoder.
const factory NdHostEvent.keyframeRequested() = NdHostEvent_KeyframeRequested;
/// The viewer changed quality targets.
const factory NdHostEvent.quality({
required int fps,
required int bitrate,
required double scale,
}) = NdHostEvent_Quality;
/// The server link dropped; auto-reconnecting.
const factory NdHostEvent.reconnecting() = NdHostEvent_Reconnecting;
/// The host loop stopped for good (host_stop / fatal).
const factory NdHostEvent.ended({
required String reason,
}) = NdHostEvent_Ended;
}
/// A display the host advertised at accept time (index + pixel size).
class NdMonitor {
final int i;
final int w;
final int h;
const NdMonitor({
required this.i,
required this.w,
required this.h,
});
@override
int get hashCode => i.hashCode ^ w.hashCode ^ h.hashCode;
@override
bool operator ==(Object other) =>
identical(this, other) ||
other is NdMonitor &&
runtimeType == other.runtimeType &&
i == other.i &&
w == other.w &&
h == other.h;
}
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
+196
View File
@@ -0,0 +1,196 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.13.0.
// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field
import 'api.dart';
import 'dart:async';
import 'dart:convert';
import 'dart:ffi' as ffi;
import 'frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_io.dart';
abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
RustLibApiImplPlatform({
required super.handler,
required super.wire,
required super.generalizedFrbRustBinding,
required super.portManager,
});
@protected
AnyhowException dco_decode_AnyhowException(dynamic raw);
@protected
RustStreamSink<NdEvent> dco_decode_StreamSink_nd_event_Sse(dynamic raw);
@protected
RustStreamSink<NdHostEvent> dco_decode_StreamSink_nd_host_event_Sse(
dynamic raw);
@protected
String dco_decode_String(dynamic raw);
@protected
bool dco_decode_bool(dynamic raw);
@protected
double dco_decode_f_32(dynamic raw);
@protected
int dco_decode_i_32(dynamic raw);
@protected
List<NdMonitor> dco_decode_list_nd_monitor(dynamic raw);
@protected
List<int> dco_decode_list_prim_u_8_loose(dynamic raw);
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
@protected
NdEvent dco_decode_nd_event(dynamic raw);
@protected
NdHostEvent dco_decode_nd_host_event(dynamic raw);
@protected
NdMonitor dco_decode_nd_monitor(dynamic raw);
@protected
int dco_decode_u_32(dynamic raw);
@protected
BigInt dco_decode_u_64(dynamic raw);
@protected
int dco_decode_u_8(dynamic raw);
@protected
void dco_decode_unit(dynamic raw);
@protected
AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer);
@protected
RustStreamSink<NdEvent> sse_decode_StreamSink_nd_event_Sse(
SseDeserializer deserializer);
@protected
RustStreamSink<NdHostEvent> sse_decode_StreamSink_nd_host_event_Sse(
SseDeserializer deserializer);
@protected
String sse_decode_String(SseDeserializer deserializer);
@protected
bool sse_decode_bool(SseDeserializer deserializer);
@protected
double sse_decode_f_32(SseDeserializer deserializer);
@protected
int sse_decode_i_32(SseDeserializer deserializer);
@protected
List<NdMonitor> sse_decode_list_nd_monitor(SseDeserializer deserializer);
@protected
List<int> sse_decode_list_prim_u_8_loose(SseDeserializer deserializer);
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
@protected
NdEvent sse_decode_nd_event(SseDeserializer deserializer);
@protected
NdHostEvent sse_decode_nd_host_event(SseDeserializer deserializer);
@protected
NdMonitor sse_decode_nd_monitor(SseDeserializer deserializer);
@protected
int sse_decode_u_32(SseDeserializer deserializer);
@protected
BigInt sse_decode_u_64(SseDeserializer deserializer);
@protected
int sse_decode_u_8(SseDeserializer deserializer);
@protected
void sse_decode_unit(SseDeserializer deserializer);
@protected
void sse_encode_AnyhowException(
AnyhowException self, SseSerializer serializer);
@protected
void sse_encode_StreamSink_nd_event_Sse(
RustStreamSink<NdEvent> self, SseSerializer serializer);
@protected
void sse_encode_StreamSink_nd_host_event_Sse(
RustStreamSink<NdHostEvent> self, SseSerializer serializer);
@protected
void sse_encode_String(String self, SseSerializer serializer);
@protected
void sse_encode_bool(bool self, SseSerializer serializer);
@protected
void sse_encode_f_32(double self, SseSerializer serializer);
@protected
void sse_encode_i_32(int self, SseSerializer serializer);
@protected
void sse_encode_list_nd_monitor(
List<NdMonitor> self, SseSerializer serializer);
@protected
void sse_encode_list_prim_u_8_loose(List<int> self, SseSerializer serializer);
@protected
void sse_encode_list_prim_u_8_strict(
Uint8List self, SseSerializer serializer);
@protected
void sse_encode_nd_event(NdEvent self, SseSerializer serializer);
@protected
void sse_encode_nd_host_event(NdHostEvent self, SseSerializer serializer);
@protected
void sse_encode_nd_monitor(NdMonitor self, SseSerializer serializer);
@protected
void sse_encode_u_32(int self, SseSerializer serializer);
@protected
void sse_encode_u_64(BigInt self, SseSerializer serializer);
@protected
void sse_encode_u_8(int self, SseSerializer serializer);
@protected
void sse_encode_unit(void self, SseSerializer serializer);
}
// Section: wire_class
class RustLibWire implements BaseWire {
factory RustLibWire.fromExternalLibrary(ExternalLibrary lib) =>
RustLibWire(lib.ffiDynamicLibrary);
/// Holds the symbol lookup function.
final ffi.Pointer<T> Function<T extends ffi.NativeType>(String symbolName)
_lookup;
/// The symbols are looked up in [dynamicLibrary].
RustLibWire(ffi.DynamicLibrary dynamicLibrary)
: _lookup = dynamicLibrary.lookup;
}
+196
View File
@@ -0,0 +1,196 @@
// This file is automatically generated, so please do not edit it.
// @generated by `flutter_rust_bridge`@ 2.13.0.
// ignore_for_file: unused_import, unused_element, unnecessary_import, duplicate_ignore, invalid_use_of_internal_member, annotate_overrides, non_constant_identifier_names, curly_braces_in_flow_control_structures, prefer_const_literals_to_create_immutables, unused_field
// Static analysis wrongly picks the IO variant, thus ignore this
// ignore_for_file: argument_type_not_assignable
import 'api.dart';
import 'dart:async';
import 'dart:convert';
import 'frb_generated.dart';
import 'package:flutter_rust_bridge/flutter_rust_bridge_for_generated_web.dart';
abstract class RustLibApiImplPlatform extends BaseApiImpl<RustLibWire> {
RustLibApiImplPlatform({
required super.handler,
required super.wire,
required super.generalizedFrbRustBinding,
required super.portManager,
});
@protected
AnyhowException dco_decode_AnyhowException(dynamic raw);
@protected
RustStreamSink<NdEvent> dco_decode_StreamSink_nd_event_Sse(dynamic raw);
@protected
RustStreamSink<NdHostEvent> dco_decode_StreamSink_nd_host_event_Sse(
dynamic raw);
@protected
String dco_decode_String(dynamic raw);
@protected
bool dco_decode_bool(dynamic raw);
@protected
double dco_decode_f_32(dynamic raw);
@protected
int dco_decode_i_32(dynamic raw);
@protected
List<NdMonitor> dco_decode_list_nd_monitor(dynamic raw);
@protected
List<int> dco_decode_list_prim_u_8_loose(dynamic raw);
@protected
Uint8List dco_decode_list_prim_u_8_strict(dynamic raw);
@protected
NdEvent dco_decode_nd_event(dynamic raw);
@protected
NdHostEvent dco_decode_nd_host_event(dynamic raw);
@protected
NdMonitor dco_decode_nd_monitor(dynamic raw);
@protected
int dco_decode_u_32(dynamic raw);
@protected
BigInt dco_decode_u_64(dynamic raw);
@protected
int dco_decode_u_8(dynamic raw);
@protected
void dco_decode_unit(dynamic raw);
@protected
AnyhowException sse_decode_AnyhowException(SseDeserializer deserializer);
@protected
RustStreamSink<NdEvent> sse_decode_StreamSink_nd_event_Sse(
SseDeserializer deserializer);
@protected
RustStreamSink<NdHostEvent> sse_decode_StreamSink_nd_host_event_Sse(
SseDeserializer deserializer);
@protected
String sse_decode_String(SseDeserializer deserializer);
@protected
bool sse_decode_bool(SseDeserializer deserializer);
@protected
double sse_decode_f_32(SseDeserializer deserializer);
@protected
int sse_decode_i_32(SseDeserializer deserializer);
@protected
List<NdMonitor> sse_decode_list_nd_monitor(SseDeserializer deserializer);
@protected
List<int> sse_decode_list_prim_u_8_loose(SseDeserializer deserializer);
@protected
Uint8List sse_decode_list_prim_u_8_strict(SseDeserializer deserializer);
@protected
NdEvent sse_decode_nd_event(SseDeserializer deserializer);
@protected
NdHostEvent sse_decode_nd_host_event(SseDeserializer deserializer);
@protected
NdMonitor sse_decode_nd_monitor(SseDeserializer deserializer);
@protected
int sse_decode_u_32(SseDeserializer deserializer);
@protected
BigInt sse_decode_u_64(SseDeserializer deserializer);
@protected
int sse_decode_u_8(SseDeserializer deserializer);
@protected
void sse_decode_unit(SseDeserializer deserializer);
@protected
void sse_encode_AnyhowException(
AnyhowException self, SseSerializer serializer);
@protected
void sse_encode_StreamSink_nd_event_Sse(
RustStreamSink<NdEvent> self, SseSerializer serializer);
@protected
void sse_encode_StreamSink_nd_host_event_Sse(
RustStreamSink<NdHostEvent> self, SseSerializer serializer);
@protected
void sse_encode_String(String self, SseSerializer serializer);
@protected
void sse_encode_bool(bool self, SseSerializer serializer);
@protected
void sse_encode_f_32(double self, SseSerializer serializer);
@protected
void sse_encode_i_32(int self, SseSerializer serializer);
@protected
void sse_encode_list_nd_monitor(
List<NdMonitor> self, SseSerializer serializer);
@protected
void sse_encode_list_prim_u_8_loose(List<int> self, SseSerializer serializer);
@protected
void sse_encode_list_prim_u_8_strict(
Uint8List self, SseSerializer serializer);
@protected
void sse_encode_nd_event(NdEvent self, SseSerializer serializer);
@protected
void sse_encode_nd_host_event(NdHostEvent self, SseSerializer serializer);
@protected
void sse_encode_nd_monitor(NdMonitor self, SseSerializer serializer);
@protected
void sse_encode_u_32(int self, SseSerializer serializer);
@protected
void sse_encode_u_64(BigInt self, SseSerializer serializer);
@protected
void sse_encode_u_8(int self, SseSerializer serializer);
@protected
void sse_encode_unit(void self, SseSerializer serializer);
}
// Section: wire_class
class RustLibWire implements BaseWire {
RustLibWire.fromExternalLibrary(ExternalLibrary lib);
}
@JS('wasm_bindgen')
external RustLibWasmModule get wasmModule;
@JS()
@anonymous
extension type RustLibWasmModule._(JSObject _) implements JSObject {}
File diff suppressed because it is too large. Load diff
+196
View File
@@ -0,0 +1,196 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
/// NetDesk design tokens + [ThemeData].
///
/// A single, dark, premium theme in the spirit of TeamViewer / RustDesk —
/// deep near-black surfaces, a confident green accent, generous spacing and
/// softly rounded cards. All colors live here as tokens so the rest of the
/// app never hardcodes a hex value.
class NetDeskColors {
NetDeskColors._();
/// App background — deep, near-black with a cool blue cast.
static const Color background = Color(0xFF0B0F15);
/// Raised surface (cards, panels) — one step lighter than the background.
static const Color surface = Color(0xFF121821);
/// A second elevation used for inputs / inset wells.
static const Color surfaceAlt = Color(0xFF0E141C);
/// Hairline borders and dividers.
static const Color border = Color(0xFF1E2733);
/// Primary brand accent — a vivid, trustworthy green.
static const Color accent = Color(0xFF22C55E);
/// Darker accent for pressed / gradient ends.
static const Color accentDeep = Color(0xFF16A34A);
/// Soft accent glow used sparingly behind the wordmark.
static const Color accentGlow = Color(0x3322C55E);
/// High-emphasis text.
static const Color textPrimary = Color(0xFFF2F5F9);
/// Medium-emphasis text (subtitles, labels).
static const Color textSecondary = Color(0xFF9AA7B8);
/// Low-emphasis text (hints, captions).
static const Color textMuted = Color(0xFF5E6B7D);
/// Destructive / disconnect actions.
static const Color danger = Color(0xFFEF4444);
}
class NetDeskRadius {
NetDeskRadius._();
static const double sm = 8;
static const double md = 12;
static const double lg = 16;
static const double xl = 22;
}
class NetDeskSpacing {
NetDeskSpacing._();
static const double xs = 6;
static const double sm = 10;
static const double md = 16;
static const double lg = 24;
static const double xl = 36;
}
class NetDeskTheme {
NetDeskTheme._();
static ThemeData get dark {
const scheme = ColorScheme.dark(
primary: NetDeskColors.accent,
onPrimary: Color(0xFF04210F),
secondary: NetDeskColors.accentDeep,
onSecondary: Color(0xFF04210F),
surface: NetDeskColors.surface,
onSurface: NetDeskColors.textPrimary,
error: NetDeskColors.danger,
onError: Colors.white,
outline: NetDeskColors.border,
);
final base = ThemeData.dark(useMaterial3: true);
return base.copyWith(
colorScheme: scheme,
scaffoldBackgroundColor: NetDeskColors.background,
canvasColor: NetDeskColors.background,
dividerColor: NetDeskColors.border,
splashFactory: InkSparkle.splashFactory,
textTheme: base.textTheme
.apply(
bodyColor: NetDeskColors.textPrimary,
displayColor: NetDeskColors.textPrimary,
fontFamily: 'Roboto',
)
.copyWith(
bodyMedium: const TextStyle(
color: NetDeskColors.textSecondary,
height: 1.4,
),
labelLarge: const TextStyle(
fontWeight: FontWeight.w600,
letterSpacing: 0.3,
),
),
appBarTheme: const AppBarTheme(
backgroundColor: NetDeskColors.surfaceAlt,
foregroundColor: NetDeskColors.textPrimary,
elevation: 0,
centerTitle: false,
titleTextStyle: TextStyle(
color: NetDeskColors.textPrimary,
fontSize: 16,
fontWeight: FontWeight.w600,
letterSpacing: 0.2,
),
systemOverlayStyle: SystemUiOverlayStyle.light,
),
cardTheme: CardThemeData(
color: NetDeskColors.surface,
elevation: 0,
margin: EdgeInsets.zero,
shape: RoundedRectangleBorder(
side: const BorderSide(color: NetDeskColors.border),
borderRadius: BorderRadius.circular(NetDeskRadius.xl),
),
),
inputDecorationTheme: InputDecorationTheme(
filled: true,
fillColor: NetDeskColors.surfaceAlt,
hintStyle: const TextStyle(color: NetDeskColors.textMuted),
labelStyle: const TextStyle(color: NetDeskColors.textSecondary),
contentPadding: const EdgeInsets.symmetric(
horizontal: NetDeskSpacing.md,
vertical: 18,
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(NetDeskRadius.md),
borderSide: const BorderSide(color: NetDeskColors.border),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(NetDeskRadius.md),
borderSide: const BorderSide(color: NetDeskColors.accent, width: 1.6),
),
errorBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(NetDeskRadius.md),
borderSide: const BorderSide(color: NetDeskColors.danger),
),
),
filledButtonTheme: FilledButtonThemeData(
style: FilledButton.styleFrom(
backgroundColor: NetDeskColors.accent,
foregroundColor: const Color(0xFF04210F),
disabledBackgroundColor: NetDeskColors.border,
disabledForegroundColor: NetDeskColors.textMuted,
minimumSize: const Size.fromHeight(54),
textStyle: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
letterSpacing: 0.3,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(NetDeskRadius.md),
),
),
),
iconButtonTheme: IconButtonThemeData(
style: IconButton.styleFrom(
foregroundColor: NetDeskColors.textSecondary,
hoverColor: NetDeskColors.surface,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(NetDeskRadius.sm),
),
),
),
tooltipTheme: const TooltipThemeData(
decoration: BoxDecoration(
color: Color(0xFF1B2430),
borderRadius: BorderRadius.all(Radius.circular(NetDeskRadius.sm)),
),
textStyle: TextStyle(color: NetDeskColors.textPrimary, fontSize: 12),
),
dividerTheme: const DividerThemeData(
color: NetDeskColors.border,
thickness: 1,
space: 1,
),
);
}
}
+605
View File
@@ -0,0 +1,605 @@
# Generated by pub
# See https://dart.dev/tools/pub/glossary#lockfile
packages:
_fe_analyzer_shared:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
url: "https://pub.dev"
source: hosted
version: "85.0.0"
analyzer:
dependency: transitive
description:
name: analyzer
sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d"
url: "https://pub.dev"
source: hosted
version: "7.7.1"
args:
dependency: transitive
description:
name: args
sha256: d0481093c50b1da8910eb0bb301626d4d8eb7284aa739614d2b394ee09e3ea04
url: "https://pub.dev"
source: hosted
version: "2.7.0"
async:
dependency: transitive
description:
name: async
sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37
url: "https://pub.dev"
source: hosted
version: "2.13.1"
boolean_selector:
dependency: transitive
description:
name: boolean_selector
sha256: "8aab1771e1243a5063b8b0ff68042d67334e3feab9e95b9490f9a6ebf73b42ea"
url: "https://pub.dev"
source: hosted
version: "2.1.2"
build:
dependency: transitive
description:
name: build
sha256: "51dc711996cbf609b90cbe5b335bbce83143875a9d58e4b5c6d3c4f684d3dda7"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
build_cli_annotations:
dependency: transitive
description:
name: build_cli_annotations
sha256: e563c2e01de8974566a1998410d3f6f03521788160a02503b0b1f1a46c7b3d95
url: "https://pub.dev"
source: hosted
version: "2.1.1"
build_config:
dependency: transitive
description:
name: build_config
sha256: "4ae2de3e1e67ea270081eaee972e1bd8f027d459f249e0f1186730784c2e7e33"
url: "https://pub.dev"
source: hosted
version: "1.1.2"
build_daemon:
dependency: transitive
description:
name: build_daemon
sha256: e1d40ef3f7934986d5da2271b1ba07794921ce263e44d622fb6c406d76589e33
url: "https://pub.dev"
source: hosted
version: "4.1.6"
build_resolvers:
dependency: transitive
description:
name: build_resolvers
sha256: ee4257b3f20c0c90e72ed2b57ad637f694ccba48839a821e87db762548c22a62
url: "https://pub.dev"
source: hosted
version: "2.5.4"
build_runner:
dependency: "direct dev"
description:
name: build_runner
sha256: "382a4d649addbfb7ba71a3631df0ec6a45d5ab9b098638144faf27f02778eb53"
url: "https://pub.dev"
source: hosted
version: "2.5.4"
build_runner_core:
dependency: transitive
description:
name: build_runner_core
sha256: "85fbbb1036d576d966332a3f5ce83f2ce66a40bea1a94ad2d5fc29a19a0d3792"
url: "https://pub.dev"
source: hosted
version: "9.1.2"
built_collection:
dependency: transitive
description:
name: built_collection
sha256: "44922ecebd8e3d0cef743dadd3b54df4e87e2a4c52a29465a1872aa9e192caa8"
url: "https://pub.dev"
source: hosted
version: "5.1.2"
built_value:
dependency: transitive
description:
name: built_value
sha256: f87ea98192116f7093cb214551ce1929caae0681fdba282b3d8b4462adee7bb7
url: "https://pub.dev"
source: hosted
version: "8.13.0"
characters:
dependency: transitive
description:
name: characters
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
name: checked_yaml
sha256: "959525d3162f249993882720d52b7e0c833978df229be20702b33d48d91de70f"
url: "https://pub.dev"
source: hosted
version: "2.0.4"
clock:
dependency: transitive
description:
name: clock
sha256: e51d50bca3217c9a9fa2b41a30e4a38971133f5f9ec7a3d57bae095007f1d28e
url: "https://pub.dev"
source: hosted
version: "1.1.3"
code_builder:
dependency: transitive
description:
name: code_builder
sha256: aa5932e94c6c39c2f9ec4e5e06dfdd11a9430a61f6c41b6ba75b28ce0c481baf
url: "https://pub.dev"
source: hosted
version: "4.12.0"
collection:
dependency: transitive
description:
name: collection
sha256: "2f5709ae4d3d59dd8f7cd309b4e023046b57d8a6c82130785d2b0e5868084e76"
url: "https://pub.dev"
source: hosted
version: "1.19.1"
convert:
dependency: transitive
description:
name: convert
sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68
url: "https://pub.dev"
source: hosted
version: "3.1.2"
crypto:
dependency: transitive
description:
name: crypto
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
url: "https://pub.dev"
source: hosted
version: "3.0.7"
cupertino_icons:
dependency: "direct main"
description:
name: cupertino_icons
sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd"
url: "https://pub.dev"
source: hosted
version: "1.0.9"
dart_style:
dependency: transitive
description:
name: dart_style
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
url: "https://pub.dev"
source: hosted
version: "3.1.1"
fake_async:
dependency: transitive
description:
name: fake_async
sha256: "5368f224a74523e8d2e7399ea1638b37aecfca824a3cc4dfdf77bf1fa905ac44"
url: "https://pub.dev"
source: hosted
version: "1.3.3"
ffi:
dependency: "direct main"
description:
name: ffi
sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
file:
dependency: transitive
description:
name: file
sha256: a3b4f84adafef897088c160faf7dfffb7696046cb13ae90b508c2cbc95d3b8d4
url: "https://pub.dev"
source: hosted
version: "7.0.1"
fixnum:
dependency: transitive
description:
name: fixnum
sha256: b6dc7065e46c974bc7c5f143080a6764ec7a4be6da1285ececdc37be96de53be
url: "https://pub.dev"
source: hosted
version: "1.1.1"
flutter:
dependency: "direct main"
description: flutter
source: sdk
version: "0.0.0"
flutter_lints:
dependency: "direct dev"
description:
name: flutter_lints
sha256: "3f41d009ba7172d5ff9be5f6e6e6abb4300e263aab8866d2a0842ed2a70f8f0c"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
flutter_rust_bridge:
dependency: "direct main"
description:
name: flutter_rust_bridge
sha256: ec69331e2335ab4d456c0a9fc700892c2c85b72b9c0cb39df3ecfe8833d5d5c8
url: "https://pub.dev"
source: hosted
version: "2.13.0"
flutter_test:
dependency: "direct dev"
description: flutter
source: sdk
version: "0.0.0"
freezed:
dependency: "direct dev"
description:
name: freezed
sha256: "59a584c24b3acdc5250bb856d0d3e9c0b798ed14a4af1ddb7dc1c7b41df91c9c"
url: "https://pub.dev"
source: hosted
version: "2.5.8"
freezed_annotation:
dependency: "direct main"
description:
name: freezed_annotation
sha256: c2e2d632dd9b8a2b7751117abcfc2b4888ecfe181bd9fca7170d9ef02e595fe2
url: "https://pub.dev"
source: hosted
version: "2.4.4"
frontend_server_client:
dependency: transitive
description:
name: frontend_server_client
sha256: "404dbf9bce29beafbe9dcb19219447b6f7d49900cd42b8d3b7af102b24508e25"
url: "https://pub.dev"
source: hosted
version: "4.1.0"
glob:
dependency: transitive
description:
name: glob
sha256: "218aeb56050c714f62a3182775320dfa04602b55074873e24e31bbd39bda96fb"
url: "https://pub.dev"
source: hosted
version: "2.2.0"
graphs:
dependency: transitive
description:
name: graphs
sha256: "741bbf84165310a68ff28fe9e727332eef1407342fca52759cb21ad8177bb8d0"
url: "https://pub.dev"
source: hosted
version: "2.3.2"
http:
dependency: transitive
description:
name: http
sha256: "87721a4a50b19c7f1d49001e51409bddc46303966ce89a65af4f4e6004896412"
url: "https://pub.dev"
source: hosted
version: "1.6.0"
http_multi_server:
dependency: transitive
description:
name: http_multi_server
sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8
url: "https://pub.dev"
source: hosted
version: "3.2.2"
http_parser:
dependency: transitive
description:
name: http_parser
sha256: "178d74305e7866013777bab2c3d8726205dc5a4dd935297175b19a23a2e66571"
url: "https://pub.dev"
source: hosted
version: "4.1.2"
io:
dependency: transitive
description:
name: io
sha256: "2635216ca6a737e60de577ffa1a48a0bec76ca8a62917cfc1bb88c14c570646f"
url: "https://pub.dev"
source: hosted
version: "1.1.0"
js:
dependency: transitive
description:
name: js
sha256: "53385261521cc4a0c4658fd0ad07a7d14591cf8fc33abbceae306ddb974888dc"
url: "https://pub.dev"
source: hosted
version: "0.7.2"
json_annotation:
dependency: transitive
description:
name: json_annotation
sha256: "2a743920d81b7910627f68ee2c9ac1fc0bfee32b9fc3403587d7c6791ca12f80"
url: "https://pub.dev"
source: hosted
version: "4.12.0"
leak_tracker:
dependency: transitive
description:
name: leak_tracker
sha256: "33e2e26bdd85a0112ec15400c8cbffea70d0f9c3407491f672a2fad47915e2de"
url: "https://pub.dev"
source: hosted
version: "11.0.2"
leak_tracker_flutter_testing:
dependency: transitive
description:
name: leak_tracker_flutter_testing
sha256: "1dbc140bb5a23c75ea9c4811222756104fbcd1a27173f0c34ca01e16bea473c1"
url: "https://pub.dev"
source: hosted
version: "3.0.10"
leak_tracker_testing:
dependency: transitive
description:
name: leak_tracker_testing
sha256: "8d5a2d49f4a66b49744b23b018848400d23e54caf9463f4eb20df3eb8acb2eb1"
url: "https://pub.dev"
source: hosted
version: "3.0.2"
lints:
dependency: transitive
description:
name: lints
sha256: "976c774dd944a42e83e2467f4cc670daef7eed6295b10b36ae8c85bcbf828235"
url: "https://pub.dev"
source: hosted
version: "4.0.0"
logging:
dependency: transitive
description:
name: logging
sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61
url: "https://pub.dev"
source: hosted
version: "1.3.0"
matcher:
dependency: transitive
description:
name: matcher
sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd"
url: "https://pub.dev"
source: hosted
version: "0.12.20"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: c82594181e3312f3d0695fc95aaaf7758d75b8d4ae2bbecf223b9fd5109a059d
url: "https://pub.dev"
source: hosted
version: "1.18.3"
mime:
dependency: transitive
description:
name: mime
sha256: bd47de35f07e27267e69c8c8b22edf9473bfee170a60d60fcc93730c5144b7f6
url: "https://pub.dev"
source: hosted
version: "2.1.0"
package_config:
dependency: transitive
description:
name: package_config
sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc
url: "https://pub.dev"
source: hosted
version: "2.2.0"
path:
dependency: transitive
description:
name: path
sha256: "75cca69d1490965be98c73ceaea117e8a04dd21217b37b292c9ddbec0d955bc5"
url: "https://pub.dev"
source: hosted
version: "1.9.1"
pool:
dependency: transitive
description:
name: pool
sha256: "4177f68c237ea2128d1bee66ac17b2ce05ba3dbaafcbdd54c5d40a39d0b6b11c"
url: "https://pub.dev"
source: hosted
version: "1.5.3"
pub_semver:
dependency: transitive
description:
name: pub_semver
sha256: "261236774e8b1d69cfc6b9eabbc96c40f25e7a2d6b171f3385d4f65d5734fb24"
url: "https://pub.dev"
source: hosted
version: "2.2.1"
pubspec_parse:
dependency: transitive
description:
name: pubspec_parse
sha256: c38b81cbf34450b67e0265d73433569d12e34782e30ed769c9cc99c9d5f2e796
url: "https://pub.dev"
source: hosted
version: "1.6.0"
shelf:
dependency: transitive
description:
name: shelf
sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12
url: "https://pub.dev"
source: hosted
version: "1.4.2"
shelf_web_socket:
dependency: transitive
description:
name: shelf_web_socket
sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925"
url: "https://pub.dev"
source: hosted
version: "3.0.0"
sky_engine:
dependency: transitive
description: flutter
source: sdk
version: "0.0.0"
source_gen:
dependency: transitive
description:
name: source_gen
sha256: "35c8150ece9e8c8d263337a265153c3329667640850b9304861faea59fc98f6b"
url: "https://pub.dev"
source: hosted
version: "2.0.0"
source_span:
dependency: transitive
description:
name: source_span
sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab"
url: "https://pub.dev"
source: hosted
version: "1.10.2"
stack_trace:
dependency: transitive
description:
name: stack_trace
sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490"
url: "https://pub.dev"
source: hosted
version: "1.12.2"
stream_channel:
dependency: transitive
description:
name: stream_channel
sha256: "969e04c80b8bcdf826f8f16579c7b14d780458bd97f56d107d3950fdbeef059d"
url: "https://pub.dev"
source: hosted
version: "2.1.4"
stream_transform:
dependency: transitive
description:
name: stream_transform
sha256: a00e5f18bffc764f923e7dec1038527f7fe7a1791361a7117f0358193f13d53a
url: "https://pub.dev"
source: hosted
version: "2.1.2"
string_scanner:
dependency: transitive
description:
name: string_scanner
sha256: "921cd31725b72fe181906c6a94d987c78e3b98c2e205b397ea399d4054872b43"
url: "https://pub.dev"
source: hosted
version: "1.4.1"
term_glyph:
dependency: transitive
description:
name: term_glyph
sha256: "7f554798625ea768a7518313e58f83891c7f5024f88e46e7182a4558850a4b8e"
url: "https://pub.dev"
source: hosted
version: "1.2.2"
test_api:
dependency: transitive
description:
name: test_api
sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11"
url: "https://pub.dev"
source: hosted
version: "0.7.12"
timing:
dependency: transitive
description:
name: timing
sha256: "62ee18aca144e4a9f29d212f5a4c6a053be252b895ab14b5821996cff4ed90fe"
url: "https://pub.dev"
source: hosted
version: "1.0.2"
typed_data:
dependency: transitive
description:
name: typed_data
sha256: f9049c039ebfeb4cf7a7104a675823cd72dba8297f264b6637062516699fa006
url: "https://pub.dev"
source: hosted
version: "1.4.0"
vector_math:
dependency: transitive
description:
name: vector_math
sha256: "1d774bbdf6b72a0b12122fc1560c9c2d2a67db5a4a4cc2bd8a5c990ab20e3188"
url: "https://pub.dev"
source: hosted
version: "2.4.0"
vm_service:
dependency: transitive
description:
name: vm_service
sha256: "5f37239c4851efcef929cea7824e76df7f2f0970aef85d66bbc430afa40e72f0"
url: "https://pub.dev"
source: hosted
version: "15.3.0"
watcher:
dependency: transitive
description:
name: watcher
sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
web:
dependency: transitive
description:
name: web
sha256: "868d88a33d8a87b18ffc05f9f030ba328ffefba92d6c127917a2ba740f9cfe4a"
url: "https://pub.dev"
source: hosted
version: "1.1.1"
web_socket:
dependency: transitive
description:
name: web_socket
sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
url: "https://pub.dev"
source: hosted
version: "1.0.1"
web_socket_channel:
dependency: transitive
description:
name: web_socket_channel
sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
url: "https://pub.dev"
source: hosted
version: "3.0.3"
yaml:
dependency: transitive
description:
name: yaml
sha256: f67cdd8e07d3c6329146aaef1ba043542b3134c12489f553ca9a7435d1068aea
url: "https://pub.dev"
source: hosted
version: "3.1.4"
sdks:
dart: ">=3.11.0 <4.0.0"
flutter: ">=3.27.0"
+43
View File
@@ -0,0 +1,43 @@
name: netdesk
description: NetDesk by Akronlogic — premium cross-platform remote desktop front-end.
publish_to: "none"
version: 0.3.2+7
environment:
# Dart 3.6 / Flutter 3.27 for the non-deprecated Color.withValues() API.
sdk: ">=3.6.0 <4.0.0"
flutter: ">=3.27.0"
dependencies:
flutter:
sdk: flutter
# Cupertino icons are handy for the cross-platform (iOS/macOS) builds.
cupertino_icons: ^1.0.8
# ---------------------------------------------------------------------------
# PHASE 3 (Rust core binding). The UI binds to the Rust `netdesk-frb` crate
# (over `netdesk-core`) through flutter_rust_bridge. The typed Dart<->Rust FFI
# glue under lib/rust/ is produced by `flutter_rust_bridge_codegen generate`
# (config: ../frb/flutter_rust_bridge.yaml). Pinned to the installed codegen.
flutter_rust_bridge: 2.13.0
ffi: ^2.1.0
# FRB generates @freezed sealed classes for the Rust enums (e.g. NdEvent);
# freezed_annotation is the runtime side of that.
freezed_annotation: ^2.4.4
# ---------------------------------------------------------------------------
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^4.0.0
# Code generators for the FRB Dart bindings: `freezed` builds the sealed
# classes, driven by `build_runner` (flutter pub run build_runner build).
freezed: ^2.5.7
build_runner: ^2.4.13
flutter:
uses-material-design: true
# Brand marks (NetDesk by Akronlogic) used by the connect screen.
assets:
- assets/icon.png
- assets/icon-512.png
+22
View File
@@ -0,0 +1,22 @@
// Smoke test for the NetDesk connect screen.
//
// Builds [NetDeskApp] directly (bypassing `main`, which would otherwise try to
// load the native Rust engine) and verifies the brand mark and primary action
// render.
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:netdesk/main.dart';
void main() {
testWidgets('Connect screen shows the NetDesk brand and Connect button',
(WidgetTester tester) async {
await tester.pumpWidget(const NetDeskApp());
expect(find.text('NetDesk'), findsOneWidget);
expect(find.text('by Akronlogic'), findsOneWidget);
expect(find.text('Connect'), findsOneWidget);
expect(find.byType(FilledButton), findsWidgets);
});
}