From 5bd457478ecd62fa5561b8941e018353d7c63e0c Mon Sep 17 00:00:00 2001
From: Youximi <1127377507@qq.com>
Date: Mon, 28 Sep 2026 19:15:57 +0800
Subject: [PATCH 1/4] =?UTF-8?q?feat:=20=E6=94=AF=E6=8C=81=E6=89=AB?=
=?UTF-8?q?=E7=A0=81=E8=AE=B0=E4=BD=8F=E8=AE=BE=E5=A4=87=E5=92=8C=E9=AA=8C?=
=?UTF-8?q?=E8=AF=81=E7=A0=81=E4=B8=B4=E6=97=B6=E8=BF=9E=E6=8E=A5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
升级配对协议为 v2:扫码校验电脑身份并保存设备密钥;手动验证码仅建立临时会话;支持断线重连。
新增开机自启功能,默认关闭。
更新文档。
---
.gitignore | 1 +
README.md | 27 +-
androidApp/app/src/main/AndroidManifest.xml | 5 +-
.../dev/youximi/appmapper/AppCoordinator.kt | 49 ++-
.../java/dev/youximi/appmapper/AppScaffold.kt | 19 +-
.../java/dev/youximi/appmapper/HomeScreen.kt | 149 ++++----
.../dev/youximi/appmapper/SettingsScreen.kt | 12 +
.../youximi/appmapper/data/DeviceKeyStore.kt | 39 +++
.../youximi/appmapper/data/LanDiscovery.kt | 50 +++
.../youximi/appmapper/data/PairingParser.kt | 14 +-
.../youximi/appmapper/data/PairingStore.kt | 55 +++
.../dev/youximi/appmapper/data/Protocol.kt | 55 ++-
.../youximi/appmapper/data/SettingsStore.kt | 17 +-
.../youximi/appmapper/data/TcpJsonClient.kt | 105 +++++-
.../service/ForegroundSyncService.kt | 298 ++++++++++++----
gradle/wrapper/gradle-wrapper.properties | 5 +-
shared/examples/hello.json | 11 +-
shared/protocol.md | 46 ++-
.../Abstractions/CoreStateSnapshot.cs | 4 +-
.../controller/Abstractions/ICoreFacade.cs | 3 +
.../controller/Abstractions/Settings.cs | 8 +
windowsApp/controller/App.xaml.cs | 25 +-
.../controller/Core/AppMapperCoreEngine.cs | 65 +++-
windowsApp/controller/Core/PairingStore.cs | 106 ++++++
windowsApp/controller/Core/SettingsService.cs | 33 +-
windowsApp/controller/Models/DeviceState.cs | 1 +
.../controller/Models/ProtocolMessage.cs | 9 -
.../Services/DeviceIdentityService.cs | 58 ++++
.../Services/LanDiscoveryService.cs | 44 +++
.../controller/Services/NetworkInfoService.cs | 2 -
.../controller/Services/PairingCodeService.cs | 45 ++-
.../controller/Services/TcpJsonServer.cs | 322 +++++++++++++-----
.../controller/ViewModels/PairingViewModel.cs | 21 +-
.../ViewModels/SettingsViewModel.cs | 15 +
windowsApp/controller/Views/PairingPage.xaml | 10 +-
windowsApp/controller/Views/SettingsPage.xaml | 4 +
36 files changed, 1340 insertions(+), 392 deletions(-)
create mode 100644 androidApp/app/src/main/java/dev/youximi/appmapper/data/DeviceKeyStore.kt
create mode 100644 androidApp/app/src/main/java/dev/youximi/appmapper/data/LanDiscovery.kt
create mode 100644 androidApp/app/src/main/java/dev/youximi/appmapper/data/PairingStore.kt
create mode 100644 windowsApp/controller/Core/PairingStore.cs
delete mode 100644 windowsApp/controller/Models/ProtocolMessage.cs
create mode 100644 windowsApp/controller/Services/DeviceIdentityService.cs
create mode 100644 windowsApp/controller/Services/LanDiscoveryService.cs
diff --git a/.gitignore b/.gitignore
index 98f6402..77395d6 100644
--- a/.gitignore
+++ b/.gitignore
@@ -30,3 +30,4 @@ release-notes.md
windowsApp/**/logs/
windowsApp/**/config/settings.json
+windowsApp/**/config/pairing.dat
diff --git a/README.md b/README.md
index 42fde04..8854e46 100644
--- a/README.md
+++ b/README.md
@@ -25,9 +25,9 @@ AppMapper/
## 技术栈
- **Android 客户端**:Kotlin、Jetpack Compose、Material3、前台服务、`UsageStatsManager`。最低 Android 13。
-- **Windows 总控端**:C# WPF(.NET 8)、TCP 服务器、二维码配对、映射进程管理。
+- **Windows 总控端**:C# WPF(.NET 8)、TLS 服务器、二维码配对、映射进程管理。
- **Windows 映射端**:C++ Win32,128×128 分层置顶窗口,不联网。
-- **传输协议**:局域网 TCP + JSON Lines(每条消息一行 JSON)。
+- **传输协议**:局域网 TLS + JSON Lines(每条消息一行 JSON),协议版本 2。
## 配对方式
@@ -35,16 +35,18 @@ AppMapper/
- 本机局域网 IP
- 端口(默认 `8765`)
-- 6 位数字验证码(每 60 秒刷新一次,只用于新连接)
-- 包含连接信息的二维码
+- 6 位数字验证码(每 60 秒刷新一次,只用于临时连接)
+- 用于记住设备的二维码
二维码内容格式:
```text
-appmapper://connect?host=192.168.1.10&port=8765&code=123456
+appmapper://connect?host=192.168.1.10&port=8765&serverId=<电脑ID>&fingerprint=<64位公钥指纹>&enrollToken=<32位一次性令牌>
```
-Android 端可以直接扫码,也可以手动输入 IP、端口、验证码连接。
+Android 端勾选「记住设备」后扫描二维码绑定;二维码中的公钥指纹用于验证电脑身份,一次性令牌授权长期配对。手机使用 Android Keystore 中的设备密钥应答电脑的一次性挑战;验证码刷新、短暂断网、电脑重启后会自动重连。没有摄像头时,可取消「记住设备」,手动输入 IP、端口和 6 位验证码进行临时连接。临时连接不会保存配对,断线后需重新输入验证码,也不会清除已有的长期配对。手机需保持同步前台服务运行,电脑需要运行总控端;Windows 登录后启动到托盘可在设置中开启。手机重启后需打开 App 并启动同步,本次没有加入手机开机自启。
+
+电脑「配对」页可以移除手机,手机「设置」页可以忘记电脑;之后要重新扫码。电脑 IP 变化时,手机会在同一 IPv4 广播网段上尝试发现电脑。发现结果仍须通过原先保存的电脑公钥指纹校验;错误的地址提示不会清除配对。若路由器隔离设备、Windows 防火墙拦截 UDP 8766,或手机和电脑不在同一网段,请重新扫码。
## 前置环境
@@ -84,7 +86,7 @@ msbuild windowsApp\mapper\AppMapper.Mapper.vcxproj /p:Configuration=Release /p:P
2. 确认 `mapper-template.exe` 已放在总控端 exe 同级目录(见上节构建说明)。
3. 手机和电脑接入**同一局域网**。
4. 在手机上安装并打开 Android 客户端,按提示授予"使用情况访问"权限(`PACKAGE_USAGE_STATS`)。
-5. 扫描总控端二维码,或手动输入 IP、端口、验证码完成配对。
+5. 勾选「记住设备」并扫描总控端二维码完成绑定;无摄像头时取消勾选,手动输入 IP、端口、验证码进行临时连接。
6. 在手机上切换到任意前台 App,电脑任务栏会出现对应的映射窗口;回到桌面 / 锁屏 / 熄屏时窗口自动关闭。
## 权限说明
@@ -93,17 +95,20 @@ Android 客户端需要以下权限,全部用于核心功能:
| 权限 | 用途 |
|-------------------------------------------------------|------------------|
-| `INTERNET` / `ACCESS_NETWORK_STATE` | 局域网 TCP 连接 |
+| `INTERNET` / `ACCESS_NETWORK_STATE` | 局域网 TLS 连接 |
| `CAMERA` | 扫描配对二维码 |
| `PACKAGE_USAGE_STATS` | 读取当前前台 App(核心功能) |
-| `FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_DATA_SYNC` | 保持后台同步 |
+| `FOREGROUND_SERVICE` / `FOREGROUND_SERVICE_CONNECTED_DEVICE` | 保持设备连接和后台同步 |
+| `CHANGE_WIFI_MULTICAST_STATE` | 满足 Android 连接设备前台服务的权限要求 |
| `POST_NOTIFICATIONS` | 前台服务通知 |
## 隐私与安全
-- 全程**仅局域网通信**,Android 端连接的目标地址完全由用户在界面上输入或扫码得到,代码里没有任何硬编码的服务器地址。
-- Windows 总控端是 TCP 服务端,只监听局域网,不会主动外联任何网络。
+- 全程**仅局域网通信**。扫码绑定与后续重连使用二维码里的电脑公钥指纹验证电脑身份;断线后可能经同网段 UDP 广播发现新地址,发现回复不能替代指纹验证。手动临时连接不验证电脑身份,局域网内的攻击者可能冒充目标电脑;仅在应急时使用。
+- 电脑身份和已配对手机公钥保存在软件目录 `config/pairing.dat`,由当前 Windows 用户的 DPAPI 加密。手机配对记录在 App 私有 no-backup 目录 `config/paired-computer.json`;私钥由 Android Keystore 管理。不要将 Windows 数据文件移到另一 Windows 用户下期待可以解密。
+- 六位码只能授权临时连接;二维码中的随机令牌才能授权长期绑定。两者都会在使用后或每 60 秒失效,不作为重连凭据存储或在日志中记录。
+- Windows 总控端监听 TCP 和 UDP;UDP 发现只回答服务器 ID 和端口,不传输设备凭据。
- 映射小程序是纯本地窗口程序,不联网。
## License
diff --git a/androidApp/app/src/main/AndroidManifest.xml b/androidApp/app/src/main/AndroidManifest.xml
index 69c23f8..b8076dd 100644
--- a/androidApp/app/src/main/AndroidManifest.xml
+++ b/androidApp/app/src/main/AndroidManifest.xml
@@ -3,7 +3,8 @@
-
+
+
@@ -31,6 +32,6 @@
+ android:foregroundServiceType="connectedDevice" />
diff --git a/androidApp/app/src/main/java/dev/youximi/appmapper/AppCoordinator.kt b/androidApp/app/src/main/java/dev/youximi/appmapper/AppCoordinator.kt
index 71070ae..6bfc7a3 100644
--- a/androidApp/app/src/main/java/dev/youximi/appmapper/AppCoordinator.kt
+++ b/androidApp/app/src/main/java/dev/youximi/appmapper/AppCoordinator.kt
@@ -3,7 +3,9 @@ package dev.youximi.appmapper
import android.app.Activity
import android.content.Intent
import dev.youximi.appmapper.data.AppLogger
-import dev.youximi.appmapper.data.PairingTarget
+import dev.youximi.appmapper.data.PairedComputer
+import dev.youximi.appmapper.data.PairingStore
+import dev.youximi.appmapper.data.PairingRequest
import dev.youximi.appmapper.data.SettingsStore
import dev.youximi.appmapper.data.UsageAppReader
import dev.youximi.appmapper.service.ForegroundSyncService
@@ -13,48 +15,37 @@ class AppCoordinator(
private val settings: SettingsStore,
private val usageReader: UsageAppReader,
) {
- fun loadInitialState(): AppState {
- val target = settings.getTarget()
- return AppState(
- pairingTarget = target,
- pollingMs = settings.getPollingMs(),
- hasUsageAccess = usageReader.hasUsageAccess(),
- )
+ private val pairing = PairingStore(activity)
+
+ fun loadInitialState(): AppState = AppState(
+ pairedComputer = runCatching { pairing.read() }.getOrNull(),
+ pollingMs = settings.getPollingMs(),
+ hasUsageAccess = usageReader.hasUsageAccess(),
+ )
+
+ fun pair(request: PairingRequest) {
+ AppLogger.write(activity, "Pairing requested for ${request.host}:${request.port}.")
+ ForegroundSyncService.pair(activity, request)
}
- fun saveTarget(target: PairingTarget) {
- AppLogger.write(activity, "Pairing target saved: ${target.host}:${target.port}, codeLength=${target.code.length}.")
- settings.saveTarget(target)
+ fun forgetComputer() {
+ ForegroundSyncService.forget(activity)
}
fun savePollingMs(value: Long) {
- AppLogger.write(activity, "Polling interval saved: ${value}ms.")
settings.savePollingMs(value)
}
fun openUsageAccessSettings() {
- AppLogger.write(activity, "Opening usage access settings.")
activity.startActivity(usageReader.usageAccessIntent())
}
- fun startService() {
- AppLogger.write(activity, "Start requested from UI.")
- ForegroundSyncService.start(activity)
- }
-
- fun stopService() {
- AppLogger.write(activity, "Stop requested from UI.")
- ForegroundSyncService.stop(activity)
- }
-
+ fun startService() = ForegroundSyncService.start(activity)
+ fun stopService() = ForegroundSyncService.stop(activity)
fun readLogs(): String = AppLogger.read(activity)
-
- fun clearLogs() {
- AppLogger.clear(activity)
- }
+ fun clearLogs() = AppLogger.clear(activity)
fun exportLogs() {
- AppLogger.write(activity, "Logs export requested.")
val logs = AppLogger.read(activity).ifBlank { "AppMapper log is empty." }
val sendIntent = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
@@ -66,7 +57,7 @@ class AppCoordinator(
}
data class AppState(
- val pairingTarget: PairingTarget,
+ val pairedComputer: PairedComputer?,
val pollingMs: Long,
val hasUsageAccess: Boolean,
)
diff --git a/androidApp/app/src/main/java/dev/youximi/appmapper/AppScaffold.kt b/androidApp/app/src/main/java/dev/youximi/appmapper/AppScaffold.kt
index 9ba16d4..ec7a0e5 100644
--- a/androidApp/app/src/main/java/dev/youximi/appmapper/AppScaffold.kt
+++ b/androidApp/app/src/main/java/dev/youximi/appmapper/AppScaffold.kt
@@ -13,9 +13,11 @@ import androidx.compose.material3.NavigationBarItem
import androidx.compose.material3.Scaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
@@ -24,6 +26,7 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleEventObserver
import androidx.lifecycle.compose.LocalLifecycleOwner
+import dev.youximi.appmapper.service.SyncStatus
private enum class MainTab {
Home,
@@ -37,7 +40,8 @@ private enum class RootPage {
@Composable
internal fun AppScaffold(coordinator: AppCoordinator) {
- val appState by rememberAppState(coordinator)
+ val connectionStatus by SyncStatus.text.collectAsState()
+ val appState by rememberAppState(coordinator, connectionStatus)
var rootPage by rememberSaveable { mutableStateOf(RootPage.Main) }
var selectedTab by rememberSaveable { mutableStateOf(MainTab.Home) }
var draftPollingMs by rememberSaveable(appState.pollingMs) { mutableStateOf(appState.pollingMs) }
@@ -76,8 +80,12 @@ internal fun AppScaffold(coordinator: AppCoordinator) {
) {
when (selectedTab) {
MainTab.Home -> HomeScreen(
- initialTarget = appState.pairingTarget,
- onSaveTarget = coordinator::saveTarget,
+ pairedComputer = appState.pairedComputer,
+ connectionStatus = connectionStatus,
+ onPair = { request ->
+ coordinator.savePollingMs(draftPollingMs)
+ coordinator.pair(request)
+ },
onStart = {
coordinator.savePollingMs(draftPollingMs)
coordinator.startService()
@@ -91,6 +99,8 @@ internal fun AppScaffold(coordinator: AppCoordinator) {
onOpenUsageAccess = coordinator::openUsageAccessSettings,
onPollingSelected = { draftPollingMs = it },
onOpenLogs = { rootPage = RootPage.Logs },
+ pairedComputer = appState.pairedComputer,
+ onForgetComputer = coordinator::forgetComputer,
)
}
}
@@ -98,9 +108,10 @@ internal fun AppScaffold(coordinator: AppCoordinator) {
}
@Composable
-private fun rememberAppState(coordinator: AppCoordinator): State {
+private fun rememberAppState(coordinator: AppCoordinator, status: String): State {
val state = remember { mutableStateOf(coordinator.loadInitialState()) }
val lifecycleOwner = LocalLifecycleOwner.current
+ LaunchedEffect(status) { state.value = coordinator.loadInitialState() }
DisposableEffect(coordinator, lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
diff --git a/androidApp/app/src/main/java/dev/youximi/appmapper/HomeScreen.kt b/androidApp/app/src/main/java/dev/youximi/appmapper/HomeScreen.kt
index 9ac2bca..f0af5c8 100644
--- a/androidApp/app/src/main/java/dev/youximi/appmapper/HomeScreen.kt
+++ b/androidApp/app/src/main/java/dev/youximi/appmapper/HomeScreen.kt
@@ -19,11 +19,13 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Switch
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
@@ -31,113 +33,84 @@ import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
import com.journeyapps.barcodescanner.ScanContract
import com.journeyapps.barcodescanner.ScanOptions
+import dev.youximi.appmapper.data.PairedComputer
import dev.youximi.appmapper.data.PairingParser
-import dev.youximi.appmapper.data.PairingTarget
+import dev.youximi.appmapper.data.PairingRequest
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun HomeScreen(
- initialTarget: PairingTarget,
- onSaveTarget: (PairingTarget) -> Unit,
+ pairedComputer: PairedComputer?,
+ connectionStatus: String,
+ onPair: (PairingRequest) -> Unit,
onStart: () -> Unit,
onStop: () -> Unit,
) {
- var host by rememberSaveable(initialTarget.host) { mutableStateOf(initialTarget.host) }
- var port by rememberSaveable(initialTarget.port) { mutableStateOf(initialTarget.port.toString()) }
- var code by rememberSaveable(initialTarget.code) { mutableStateOf(initialTarget.code) }
- var connectionStatus by rememberSaveable { mutableStateOf("未连接") }
+ var host by rememberSaveable { mutableStateOf("") }
+ var port by rememberSaveable { mutableStateOf("8765") }
+ var code by remember { mutableStateOf("") }
+ var rememberDevice by rememberSaveable { mutableStateOf(true) }
+ var inputError by rememberSaveable { mutableStateOf("") }
val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
val qrScanner = rememberLauncherForActivityResult(ScanContract()) { result ->
- val content = result.contents ?: return@rememberLauncherForActivityResult
- PairingParser.parseUri(content)?.let { parsed ->
- host = parsed.host
- port = parsed.port.toString()
- code = parsed.code
- }
+ if (result.contents != null) PairingParser.parseUri(result.contents)?.let { request ->
+ inputError = ""
+ notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
+ onPair(request)
+ } ?: run { inputError = "二维码无效或版本过旧" }
}
- Scaffold(
- topBar = { TopAppBar(title = { Text("配对") }) },
- bottomBar = {},
- ) { padding ->
+ Scaffold(topBar = { TopAppBar(title = { Text("配对") }) }) { padding ->
Column(
- modifier = Modifier
- .padding(padding)
- .fillMaxSize()
- .verticalScroll(rememberScrollState())
+ modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState())
.padding(horizontal = 16.dp, vertical = 12.dp),
verticalArrangement = Arrangement.spacedBy(12.dp),
) {
- ListItem(
- headlineContent = { Text("运行状态") },
- supportingContent = { Text(connectionStatus) },
- )
-
- HorizontalDivider()
-
- OutlinedTextField(
- modifier = Modifier.fillMaxWidth(),
- value = host,
- onValueChange = { host = it },
- label = { Text("IP") },
- singleLine = true,
- )
- OutlinedTextField(
- modifier = Modifier.fillMaxWidth(),
- value = port,
- onValueChange = { port = it.filter(Char::isDigit) },
- label = { Text("端口") },
- singleLine = true,
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
- )
- OutlinedTextField(
- modifier = Modifier.fillMaxWidth(),
- value = code,
- onValueChange = { code = it.filter(Char::isDigit).take(6) },
- label = { Text("验证码") },
- singleLine = true,
- keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number),
- )
-
- Button(
- modifier = Modifier.fillMaxWidth(),
- onClick = {
- val target = PairingTarget(host.trim(), port.toIntOrNull() ?: 8765, code.trim())
- onSaveTarget(target)
- notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
- connectionStatus = "已保存"
- onStart()
- },
- ) {
- Text("开始")
+ ListItem(headlineContent = { Text("运行状态") }, supportingContent = { Text(connectionStatus) })
+ pairedComputer?.let { computer ->
+ ListItem(headlineContent = { Text("已配对电脑") },
+ supportingContent = { Text("${computer.name} · ${computer.host}:${computer.port}") })
+ Button(modifier = Modifier.fillMaxWidth(), onClick = onStart) { Text("连接已配对电脑") }
}
- OutlinedButton(
- modifier = Modifier.fillMaxWidth(),
- onClick = {
- connectionStatus = "已停止"
- onStop()
- },
- ) {
- Text("停止")
- }
-
+ OutlinedButton(modifier = Modifier.fillMaxWidth(), onClick = onStop) { Text("停止同步") }
HorizontalDivider()
-
- Text("二维码", style = androidx.compose.material3.MaterialTheme.typography.titleSmall)
- FilledTonalButton(
- modifier = Modifier.fillMaxWidth(),
- onClick = {
- qrScanner.launch(
- ScanOptions()
- .setDesiredBarcodeFormats(ScanOptions.QR_CODE)
- .setPrompt("扫描 AppMapper 配对二维码")
- .setBeepEnabled(false)
- .setOrientationLocked(false),
- )
- },
- ) {
- Text("扫描二维码")
+ Text("首次连接或更换电脑")
+ ListItem(headlineContent = { Text("记住设备") },
+ supportingContent = { Text(if (rememberDevice) "扫码绑定,断线后可重连" else "手动临时连接,断线后需重新输入验证码") },
+ trailingContent = { Switch(checked = rememberDevice, onCheckedChange = {
+ rememberDevice = it
+ inputError = ""
+ }) })
+ if (rememberDevice) {
+ FilledTonalButton(modifier = Modifier.fillMaxWidth(), onClick = {
+ qrScanner.launch(ScanOptions().setDesiredBarcodeFormats(ScanOptions.QR_CODE)
+ .setPrompt("扫描 AppMapper 配对二维码").setBeepEnabled(false)
+ .setOrientationLocked(false))
+ }) { Text("扫码绑定并开始") }
+ } else {
+ OutlinedTextField(modifier = Modifier.fillMaxWidth(), value = host,
+ onValueChange = { host = it }, label = { Text("电脑 IP") }, singleLine = true)
+ OutlinedTextField(modifier = Modifier.fillMaxWidth(), value = port,
+ onValueChange = { port = it.filter(Char::isDigit) }, label = { Text("端口") },
+ singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
+ OutlinedTextField(modifier = Modifier.fillMaxWidth(), value = code,
+ onValueChange = { code = it.filter(Char::isDigit).take(6) }, label = { Text("6 位验证码") },
+ singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
+ }
+ if (inputError.isNotBlank()) Text(inputError)
+ if (!rememberDevice) {
+ Button(modifier = Modifier.fillMaxWidth(), onClick = {
+ val p = port.toIntOrNull()
+ if (host.isBlank() || p == null || p !in 1..65535 || code.length != 6) {
+ inputError = "请填写地址、端口和 6 位验证码"
+ } else {
+ inputError = ""
+ notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
+ onPair(PairingRequest.Temporary(host.trim(), p, code))
+ code = ""
+ }
+ }) { Text("临时连接并开始") }
}
}
}
diff --git a/androidApp/app/src/main/java/dev/youximi/appmapper/SettingsScreen.kt b/androidApp/app/src/main/java/dev/youximi/appmapper/SettingsScreen.kt
index bc9dc3d..d3707e0 100644
--- a/androidApp/app/src/main/java/dev/youximi/appmapper/SettingsScreen.kt
+++ b/androidApp/app/src/main/java/dev/youximi/appmapper/SettingsScreen.kt
@@ -32,6 +32,7 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
+import dev.youximi.appmapper.data.PairedComputer
@OptIn(ExperimentalMaterial3Api::class)
@Composable
@@ -41,6 +42,8 @@ internal fun SettingsScreen(
onOpenUsageAccess: () -> Unit,
onPollingSelected: (Long) -> Unit,
onOpenLogs: () -> Unit,
+ pairedComputer: PairedComputer?,
+ onForgetComputer: () -> Unit,
) {
var selectedPolling by rememberSaveable(pollingMs) { mutableStateOf(pollingMs) }
var pollingMenuExpanded by rememberSaveable { mutableStateOf(false) }
@@ -128,6 +131,15 @@ internal fun SettingsScreen(
HorizontalDivider()
+ pairedComputer?.let { computer ->
+ ListItem(
+ headlineContent = { Text("已配对:${computer.name}") },
+ supportingContent = { Text("忘记后需要重新扫码") },
+ trailingContent = { TextButton(onClick = onForgetComputer) { Text("忘记电脑") } },
+ )
+ HorizontalDivider()
+ }
+
Text("更多", style = MaterialTheme.typography.titleSmall)
ListItem(
headlineContent = { Text("日志") },
diff --git a/androidApp/app/src/main/java/dev/youximi/appmapper/data/DeviceKeyStore.kt b/androidApp/app/src/main/java/dev/youximi/appmapper/data/DeviceKeyStore.kt
new file mode 100644
index 0000000..73dbd2e
--- /dev/null
+++ b/androidApp/app/src/main/java/dev/youximi/appmapper/data/DeviceKeyStore.kt
@@ -0,0 +1,39 @@
+package dev.youximi.appmapper.data
+
+import android.security.keystore.KeyGenParameterSpec
+import android.security.keystore.KeyProperties
+import android.util.Base64
+import java.security.KeyPairGenerator
+import java.security.KeyStore
+import java.security.Signature
+import java.security.spec.ECGenParameterSpec
+import java.util.UUID
+
+object DeviceKeyStore {
+ fun create(): Pair {
+ val alias = "appmapper-${UUID.randomUUID()}"
+ val generator = KeyPairGenerator.getInstance(KeyProperties.KEY_ALGORITHM_EC, "AndroidKeyStore")
+ generator.initialize(
+ KeyGenParameterSpec.Builder(alias, KeyProperties.PURPOSE_SIGN)
+ .setAlgorithmParameterSpec(ECGenParameterSpec("secp256r1"))
+ .setDigests(KeyProperties.DIGEST_SHA256)
+ .build(),
+ )
+ val pair = generator.generateKeyPair()
+ return alias to Base64.encodeToString(pair.public.encoded, Base64.NO_WRAP)
+ }
+
+ fun sign(alias: String, payload: ByteArray): String {
+ val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
+ val key = store.getKey(alias, null) ?: error("Pairing key is unavailable")
+ val signer = Signature.getInstance("SHA256withECDSA")
+ signer.initSign(key as java.security.PrivateKey)
+ signer.update(payload)
+ return Base64.encodeToString(signer.sign(), Base64.NO_WRAP)
+ }
+
+ fun delete(alias: String) {
+ val store = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
+ store.deleteEntry(alias)
+ }
+}
diff --git a/androidApp/app/src/main/java/dev/youximi/appmapper/data/LanDiscovery.kt b/androidApp/app/src/main/java/dev/youximi/appmapper/data/LanDiscovery.kt
new file mode 100644
index 0000000..d972678
--- /dev/null
+++ b/androidApp/app/src/main/java/dev/youximi/appmapper/data/LanDiscovery.kt
@@ -0,0 +1,50 @@
+package dev.youximi.appmapper.data
+
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.withContext
+import java.net.DatagramPacket
+import java.net.DatagramSocket
+import java.net.InetAddress
+import java.net.NetworkInterface
+import java.net.SocketTimeoutException
+import java.util.Collections
+
+data class DiscoveredComputer(val host: String, val port: Int)
+
+/** Finds a known server ID on the local IPv4 network; TLS pinning authenticates the result. */
+object LanDiscovery {
+ private const val port = 8766
+
+ suspend fun find(serverId: String): DiscoveredComputer? = withContext(Dispatchers.IO) {
+ if (serverId.isBlank()) return@withContext null
+ val request = "appmapper-discover-v2|$serverId".toByteArray(Charsets.US_ASCII)
+ val expected = "appmapper-discovery-v2|$serverId|"
+ DatagramSocket().use { socket ->
+ socket.broadcast = true
+ socket.soTimeout = 300
+ val broadcasts = mutableSetOf(InetAddress.getByName("255.255.255.255"))
+ runCatching {
+ Collections.list(NetworkInterface.getNetworkInterfaces())
+ .filter { it.isUp && !it.isLoopback }
+ .flatMap { it.interfaceAddresses }
+ .mapNotNullTo(broadcasts) { it.broadcast }
+ }
+ broadcasts.forEach { address ->
+ runCatching { socket.send(DatagramPacket(request, request.size, address, port)) }
+ }
+ val deadline = System.currentTimeMillis() + 1600
+ while (System.currentTimeMillis() < deadline) {
+ val bytes = ByteArray(256)
+ val reply = DatagramPacket(bytes, bytes.size)
+ try { socket.receive(reply) } catch (_: SocketTimeoutException) { continue }
+ val message = String(reply.data, reply.offset, reply.length, Charsets.US_ASCII)
+ if (message.startsWith(expected)) {
+ val tcpPort = message.removePrefix(expected).toIntOrNull()
+ if (tcpPort != null && tcpPort in 1..65535)
+ return@withContext DiscoveredComputer(reply.address.hostAddress ?: continue, tcpPort)
+ }
+ }
+ null
+ }
+ }
+}
diff --git a/androidApp/app/src/main/java/dev/youximi/appmapper/data/PairingParser.kt b/androidApp/app/src/main/java/dev/youximi/appmapper/data/PairingParser.kt
index 8df814c..4b5cb6e 100644
--- a/androidApp/app/src/main/java/dev/youximi/appmapper/data/PairingParser.kt
+++ b/androidApp/app/src/main/java/dev/youximi/appmapper/data/PairingParser.kt
@@ -3,13 +3,19 @@ package dev.youximi.appmapper.data
import android.net.Uri
object PairingParser {
- fun parseUri(value: String): PairingTarget? {
+ fun parseUri(value: String): PairingRequest.Remembered? {
val uri = runCatching { Uri.parse(value.trim()) }.getOrNull() ?: return null
if (uri.scheme != "appmapper" || uri.host != "connect") return null
val host = uri.getQueryParameter("host")?.takeIf { it.isNotBlank() } ?: return null
- val port = uri.getQueryParameter("port")?.toIntOrNull() ?: return null
- val code = uri.getQueryParameter("code")?.takeIf { it.all(Char::isDigit) } ?: return null
- return PairingTarget(host = host, port = port, code = code)
+ val port = uri.getQueryParameter("port")?.toIntOrNull()?.takeIf { it in 1..65535 } ?: return null
+ val serverId = uri.getQueryParameter("serverId")?.takeIf { it.isNotBlank() } ?: return null
+ val fingerprint = uri.getQueryParameter("fingerprint")?.lowercase()
+ ?.takeIf { it.length == 64 && it.all { ch -> ch in '0'..'9' || ch in 'a'..'f' } }
+ ?: return null
+ val enrollToken = uri.getQueryParameter("enrollToken")?.lowercase()
+ ?.takeIf { it.length == 32 && it.all { ch -> ch in '0'..'9' || ch in 'a'..'f' } }
+ ?: return null
+ return PairingRequest.Remembered(host, port, serverId, fingerprint, enrollToken)
}
}
diff --git a/androidApp/app/src/main/java/dev/youximi/appmapper/data/PairingStore.kt b/androidApp/app/src/main/java/dev/youximi/appmapper/data/PairingStore.kt
new file mode 100644
index 0000000..75b1a9f
--- /dev/null
+++ b/androidApp/app/src/main/java/dev/youximi/appmapper/data/PairingStore.kt
@@ -0,0 +1,55 @@
+package dev.youximi.appmapper.data
+
+import android.content.Context
+import org.json.JSONObject
+import java.io.File
+import java.nio.file.Files
+import java.nio.file.StandardCopyOption
+
+data class PairedComputer(
+ val host: String,
+ val port: Int,
+ val serverId: String,
+ val fingerprint: String,
+ val deviceId: String,
+ val keyAlias: String,
+ val name: String,
+)
+
+class PairingStore(context: Context) {
+ private val file = File(context.applicationContext.noBackupFilesDir, "config/paired-computer.json")
+
+ @Synchronized
+ fun read(): PairedComputer? {
+ if (!file.exists()) return null
+ val json = JSONObject(file.readText(Charsets.UTF_8))
+ return PairedComputer(
+ json.getString("host"), json.getInt("port"), json.getString("serverId"),
+ json.getString("fingerprint"), json.getString("deviceId"),
+ json.getString("keyAlias"), json.getString("name"),
+ )
+ }
+
+ @Synchronized
+ fun save(computer: PairedComputer) {
+ file.parentFile?.mkdirs()
+ val json = JSONObject()
+ .put("host", computer.host).put("port", computer.port)
+ .put("serverId", computer.serverId).put("fingerprint", computer.fingerprint)
+ .put("deviceId", computer.deviceId).put("keyAlias", computer.keyAlias)
+ .put("name", computer.name)
+ val temporary = File(file.parentFile, "${file.name}.tmp")
+ try {
+ temporary.writeText(json.toString(), Charsets.UTF_8)
+ Files.move(temporary.toPath(), file.toPath(), StandardCopyOption.ATOMIC_MOVE,
+ StandardCopyOption.REPLACE_EXISTING)
+ } finally {
+ temporary.delete()
+ }
+ }
+
+ @Synchronized
+ fun clear() {
+ if (file.exists() && !file.delete()) error("Could not delete pairing record")
+ }
+}
diff --git a/androidApp/app/src/main/java/dev/youximi/appmapper/data/Protocol.kt b/androidApp/app/src/main/java/dev/youximi/appmapper/data/Protocol.kt
index a399bcb..2f1bc58 100644
--- a/androidApp/app/src/main/java/dev/youximi/appmapper/data/Protocol.kt
+++ b/androidApp/app/src/main/java/dev/youximi/appmapper/data/Protocol.kt
@@ -3,13 +3,26 @@ package dev.youximi.appmapper.data
import dev.youximi.appmapper.BuildConfig
import org.json.JSONObject
-const val ProtocolVersion = 1
+const val ProtocolVersion = 2
-data class PairingTarget(
- val host: String,
- val port: Int,
- val code: String,
-)
+sealed interface PairingRequest {
+ val host: String
+ val port: Int
+
+ data class Temporary(
+ override val host: String,
+ override val port: Int,
+ val code: String,
+ ) : PairingRequest
+
+ data class Remembered(
+ override val host: String,
+ override val port: Int,
+ val serverId: String,
+ val fingerprint: String,
+ val enrollToken: String,
+ ) : PairingRequest
+}
data class ActiveApp(
val appId: String,
@@ -18,16 +31,30 @@ data class ActiveApp(
val iconPngBase64: String?,
)
-fun helloJson(deviceId: String, deviceName: String, pairingCode: String): JSONObject =
- JSONObject()
- .put("type", "hello")
- .put("protocolVersion", ProtocolVersion)
- .put("pairingCode", pairingCode)
- .put("deviceId", deviceId)
- .put("deviceName", deviceName)
+fun pairHelloJson(deviceId: String, deviceName: String, request: PairingRequest,
+ publicKeySpki: String?): JSONObject {
+ val hello = JSONObject()
+ .put("type", "hello").put("protocolVersion", ProtocolVersion)
+ .put("deviceId", deviceId).put("deviceName", deviceName)
.put("androidVersion", android.os.Build.VERSION.SDK_INT)
.put("appVersion", BuildConfig.VERSION_NAME)
- .put("timestamp", System.currentTimeMillis())
+ return when (request) {
+ is PairingRequest.Temporary -> hello.put("mode", "pair_temporary")
+ .put("pairingCode", request.code)
+ is PairingRequest.Remembered -> hello.put("mode", "pair_remember")
+ .put("enrollToken", request.enrollToken)
+ .put("publicKeySpki", requireNotNull(publicKeySpki))
+ }
+}
+
+fun resumeHelloJson(deviceId: String, deviceName: String): JSONObject =
+ JSONObject().put("type", "hello").put("protocolVersion", ProtocolVersion)
+ .put("mode", "resume").put("deviceId", deviceId).put("deviceName", deviceName)
+
+fun proofJson(signature: String): JSONObject =
+ JSONObject().put("type", "proof").put("signature", signature)
+
+fun forgetJson(): JSONObject = JSONObject().put("type", "forget")
fun activeAppJson(deviceId: String, sequence: Long, app: ActiveApp, screenOn: Boolean, locked: Boolean): JSONObject =
JSONObject()
diff --git a/androidApp/app/src/main/java/dev/youximi/appmapper/data/SettingsStore.kt b/androidApp/app/src/main/java/dev/youximi/appmapper/data/SettingsStore.kt
index 92fe116..ed4c54e 100644
--- a/androidApp/app/src/main/java/dev/youximi/appmapper/data/SettingsStore.kt
+++ b/androidApp/app/src/main/java/dev/youximi/appmapper/data/SettingsStore.kt
@@ -6,19 +6,10 @@ import java.util.UUID
class SettingsStore(context: Context) {
private val prefs = context.getSharedPreferences("appmapper", Context.MODE_PRIVATE)
- fun getTarget(): PairingTarget =
- PairingTarget(
- host = prefs.getString("host", "") ?: "",
- port = prefs.getInt("port", 8765),
- code = prefs.getString("code", "") ?: "",
- )
-
- fun saveTarget(target: PairingTarget) {
- prefs.edit()
- .putString("host", target.host)
- .putInt("port", target.port)
- .putString("code", target.code)
- .apply()
+ init {
+ // Version 1 saved a rotating pairing code here; it is never a durable credential.
+ if (listOf("code", "host", "port").any(prefs::contains))
+ prefs.edit().remove("code").remove("host").remove("port").commit()
}
fun getPollingMs(): Long = prefs.getLong("pollingMs", 1000L)
diff --git a/androidApp/app/src/main/java/dev/youximi/appmapper/data/TcpJsonClient.kt b/androidApp/app/src/main/java/dev/youximi/appmapper/data/TcpJsonClient.kt
index c94f202..d564efd 100644
--- a/androidApp/app/src/main/java/dev/youximi/appmapper/data/TcpJsonClient.kt
+++ b/androidApp/app/src/main/java/dev/youximi/appmapper/data/TcpJsonClient.kt
@@ -5,31 +5,102 @@ import kotlinx.coroutines.withContext
import org.json.JSONObject
import java.io.BufferedReader
import java.io.BufferedWriter
+import java.io.EOFException
import java.io.InputStreamReader
import java.io.OutputStreamWriter
import java.net.InetSocketAddress
-import java.net.Socket
+import java.net.SocketTimeoutException
+import java.security.MessageDigest
+import java.security.SecureRandom
+import java.security.cert.CertificateException
+import java.security.cert.X509Certificate
+import javax.net.ssl.SSLContext
+import javax.net.ssl.SSLHandshakeException
+import javax.net.ssl.SSLSocket
+import javax.net.ssl.X509TrustManager
+
+class PairingRejected(val reason: String) : Exception(reason)
class TcpJsonClient {
- private var socket: Socket? = null
+ private var socket: SSLSocket? = null
private var writer: BufferedWriter? = null
private var reader: BufferedReader? = null
val isConnected: Boolean
get() = socket?.isConnected == true && socket?.isClosed == false
- suspend fun connect(target: PairingTarget, hello: JSONObject): JSONObject? = withContext(Dispatchers.IO) {
+ suspend fun pair(request: PairingRequest, deviceId: String, name: String,
+ publicKeySpki: String?): JSONObject = withContext(Dispatchers.IO) {
+ val fingerprint = (request as? PairingRequest.Remembered)?.fingerprint
+ open(request.host, request.port, fingerprint)
+ send(pairHelloJson(deviceId, name, request, publicKeySpki))
+ val ack = readRequired()
+ checkAck(ack)
+ if (request is PairingRequest.Remembered && ack.optString("serverId") != request.serverId)
+ throw PairingRejected("computer_identity_changed")
+ ack
+ }
+
+ suspend fun resume(computer: PairedComputer, name: String): JSONObject = withContext(Dispatchers.IO) {
+ open(computer.host, computer.port, computer.fingerprint)
+ send(resumeHelloJson(computer.deviceId, name))
+ val challenge = readRequired()
+ if (challenge.optString("type") == "error")
+ throw PairingRejected(challenge.optString("code"))
+ if (challenge.optString("type") != "challenge") throw PairingRejected("invalid_challenge")
+ val nonce = challenge.getString("nonce")
+ val payload = "appmapper-v2|${computer.serverId}|${computer.deviceId}|$nonce".toByteArray(Charsets.UTF_8)
+ send(proofJson(DeviceKeyStore.sign(computer.keyAlias, payload)))
+ val ack = readRequired()
+ checkAck(ack)
+ if (ack.optString("serverId") != computer.serverId) throw PairingRejected("computer_identity_changed")
+ ack
+ }
+
+ private fun open(host: String, port: Int, fingerprint: String?) {
close()
- val nextSocket = Socket()
- nextSocket.connect(InetSocketAddress(target.host, target.port), 5000)
- nextSocket.soTimeout = 5000
- socket = nextSocket
- writer = BufferedWriter(OutputStreamWriter(nextSocket.getOutputStream(), Charsets.UTF_8))
- reader = BufferedReader(InputStreamReader(nextSocket.getInputStream(), Charsets.UTF_8))
- send(hello)
- reader?.readLine()?.let { JSONObject(it) }
+ val expected = fingerprint?.lowercase()
+ var identityMismatch = false
+ val trust = object : X509TrustManager {
+ override fun getAcceptedIssuers(): Array = emptyArray()
+ override fun checkClientTrusted(chain: Array, authType: String) =
+ throw CertificateException("Client certificates are not accepted")
+ override fun checkServerTrusted(chain: Array, authType: String) {
+ val actual = MessageDigest.getInstance("SHA-256")
+ .digest(chain.firstOrNull()?.publicKey?.encoded ?: throw CertificateException("No certificate"))
+ .joinToString("") { "%02x".format(it) }
+ if (expected != null && actual != expected) {
+ identityMismatch = true
+ throw CertificateException("Computer identity does not match")
+ }
+ }
+ }
+ val context = SSLContext.getInstance("TLS")
+ context.init(null, arrayOf(trust), SecureRandom())
+ val next = context.socketFactory.createSocket() as SSLSocket
+ try {
+ next.connect(InetSocketAddress(host, port), 5000)
+ next.soTimeout = 5000
+ next.startHandshake()
+ socket = next
+ writer = BufferedWriter(OutputStreamWriter(next.outputStream, Charsets.UTF_8))
+ reader = BufferedReader(InputStreamReader(next.inputStream, Charsets.UTF_8))
+ } catch (failure: Exception) {
+ next.close()
+ if (identityMismatch && failure is SSLHandshakeException)
+ throw PairingRejected("computer_identity_changed")
+ throw failure
+ }
}
+ private fun checkAck(ack: JSONObject) {
+ if (ack.optString("type") == "error") throw PairingRejected(ack.optString("code"))
+ if (ack.optString("type") != "hello_ack" || !ack.optBoolean("accepted"))
+ throw PairingRejected("invalid_ack")
+ }
+
+ private fun readRequired(): JSONObject = JSONObject(reader?.readLine() ?: throw EOFException("Computer disconnected"))
+
suspend fun send(json: JSONObject) = withContext(Dispatchers.IO) {
val out = writer ?: error("TCP client is not connected")
out.write(json.toString())
@@ -37,9 +108,17 @@ class TcpJsonClient {
out.flush()
}
+ suspend fun poll(): JSONObject? = withContext(Dispatchers.IO) {
+ val input = reader ?: return@withContext null
+ socket?.soTimeout = 100
+ try {
+ JSONObject(input.readLine() ?: throw EOFException("Computer disconnected"))
+ } catch (_: SocketTimeoutException) {
+ null
+ }
+ }
+
fun close() {
- runCatching { writer?.close() }
- runCatching { reader?.close() }
runCatching { socket?.close() }
writer = null
reader = null
diff --git a/androidApp/app/src/main/java/dev/youximi/appmapper/service/ForegroundSyncService.kt b/androidApp/app/src/main/java/dev/youximi/appmapper/service/ForegroundSyncService.kt
index 351fa46..e230eb4 100644
--- a/androidApp/app/src/main/java/dev/youximi/appmapper/service/ForegroundSyncService.kt
+++ b/androidApp/app/src/main/java/dev/youximi/appmapper/service/ForegroundSyncService.kt
@@ -13,37 +13,52 @@ import dev.youximi.appmapper.R
import dev.youximi.appmapper.data.ActiveApp
import dev.youximi.appmapper.data.AppLogger
import dev.youximi.appmapper.data.CurrentAppResult
+import dev.youximi.appmapper.data.DeviceKeyStore
+import dev.youximi.appmapper.data.LanDiscovery
+import dev.youximi.appmapper.data.PairedComputer
+import dev.youximi.appmapper.data.PairingRejected
+import dev.youximi.appmapper.data.PairingRequest
+import dev.youximi.appmapper.data.PairingStore
import dev.youximi.appmapper.data.SettingsStore
import dev.youximi.appmapper.data.TcpJsonClient
import dev.youximi.appmapper.data.UsageAppReader
import dev.youximi.appmapper.data.activeAppJson
+import dev.youximi.appmapper.data.forgetJson
import dev.youximi.appmapper.data.heartbeatJson
-import dev.youximi.appmapper.data.helloJson
import dev.youximi.appmapper.data.idleJson
-import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.CancellationException
+import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
+import java.io.IOException
+
+object SyncStatus {
+ val text = MutableStateFlow("未连接")
+}
class ForegroundSyncService : Service() {
+ private enum class SessionKind { Temporary, Remembered }
+
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
private val client = TcpJsonClient()
private var syncJob: Job? = null
+ @Volatile private var pendingRequest: PairingRequest? = null
+ @Volatile private var activeSession: SessionKind? = null
+ @Volatile private var waitingForUser = false
private var sequence = 0L
private var lastAppId: String? = null
private var lastIdleReason: String? = null
- private var waitingForTargetLogged = false
private var missingUsageAccessLogged = false
private var unknownCurrentAppLogged = false
override fun onCreate() {
super.onCreate()
- AppLogger.write(this, "Foreground service created.")
createChannel()
startForeground(
NotificationId,
@@ -57,19 +72,61 @@ class ForegroundSyncService : Service() {
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
- if (syncJob?.isActive != true) {
- AppLogger.write(this, "Foreground sync loop starting.")
- syncJob = scope.launch { runSyncLoop() }
- } else {
- AppLogger.write(this, "Foreground sync loop already running.")
+ if (intent?.action == ActionForget) {
+ val running = syncJob
+ running?.cancel()
+ scope.launch {
+ running?.join()
+ val store = PairingStore(this@ForegroundSyncService)
+ try {
+ val saved = store.read()
+ if (activeSession == SessionKind.Remembered && client.isConnected)
+ runCatching { client.send(forgetJson()) }
+ client.close()
+ activeSession = null
+ store.clear()
+ saved?.let { deleteKey(it.keyAlias) }
+ pendingRequest = null
+ waitingForUser = true
+ SyncStatus.text.value = "已忘记电脑,需要重新扫码"
+ } catch (failure: Exception) {
+ waitingForUser = true
+ SyncStatus.text.value = "移除失败,请重试"
+ AppLogger.write(this@ForegroundSyncService, "Forget failed: ${failure.javaClass.simpleName}.")
+ } finally {
+ stopSelf()
+ }
+ }
+ return START_NOT_STICKY
+ }
+ if (intent?.action == ActionPair) {
+ val host = intent.getStringExtra("host") ?: ""
+ val port = intent.getIntExtra("port", 8765)
+ pendingRequest = when (intent.getStringExtra("mode")) {
+ "temporary" -> PairingRequest.Temporary(host, port, intent.getStringExtra("code") ?: "")
+ "remembered" -> PairingRequest.Remembered(host, port,
+ intent.getStringExtra("serverId") ?: "", intent.getStringExtra("fingerprint") ?: "",
+ intent.getStringExtra("enrollToken") ?: "")
+ else -> null
+ }
+ waitingForUser = false
+ activeSession = null
+ client.close()
+ } else if (intent?.action == ActionStart) {
+ pendingRequest = null
+ waitingForUser = false
+ activeSession = null
+ client.close()
}
+ if (syncJob?.isActive != true) syncJob = scope.launch { runSyncLoop() }
return START_STICKY
}
override fun onDestroy() {
- AppLogger.write(this, "Foreground service destroyed.")
syncJob?.cancel()
client.close()
+ activeSession = null
+ if (!waitingForUser) SyncStatus.text.value = "已停止"
super.onDestroy()
}
@@ -77,43 +134,84 @@ class ForegroundSyncService : Service() {
private suspend fun runSyncLoop() {
val settings = SettingsStore(this)
+ val store = PairingStore(this)
val reader = UsageAppReader(this)
val deviceId = settings.getDeviceId()
var lastHeartbeat = 0L
+ var lastResponse = 0L
+ var retryMs = 1000L
while (currentCoroutineContext().isActive) {
- val target = settings.getTarget()
- if (target.host.isBlank() || target.code.isBlank()) {
- if (!waitingForTargetLogged) {
- AppLogger.write(this, "Waiting for pairing target.")
- waitingForTargetLogged = true
- }
+ if (waitingForUser && pendingRequest == null) {
delay(1000)
continue
}
- waitingForTargetLogged = false
-
- runCatching {
+ var resumingSaved = false
+ try {
if (!client.isConnected) {
- AppLogger.write(this, "Connecting to ${target.host}:${target.port}.")
- val ack = client.connect(
- target,
- helloJson(
- deviceId = deviceId,
- deviceName = android.os.Build.MODEL ?: "Android",
- pairingCode = target.code,
- ),
- )
- if (ack?.optBoolean("accepted") != true) {
- AppLogger.write(this, "Pairing rejected. ack=${ack?.optString("message") ?: "null"}")
- client.close()
- delay(3000)
- return@runCatching
+ val request = pendingRequest
+ val saved = if (request != null) runCatching { store.read() }.getOrNull()
+ else try { store.read() }
+ catch (_: Exception) { throw PairingRejected("paired_data_unavailable") }
+ if (request == null && saved == null) {
+ SyncStatus.text.value = "等待连接"
+ delay(1000)
+ continue
}
- AppLogger.write(this, "Connected and paired with ${target.host}:${target.port}.")
+ SyncStatus.text.value = "正在连接电脑"
+ if (request != null) {
+ val name = android.os.Build.MODEL ?: "Android"
+ var keyAlias: String? = null
+ try {
+ val key = if (request is PairingRequest.Remembered) DeviceKeyStore.create() else null
+ keyAlias = key?.first
+ val ack = client.pair(request, deviceId, name, key?.second)
+ if (request is PairingRequest.Remembered) {
+ val computer = PairedComputer(request.host, request.port,
+ ack.getString("serverId"), request.fingerprint, deviceId,
+ keyAlias!!, ack.optString("serverName", "AppMapper"))
+ try { store.save(computer) }
+ catch (_: Exception) {
+ runCatching { client.send(forgetJson()) }
+ throw PairingRejected("device_storage_failed")
+ }
+ keyAlias = null
+ saved?.let { if (it.keyAlias != computer.keyAlias) deleteKey(it.keyAlias) }
+ }
+ pendingRequest = null
+ activeSession = if (request is PairingRequest.Remembered)
+ SessionKind.Remembered else SessionKind.Temporary
+ } finally {
+ if (keyAlias != null) deleteKey(keyAlias)
+ }
+ } else {
+ resumingSaved = true
+ val computer = saved!!
+ val name = android.os.Build.MODEL ?: "Android"
+ try {
+ client.resume(computer, name)
+ } catch (failure: Exception) {
+ if (failure is CancellationException ||
+ failure is PairingRejected && failure.reason != "computer_identity_changed") throw failure
+ client.close()
+ val found = LanDiscovery.find(computer.serverId) ?: throw failure
+ val updated = computer.copy(host = found.host, port = found.port)
+ if (updated.host == computer.host && updated.port == computer.port) throw failure
+ client.resume(updated, name)
+ try { store.save(updated) }
+ catch (_: Exception) { throw PairingRejected("device_storage_failed") }
+ }
+ activeSession = SessionKind.Remembered
+ }
+ SyncStatus.text.value = if (activeSession == SessionKind.Temporary) "临时连接中" else "已连接"
+ AppLogger.write(this, if (activeSession == SessionKind.Temporary)
+ "Temporary connection established." else "Authenticated connection established.")
lastAppId = null
lastIdleReason = null
unknownCurrentAppLogged = false
+ lastHeartbeat = 0L
+ lastResponse = System.currentTimeMillis()
+ retryMs = 1000L
}
val idleReason = currentIdleReason()
@@ -124,7 +222,6 @@ class ForegroundSyncService : Service() {
} else if (hasUsageAccess) {
missingUsageAccessLogged = false
}
-
if (idleReason != null) {
sendIdleIfChanged(deviceId, idleReason)
} else if (!hasUsageAccess) {
@@ -135,32 +232,79 @@ class ForegroundSyncService : Service() {
unknownCurrentAppLogged = false
sendActiveIfNeeded(deviceId, result.app)
}
-
CurrentAppResult.Launcher -> {
unknownCurrentAppLogged = false
sendIdleIfChanged(deviceId, "launcher")
}
-
- CurrentAppResult.Unknown -> {
- sendUnknownIfNoActiveWindow(deviceId, "usage_events_empty")
- }
+ CurrentAppResult.Unknown -> sendUnknownIfNoActiveWindow(deviceId, "usage_events_empty")
}
}
-
val now = System.currentTimeMillis()
- if (now - lastHeartbeat > 5000) {
+ if (now - lastHeartbeat >= 5000) {
client.send(heartbeatJson(deviceId))
lastHeartbeat = now
}
- }.onFailure {
- if (it is CancellationException) throw it
- AppLogger.write(this, "Sync loop failure. Closing TCP client and retrying.", it)
+ client.poll()?.let { message ->
+ if (message.optString("type") == "heartbeat_ack") lastResponse = System.currentTimeMillis()
+ if (message.optString("type") == "error")
+ throw PairingRejected(message.optString("code"))
+ }
+ if (now - lastResponse > 15000) throw IOException("Heartbeat timed out")
+ delay(settings.getPollingMs())
+ } catch (cancel: CancellationException) {
+ throw cancel
+ } catch (rejected: PairingRejected) {
+ val wasTemporary = activeSession == SessionKind.Temporary ||
+ pendingRequest is PairingRequest.Temporary
+ client.close()
+ val wasRemembered = activeSession == SessionKind.Remembered || resumingSaved
+ activeSession = null
+ if (rejected.reason == "computer_identity_changed" && resumingSaved) {
+ SyncStatus.text.value = "电脑身份不匹配,正在重试"
+ delay(retryMs)
+ retryMs = (retryMs * 2).coerceAtMost(30000L)
+ continue
+ }
+ pendingRequest = null
+ waitingForUser = true
+ if (wasRemembered && (rejected.reason == "pairing_required" ||
+ rejected.reason == "authentication_failed")) {
+ try {
+ val old = store.read()
+ store.clear()
+ old?.let { deleteKey(it.keyAlias) }
+ SyncStatus.text.value = "电脑已移除配对,请重新扫码"
+ } catch (_: Exception) {
+ SyncStatus.text.value = "电脑已拒绝配对,本地记录无法移除"
+ }
+ } else {
+ SyncStatus.text.value = when (rejected.reason) {
+ "invalid_pairing_code" -> "验证码已失效,请重新输入"
+ "invalid_enroll_token" -> "二维码已失效,请重新扫码"
+ "computer_identity_changed" -> "电脑身份不匹配,请重新扫码"
+ "device_storage_failed", "paired_data_unavailable" -> "本机配对记录无法保存或读取,请检查应用存储"
+ "max_devices_reached" -> "电脑连接数已满,请先断开其他设备"
+ else -> if (wasTemporary) "临时连接失败,请重新输入验证码(${rejected.reason})"
+ else "配对失败,请重新扫码(${rejected.reason})"
+ }
+ }
+ AppLogger.write(this, "Pairing rejected: ${rejected.reason}.")
+ } catch (failure: Exception) {
+ val wasTemporary = activeSession == SessionKind.Temporary
client.close()
+ activeSession = null
lastAppId = null
- delay(3000)
+ if (wasTemporary) {
+ waitingForUser = true
+ SyncStatus.text.value = "临时连接已结束,需要重新输入验证码"
+ AppLogger.write(this, "Temporary connection ended: ${failure.javaClass.simpleName}.")
+ continue
+ }
+ SyncStatus.text.value = "连接中断,正在重试"
+ AppLogger.write(this, "Connection interrupted: ${failure.javaClass.simpleName}.")
+ delay(retryMs)
+ retryMs = (retryMs * 2).coerceAtMost(30000L)
}
-
- delay(settings.getPollingMs())
}
}
@@ -169,13 +313,9 @@ class ForegroundSyncService : Service() {
sequence += 1
AppLogger.write(this, "Sending active_app seq=$sequence app=${app.displayName} package=${app.packageName} icon=${!app.iconPngBase64.isNullOrBlank()}.")
client.send(
- activeAppJson(
- deviceId = deviceId,
- sequence = sequence,
- app = app,
+ activeAppJson(deviceId, sequence, app,
screenOn = currentIdleReason() != "screen_off",
- locked = currentIdleReason() == "locked",
- ),
+ locked = currentIdleReason() == "locked"),
)
lastAppId = app.appId
lastIdleReason = null
@@ -199,40 +339,70 @@ class ForegroundSyncService : Service() {
}
return
}
-
sendIdleIfChanged(deviceId, "unknown")
}
private fun currentIdleReason(): String? {
val powerManager = getSystemService(PowerManager::class.java)
if (!powerManager.isInteractive) return "screen_off"
-
val keyguardManager = getSystemService(KeyguardManager::class.java)
if (keyguardManager.isKeyguardLocked) return "locked"
-
return null
}
+ private fun deleteKey(alias: String) {
+ try { DeviceKeyStore.delete(alias) }
+ catch (failure: Exception) {
+ AppLogger.write(this, "Key cleanup failed: ${failure.javaClass.simpleName}.")
+ }
+ }
+
private fun createChannel() {
val manager = getSystemService(NotificationManager::class.java)
- val channel = NotificationChannel(
- ChannelId,
- getString(R.string.foreground_service_channel),
- NotificationManager.IMPORTANCE_LOW,
- )
- manager.createNotificationChannel(channel)
+ manager.createNotificationChannel(NotificationChannel(ChannelId,
+ getString(R.string.foreground_service_channel), NotificationManager.IMPORTANCE_LOW))
}
companion object {
private const val ChannelId = "appmapper_sync"
private const val NotificationId = 1001
+ private const val ActionPair = "dev.youximi.appmapper.PAIR"
+ private const val ActionForget = "dev.youximi.appmapper.FORGET"
+ private const val ActionStart = "dev.youximi.appmapper.START"
+
+ fun pair(context: Context, request: PairingRequest) {
+ context.startForegroundService(Intent(context, ForegroundSyncService::class.java).apply {
+ action = ActionPair
+ putExtra("host", request.host)
+ putExtra("port", request.port)
+ when (request) {
+ is PairingRequest.Temporary -> {
+ putExtra("mode", "temporary")
+ putExtra("code", request.code)
+ }
+ is PairingRequest.Remembered -> {
+ putExtra("mode", "remembered")
+ putExtra("serverId", request.serverId)
+ putExtra("fingerprint", request.fingerprint)
+ putExtra("enrollToken", request.enrollToken)
+ }
+ }
+ })
+ }
fun start(context: Context) {
- context.startForegroundService(Intent(context, ForegroundSyncService::class.java))
+ context.startForegroundService(Intent(context, ForegroundSyncService::class.java).apply {
+ action = ActionStart
+ })
+ }
+
+ fun forget(context: Context) {
+ context.startService(Intent(context, ForegroundSyncService::class.java).apply { action = ActionForget })
}
fun stop(context: Context) {
context.stopService(Intent(context, ForegroundSyncService::class.java))
+ SyncStatus.text.value = "已停止"
}
}
}
diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties
index 2a84e18..1a4b84d 100644
--- a/gradle/wrapper/gradle-wrapper.properties
+++ b/gradle/wrapper/gradle-wrapper.properties
@@ -1,7 +1,6 @@
+#Mon Sep 28 17:18:12 HKT 2026
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
-distributionUrl=https\://services.gradle.org/distributions/gradle-9.0.0-bin.zip
-networkTimeout=10000
-validateDistributionUrl=true
+distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists
diff --git a/shared/examples/hello.json b/shared/examples/hello.json
index 3136951..048d397 100644
--- a/shared/examples/hello.json
+++ b/shared/examples/hello.json
@@ -1,10 +1,9 @@
{
"type": "hello",
- "protocolVersion": 1,
- "pairingCode": "123456",
+ "protocolVersion": 2,
+ "mode": "pair_remember",
+ "enrollToken": "0123456789abcdef0123456789abcdef",
+ "publicKeySpki": "",
"deviceId": "android-8f3a2c1b",
- "deviceName": "Pixel 8",
- "androidVersion": 15,
- "appVersion": "0.1.0",
- "timestamp": 1782530000000
+ "deviceName": "Pixel 8"
}
diff --git a/shared/protocol.md b/shared/protocol.md
index c90b01d..7312cf3 100644
--- a/shared/protocol.md
+++ b/shared/protocol.md
@@ -1,5 +1,45 @@
-# Shared Protocol
+# AppMapper pairing and sync protocol (v2)
-The canonical protocol document is `docs/协议.md`.
+Transport: TLS 1.2/1.3 over TCP (default port 8765). The computer presents a self-signed certificate backed by its persistent P-256 identity key. Remembered enrollment and resume pin the SHA-256 fingerprint of the certificate's SubjectPublicKeyInfo. Temporary manual connections accept the certificate without a trusted fingerprint; they are encrypted but do not authenticate the computer. The payload is UTF-8 JSON Lines: one JSON object per line.
-Transport is TCP with UTF-8 JSON Lines. Each message is one JSON object followed by `\n`.
+Version 1 clients receive `upgrade_required`. The rotating six-digit manual code can only start a temporary session. A separate random 128-bit enrollment token appears only in the QR code and can register a remembered phone. Both credentials rotate every 60 seconds or after either is consumed. Neither credential is stored for reconnect or logged.
+
+## Temporary manual connection
+
+The user enters the computer's IP, port, and displayed six-digit code. The phone sends:
+
+`{"type":"hello","protocolVersion":2,"mode":"pair_temporary","deviceId":"android-8f3a2c1b","deviceName":"Pixel 8","pairingCode":"123456"}`
+
+The computer validates and consumes the code and replies with `hello_ack`. It does not save a phone key or remove an existing pairing. A temporary session ends when its connection closes and cannot send `forget` to revoke a remembered pairing. The phone retains any previously remembered computer but waits for a new user action after a temporary connection ends. Because the phone cannot verify the computer's identity in this mode, an on-path LAN attacker can impersonate it.
+
+## Remembered enrollment
+
+QR format: `appmapper://connect?host=192.168.1.10&port=8765&serverId=&fingerprint=<64-hex-sha256-spki>&enrollToken=<32-hex-random-token>`.
+
+After verifying the TLS public key against the fingerprint, the phone generates an Android Keystore P-256 key pair and sends:
+
+`{"type":"hello","protocolVersion":2,"mode":"pair_remember","deviceId":"android-8f3a2c1b","deviceName":"Pixel 8","enrollToken":"0123456789abcdef0123456789abcdef","publicKeySpki":""}`
+
+The computer validates and consumes the enrollment token, saves the phone public key, and answers:
+
+`{"type":"hello_ack","accepted":true,"serverId":"","serverName":""}`
+
+The server decides whether to save a key from the validated connection mode and credential, never from a client-supplied `remember` flag. A six-digit manual code cannot authorize enrollment. The QR fingerprint authenticates the computer to the phone; the enrollment token authorizes the phone to register with the computer.
+
+## Resume
+
+The phone uses its saved address and fingerprint to establish pinned TLS, then sends:
+
+`{"type":"hello","protocolVersion":2,"mode":"resume","deviceId":"android-8f3a2c1b","deviceName":"Pixel 8"}`
+
+The computer returns `{"type":"challenge","nonce":""}`. The phone signs the UTF-8 bytes `appmapper-v2|||` using its Android Keystore P-256 key and sends `{"type":"proof","signature":""}`. The computer verifies the registered public key and returns `hello_ack`. An unknown or removed device gets `pairing_required`; an invalid proof gets `authentication_failed`.
+
+Accepted temporary and remembered sessions may send `active_app`, `idle`, and `heartbeat`. Only remembered sessions may send `forget`, which removes the sender's computer-side pairing and closes its session. The Windows pairing page can also remove a phone and disconnect it. Existing active/idle payloads remain in `shared/examples/`.
+
+## Address discovery
+
+After a saved address fails, the phone broadcasts ASCII `appmapper-discover-v2|` over UDP port 8766 on local IPv4 interfaces. The computer replies directly with `appmapper-discovery-v2||`. This response is an address hint, never authentication: the phone still pins the original TLS public key and checks the server ID before saving the new address. A forged or incorrect hint does not clear the saved pairing; the phone continues retrying. Broadcast needs the same IPv4 broadcast domain and a firewall rule allowing UDP 8766.
+
+## Storage
+
+Windows: `/config/pairing.dat`, protected using DPAPI CurrentUser. Android: `/config/paired-computer.json` stores the non-secret public fingerprint, server ID, device ID and Android Keystore key alias; the private key remains in Android Keystore. Forgetting a device removes its local record and key. If the remembered computer is connected, `forget` also removes its computer-side record. Offline local removal may leave the computer-side record, which can be removed on the Windows pairing page.
diff --git a/windowsApp/controller/Abstractions/CoreStateSnapshot.cs b/windowsApp/controller/Abstractions/CoreStateSnapshot.cs
index 23460bc..737356f 100644
--- a/windowsApp/controller/Abstractions/CoreStateSnapshot.cs
+++ b/windowsApp/controller/Abstractions/CoreStateSnapshot.cs
@@ -6,7 +6,8 @@ namespace AppMapper.Controller.Abstractions;
/// 配对信息(核心实时事件载荷)。二维码图片由 UI 层依据
/// 自行生成(BitmapImage 是 WPF 类型,不进入核心层)。
///
-public sealed record PairingInfo(string Code, string ServerAddress, string PairingUri, int Port, string NetworkWarning);
+public sealed record PairingInfo(string Code, string ServerAddress, string PairingUri,
+ string NetworkWarning);
///
/// 核心层在某一时刻的状态快照。UI 在启动或从卡死/隐藏恢复时调用
@@ -18,7 +19,6 @@ public sealed class CoreStateSnapshot
public string ServerAddress { get; init; } = string.Empty;
public string PairingUri { get; init; } = string.Empty;
public string NetworkWarning { get; init; } = string.Empty;
- public int Port { get; init; }
public string CurrentStatus { get; init; } = string.Empty;
public IReadOnlyList Devices { get; init; } = Array.Empty();
public IReadOnlyList RecentLogs { get; init; } = Array.Empty();
diff --git a/windowsApp/controller/Abstractions/ICoreFacade.cs b/windowsApp/controller/Abstractions/ICoreFacade.cs
index f0bcf14..35e09ba 100644
--- a/windowsApp/controller/Abstractions/ICoreFacade.cs
+++ b/windowsApp/controller/Abstractions/ICoreFacade.cs
@@ -27,6 +27,9 @@ public interface ICoreFacade
/// 打开 apps 目录。
void OpenAppsDirectory();
+ /// Revoke a remembered phone and close its active connection.
+ void RemovePairedDevice(string deviceId);
+
/// 获取核心当前完整状态快照(供 UI 启动/恢复时重取)。
CoreStateSnapshot GetStateSnapshot();
diff --git a/windowsApp/controller/Abstractions/Settings.cs b/windowsApp/controller/Abstractions/Settings.cs
index 4f66d7a..2e32bf0 100644
--- a/windowsApp/controller/Abstractions/Settings.cs
+++ b/windowsApp/controller/Abstractions/Settings.cs
@@ -14,6 +14,7 @@ public sealed class Settings : INotifyPropertyChanged
private int port = 8765;
private bool relaunchMapperWhenClosed = true;
private bool closeToTray = true;
+ private bool startWithWindows;
private bool paneOpen = true;
private string networkAdapterId = "";
private string networkAdapterName = "";
@@ -41,6 +42,13 @@ public bool CloseToTray
set => SetField(ref closeToTray, value);
}
+ /// Launch when the current Windows user signs in.
+ public bool StartWithWindows
+ {
+ get => startWithWindows;
+ set => SetField(ref startWithWindows, value);
+ }
+
/// 左侧导航栏是否展开显示文字(持久化折叠状态)。
public bool PaneOpen
{
diff --git a/windowsApp/controller/App.xaml.cs b/windowsApp/controller/App.xaml.cs
index 59aa569..6701227 100644
--- a/windowsApp/controller/App.xaml.cs
+++ b/windowsApp/controller/App.xaml.cs
@@ -13,6 +13,8 @@ namespace AppMapper.Controller;
///
public partial class App : Application
{
+ private System.Threading.Mutex? singleInstance;
+ private bool ownsSingleInstance;
/// 核心门面,UI 唯一业务入口。
public static AppMapperCoreEngine Core { get; private set; } = null!;
@@ -26,16 +28,33 @@ protected override void OnStartup(StartupEventArgs e)
WriteCrash("TaskScheduler", args.Exception);
base.OnStartup(e);
+ singleInstance = new System.Threading.Mutex(true, @"Local\AppMapper.Controller", out ownsSingleInstance);
+ if (!ownsSingleInstance)
+ {
+ Shutdown();
+ return;
+ }
var baseDir = AppContext.BaseDirectory;
var log = new LogService(baseDir);
var settings = new SettingsService(baseDir);
- Core = new AppMapperCoreEngine(log, settings);
- Core.StartAsync();
+ try
+ {
+ Core = new AppMapperCoreEngine(log, settings);
+ Core.StartAsync();
+ }
+ catch (Exception ex)
+ {
+ System.Windows.MessageBox.Show($"无法启动 AppMapper:{ex.Message}\n请检查软件目录中的 config/pairing.dat 及目录写入权限。不会自动清除现有配对。",
+ "启动失败", MessageBoxButton.OK, MessageBoxImage.Error);
+ Shutdown();
+ return;
+ }
var mainWindow = new MainWindow();
mainWindow.Show();
+ if (e.Args.Contains("--background")) mainWindow.Hide();
}
protected override void OnExit(ExitEventArgs e)
@@ -49,6 +68,8 @@ protected override void OnExit(ExitEventArgs e)
// 退出时忽略清理异常。
}
+ if (ownsSingleInstance) singleInstance?.ReleaseMutex();
+ singleInstance?.Dispose();
base.OnExit(e);
}
diff --git a/windowsApp/controller/Core/AppMapperCoreEngine.cs b/windowsApp/controller/Core/AppMapperCoreEngine.cs
index 69054e3..d561fa8 100644
--- a/windowsApp/controller/Core/AppMapperCoreEngine.cs
+++ b/windowsApp/controller/Core/AppMapperCoreEngine.cs
@@ -1,4 +1,3 @@
-using System.Collections.Concurrent;
using System.Diagnostics;
using System.IO;
using AppMapper.Controller.Abstractions;
@@ -20,8 +19,10 @@ public sealed class AppMapperCoreEngine : ICoreFacade, IDisposable
private readonly TcpJsonServer server;
private readonly PairingCodeService pairingCode;
private readonly AppMappingService mapping;
+ private readonly PairingStore pairing;
+ private readonly DeviceIdentityService identity;
- private readonly ConcurrentDictionary devices = new();
+ private readonly Dictionary devices = new();
private readonly object stateLock = new();
private string activeDeviceId = "";
private string currentStatus = "等待设备连接";
@@ -43,7 +44,10 @@ public AppMapperCoreEngine(LogService log, SettingsService settingsService)
{
this.log = log;
this.settingsService = settingsService;
- server = new TcpJsonServer(log);
+ settingsService.AutoStartError += log.Warn;
+ pairing = new PairingStore(AppContext.BaseDirectory);
+ identity = new DeviceIdentityService(pairing);
+ server = new TcpJsonServer(log, pairing, identity);
pairingCode = new PairingCodeService();
mapping = new AppMappingService(log);
@@ -53,8 +57,9 @@ public AppMapperCoreEngine(LogService log, SettingsService settingsService)
server.ActiveAppReceived += OnActiveApp;
server.IdleReceived += OnIdle;
server.Disconnected += OnDisconnected;
+ server.PairingChanged += () => { lock (stateLock) RaiseDevicesChanged(); };
- pairingCode.CodeChanged += _ => UpdatePairingDisplay();
+ pairingCode.Changed += UpdatePairingDisplay;
// 设置副作用:RelaunchMapperWhenClosed 变化同步到映射服务。
Settings.PropertyChanged += (_, e) =>
@@ -83,6 +88,7 @@ public async Task StopAsync()
server.Stop();
mapping.CloseCurrent();
pairingCode.Dispose();
+ identity.Dispose();
}
catch (Exception ex)
{
@@ -95,9 +101,13 @@ public void RestartServer(int port)
{
if (port < 1 || port > 65535) port = 8765;
Settings.Port = port;
- server.Start(port, pairingCode.IsValid, maxDevices: 3);
+ server.Start(port, pairingCode.TryConsumeManual, pairingCode.TryConsumeEnrollment, maxDevices: 3);
UpdatePairingDisplay();
- log.Info($"Pairing code: {pairingCode.CurrentCode}");
+ }
+
+ public void RemovePairedDevice(string deviceId)
+ {
+ if (server.RemovePairedDevice(deviceId)) log.Info($"Device removed: {deviceId}.");
}
public void OpenAppsDirectory()
@@ -121,13 +131,13 @@ public CoreStateSnapshot GetStateSnapshot()
{
lock (stateLock)
{
+ var invitation = pairingCode.Current;
return new CoreStateSnapshot
{
- PairingCode = pairingCode.CurrentCode,
+ PairingCode = invitation.Code,
ServerAddress = serverAddress,
PairingUri = pairingUri,
NetworkWarning = networkWarning,
- Port = Settings.Port,
CurrentStatus = currentStatus,
Devices = GetDevicesSnapshot(),
RecentLogs = log.GetRecent(),
@@ -137,11 +147,13 @@ public CoreStateSnapshot GetStateSnapshot()
private void UpdatePairingDisplay()
{
+ PairingInfo info;
lock (stateLock)
{
+ var invitation = pairingCode.Current;
var address = NetworkInfoService.ResolveAddress(Settings);
serverAddress = $"{address.ServerAddressHost}:{Settings.Port}";
- pairingUri = $"appmapper://connect?host={NetworkInfoService.FormatHostForUri(address.Host)}&port={Settings.Port}&code={pairingCode.CurrentCode}";
+ pairingUri = $"appmapper://connect?host={NetworkInfoService.FormatHostForUri(address.Host)}&port={Settings.Port}&serverId={identity.ServerId}&fingerprint={identity.Fingerprint}&enrollToken={invitation.EnrollToken}";
networkWarning = address.Warning ?? "";
if (!string.IsNullOrWhiteSpace(address.Warning) && address.Warning != lastNetworkWarning)
{
@@ -152,18 +164,25 @@ private void UpdatePairingDisplay()
{
lastNetworkWarning = null;
}
+ info = new PairingInfo(invitation.Code, serverAddress, pairingUri, networkWarning);
}
- PairingChanged?.Invoke(new PairingInfo(pairingCode.CurrentCode, serverAddress, pairingUri, Settings.Port, networkWarning));
+ PairingChanged?.Invoke(info);
}
- private void OnHello(string deviceId, string deviceName, string remoteEndpoint)
+ private void OnHello(string deviceId, string deviceName, string remoteEndpoint, bool rememberedSession)
{
lock (stateLock)
{
var device = GetOrCreateDevice(deviceId);
device.DeviceName = deviceName;
- device.State = "Connected";
+ device.IsPaired = pairing.Find(deviceId) != null;
+ if (rememberedSession && device.IsPaired)
+ {
+ try { pairing.Seen(deviceId); }
+ catch (IOException) { log.Warn("Could not update paired device last-seen time."); }
+ }
+ device.State = rememberedSession ? "Connected" : "Temporary";
device.LastSeen = DateTime.Now;
device.LastSequence = 0;
RaiseDevicesChanged();
@@ -223,9 +242,7 @@ private void OnDisconnected(string deviceId)
{
lock (stateLock)
{
- var device = GetOrCreateDevice(deviceId);
- device.State = "Disconnected";
- device.LastSeen = DateTime.Now;
+ devices.Remove(deviceId);
if (activeDeviceId == deviceId)
{
@@ -247,8 +264,22 @@ private DeviceState GetOrCreateDevice(string deviceId)
return created;
}
- private IReadOnlyList GetDevicesSnapshot() =>
- devices.Values.OrderByDescending(x => x.LastSeen).ToList();
+ private IReadOnlyList GetDevicesSnapshot()
+ {
+ var all = pairing.GetDevices().ToDictionary(x => x.DeviceId, x => new DeviceState
+ {
+ DeviceId = x.DeviceId, DeviceName = x.DeviceName, State = "Disconnected",
+ IsPaired = true, LastSeen = x.LastSeen.LocalDateTime,
+ });
+ foreach (var active in devices.Values)
+ all[active.DeviceId] = new DeviceState
+ {
+ DeviceId = active.DeviceId, DeviceName = active.DeviceName, State = active.State,
+ IsPaired = active.IsPaired, CurrentApp = active.CurrentApp,
+ LastSeen = active.LastSeen, LastSequence = active.LastSequence,
+ };
+ return all.Values.OrderByDescending(x => x.LastSeen).ToList();
+ }
private void RaiseDevicesChanged() => DevicesChanged?.Invoke(GetDevicesSnapshot());
diff --git a/windowsApp/controller/Core/PairingStore.cs b/windowsApp/controller/Core/PairingStore.cs
new file mode 100644
index 0000000..91ef151
--- /dev/null
+++ b/windowsApp/controller/Core/PairingStore.cs
@@ -0,0 +1,106 @@
+using System.IO;
+using System.Security.Cryptography;
+using System.Text.Json;
+
+namespace AppMapper.Controller.Core;
+
+public sealed record PairedDeviceRecord(string DeviceId, string DeviceName, string PublicKeySpki, DateTimeOffset LastSeen);
+
+/// One protected, durable source of truth for this computer's identity and paired phones.
+public sealed class PairingStore
+{
+ private readonly string path;
+ private readonly object gate = new();
+ private PairingData data;
+
+ public PairingStore(string baseDirectory)
+ {
+ var directory = Path.Combine(baseDirectory, "config");
+ Directory.CreateDirectory(directory);
+ path = Path.Combine(directory, "pairing.dat");
+ if (File.Exists(path))
+ {
+ var protectedBytes = File.ReadAllBytes(path);
+ var bytes = ProtectedData.Unprotect(protectedBytes, null, DataProtectionScope.CurrentUser);
+ data = JsonSerializer.Deserialize(bytes)
+ ?? throw new InvalidDataException("Pairing data is empty.");
+ if (string.IsNullOrWhiteSpace(data.ServerId) || string.IsNullOrWhiteSpace(data.PrivateKeyPkcs8))
+ throw new InvalidDataException("Pairing identity is incomplete.");
+ }
+ else
+ {
+ using var key = ECDsa.Create(ECCurve.NamedCurves.nistP256);
+ data = new PairingData(Guid.NewGuid().ToString("N"),
+ Convert.ToBase64String(key.ExportPkcs8PrivateKey()), new Dictionary());
+ Save();
+ }
+ }
+
+ public string ServerId => data.ServerId;
+ public byte[] PrivateKeyPkcs8 => Convert.FromBase64String(data.PrivateKeyPkcs8);
+
+ public IReadOnlyList GetDevices()
+ {
+ lock (gate) return data.Devices.Values.ToList();
+ }
+
+ public PairedDeviceRecord? Find(string deviceId)
+ {
+ lock (gate) return data.Devices.GetValueOrDefault(deviceId);
+ }
+
+ public void Pair(PairedDeviceRecord device)
+ {
+ lock (gate)
+ {
+ var next = new Dictionary(data.Devices) { [device.DeviceId] = device };
+ Save(new PairingData(data.ServerId, data.PrivateKeyPkcs8, next));
+ }
+ }
+
+ public bool Remove(string deviceId)
+ {
+ lock (gate)
+ {
+ if (!data.Devices.ContainsKey(deviceId)) return false;
+ var next = new Dictionary(data.Devices);
+ next.Remove(deviceId);
+ Save(new PairingData(data.ServerId, data.PrivateKeyPkcs8, next));
+ return true;
+ }
+ }
+
+ public void Seen(string deviceId)
+ {
+ lock (gate)
+ {
+ if (!data.Devices.TryGetValue(deviceId, out var device)) return;
+ var next = new Dictionary(data.Devices)
+ {
+ [deviceId] = device with { LastSeen = DateTimeOffset.Now },
+ };
+ Save(new PairingData(data.ServerId, data.PrivateKeyPkcs8, next));
+ }
+ }
+
+ private void Save(PairingData? next = null)
+ {
+ next ??= data;
+ var encrypted = ProtectedData.Protect(JsonSerializer.SerializeToUtf8Bytes(next), null,
+ DataProtectionScope.CurrentUser);
+ var temporary = path + ".tmp";
+ try
+ {
+ File.WriteAllBytes(temporary, encrypted);
+ File.Move(temporary, path, true);
+ data = next;
+ }
+ finally
+ {
+ if (File.Exists(temporary)) File.Delete(temporary);
+ }
+ }
+
+ private sealed record PairingData(string ServerId, string PrivateKeyPkcs8,
+ Dictionary Devices);
+}
diff --git a/windowsApp/controller/Core/SettingsService.cs b/windowsApp/controller/Core/SettingsService.cs
index 73d7326..389be54 100644
--- a/windowsApp/controller/Core/SettingsService.cs
+++ b/windowsApp/controller/Core/SettingsService.cs
@@ -1,4 +1,5 @@
using System.IO;
+using Microsoft.Win32;
using System.Text.Json;
using System.Text.Json.Serialization;
using AppMapper.Controller.Abstractions;
@@ -24,6 +25,7 @@ public sealed class SettingsService : IDisposable
private bool disposed;
public Settings Current { get; } = new();
+ public event Action? AutoStartError;
public SettingsService(string? baseDirectory = null)
{
@@ -31,12 +33,24 @@ public SettingsService(string? baseDirectory = null)
Directory.CreateDirectory(dir);
configPath = Path.Combine(dir, "settings.json");
Load();
+ Current.StartWithWindows = ReadAutoStart();
Current.PropertyChanged += OnSettingChanged;
saveTimer = new Timer(_ => SaveNow(), null, Timeout.Infinite, Timeout.Infinite);
}
private void OnSettingChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
+ if (e.PropertyName == nameof(Settings.StartWithWindows))
+ {
+ try { SetAutoStart(Current.StartWithWindows); }
+ catch (Exception ex)
+ {
+ Current.PropertyChanged -= OnSettingChanged;
+ try { Current.StartWithWindows = ReadAutoStart(); }
+ finally { Current.PropertyChanged += OnSettingChanged; }
+ AutoStartError?.Invoke($"Windows auto-start setting failed: {ex.GetType().Name}.");
+ }
+ }
// 防抖:500ms 内多次改动合并为一次落盘。
lock (saveLock)
{
@@ -44,6 +58,23 @@ private void OnSettingChanged(object? sender, System.ComponentModel.PropertyChan
}
}
+ private const string RunKey = @"Software\Microsoft\Windows\CurrentVersion\Run";
+ private const string RunName = "AppMapper";
+
+ private static bool ReadAutoStart()
+ {
+ using var key = Registry.CurrentUser.OpenSubKey(RunKey);
+ return key?.GetValue(RunName) is string value &&
+ string.Equals(value, $"\"{Environment.ProcessPath}\" --background", StringComparison.OrdinalIgnoreCase);
+ }
+
+ private static void SetAutoStart(bool enabled)
+ {
+ using var key = Registry.CurrentUser.CreateSubKey(RunKey);
+ if (enabled) key.SetValue(RunName, $"\"{Environment.ProcessPath}\" --background");
+ else key.DeleteValue(RunName, throwOnMissingValue: false);
+ }
+
private void Load()
{
try
@@ -108,8 +139,8 @@ public void Dispose()
{
if (disposed) return;
disposed = true;
- saveTimer.Dispose();
Flush();
+ saveTimer.Dispose();
}
private sealed class SettingsDto
diff --git a/windowsApp/controller/Models/DeviceState.cs b/windowsApp/controller/Models/DeviceState.cs
index d184f02..a187bf0 100644
--- a/windowsApp/controller/Models/DeviceState.cs
+++ b/windowsApp/controller/Models/DeviceState.cs
@@ -5,6 +5,7 @@ public sealed class DeviceState
public required string DeviceId { get; init; }
public string DeviceName { get; set; } = "";
public string State { get; set; } = "Connected";
+ public bool IsPaired { get; set; }
public string CurrentApp { get; set; } = "";
public DateTime LastSeen { get; set; } = DateTime.Now;
public long LastSequence { get; set; }
diff --git a/windowsApp/controller/Models/ProtocolMessage.cs b/windowsApp/controller/Models/ProtocolMessage.cs
deleted file mode 100644
index fee29e3..0000000
--- a/windowsApp/controller/Models/ProtocolMessage.cs
+++ /dev/null
@@ -1,9 +0,0 @@
-using System.Text.Json;
-
-namespace AppMapper.Controller.Models;
-
-public sealed class ProtocolMessage
-{
- public required string Type { get; init; }
- public required JsonElement Root { get; init; }
-}
diff --git a/windowsApp/controller/Services/DeviceIdentityService.cs b/windowsApp/controller/Services/DeviceIdentityService.cs
new file mode 100644
index 0000000..005b17a
--- /dev/null
+++ b/windowsApp/controller/Services/DeviceIdentityService.cs
@@ -0,0 +1,58 @@
+using System.Security.Cryptography;
+using System.Security.Cryptography.X509Certificates;
+using System.Text;
+using AppMapper.Controller.Core;
+
+namespace AppMapper.Controller.Services;
+
+public sealed class DeviceIdentityService : IDisposable
+{
+ private readonly ECDsa privateKey;
+
+ public DeviceIdentityService(PairingStore store)
+ {
+ ServerId = store.ServerId;
+ privateKey = ECDsa.Create();
+ privateKey.ImportPkcs8PrivateKey(store.PrivateKeyPkcs8, out _);
+ Fingerprint = Convert.ToHexString(SHA256.HashData(privateKey.ExportSubjectPublicKeyInfo())).ToLowerInvariant();
+ var request = new CertificateRequest("CN=AppMapper", privateKey, HashAlgorithmName.SHA256);
+ using var generated = request.CreateSelfSigned(DateTimeOffset.UtcNow.AddDays(-1), DateTimeOffset.UtcNow.AddYears(5));
+ Certificate = new X509Certificate2(generated.Export(X509ContentType.Pkcs12), (string?)null,
+ X509KeyStorageFlags.UserKeySet);
+ }
+
+ public string ServerId { get; }
+ public string Fingerprint { get; }
+ public X509Certificate2 Certificate { get; }
+
+ public static byte[] ProofPayload(string serverId, string deviceId, string nonce) =>
+ Encoding.UTF8.GetBytes($"appmapper-v2|{serverId}|{deviceId}|{nonce}");
+
+ public static bool Verify(string publicKeySpki, byte[] payload, string signature)
+ {
+ using var key = ECDsa.Create();
+ key.ImportSubjectPublicKeyInfo(Convert.FromBase64String(publicKeySpki), out _);
+ return key.VerifyData(payload, Convert.FromBase64String(signature), HashAlgorithmName.SHA256,
+ DSASignatureFormat.Rfc3279DerSequence);
+ }
+
+ public static bool IsValidPublicKey(string publicKeySpki)
+ {
+ try
+ {
+ using var key = ECDsa.Create();
+ key.ImportSubjectPublicKeyInfo(Convert.FromBase64String(publicKeySpki), out _);
+ return key.KeySize == 256;
+ }
+ catch (Exception ex) when (ex is FormatException or CryptographicException or ArgumentException)
+ {
+ return false;
+ }
+ }
+
+ public void Dispose()
+ {
+ Certificate.Dispose();
+ privateKey.Dispose();
+ }
+}
diff --git a/windowsApp/controller/Services/LanDiscoveryService.cs b/windowsApp/controller/Services/LanDiscoveryService.cs
new file mode 100644
index 0000000..57f7e93
--- /dev/null
+++ b/windowsApp/controller/Services/LanDiscoveryService.cs
@@ -0,0 +1,44 @@
+using System.Net;
+using System.Net.Sockets;
+using System.Text;
+
+namespace AppMapper.Controller.Services;
+
+/// Locates this computer after its LAN address changes. No credentials travel over UDP.
+public sealed class LanDiscoveryService : IDisposable
+{
+ public const int DiscoveryPort = 8766;
+ private readonly UdpClient socket;
+ private readonly CancellationTokenSource cancellation = new();
+
+ public LanDiscoveryService(string serverId, int tcpPort)
+ {
+ socket = new UdpClient(new IPEndPoint(IPAddress.Any, DiscoveryPort));
+ _ = ReplyLoop(serverId, tcpPort);
+ }
+
+ private async Task ReplyLoop(string serverId, int tcpPort)
+ {
+ var query = $"appmapper-discover-v2|{serverId}";
+ var reply = Encoding.ASCII.GetBytes($"appmapper-discovery-v2|{serverId}|{tcpPort}");
+ while (!cancellation.IsCancellationRequested)
+ {
+ try
+ {
+ var request = await socket.ReceiveAsync(cancellation.Token);
+ if (request.Buffer.Length <= 256 && Encoding.ASCII.GetString(request.Buffer) == query)
+ await socket.SendAsync(reply, reply.Length, request.RemoteEndPoint);
+ }
+ catch (OperationCanceledException) { return; }
+ catch (ObjectDisposedException) when (cancellation.IsCancellationRequested) { return; }
+ catch (SocketException) { return; }
+ }
+ }
+
+ public void Dispose()
+ {
+ cancellation.Cancel();
+ socket.Dispose();
+ cancellation.Dispose();
+ }
+}
diff --git a/windowsApp/controller/Services/NetworkInfoService.cs b/windowsApp/controller/Services/NetworkInfoService.cs
index 2adfa11..5f65936 100644
--- a/windowsApp/controller/Services/NetworkInfoService.cs
+++ b/windowsApp/controller/Services/NetworkInfoService.cs
@@ -112,8 +112,6 @@ public static NetworkAddressSelection ResolveAddress(Settings settings)
return CreateSelection(address.Address, address.Version, null);
}
- public static string GetPrimaryIPv4() => ResolveAddress(new Settings()).Host;
-
public static string FormatHostForUri(string address) =>
Uri.EscapeDataString(address);
diff --git a/windowsApp/controller/Services/PairingCodeService.cs b/windowsApp/controller/Services/PairingCodeService.cs
index 52968a1..1163e3f 100644
--- a/windowsApp/controller/Services/PairingCodeService.cs
+++ b/windowsApp/controller/Services/PairingCodeService.cs
@@ -3,27 +3,56 @@ namespace AppMapper.Controller.Services;
public sealed class PairingCodeService : IDisposable
{
private readonly System.Threading.Timer timer;
- private readonly Random random = new();
+ private readonly object gate = new();
+ private string currentCode;
+ private string currentEnrollToken;
public PairingCodeService()
{
- CurrentCode = CreateCode();
+ currentCode = CreateCode();
+ currentEnrollToken = CreateEnrollToken();
timer = new System.Threading.Timer(_ => Refresh(), null, TimeSpan.FromMinutes(1), TimeSpan.FromMinutes(1));
}
- public string CurrentCode { get; private set; }
+ public (string Code, string EnrollToken) Current
+ {
+ get { lock (gate) return (currentCode, currentEnrollToken); }
+ }
+
+ public event Action? Changed;
- public event Action? CodeChanged;
+ public bool TryConsumeManual(string? code) => TryConsume(code, enrollment: false);
- public bool IsValid(string? code) => code == CurrentCode;
+ public bool TryConsumeEnrollment(string? token) => TryConsume(token, enrollment: true);
+
+ private bool TryConsume(string? value, bool enrollment)
+ {
+ lock (gate)
+ {
+ if (value == null || value != (enrollment ? currentEnrollToken : currentCode)) return false;
+ Rotate();
+ }
+ Changed?.Invoke();
+ return true;
+ }
public void Refresh()
{
- CurrentCode = CreateCode();
- CodeChanged?.Invoke(CurrentCode);
+ lock (gate) Rotate();
+ Changed?.Invoke();
}
public void Dispose() => timer.Dispose();
- private string CreateCode() => random.Next(0, 1_000_000).ToString("D6");
+ private void Rotate()
+ {
+ currentCode = CreateCode();
+ currentEnrollToken = CreateEnrollToken();
+ }
+
+ private static string CreateCode() =>
+ System.Security.Cryptography.RandomNumberGenerator.GetInt32(0, 1_000_000).ToString("D6");
+
+ private static string CreateEnrollToken() =>
+ Convert.ToHexString(System.Security.Cryptography.RandomNumberGenerator.GetBytes(16)).ToLowerInvariant();
}
diff --git a/windowsApp/controller/Services/TcpJsonServer.cs b/windowsApp/controller/Services/TcpJsonServer.cs
index dee1bf2..a7f8632 100644
--- a/windowsApp/controller/Services/TcpJsonServer.cs
+++ b/windowsApp/controller/Services/TcpJsonServer.cs
@@ -1,6 +1,10 @@
+using System.Collections.Concurrent;
using System.IO;
using System.Net;
+using System.Net.Security;
using System.Net.Sockets;
+using System.Security.Authentication;
+using System.Security.Cryptography;
using System.Text;
using System.Text.Json;
using AppMapper.Controller.Core;
@@ -11,168 +15,308 @@ namespace AppMapper.Controller.Services;
public sealed class TcpJsonServer
{
private readonly LogService log;
+ private readonly PairingStore pairing;
+ private readonly DeviceIdentityService identity;
+ private readonly object sessionsLock = new();
+ private readonly Dictionary sessions = new();
+ private readonly ConcurrentDictionary failedCodes = new();
private TcpListener? listener;
private CancellationTokenSource? cancellation;
- private int activeClients;
+ private LanDiscoveryService? discovery;
- public TcpJsonServer(LogService log) => this.log = log;
+ public TcpJsonServer(LogService log, PairingStore pairing, DeviceIdentityService identity)
+ {
+ this.log = log;
+ this.pairing = pairing;
+ this.identity = identity;
+ }
- public event Action? HelloReceived;
+ public event Action? HelloReceived;
public event Action? ActiveAppReceived;
public event Action? IdleReceived;
public event Action? Disconnected;
+ public event Action? PairingChanged;
public bool IsRunning => listener != null;
- public void Start(int port, Func validateCode, int maxDevices)
+ public void Start(int port, Func consumeManualCode,
+ Func consumeEnrollToken, int maxDevices)
{
Stop();
cancellation = new CancellationTokenSource();
listener = new TcpListener(IPAddress.Any, port);
listener.Start();
- _ = AcceptLoop(validateCode, maxDevices, cancellation.Token);
+ try { discovery = new LanDiscoveryService(identity.ServerId, port); }
+ catch (SocketException) { log.Warn("LAN discovery unavailable on UDP port 8766."); }
+ _ = AcceptLoop(listener, consumeManualCode, consumeEnrollToken, maxDevices, cancellation.Token);
log.Info($"TCP server started on port {port}.");
}
public void Stop()
{
cancellation?.Cancel();
+ discovery?.Dispose();
+ discovery = null;
listener?.Stop();
listener = null;
+ string[] disconnected;
+ lock (sessionsLock)
+ {
+ disconnected = sessions.Keys.ToArray();
+ foreach (var client in sessions.Values) client.Close();
+ sessions.Clear();
+ }
+ foreach (var id in disconnected) Disconnected?.Invoke(id);
+ }
+
+ public bool RemovePairedDevice(string deviceId)
+ {
+ bool disconnected;
+ lock (sessionsLock)
+ {
+ if (!pairing.Remove(deviceId)) return false;
+ disconnected = sessions.Remove(deviceId, out var client);
+ if (disconnected) client!.Close();
+ }
+ if (disconnected) Disconnected?.Invoke(deviceId);
+ PairingChanged?.Invoke();
+ return true;
}
- private async Task AcceptLoop(Func validateCode, int maxDevices, CancellationToken token)
+ private async Task AcceptLoop(TcpListener activeListener, Func consumeManualCode,
+ Func consumeEnrollToken,
+ int maxDevices, CancellationToken token)
{
- while (!token.IsCancellationRequested && listener != null)
+ while (!token.IsCancellationRequested)
{
try
{
- var client = await listener.AcceptTcpClientAsync(token);
- _ = HandleClient(client, validateCode, maxDevices, token);
- }
- catch (OperationCanceledException)
- {
- return;
- }
- catch (Exception ex)
- {
- log.Warn($"Accept failed: {ex.Message}");
+ var client = await activeListener.AcceptTcpClientAsync(token);
+ _ = HandleClient(client, consumeManualCode, consumeEnrollToken, maxDevices, token);
}
+ catch (OperationCanceledException) { return; }
+ catch (ObjectDisposedException) when (token.IsCancellationRequested) { return; }
+ catch (SocketException) when (token.IsCancellationRequested) { return; }
+ catch (Exception ex) { log.Warn($"Accept failed: {ex.GetType().Name}."); }
}
}
- private async Task HandleClient(TcpClient client, Func validateCode, int maxDevices, CancellationToken token)
+ private async Task HandleClient(TcpClient client, Func consumeManualCode,
+ Func consumeEnrollToken,
+ int maxDevices, CancellationToken token)
{
string? deviceId = null;
- var countedClient = false;
- await using var stream = client.GetStream();
- using var reader = new StreamReader(stream, Encoding.UTF8, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true);
- await using var writer = new StreamWriter(stream, new UTF8Encoding(encoderShouldEmitUTF8Identifier: false), bufferSize: 1024, leaveOpen: true) { AutoFlush = true };
-
+ var rememberedSession = false;
+ var remote = (client.Client.RemoteEndPoint as IPEndPoint)?.Address.ToString() ?? "unknown";
try
{
- var firstLine = await reader.ReadLineAsync(token);
- if (firstLine == null) return;
-
+ await using var ssl = new SslStream(client.GetStream(), leaveInnerStreamOpen: false);
+ using var handshake = CancellationTokenSource.CreateLinkedTokenSource(token);
+ handshake.CancelAfter(TimeSpan.FromSeconds(10));
+ await ssl.AuthenticateAsServerAsync(new SslServerAuthenticationOptions
+ {
+ ServerCertificate = identity.Certificate,
+ EnabledSslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13,
+ }, handshake.Token);
+ using var reader = new StreamReader(ssl, Encoding.UTF8, false, 1024, true);
+ await using var writer = new StreamWriter(ssl, new UTF8Encoding(false), 1024, true) { AutoFlush = true };
+ var firstLine = await reader.ReadLineAsync(handshake.Token);
+ if (firstLine == null || firstLine.Length > 8192) return;
using var helloDocument = JsonDocument.Parse(firstLine);
var hello = helloDocument.RootElement;
- if (hello.GetProperty("type").GetString() != "hello")
+ if (ReadString(hello, "type") != "hello" || ReadInt(hello, "protocolVersion") != 2)
{
- await SendError(writer, "invalid_message", "First message must be hello.");
+ await SendError(writer, "upgrade_required", "Please update both AppMapper apps.");
return;
}
+ var requestedId = ReadString(hello, "deviceId");
+ if (string.IsNullOrWhiteSpace(requestedId) || requestedId.Length > 100) return;
+ var name = ReadString(hello, "deviceName") ?? requestedId;
+ if (name.Length > 100) name = name[..100];
- var code = hello.TryGetProperty("pairingCode", out var codeElement) ? codeElement.GetString() : null;
- if (!validateCode(code))
+ var mode = ReadString(hello, "mode");
+ if (mode is "pair_temporary" or "pair_remember")
{
- await SendError(writer, "invalid_pairing_code", "Pairing code is invalid or expired.");
- return;
+ var remember = mode == "pair_remember";
+ var credential = ReadString(hello, remember ? "enrollToken" : "pairingCode");
+ var invalidCredential = remember ? "invalid_enroll_token" : "invalid_pairing_code";
+ if (!remember && !CanTryCode(remote))
+ {
+ await SendError(writer, "invalid_pairing_code", "Pairing code is invalid or expired.");
+ return;
+ }
+ var publicKey = ReadString(hello, "publicKeySpki");
+ if (remember && (publicKey == null || !DeviceIdentityService.IsValidPublicKey(publicKey)))
+ {
+ await SendError(writer, "invalid_key", "Device key is invalid.");
+ return;
+ }
+ var failure = RegisterPair(requestedId, name, credential, publicKey,
+ remember, client, maxDevices, remember ? consumeEnrollToken : consumeManualCode,
+ invalidCredential);
+ if (failure != null)
+ {
+ if (failure == "invalid_pairing_code") RecordFailedCode(remote);
+ await SendError(writer, failure, failure == "max_devices_reached"
+ ? "Maximum connected devices reached." : "Pairing credential is invalid or expired.");
+ return;
+ }
+ deviceId = requestedId;
+ rememberedSession = remember;
+ if (remember) PairingChanged?.Invoke();
+ await SendAck(writer);
}
-
- if (Interlocked.Increment(ref activeClients) > maxDevices)
+ else if (mode == "resume")
{
- Interlocked.Decrement(ref activeClients);
- await SendError(writer, "max_devices_reached", "Maximum connected devices reached.");
+ var saved = pairing.Find(requestedId);
+ if (saved == null)
+ {
+ await SendError(writer, "pairing_required", "Pair again on this computer.");
+ return;
+ }
+ var nonce = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
+ await writer.WriteLineAsync(JsonSerializer.Serialize(new { type = "challenge", nonce }));
+ var proofLine = await reader.ReadLineAsync(handshake.Token);
+ if (proofLine == null || proofLine.Length > 8192) return;
+ using var proofDocument = JsonDocument.Parse(proofLine);
+ var proof = proofDocument.RootElement;
+ var signature = ReadString(proof, "signature");
+ if (ReadString(proof, "type") != "proof" || signature == null ||
+ !DeviceIdentityService.Verify(saved.PublicKeySpki,
+ DeviceIdentityService.ProofPayload(identity.ServerId, requestedId, nonce), signature))
+ {
+ await SendError(writer, "authentication_failed", "Device authentication failed.");
+ return;
+ }
+ deviceId = requestedId;
+ var failure = RegisterResume(deviceId, saved.PublicKeySpki, client, maxDevices);
+ if (failure != null)
+ {
+ await SendError(writer, failure, failure == "pairing_required"
+ ? "Pair again on this computer." : "Maximum connected devices reached.");
+ return;
+ }
+ rememberedSession = true;
+ await SendAck(writer);
+ }
+ else
+ {
+ await SendError(writer, "invalid_message", "Unknown connection mode.");
return;
}
- countedClient = true;
-
- deviceId = hello.GetProperty("deviceId").GetString() ?? "";
- var deviceName = hello.TryGetProperty("deviceName", out var nameElement) ? nameElement.GetString() ?? deviceId : deviceId;
- HelloReceived?.Invoke(deviceId, deviceName, client.Client.RemoteEndPoint?.ToString() ?? "");
- await writer.WriteLineAsync(CreateHelloAck());
+ HelloReceived?.Invoke(deviceId, name, remote, rememberedSession);
while (!token.IsCancellationRequested)
{
var line = await reader.ReadLineAsync(token);
if (line == null) return;
- HandleMessage(deviceId, line);
+ if (!IsCurrent(deviceId, client)) return;
+ using var document = JsonDocument.Parse(line);
+ var root = document.RootElement;
+ switch (ReadString(root, "type"))
+ {
+ case "active_app":
+ var app = root.GetProperty("app");
+ ActiveAppReceived?.Invoke(deviceId, new AppInfo
+ {
+ AppId = ReadString(app, "appId") ?? "",
+ PackageName = ReadString(app, "packageName") ?? "",
+ DisplayName = ReadString(app, "displayName") ?? "",
+ IconPngBase64 = ReadString(app, "iconPngBase64"),
+ }, ReadLong(root, "sequence"));
+ break;
+ case "idle":
+ IdleReceived?.Invoke(deviceId, ReadString(root, "reason") ?? "unknown",
+ ReadLong(root, "sequence"));
+ break;
+ case "heartbeat":
+ await writer.WriteLineAsync("{\"type\":\"heartbeat_ack\"}");
+ break;
+ case "forget":
+ if (rememberedSession) RemovePairedDevice(deviceId);
+ return;
+ }
}
}
- catch (OperationCanceledException)
- {
- }
+ catch (OperationCanceledException) { }
catch (Exception ex)
{
- log.Warn($"Client error: {ex.Message}");
+ log.Warn($"Client connection ended: {ex.GetType().Name}.");
}
finally
{
- if (countedClient) Interlocked.Decrement(ref activeClients);
- if (!string.IsNullOrWhiteSpace(deviceId)) Disconnected?.Invoke(deviceId);
+ var disconnected = false;
+ if (deviceId != null)
+ {
+ lock (sessionsLock)
+ {
+ if (sessions.TryGetValue(deviceId, out var current) && ReferenceEquals(current, client))
+ {
+ sessions.Remove(deviceId);
+ disconnected = true;
+ }
+ }
+ }
client.Close();
+ if (disconnected) Disconnected?.Invoke(deviceId!);
}
}
- private void HandleMessage(string deviceId, string line)
+ private string? RegisterPair(string id, string name, string? credential, string? publicKey,
+ bool remember, TcpClient client, int maxDevices, Func consumeCredential,
+ string invalidCredential)
{
- using var document = JsonDocument.Parse(line);
- var root = document.RootElement;
- var type = root.GetProperty("type").GetString();
-
- if (type == "active_app")
+ lock (sessionsLock)
{
- var app = root.GetProperty("app");
- var appInfo = new AppInfo
- {
- AppId = app.GetProperty("appId").GetString() ?? "",
- PackageName = app.GetProperty("packageName").GetString() ?? "",
- DisplayName = app.GetProperty("displayName").GetString() ?? "",
- IconPngBase64 = app.TryGetProperty("iconPngBase64", out var icon) ? icon.GetString() : null,
- };
- ActiveAppReceived?.Invoke(deviceId, appInfo, ReadSequence(root));
- return;
+ if (!sessions.ContainsKey(id) && sessions.Count >= maxDevices) return "max_devices_reached";
+ if (!consumeCredential(credential)) return invalidCredential;
+ if (remember)
+ pairing.Pair(new PairedDeviceRecord(id, name, publicKey!, DateTimeOffset.Now));
+ if (sessions.TryGetValue(id, out var previous)) previous.Close();
+ sessions[id] = client;
+ return null;
}
+ }
- if (type == "idle")
+ private string? RegisterResume(string id, string publicKey, TcpClient client, int maxDevices)
+ {
+ lock (sessionsLock)
{
- var reason = root.TryGetProperty("reason", out var reasonElement) ? reasonElement.GetString() ?? "unknown" : "unknown";
- IdleReceived?.Invoke(deviceId, reason, ReadSequence(root));
- return;
+ if (pairing.Find(id)?.PublicKeySpki != publicKey) return "pairing_required";
+ if (!sessions.ContainsKey(id) && sessions.Count >= maxDevices) return "max_devices_reached";
+ if (sessions.TryGetValue(id, out var previous)) previous.Close();
+ sessions[id] = client;
+ return null;
}
}
- private static long ReadSequence(JsonElement root) =>
- root.TryGetProperty("sequence", out var sequence) ? sequence.GetInt64() : 0;
+ private bool IsCurrent(string id, TcpClient client)
+ {
+ lock (sessionsLock) return sessions.TryGetValue(id, out var current) && ReferenceEquals(current, client);
+ }
- private static string CreateHelloAck() =>
- JsonSerializer.Serialize(new
- {
- type = "hello_ack",
- protocolVersion = 1,
- serverName = Environment.MachineName,
- maxDevices = 3,
- accepted = true,
- message = "ok",
- timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
- });
+ private bool CanTryCode(string address) =>
+ !failedCodes.TryGetValue(address, out var attempts) ||
+ DateTimeOffset.UtcNow - attempts.Since > TimeSpan.FromMinutes(1) || attempts.Count < 5;
+
+ private void RecordFailedCode(string address) => failedCodes.AddOrUpdate(address,
+ _ => (1, DateTimeOffset.UtcNow), (_, previous) =>
+ DateTimeOffset.UtcNow - previous.Since > TimeSpan.FromMinutes(1)
+ ? (1, DateTimeOffset.UtcNow) : (previous.Count + 1, previous.Since));
+
+ private Task SendAck(StreamWriter writer) => writer.WriteLineAsync(JsonSerializer.Serialize(new
+ {
+ type = "hello_ack", accepted = true, serverId = identity.ServerId,
+ serverName = Environment.MachineName,
+ }));
private static Task SendError(StreamWriter writer, string code, string message) =>
- writer.WriteLineAsync(JsonSerializer.Serialize(new
- {
- type = "error",
- code,
- message,
- timestamp = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
- }));
+ writer.WriteLineAsync(JsonSerializer.Serialize(new { type = "error", code, message }));
+
+ private static string? ReadString(JsonElement root, string name) =>
+ root.TryGetProperty(name, out var value) && value.ValueKind == JsonValueKind.String ? value.GetString() : null;
+ private static int ReadInt(JsonElement root, string name) =>
+ root.TryGetProperty(name, out var value) && value.TryGetInt32(out var result) ? result : 0;
+ private static long ReadLong(JsonElement root, string name) =>
+ root.TryGetProperty(name, out var value) && value.TryGetInt64(out var result) ? result : 0;
}
diff --git a/windowsApp/controller/ViewModels/PairingViewModel.cs b/windowsApp/controller/ViewModels/PairingViewModel.cs
index 5af2895..7e706bc 100644
--- a/windowsApp/controller/ViewModels/PairingViewModel.cs
+++ b/windowsApp/controller/ViewModels/PairingViewModel.cs
@@ -1,4 +1,6 @@
using System.Collections.ObjectModel;
+using System.Windows;
+using System.Windows.Input;
using System.Windows.Media.Imaging;
using AppMapper.Controller.Abstractions;
using AppMapper.Controller.Models;
@@ -11,21 +13,25 @@ public sealed class PairingViewModel : ViewModelBase
{
private string serverAddress = "";
private string pairingCode = "";
- private string pairingUri = "";
private string networkWarning = "";
private BitmapImage? qrImage;
+ private DeviceState? selectedDevice;
+ private readonly ICoreFacade core;
public PairingViewModel(ICoreFacade core)
{
+ this.core = core;
+ RemoveSelectedCommand = new RelayCommand(RemoveSelected);
ApplySnapshot(core.GetStateSnapshot());
core.PairingChanged += OnPairingChanged;
core.DevicesChanged += OnDevicesChanged;
}
public ObservableCollection Devices { get; } = new();
+ public ICommand RemoveSelectedCommand { get; }
+ public DeviceState? SelectedDevice { get => selectedDevice; set => SetField(ref selectedDevice, value); }
public string ServerAddress { get => serverAddress; private set => SetField(ref serverAddress, value); }
public string PairingCode { get => pairingCode; private set => SetField(ref pairingCode, value); }
- public string PairingUri { get => pairingUri; private set => SetField(ref pairingUri, value); }
public string NetworkWarning { get => networkWarning; private set => SetField(ref networkWarning, value); }
public bool HasNetworkWarning => !string.IsNullOrWhiteSpace(networkWarning);
public BitmapImage? QrImage { get => qrImage; private set => SetField(ref qrImage, value); }
@@ -34,7 +40,6 @@ private void ApplySnapshot(CoreStateSnapshot snap)
{
ServerAddress = snap.ServerAddress;
PairingCode = snap.PairingCode;
- PairingUri = snap.PairingUri;
NetworkWarning = snap.NetworkWarning;
QrImage = GenerateQr(snap.PairingUri);
ReplaceDevices(snap.Devices);
@@ -46,7 +51,6 @@ private void OnPairingChanged(PairingInfo info)
{
ServerAddress = info.ServerAddress;
PairingCode = info.Code;
- PairingUri = info.PairingUri;
NetworkWarning = info.NetworkWarning;
QrImage = GenerateQr(info.PairingUri);
});
@@ -62,6 +66,15 @@ protected override void OnPropertyChanged(string? propertyName = null)
base.OnPropertyChanged(nameof(HasNetworkWarning));
}
+ private void RemoveSelected()
+ {
+ var device = SelectedDevice;
+ if (device?.IsPaired != true) return;
+ if (System.Windows.MessageBox.Show($"移除 {device.DeviceName}?手机需要重新扫码配对。", "移除已配对手机",
+ MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes)
+ core.RemovePairedDevice(device.DeviceId);
+ }
+
private static BitmapImage? GenerateQr(string uri) =>
string.IsNullOrEmpty(uri) ? null : QrCodeService.Generate(uri);
diff --git a/windowsApp/controller/ViewModels/SettingsViewModel.cs b/windowsApp/controller/ViewModels/SettingsViewModel.cs
index 654ce16..a8ea888 100644
--- a/windowsApp/controller/ViewModels/SettingsViewModel.cs
+++ b/windowsApp/controller/ViewModels/SettingsViewModel.cs
@@ -13,6 +13,7 @@ public sealed class SettingsViewModel : ViewModelBase
private string port = "8765";
private bool relaunchMapperWhenClosed = true;
private bool closeToTray = true;
+ private bool startWithWindows;
private bool paneOpen = true;
private string selectedNetworkAdapterId = "";
private string preferredIpVersion = "IPv4";
@@ -31,6 +32,7 @@ public SettingsViewModel(ICoreFacade core)
port = s.Port.ToString();
relaunchMapperWhenClosed = s.RelaunchMapperWhenClosed;
closeToTray = s.CloseToTray;
+ startWithWindows = s.StartWithWindows;
paneOpen = s.PaneOpen;
selectedNetworkAdapterId = s.NetworkAdapterId;
preferredIpVersion = s.PreferredIpVersion;
@@ -138,6 +140,15 @@ public bool CloseToTray
}
}
+ public bool StartWithWindows
+ {
+ get => startWithWindows;
+ set
+ {
+ if (SetField(ref startWithWindows, value)) core.Settings.StartWithWindows = value;
+ }
+ }
+
public bool PaneOpen
{
get => paneOpen;
@@ -166,6 +177,10 @@ private void OnSettingChanged(object? sender, System.ComponentModel.PropertyChan
OnPropertyChanged(nameof(RelaunchMapperWhenClosed));
}
break;
+ case nameof(Settings.StartWithWindows):
+ startWithWindows = s.StartWithWindows;
+ OnPropertyChanged(nameof(StartWithWindows));
+ break;
case nameof(Settings.CloseToTray):
if (closeToTray != s.CloseToTray)
{
diff --git a/windowsApp/controller/Views/PairingPage.xaml b/windowsApp/controller/Views/PairingPage.xaml
index ee11fe9..fb64376 100644
--- a/windowsApp/controller/Views/PairingPage.xaml
+++ b/windowsApp/controller/Views/PairingPage.xaml
@@ -23,11 +23,12 @@
Visibility="{Binding HasNetworkWarning, Converter={StaticResource BooleanToVisibilityConverter}}"
Margin="0,-8,0,16" />
-
-
+
+
+
-
+
@@ -37,7 +38,7 @@
-
+
@@ -49,6 +50,7 @@
+
diff --git a/windowsApp/controller/Views/SettingsPage.xaml b/windowsApp/controller/Views/SettingsPage.xaml
index 1f5ecd9..fb05e17 100644
--- a/windowsApp/controller/Views/SettingsPage.xaml
+++ b/windowsApp/controller/Views/SettingsPage.xaml
@@ -82,6 +82,10 @@
IsChecked="{Binding CloseToTray, Mode=TwoWay}"
Margin="0,0,0,12" />
+
+
Date: Mon, 28 Sep 2026 20:16:39 +0800
Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=E7=A7=BB=E9=99=A4=E5=BA=9F?=
=?UTF-8?q?=E5=BC=83=E5=8A=9F=E8=83=BD=E4=BB=A5=E5=8F=8A=E4=BF=AE=E6=94=B9?=
=?UTF-8?q?=E9=83=A8=E5=88=86=E6=96=87=E6=A1=88?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
README.md | 8 +--
.../youximi/appmapper/data/LanDiscovery.kt | 50 -------------------
.../service/ForegroundSyncService.kt | 15 +-----
shared/protocol.md | 6 +--
.../Services/LanDiscoveryService.cs | 44 ----------------
.../controller/Services/TcpJsonServer.cs | 5 --
windowsApp/controller/Views/SettingsPage.xaml | 2 +-
7 files changed, 7 insertions(+), 123 deletions(-)
delete mode 100644 androidApp/app/src/main/java/dev/youximi/appmapper/data/LanDiscovery.kt
delete mode 100644 windowsApp/controller/Services/LanDiscoveryService.cs
diff --git a/README.md b/README.md
index 8854e46..53bed08 100644
--- a/README.md
+++ b/README.md
@@ -44,9 +44,9 @@ AppMapper/
appmapper://connect?host=192.168.1.10&port=8765&serverId=<电脑ID>&fingerprint=<64位公钥指纹>&enrollToken=<32位一次性令牌>
```
-Android 端勾选「记住设备」后扫描二维码绑定;二维码中的公钥指纹用于验证电脑身份,一次性令牌授权长期配对。手机使用 Android Keystore 中的设备密钥应答电脑的一次性挑战;验证码刷新、短暂断网、电脑重启后会自动重连。没有摄像头时,可取消「记住设备」,手动输入 IP、端口和 6 位验证码进行临时连接。临时连接不会保存配对,断线后需重新输入验证码,也不会清除已有的长期配对。手机需保持同步前台服务运行,电脑需要运行总控端;Windows 登录后启动到托盘可在设置中开启。手机重启后需打开 App 并启动同步,本次没有加入手机开机自启。
+Android 端勾选「记住设备」后扫描二维码绑定;二维码中的公钥指纹用于验证电脑身份,一次性令牌授权长期配对。手机使用 Android Keystore 中的设备密钥应答电脑的一次性挑战;验证码刷新、短暂断网、电脑重启后会自动重连。没有摄像头时,可取消「记住设备」,手动输入 IP、端口和 6 位验证码进行临时连接。临时连接不会保存配对,断线后需重新输入验证码,也不会清除已有的长期配对。手机需保持同步前台服务运行,电脑需要运行总控端;电脑端可在设置中开启「开机自启」(Windows 登录后启动到托盘)。手机重启后需打开 App 并启动同步,本次没有加入手机开机自启。
-电脑「配对」页可以移除手机,手机「设置」页可以忘记电脑;之后要重新扫码。电脑 IP 变化时,手机会在同一 IPv4 广播网段上尝试发现电脑。发现结果仍须通过原先保存的电脑公钥指纹校验;错误的地址提示不会清除配对。若路由器隔离设备、Windows 防火墙拦截 UDP 8766,或手机和电脑不在同一网段,请重新扫码。
+电脑「配对」页可以移除手机,手机「设置」页可以忘记电脑;之后要重新扫码。断线后手机只会按已保存的地址重试。电脑 IP 或端口变化时,请在手机上重新扫描电脑当前的配对二维码。
## 前置环境
@@ -105,10 +105,10 @@ Android 客户端需要以下权限,全部用于核心功能:
## 隐私与安全
-- 全程**仅局域网通信**。扫码绑定与后续重连使用二维码里的电脑公钥指纹验证电脑身份;断线后可能经同网段 UDP 广播发现新地址,发现回复不能替代指纹验证。手动临时连接不验证电脑身份,局域网内的攻击者可能冒充目标电脑;仅在应急时使用。
+- 全程**仅局域网通信**。扫码绑定与后续按已保存地址重连使用二维码里的电脑公钥指纹验证电脑身份。手动临时连接不验证电脑身份,局域网内的攻击者可能冒充目标电脑;仅在应急时使用。
- 电脑身份和已配对手机公钥保存在软件目录 `config/pairing.dat`,由当前 Windows 用户的 DPAPI 加密。手机配对记录在 App 私有 no-backup 目录 `config/paired-computer.json`;私钥由 Android Keystore 管理。不要将 Windows 数据文件移到另一 Windows 用户下期待可以解密。
- 六位码只能授权临时连接;二维码中的随机令牌才能授权长期绑定。两者都会在使用后或每 60 秒失效,不作为重连凭据存储或在日志中记录。
-- Windows 总控端监听 TCP 和 UDP;UDP 发现只回答服务器 ID 和端口,不传输设备凭据。
+- Windows 总控端仅监听用于配对和同步的 TCP 端口。
- 映射小程序是纯本地窗口程序,不联网。
## License
diff --git a/androidApp/app/src/main/java/dev/youximi/appmapper/data/LanDiscovery.kt b/androidApp/app/src/main/java/dev/youximi/appmapper/data/LanDiscovery.kt
deleted file mode 100644
index d972678..0000000
--- a/androidApp/app/src/main/java/dev/youximi/appmapper/data/LanDiscovery.kt
+++ /dev/null
@@ -1,50 +0,0 @@
-package dev.youximi.appmapper.data
-
-import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.withContext
-import java.net.DatagramPacket
-import java.net.DatagramSocket
-import java.net.InetAddress
-import java.net.NetworkInterface
-import java.net.SocketTimeoutException
-import java.util.Collections
-
-data class DiscoveredComputer(val host: String, val port: Int)
-
-/** Finds a known server ID on the local IPv4 network; TLS pinning authenticates the result. */
-object LanDiscovery {
- private const val port = 8766
-
- suspend fun find(serverId: String): DiscoveredComputer? = withContext(Dispatchers.IO) {
- if (serverId.isBlank()) return@withContext null
- val request = "appmapper-discover-v2|$serverId".toByteArray(Charsets.US_ASCII)
- val expected = "appmapper-discovery-v2|$serverId|"
- DatagramSocket().use { socket ->
- socket.broadcast = true
- socket.soTimeout = 300
- val broadcasts = mutableSetOf(InetAddress.getByName("255.255.255.255"))
- runCatching {
- Collections.list(NetworkInterface.getNetworkInterfaces())
- .filter { it.isUp && !it.isLoopback }
- .flatMap { it.interfaceAddresses }
- .mapNotNullTo(broadcasts) { it.broadcast }
- }
- broadcasts.forEach { address ->
- runCatching { socket.send(DatagramPacket(request, request.size, address, port)) }
- }
- val deadline = System.currentTimeMillis() + 1600
- while (System.currentTimeMillis() < deadline) {
- val bytes = ByteArray(256)
- val reply = DatagramPacket(bytes, bytes.size)
- try { socket.receive(reply) } catch (_: SocketTimeoutException) { continue }
- val message = String(reply.data, reply.offset, reply.length, Charsets.US_ASCII)
- if (message.startsWith(expected)) {
- val tcpPort = message.removePrefix(expected).toIntOrNull()
- if (tcpPort != null && tcpPort in 1..65535)
- return@withContext DiscoveredComputer(reply.address.hostAddress ?: continue, tcpPort)
- }
- }
- null
- }
- }
-}
diff --git a/androidApp/app/src/main/java/dev/youximi/appmapper/service/ForegroundSyncService.kt b/androidApp/app/src/main/java/dev/youximi/appmapper/service/ForegroundSyncService.kt
index e230eb4..3eb0415 100644
--- a/androidApp/app/src/main/java/dev/youximi/appmapper/service/ForegroundSyncService.kt
+++ b/androidApp/app/src/main/java/dev/youximi/appmapper/service/ForegroundSyncService.kt
@@ -14,7 +14,6 @@ import dev.youximi.appmapper.data.ActiveApp
import dev.youximi.appmapper.data.AppLogger
import dev.youximi.appmapper.data.CurrentAppResult
import dev.youximi.appmapper.data.DeviceKeyStore
-import dev.youximi.appmapper.data.LanDiscovery
import dev.youximi.appmapper.data.PairedComputer
import dev.youximi.appmapper.data.PairingRejected
import dev.youximi.appmapper.data.PairingRequest
@@ -188,19 +187,7 @@ class ForegroundSyncService : Service() {
resumingSaved = true
val computer = saved!!
val name = android.os.Build.MODEL ?: "Android"
- try {
- client.resume(computer, name)
- } catch (failure: Exception) {
- if (failure is CancellationException ||
- failure is PairingRejected && failure.reason != "computer_identity_changed") throw failure
- client.close()
- val found = LanDiscovery.find(computer.serverId) ?: throw failure
- val updated = computer.copy(host = found.host, port = found.port)
- if (updated.host == computer.host && updated.port == computer.port) throw failure
- client.resume(updated, name)
- try { store.save(updated) }
- catch (_: Exception) { throw PairingRejected("device_storage_failed") }
- }
+ client.resume(computer, name)
activeSession = SessionKind.Remembered
}
SyncStatus.text.value = if (activeSession == SessionKind.Temporary) "临时连接中" else "已连接"
diff --git a/shared/protocol.md b/shared/protocol.md
index 7312cf3..f8f9625 100644
--- a/shared/protocol.md
+++ b/shared/protocol.md
@@ -32,14 +32,10 @@ The phone uses its saved address and fingerprint to establish pinned TLS, then s
`{"type":"hello","protocolVersion":2,"mode":"resume","deviceId":"android-8f3a2c1b","deviceName":"Pixel 8"}`
-The computer returns `{"type":"challenge","nonce":""}`. The phone signs the UTF-8 bytes `appmapper-v2|||` using its Android Keystore P-256 key and sends `{"type":"proof","signature":""}`. The computer verifies the registered public key and returns `hello_ack`. An unknown or removed device gets `pairing_required`; an invalid proof gets `authentication_failed`.
+The computer returns `{"type":"challenge","nonce":""}`. The phone signs the UTF-8 bytes `appmapper-v2|||` using its Android Keystore P-256 key and sends `{"type":"proof","signature":""}`. The computer verifies the registered public key and returns `hello_ack`. An unknown or removed device gets `pairing_required`; an invalid proof gets `authentication_failed`. After a connection fails, the phone retries the saved address. If the computer's address or port changes, the user scans its current QR code again.
Accepted temporary and remembered sessions may send `active_app`, `idle`, and `heartbeat`. Only remembered sessions may send `forget`, which removes the sender's computer-side pairing and closes its session. The Windows pairing page can also remove a phone and disconnect it. Existing active/idle payloads remain in `shared/examples/`.
-## Address discovery
-
-After a saved address fails, the phone broadcasts ASCII `appmapper-discover-v2|` over UDP port 8766 on local IPv4 interfaces. The computer replies directly with `appmapper-discovery-v2||`. This response is an address hint, never authentication: the phone still pins the original TLS public key and checks the server ID before saving the new address. A forged or incorrect hint does not clear the saved pairing; the phone continues retrying. Broadcast needs the same IPv4 broadcast domain and a firewall rule allowing UDP 8766.
-
## Storage
Windows: `/config/pairing.dat`, protected using DPAPI CurrentUser. Android: `/config/paired-computer.json` stores the non-secret public fingerprint, server ID, device ID and Android Keystore key alias; the private key remains in Android Keystore. Forgetting a device removes its local record and key. If the remembered computer is connected, `forget` also removes its computer-side record. Offline local removal may leave the computer-side record, which can be removed on the Windows pairing page.
diff --git a/windowsApp/controller/Services/LanDiscoveryService.cs b/windowsApp/controller/Services/LanDiscoveryService.cs
deleted file mode 100644
index 57f7e93..0000000
--- a/windowsApp/controller/Services/LanDiscoveryService.cs
+++ /dev/null
@@ -1,44 +0,0 @@
-using System.Net;
-using System.Net.Sockets;
-using System.Text;
-
-namespace AppMapper.Controller.Services;
-
-/// Locates this computer after its LAN address changes. No credentials travel over UDP.
-public sealed class LanDiscoveryService : IDisposable
-{
- public const int DiscoveryPort = 8766;
- private readonly UdpClient socket;
- private readonly CancellationTokenSource cancellation = new();
-
- public LanDiscoveryService(string serverId, int tcpPort)
- {
- socket = new UdpClient(new IPEndPoint(IPAddress.Any, DiscoveryPort));
- _ = ReplyLoop(serverId, tcpPort);
- }
-
- private async Task ReplyLoop(string serverId, int tcpPort)
- {
- var query = $"appmapper-discover-v2|{serverId}";
- var reply = Encoding.ASCII.GetBytes($"appmapper-discovery-v2|{serverId}|{tcpPort}");
- while (!cancellation.IsCancellationRequested)
- {
- try
- {
- var request = await socket.ReceiveAsync(cancellation.Token);
- if (request.Buffer.Length <= 256 && Encoding.ASCII.GetString(request.Buffer) == query)
- await socket.SendAsync(reply, reply.Length, request.RemoteEndPoint);
- }
- catch (OperationCanceledException) { return; }
- catch (ObjectDisposedException) when (cancellation.IsCancellationRequested) { return; }
- catch (SocketException) { return; }
- }
- }
-
- public void Dispose()
- {
- cancellation.Cancel();
- socket.Dispose();
- cancellation.Dispose();
- }
-}
diff --git a/windowsApp/controller/Services/TcpJsonServer.cs b/windowsApp/controller/Services/TcpJsonServer.cs
index a7f8632..d690aca 100644
--- a/windowsApp/controller/Services/TcpJsonServer.cs
+++ b/windowsApp/controller/Services/TcpJsonServer.cs
@@ -22,7 +22,6 @@ public sealed class TcpJsonServer
private readonly ConcurrentDictionary failedCodes = new();
private TcpListener? listener;
private CancellationTokenSource? cancellation;
- private LanDiscoveryService? discovery;
public TcpJsonServer(LogService log, PairingStore pairing, DeviceIdentityService identity)
{
@@ -46,8 +45,6 @@ public void Start(int port, Func consumeManualCode,
cancellation = new CancellationTokenSource();
listener = new TcpListener(IPAddress.Any, port);
listener.Start();
- try { discovery = new LanDiscoveryService(identity.ServerId, port); }
- catch (SocketException) { log.Warn("LAN discovery unavailable on UDP port 8766."); }
_ = AcceptLoop(listener, consumeManualCode, consumeEnrollToken, maxDevices, cancellation.Token);
log.Info($"TCP server started on port {port}.");
}
@@ -55,8 +52,6 @@ public void Start(int port, Func consumeManualCode,
public void Stop()
{
cancellation?.Cancel();
- discovery?.Dispose();
- discovery = null;
listener?.Stop();
listener = null;
string[] disconnected;
diff --git a/windowsApp/controller/Views/SettingsPage.xaml b/windowsApp/controller/Views/SettingsPage.xaml
index fb05e17..cc3e5db 100644
--- a/windowsApp/controller/Views/SettingsPage.xaml
+++ b/windowsApp/controller/Views/SettingsPage.xaml
@@ -82,7 +82,7 @@
IsChecked="{Binding CloseToTray, Mode=TwoWay}"
Margin="0,0,0,12" />
-
From ae3ceb962f9872ff76c88d11d2cb4bc276fd3c1e Mon Sep 17 00:00:00 2001
From: Youximi <1127377507@qq.com>
Date: Tue, 29 Sep 2026 01:11:41 +0800
Subject: [PATCH 3/4] =?UTF-8?q?build:=20=E5=8D=87=E7=BA=A7=20Android=20?=
=?UTF-8?q?=E6=9E=84=E5=BB=BA=E5=B7=A5=E5=85=B7=E5=B9=B6=E4=BF=AE=E5=A4=8D?=
=?UTF-8?q?=20CI=20SDK=20=E5=AE=89=E8=A3=85?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
升级 AGP 至 9.1.0、Kotlin 至 2.2.10,调整 Gradle 配置。
在 Actions 中指定 platform-tools,避免安装已废弃的 tools 包。
---
.github/workflows/experiment-release.yml | 2 ++
build.gradle.kts | 6 +++---
gradle.properties | 10 ++++++++++
3 files changed, 15 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/experiment-release.yml b/.github/workflows/experiment-release.yml
index ffee542..69e1177 100644
--- a/.github/workflows/experiment-release.yml
+++ b/.github/workflows/experiment-release.yml
@@ -121,6 +121,8 @@ jobs:
- name: Set up Android SDK
uses: android-actions/setup-android@v3
+ with:
+ packages: platform-tools
- name: Install Android SDK packages
run: |
diff --git a/build.gradle.kts b/build.gradle.kts
index 1b4481a..305d5b5 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -1,5 +1,5 @@
plugins {
- id("com.android.application") version "8.7.3" apply false
- id("org.jetbrains.kotlin.android") version "2.0.21" apply false
- id("org.jetbrains.kotlin.plugin.compose") version "2.0.21" apply false
+ id("com.android.application") version "9.1.0" apply false
+ id("org.jetbrains.kotlin.android") version "2.2.10" apply false
+ id("org.jetbrains.kotlin.plugin.compose") version "2.2.10" apply false
}
diff --git a/gradle.properties b/gradle.properties
index 736fd66..a825a36 100644
--- a/gradle.properties
+++ b/gradle.properties
@@ -1,3 +1,13 @@
android.useAndroidX=true
android.nonTransitiveRClass=true
org.gradle.jvmargs=-Xmx2g -XX:MaxMetaspaceSize=1g -Dfile.encoding=UTF-8
+android.defaults.buildfeatures.resvalues=true
+android.sdk.defaultTargetSdkToCompileSdkIfUnset=false
+android.enableAppCompileTimeRClass=false
+android.usesSdkInManifest.disallowed=false
+android.uniquePackageNames=false
+android.dependency.useConstraints=true
+android.r8.strictFullModeForKeepRules=false
+android.r8.optimizedResourceShrinking=false
+android.builtInKotlin=false
+android.newDsl=false
From f5339e3c2b47c6900d60271e6351a854587978cf Mon Sep 17 00:00:00 2001
From: Youximi <1127377507@qq.com>
Date: Wed, 30 Sep 2026 13:55:21 +0800
Subject: [PATCH 4/4] =?UTF-8?q?UI:=20=E4=BC=98=E5=8C=96=E5=AE=89=E5=8D=93?=
=?UTF-8?q?=E7=AB=AF=20APP=20=E7=9A=84=20Material=203=20=E7=95=8C=E9=9D=A2?=
=?UTF-8?q?=E5=B9=B6=E6=96=B0=E5=A2=9E=E5=BC=80=E6=BA=90=E8=AE=B8=E5=8F=AF?=
=?UTF-8?q?=E9=A1=B5?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 优化安卓端 APP 的 Material 3 界面,加入宽屏侧栏、明暗启动主题及图标资源
- 将扫码绑定整合到应用内页面,完善相机权限、临时连接表单校验和连接状态展示
- 优化同步档位设置与日志操作,为忘记电脑和清空日志加入确认提示
- 补齐 GPLv3 全文、两端第三方许可及上游告知,修正 QRCoder 版权年份
- 在设置的版本信息下新增开源许可入口,以圆角列表展示 57 个 Android 发行版引用库,支持离线查看版本、许可详情、项目 GPL 和完整声明
- 更新 README
---
.github/workflows/experiment-release.yml | 6 +
LICENSE | 236 +-
README.md | 2 +-
THIRD_PARTY_LICENSES.md | 1911 ++++++++++++++++-
androidApp/app/build.gradle.kts | 11 +
androidApp/app/src/main/AndroidManifest.xml | 14 +-
.../java/dev/youximi/appmapper/AppScaffold.kt | 261 ++-
.../java/dev/youximi/appmapper/HomeScreen.kt | 412 +++-
.../java/dev/youximi/appmapper/LogsScreen.kt | 144 +-
.../dev/youximi/appmapper/MainActivity.kt | 2 +
.../dev/youximi/appmapper/SettingsScreen.kt | 391 +++-
.../service/ForegroundSyncService.kt | 3 +
.../app/src/main/res/drawable/ic_computer.xml | 6 +
.../app/src/main/res/drawable/ic_qr_code.xml | 6 +
.../app/src/main/res/values-night/styles.xml | 10 +
androidApp/app/src/main/res/values/styles.xml | 3 +
16 files changed, 3097 insertions(+), 321 deletions(-)
create mode 100644 androidApp/app/src/main/res/drawable/ic_computer.xml
create mode 100644 androidApp/app/src/main/res/drawable/ic_qr_code.xml
create mode 100644 androidApp/app/src/main/res/values-night/styles.xml
diff --git a/.github/workflows/experiment-release.yml b/.github/workflows/experiment-release.yml
index 69e1177..5907a2c 100644
--- a/.github/workflows/experiment-release.yml
+++ b/.github/workflows/experiment-release.yml
@@ -190,6 +190,8 @@ jobs:
-o dist\windows
Copy-Item windowsApp\mapper\bin\Release\mapper-template.exe dist\windows\
+ Copy-Item LICENSE dist\windows\LICENSE
+ Copy-Item THIRD_PARTY_LICENSES.md dist\windows\THIRD_PARTY_LICENSES.md
- name: Package release assets
run: |
@@ -231,6 +233,7 @@ jobs:
}
$releaseType = if ($env:IS_PRERELEASE -eq "true") { "Experiment Build" } else { "Release Build" }
+ $sourceArchive = "${{ github.server_url }}/${{ github.repository }}/archive/$env:GITHUB_SHA.zip"
$notes = @(
$releaseType,
@@ -247,6 +250,9 @@ jobs:
$notes += $changes -split "`r?`n"
$notes += ""
$notes += "Full Comparison: $comparison"
+ $notes += ""
+ $notes += "Corresponding source ($env:GITHUB_SHA): $sourceArchive"
+ $notes += "License texts: included in the Android app (Settings > Open-source licenses) and in the Windows ZIP."
Set-Content -Path release-notes.md -Value $notes -Encoding UTF8
diff --git a/LICENSE b/LICENSE
index 22b11ad..773c253 100644
--- a/LICENSE
+++ b/LICENSE
@@ -2,9 +2,235 @@ GPL-3.0-only
Copyright (C) 2026 AppMapper contributors
-This project is licensed under the GNU General Public License version 3 only.
-You may use, copy, modify, and redistribute this code under the terms of GPL-3.0-only.
-Any distributed modified version or derivative work must preserve the same license and provide the corresponding source code.
+GNU GENERAL PUBLIC LICENSE
+Version 3, 29 June 2007
-Full license text:
-https://www.gnu.org/licenses/gpl-3.0.en.html
+Copyright © 2007 Free Software Foundation, Inc.
+
+Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed.
+
+Preamble
+
+The GNU General Public License is a free, copyleft license for software and other kinds of works.
+
+The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too.
+
+When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things.
+
+To protect your rights, we need to prevent others from denying you these rights or asking you to surrender the rights. Therefore, you have certain responsibilities if you distribute copies of the software, or if you modify it: responsibilities to respect the freedom of others.
+
+For example, if you distribute copies of such a program, whether gratis or for a fee, you must pass on to the recipients the same freedoms that you received. You must make sure that they, too, receive or can get the source code. And you must show them these terms so they know their rights.
+
+Developers that use the GNU GPL protect your rights with two steps: (1) assert copyright on the software, and (2) offer you this License giving you legal permission to copy, distribute and/or modify it.
+
+For the developers' and authors' protection, the GPL clearly explains that there is no warranty for this free software. For both users' and authors' sake, the GPL requires that modified versions be marked as changed, so that their problems will not be attributed erroneously to authors of previous versions.
+
+Some devices are designed to deny users access to install or run modified versions of the software inside them, although the manufacturer can do so. This is fundamentally incompatible with the aim of protecting users' freedom to change the software. The systematic pattern of such abuse occurs in the area of products for individuals to use, which is precisely where it is most unacceptable. Therefore, we have designed this version of the GPL to prohibit the practice for those products. If such problems arise substantially in other domains, we stand ready to extend this provision to those domains in future versions of the GPL, as needed to protect the freedom of users.
+
+Finally, every program is threatened constantly by software patents. States should not allow patents to restrict development and use of software on general-purpose computers, but in those that do, we wish to avoid the special danger that patents applied to a free program could make it effectively proprietary. To prevent this, the GPL assures that patents cannot be used to render the program non-free.
+
+The precise terms and conditions for copying, distribution and modification follow.
+
+TERMS AND CONDITIONS
+
+0. Definitions.
+
+“This License” refers to version 3 of the GNU General Public License.
+
+“Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks.
+
+“The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations.
+
+To “modify” a work means to copy from or adapt all or part of the work in a fashion requiring copyright permission, other than the making of an exact copy. The resulting work is called a “modified version” of the earlier work or a work “based on” the earlier work.
+
+A “covered work” means either the unmodified Program or a work based on the Program.
+
+To “propagate” a work means to do anything with it that, without permission, would make you directly or secondarily liable for infringement under applicable copyright law, except executing it on a computer or modifying a private copy. Propagation includes copying, distribution (with or without modification), making available to the public, and in some countries other activities as well.
+
+To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying.
+
+An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion.
+
+1. Source Code.
+The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work.
+
+A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language.
+
+The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it.
+
+The “Corresponding Source” for a work in object code form means all the source code needed to generate, install, and (for an executable work) run the object code and to modify the work, including scripts to control those activities. However, it does not include the work's System Libraries, or general-purpose tools or generally available free programs which are used unmodified in performing those activities but which are not part of the work. For example, Corresponding Source includes interface definition files associated with source files for the work, and the source code for shared libraries and dynamically linked subprograms that the work is specifically designed to require, such as by intimate data communication or control flow between those subprograms and other parts of the work.
+
+The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source.
+
+The Corresponding Source for a work in source code form is that same work.
+
+2. Basic Permissions.
+All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law.
+
+You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you.
+
+Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary.
+
+3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures.
+
+When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures.
+
+4. Conveying Verbatim Copies.
+You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program.
+
+You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee.
+
+5. Conveying Modified Source Versions.
+You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”.
+
+ c) You must license the entire work, as a whole, under this License to anyone who comes into possession of a copy. This License will therefore apply, along with any applicable section 7 additional terms, to the whole of the work, and all its parts, regardless of how they are packaged. This License gives no permission to license the work in any other way, but it does not invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so.
+
+A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate.
+
+6. Conveying Non-Source Forms.
+You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the written offer to provide the Corresponding Source. This alternative is allowed only occasionally and noncommercially, and only if you received the object code with such an offer, in accord with subsection 6b.
+
+ d) Convey the object code by offering access from a designated place (gratis or for a charge), and offer equivalent access to the Corresponding Source in the same way through the same place at no further charge. You need not require recipients to copy the Corresponding Source along with the object code. If the place to copy the object code is a network server, the Corresponding Source may be on a different server (operated by you or a third party) that supports equivalent copying facilities, provided you maintain clear directions next to the object code saying where to find the Corresponding Source. Regardless of what server hosts the Corresponding Source, you remain obligated to ensure that it is available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided you inform other peers where the object code and Corresponding Source of the work are being offered to the general public at no charge under subsection 6d.
+
+A separable portion of the object code, whose source code is excluded from the Corresponding Source as a System Library, need not be included in conveying the object code work.
+
+A “User Product” is either (1) a “consumer product”, which means any tangible personal property which is normally used for personal, family, or household purposes, or (2) anything designed or sold for incorporation into a dwelling. In determining whether a product is a consumer product, doubtful cases shall be resolved in favor of coverage. For a particular product received by a particular user, “normally used” refers to a typical or common use of that class of product, regardless of the status of the particular user or of the way in which the particular user actually uses, or expects or is expected to use, the product. A product is a consumer product regardless of whether the product has substantial commercial, industrial or non-consumer uses, unless such uses represent the only significant mode of use of the product.
+
+“Installation Information” for a User Product means any methods, procedures, authorization keys, or other information required to install and execute modified versions of a covered work in that User Product from a modified version of its Corresponding Source. The information must suffice to ensure that the continued functioning of the modified object code is in no case prevented or interfered with solely because modification has been made.
+
+If you convey an object code work under this section in, or with, or specifically for use in, a User Product, and the conveying occurs as part of a transaction in which the right of possession and use of the User Product is transferred to the recipient in perpetuity or for a fixed term (regardless of how the transaction is characterized), the Corresponding Source conveyed under this section must be accompanied by the Installation Information. But this requirement does not apply if neither you nor any third party retains the ability to install modified object code on the User Product (for example, the work has been installed in ROM).
+
+The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network.
+
+Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying.
+
+7. Additional Terms.
+“Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions.
+
+When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission.
+
+Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or author attributions in that material or in the Appropriate Legal Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or requiring that modified versions of such material be marked in reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that material by anyone who conveys the material (or modified versions of it) with contractual assumptions of liability to the recipient, for any liability that these contractual assumptions directly impose on those licensors and authors.
+
+All other non-permissive additional terms are considered “further restrictions” within the meaning of section 10. If the Program as you received it, or any part of it, contains a notice stating that it is governed by this License along with a term that is a further restriction, you may remove that term. If a license document contains a further restriction but permits relicensing or conveying under this License, you may add to a covered work material governed by the terms of that license document, provided that the further restriction does not survive such relicensing or conveying.
+
+If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms.
+
+Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way.
+
+8. Termination.
+You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11).
+
+However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation.
+
+Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice.
+
+Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10.
+
+9. Acceptance Not Required for Having Copies.
+You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so.
+
+10. Automatic Licensing of Downstream Recipients.
+Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License.
+
+An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts.
+
+You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it.
+
+11. Patents.
+A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”.
+
+A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License.
+
+Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version.
+
+In the following three paragraphs, a “patent license” is any express agreement or commitment, however denominated, not to enforce a patent (such as an express permission to practice a patent or covenant not to sue for patent infringement). To “grant” such a patent license to a party means to make such an agreement or commitment not to enforce a patent against the party.
+
+If you convey a covered work, knowingly relying on a patent license, and the Corresponding Source of the work is not available for anyone to copy, free of charge and under the terms of this License, through a publicly available network server or other readily accessible means, then you must either (1) cause the Corresponding Source to be so available, or (2) arrange to deprive yourself of the benefit of the patent license for this particular work, or (3) arrange, in a manner consistent with the requirements of this License, to extend the patent license to downstream recipients. “Knowingly relying” means you have actual knowledge that, but for the patent license, your conveying the covered work in a country, or your recipient's use of the covered work in a country, would infringe one or more identifiable patents in that country that you have reason to believe are valid.
+
+If, pursuant to or in connection with a single transaction or arrangement, you convey, or propagate by procuring conveyance of, a covered work, and grant a patent license to some of the parties receiving the covered work authorizing them to use, propagate, modify or convey a specific copy of the covered work, then the patent license you grant is automatically extended to all recipients of the covered work and works based on it.
+
+A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007.
+
+Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law.
+
+12. No Surrender of Others' Freedom.
+If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program.
+
+13. Use with the GNU Affero General Public License.
+Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such.
+
+14. Revised Versions of this License.
+The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns.
+
+Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation.
+
+If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program.
+
+Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version.
+
+15. Disclaimer of Warranty.
+THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+16. Limitation of Liability.
+IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES.
+
+17. Interpretation of Sections 15 and 16.
+If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee.
+
+END OF TERMS AND CONDITIONS
+
+How to Apply These Terms to Your New Programs
+
+If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms.
+
+To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”.
+
+You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see .
+
+The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read .
diff --git a/README.md b/README.md
index 53bed08..e6e1213 100644
--- a/README.md
+++ b/README.md
@@ -113,6 +113,6 @@ Android 客户端需要以下权限,全部用于核心功能:
## License
-本项目采用 [GPL-3.0-only](./LICENSE) 协议。所用第三方组件(WPF-UI、QRCoder)的许可声明见 [THIRD_PARTY_LICENSES.md](./THIRD_PARTY_LICENSES.md)。
+本项目采用 [GPL-3.0-only](./LICENSE) 协议。Windows 与 Android 发行版所用组件的许可和版权声明见 [THIRD_PARTY_LICENSES.md](./THIRD_PARTY_LICENSES.md)。Android APK 内置这两份文件,可在「设置 → 开源许可」中离线查看;Windows 压缩包内也包含两份文件。GitHub Release 提供对应构建提交的源码下载入口。
diff --git a/THIRD_PARTY_LICENSES.md b/THIRD_PARTY_LICENSES.md
index 727156c..63a938a 100644
--- a/THIRD_PARTY_LICENSES.md
+++ b/THIRD_PARTY_LICENSES.md
@@ -1,15 +1,22 @@
# 第三方组件许可 (Third-Party Licenses)
-本项目(AppMapper,GPL-3.0-only)使用了以下第三方组件,它们的许可如下。
-这些组件的版权声明与许可条款在此完整保留,以符合各自许可证的要求。
+AppMapper 自有代码采用 GPL-3.0-only,完整条款见 [LICENSE](./LICENSE)。下列第三方组件保留各自的许可证与版权声明。本文件与 LICENSE 一同包含在 Windows 压缩包及 Android APK 内,Android 用户可在「设置 → 开源许可」中离线查看。
-发布二进制时,请将本文件随软件一同分发。
+版本清单依据 2026-09-29 的 Windows NuGet 运行时还原结果及 Android releaseRuntimeClasspath 整理。升级依赖或改变构建配置时应重新核对。
-## WPF-UI
-- 版本:4.3.0
-- 仓库:https://github.com/lepoco/wpfui
-- 协议:MIT
+## Windows 运行时组件
+| NuGet 包 | 版本 | 许可 | 上游 |
+| --- | --- | --- | --- |
+| WPF-UI、WPF-UI.Tray、WPF-UI.Abstractions | 4.3.0 | MIT | [WPF UI](https://github.com/lepoco/wpfui/tree/153baadeaf475875a81a4605881ca5c2dbe53972) |
+| QRCoder | 1.6.0 | MIT | [QRCoder](https://github.com/Shane32/QRCoder/tree/bd980577640c47f8bb881cf24c8443415a579d36) |
+| System.Drawing.Common、Microsoft.Win32.SystemEvents | 10.0.7 | MIT | [.NET Runtime](https://github.com/dotnet/runtime/tree/v10.0.7) |
+
+三个 WPF UI 包中的 LICENSE.md 和 ThirdPartyNotices.txt 内容相同,以下各保留一次。.NET 两个包分别附带的第三方声明也在下文原文保留。
+
+### WPF UI MIT 许可(上述三个包)
+
+```text
MIT License
Copyright (c) 2021-2025 Leszek Pomianowski and WPF UI Contributors. https://lepo.co/
@@ -31,19 +38,388 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+```
+
+### QRCoder MIT 许可
+
+```text
+The MIT License (MIT)
+
+Copyright (c) 2013-2018 Raffael Herrmann
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+```
+
+### .NET Runtime MIT 许可(上述两个包)
+
+```text
+The MIT License (MIT)
+
+Copyright (c) .NET Foundation and Contributors
+
+All rights reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+```
+
+## Android 发行版依赖
+
+下表列出当前已解析的 57 个 release 运行时二进制构件,按 Maven group 和版本合并展示。Compose BOM 和仅调试使用的 ui-tooling 没有计入。列出的组件采用 Apache-2.0;许可证全文在表后保留一次。
+
+| Maven group | artifact(同组已解析构件) | 版本 | 许可 |
+| --- | --- | --- | --- |
+| `androidx.activity` | `activity`, `activity-compose`, `activity-ktx` | `1.9.3` | Apache-2.0 |
+| `androidx.compose.material3` | `material3-android` | `1.3.0` | Apache-2.0 |
+| `androidx.compose.ui` | `ui-util-android`, `ui-unit-android`, `ui-graphics-android`, `ui-geometry-android`, `ui-text-android`, `ui-tooling-preview-android`, `ui-android` | `1.7.4` | Apache-2.0 |
+| `androidx.compose.foundation` | `foundation-layout-android`, `foundation-android` | `1.7.4` | Apache-2.0 |
+| `androidx.compose.material` | `material-ripple-android`, `material-icons-core-android` | `1.7.4` | Apache-2.0 |
+| `androidx.compose.animation` | `animation-core-android`, `animation-android` | `1.7.4` | Apache-2.0 |
+| `androidx.core` | `core`, `core-ktx` | `1.13.1` | Apache-2.0 |
+| `androidx.lifecycle` | `lifecycle-common-jvm`, `lifecycle-livedata-core`, `lifecycle-runtime-android`, `lifecycle-viewmodel-android`, `lifecycle-viewmodel-ktx`, `lifecycle-runtime-compose-android`, `lifecycle-service`, `lifecycle-runtime-ktx-android`, `lifecycle-viewmodel-savedstate`, `lifecycle-process`, `lifecycle-common-java8` | `2.8.7` | Apache-2.0 |
+| `androidx.compose.runtime` | `runtime-saveable-android`, `runtime-android` | `1.7.4` | Apache-2.0 |
+| `org.jetbrains.kotlinx` | `kotlinx-coroutines-core-jvm`, `kotlinx-coroutines-android` | `1.9.0` | Apache-2.0 |
+| `androidx.savedstate` | `savedstate`, `savedstate-ktx` | `1.2.1` | Apache-2.0 |
+| `androidx.annotation` | `annotation-experimental` | `1.4.1` | Apache-2.0 |
+| `androidx.versionedparcelable` | `versionedparcelable` | `1.1.1` | Apache-2.0 |
+| `androidx.arch.core` | `core-common`, `core-runtime` | `2.2.0` | Apache-2.0 |
+| `androidx.collection` | `collection-jvm`, `collection-ktx` | `1.4.4` | Apache-2.0 |
+| `androidx.annotation` | `annotation-jvm` | `1.8.1` | Apache-2.0 |
+| `org.jetbrains.kotlin` | `kotlin-stdlib` | `2.2.10` | Apache-2.0 |
+| `com.journeyapps` | `zxing-android-embedded` | `4.3.0` | Apache-2.0 |
+| `org.jetbrains` | `annotations` | `23.0.0` | Apache-2.0 |
+| `com.google.zxing` | `core` | `3.4.1` | Apache-2.0 |
+| `androidx.autofill` | `autofill` | `1.0.0` | Apache-2.0 |
+| `androidx.emoji2` | `emoji2` | `1.3.0` | Apache-2.0 |
+| `androidx.graphics` | `graphics-path` | `1.0.1` | Apache-2.0 |
+| `androidx.customview` | `customview-poolingcontainer` | `1.0.0` | Apache-2.0 |
+| `androidx.interpolator` | `interpolator` | `1.0.0` | Apache-2.0 |
+| `androidx.profileinstaller` | `profileinstaller` | `1.3.1` | Apache-2.0 |
+| `androidx.startup` | `startup-runtime` | `1.1.1` | Apache-2.0 |
+| `androidx.tracing` | `tracing` | `1.0.0` | Apache-2.0 |
+| `androidx.concurrent` | `concurrent-futures` | `1.1.0` | Apache-2.0 |
+| `com.google.guava` | `listenablefuture` | `1.0` | Apache-2.0 |
+
+许可来源:[AndroidX](https://github.com/androidx/androidx/blob/androidx-main/LICENSE.txt)、[Kotlin](https://github.com/JetBrains/kotlin/blob/v2.2.10/license/LICENSE.txt)、[kotlinx.coroutines 1.9.0](https://github.com/Kotlin/kotlinx.coroutines/blob/1.9.0/LICENSE.txt)、[JetBrains annotations](https://github.com/JetBrains/java-annotations)、[JourneyApps ZXing 4.3.0](https://github.com/journeyapps/zxing-android-embedded/blob/v4.3.0/COPYING)、[ZXing Core 3.4.1](https://github.com/zxing/zxing/blob/zxing-3.4.1/LICENSE)、[Guava ListenableFuture](https://github.com/google/guava/blob/master/LICENSE)。
+
+### Apache License, Version 2.0(Android 组件及 Gradle Wrapper)
+
+```text
+
+ Apache License
+ Version 2.0, January 2004
+ http://www.apache.org/licenses/
+
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
+
+ 1. Definitions.
+
+ "License" shall mean the terms and conditions for use, reproduction,
+ and distribution as defined by Sections 1 through 9 of this document.
+
+ "Licensor" shall mean the copyright owner or entity authorized by
+ the copyright owner that is granting the License.
+
+ "Legal Entity" shall mean the union of the acting entity and all
+ other entities that control, are controlled by, or are under common
+ control with that entity. For the purposes of this definition,
+ "control" means (i) the power, direct or indirect, to cause the
+ direction or management of such entity, whether by contract or
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
+ outstanding shares, or (iii) beneficial ownership of such entity.
+
+ "You" (or "Your") shall mean an individual or Legal Entity
+ exercising permissions granted by this License.
+
+ "Source" form shall mean the preferred form for making modifications,
+ including but not limited to software source code, documentation
+ source, and configuration files.
+
+ "Object" form shall mean any form resulting from mechanical
+ transformation or translation of a Source form, including but
+ not limited to compiled object code, generated documentation,
+ and conversions to other media types.
+
+ "Work" shall mean the work of authorship, whether in Source or
+ Object form, made available under the License, as indicated by a
+ copyright notice that is included in or attached to the work
+ (an example is provided in the Appendix below).
+
+ "Derivative Works" shall mean any work, whether in Source or Object
+ form, that is based on (or derived from) the Work and for which the
+ editorial revisions, annotations, elaborations, or other modifications
+ represent, as a whole, an original work of authorship. For the purposes
+ of this License, Derivative Works shall not include works that remain
+ separable from, or merely link (or bind by name) to the interfaces of,
+ the Work and Derivative Works thereof.
+
+ "Contribution" shall mean any work of authorship, including
+ the original version of the Work and any modifications or additions
+ to that Work or Derivative Works thereof, that is intentionally
+ submitted to Licensor for inclusion in the Work by the copyright owner
+ or by an individual or Legal Entity authorized to submit on behalf of
+ the copyright owner. For the purposes of this definition, "submitted"
+ means any form of electronic, verbal, or written communication sent
+ to the Licensor or its representatives, including but not limited to
+ communication on electronic mailing lists, source code control systems,
+ and issue tracking systems that are managed by, or on behalf of, the
+ Licensor for the purpose of discussing and improving the Work, but
+ excluding communication that is conspicuously marked or otherwise
+ designated in writing by the copyright owner as "Not a Contribution."
+
+ "Contributor" shall mean Licensor and any individual or Legal Entity
+ on behalf of whom a Contribution has been received by Licensor and
+ subsequently incorporated within the Work.
+
+ 2. Grant of Copyright License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ copyright license to reproduce, prepare Derivative Works of,
+ publicly display, publicly perform, sublicense, and distribute the
+ Work and such Derivative Works in Source or Object form.
+
+ 3. Grant of Patent License. Subject to the terms and conditions of
+ this License, each Contributor hereby grants to You a perpetual,
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
+ (except as stated in this section) patent license to make, have made,
+ use, offer to sell, sell, import, and otherwise transfer the Work,
+ where such license applies only to those patent claims licensable
+ by such Contributor that are necessarily infringed by their
+ Contribution(s) alone or by combination of their Contribution(s)
+ with the Work to which such Contribution(s) was submitted. If You
+ institute patent litigation against any entity (including a
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
+ or a Contribution incorporated within the Work constitutes direct
+ or contributory patent infringement, then any patent licenses
+ granted to You under this License for that Work shall terminate
+ as of the date such litigation is filed.
+
+ 4. Redistribution. You may reproduce and distribute copies of the
+ Work or Derivative Works thereof in any medium, with or without
+ modifications, and in Source or Object form, provided that You
+ meet the following conditions:
+
+ (a) You must give any other recipients of the Work or
+ Derivative Works a copy of this License; and
+
+ (b) You must cause any modified files to carry prominent notices
+ stating that You changed the files; and
+
+ (c) You must retain, in the Source form of any Derivative Works
+ that You distribute, all copyright, patent, trademark, and
+ attribution notices from the Source form of the Work,
+ excluding those notices that do not pertain to any part of
+ the Derivative Works; and
+
+ (d) If the Work includes a "NOTICE" text file as part of its
+ distribution, then any Derivative Works that You distribute must
+ include a readable copy of the attribution notices contained
+ within such NOTICE file, excluding those notices that do not
+ pertain to any part of the Derivative Works, in at least one
+ of the following places: within a NOTICE text file distributed
+ as part of the Derivative Works; within the Source form or
+ documentation, if provided along with the Derivative Works; or,
+ within a display generated by the Derivative Works, if and
+ wherever such third-party notices normally appear. The contents
+ of the NOTICE file are for informational purposes only and
+ do not modify the License. You may add Your own attribution
+ notices within Derivative Works that You distribute, alongside
+ or as an addendum to the NOTICE text from the Work, provided
+ that such additional attribution notices cannot be construed
+ as modifying the License.
+
+ You may add Your own copyright statement to Your modifications and
+ may provide additional or different license terms and conditions
+ for use, reproduction, or distribution of Your modifications, or
+ for any such Derivative Works as a whole, provided Your use,
+ reproduction, and distribution of the Work otherwise complies with
+ the conditions stated in this License.
+
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
+ any Contribution intentionally submitted for inclusion in the Work
+ by You to the Licensor shall be under the terms and conditions of
+ this License, without any additional terms or conditions.
+ Notwithstanding the above, nothing herein shall supersede or modify
+ the terms of any separate license agreement you may have executed
+ with Licensor regarding such Contributions.
+
+ 6. Trademarks. This License does not grant permission to use the trade
+ names, trademarks, service marks, or product names of the Licensor,
+ except as required for reasonable and customary use in describing the
+ origin of the Work and reproducing the content of the NOTICE file.
+
+ 7. Disclaimer of Warranty. Unless required by applicable law or
+ agreed to in writing, Licensor provides the Work (and each
+ Contributor provides its Contributions) on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
+ implied, including, without limitation, any warranties or conditions
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
+ PARTICULAR PURPOSE. You are solely responsible for determining the
+ appropriateness of using or redistributing the Work and assume any
+ risks associated with Your exercise of permissions under this License.
-> WPF-UI 内部还捆绑了 microsoft/fluentui-system-icons、microsoft/segoe-fluent-icons-font、
-> sbaeumlisberger/VirtualizingWrapPanel、dotnet/wpf、microsoft/microsoft-ui-xaml 等组件,
-> 其声明见 WPF-UI 仓库的 ThirdPartyNotices.txt。
+ 8. Limitation of Liability. In no event and under no legal theory,
+ whether in tort (including negligence), contract, or otherwise,
+ unless required by applicable law (such as deliberate and grossly
+ negligent acts) or agreed to in writing, shall any Contributor be
+ liable to You for damages, including any direct, indirect, special,
+ incidental, or consequential damages of any character arising as a
+ result of this License or out of the use or inability to use the
+ Work (including but not limited to damages for loss of goodwill,
+ work stoppage, computer failure or malfunction, or any and all
+ other commercial damages or losses), even if such Contributor
+ has been advised of the possibility of such damages.
-## QRCoder
-- 版本:1.6.0
-- 仓库:https://github.com/codebude/QRCoder
-- 协议:MIT
+ 9. Accepting Warranty or Additional Liability. While redistributing
+ the Work or Derivative Works thereof, You may choose to offer,
+ and charge a fee for, acceptance of support, warranty, indemnity,
+ or other liability obligations and/or rights consistent with this
+ License. However, in accepting such obligations, You may act only
+ on Your own behalf and on Your sole responsibility, not on behalf
+ of any other Contributor, and only if You agree to indemnify,
+ defend, and hold each Contributor harmless for any liability
+ incurred by, or claims asserted against, such Contributor by reason
+ of your accepting any such warranty or additional liability.
+ END OF TERMS AND CONDITIONS
+
+ APPENDIX: How to apply the Apache License to your work.
+
+ To apply the Apache License to your work, attach the following
+ boilerplate notice, with the fields enclosed by brackets "[]"
+ replaced with your own identifying information. (Don't include
+ the brackets!) The text should be enclosed in the appropriate
+ comment syntax for the file format. We also recommend that a
+ file or class name and description of purpose be included on the
+ same "printed page" as the copyright notice for easier
+ identification within third-party archives.
+
+ Copyright [yyyy] [name of copyright owner]
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+```
+
+源码包还含有 Gradle Wrapper 9.7.1(gradlew、gradlew.bat、gradle-wrapper.jar),其脚本标头及 JAR 内的 META-INF/LICENSE 均标明 Apache-2.0。
+
+## 上游第三方告知原文
+
+以下文本来自上述确切版本的 NuGet 包,不作翻译。
+
+### WPF UI 4.3.0 — ThirdPartyNotices.txt
+
+```text
+WPF-UI
+
+THIRD-PARTY SOFTWARE NOTICES AND INFORMATION
+Do Not Translate or Localize
+
+This project incorporates components from the projects listed below. The original copyright notices and the licenses under which the authors received such components are set forth below.
+
+1. sbaeumlisberger/VirtualizingWrapPanel version 2.0.6 (https://github.com/sbaeumlisberger/VirtualizingWrapPanel), included in Wpf.Ui.
+2. microsoft/fluentui-system-icons version 1.1.242 (https://github.com/microsoft/fluentui-system-icons), included in Wpf.Ui.
+3. dotnet/wpf version 8.0 (https://github.com/dotnet/wpf), included in Wpf.Ui.
+4. microsoft/microsoft-ui-xaml version 3.0 (https://github.com/microsoft/microsoft-ui-xaml), included in Wpf.Ui.
+5. microsoft/segoe-fluent-icons-font version 3.0 (https://learn.microsoft.com/en-us/windows/apps/design/style/segoe-fluent-icons-font), included in Wpf.Ui.
+
+%% sbaeumlisberger/VirtualizingWrapPanel NOTICES AND INFORMATION BEGIN HERE
+=========================================
+MIT License
+
+Copyright (c) 2019 S. Bäumlisberger
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+=========================================
+END OF sbaeumlisberger/VirtualizingWrapPanel NOTICES AND INFORMATION
+
+%% microsoft/fluentui-system-icons NOTICES AND INFORMATION BEGIN HERE
+=========================================
MIT License
-Copyright (c) 2013-2024 Raffael Herrmann
+Copyright (c) 2020 Microsoft Corporation
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+=========================================
+END OF microsoft/fluentui-system-icons NOTICES AND INFORMATION
+
+%% dotnet/wpf NOTICES AND INFORMATION BEGIN HERE
+=========================================
+The MIT License (MIT)
+
+Copyright (c) .NET Foundation and Contributors
+
+All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
@@ -62,3 +438,1508 @@ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+=========================================
+END OF dotnet/wpf NOTICES AND INFORMATION
+
+%% microsoft/microsoft-ui-xaml NOTICES AND INFORMATION BEGIN HERE
+=========================================
+MIT License
+
+Copyright (c) Microsoft Corporation. All rights reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE
+=========================================
+END OF microsoft/microsoft-ui-xaml NOTICES AND INFORMATION
+
+%% microsoft/segoe-fluent-icons-font NOTICES AND INFORMATION BEGIN HERE
+=========================================
+You may use the Segoe and icon fonts, or glyphs included in this file (“Software”) solely to design, develop and test your programs that run on a Microsoft Platform, a Microsoft Platform includes but is not limited to any hardware or software product or service branded by trademark, trade dress, copyright or some other recognized means, as a product or service of Microsoft. This license does not grant you the right to distribute or sublicense all or part of the Software to any third party. By using the Software, you agree to these terms. If you do not agree to these terms, do not use the Software.
+=========================================
+END OF microsoft/segoe-fluent-icons-font NOTICES AND INFORMATION
+```
+
+### System.Drawing.Common 10.0.7 — THIRD-PARTY-NOTICES.TXT
+
+```text
+.NET Core uses third-party libraries or other resources that may be
+distributed under licenses different than the .NET Core software.
+
+In the event that we accidentally failed to list a required notice, please
+bring it to our attention. Post an issue or email us:
+
+ dotnet@microsoft.com
+
+The attached notices are provided for information only.
+
+License notice for Ookie.Dialogs
+--------------------------------
+
+http://www.ookii.org/software/dialogs/
+
+Copyright © Sven Groot (Ookii.org) 2009
+All rights reserved.
+
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1) Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+2) Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+3) Neither the name of the ORGANIZATION nor the names of its contributors
+ may be used to endorse or promote products derived from this software
+ without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF
+THE POSSIBILITY OF SUCH DAMAGE.
+```
+
+### Microsoft.Win32.SystemEvents 10.0.7 — THIRD-PARTY-NOTICES.TXT
+
+```text
+.NET Runtime uses third-party libraries or other resources that may be
+distributed under licenses different than the .NET Runtime software.
+
+In the event that we accidentally failed to list a required notice, please
+bring it to our attention. Post an issue or email us:
+
+ dotnet@microsoft.com
+
+The attached notices are provided for information only.
+
+License notice for ASP.NET
+-------------------------------
+
+Copyright (c) .NET Foundation. All rights reserved.
+Licensed under the Apache License, Version 2.0.
+
+Available at
+https://github.com/dotnet/aspnetcore/blob/main/LICENSE.txt
+
+License notice for Slicing-by-8
+-------------------------------
+
+http://sourceforge.net/projects/slicing-by-8/
+
+Copyright (c) 2004-2006 Intel Corporation - All Rights Reserved
+
+
+This software program is licensed subject to the BSD License, available at
+http://www.opensource.org/licenses/bsd-license.html.
+
+
+License notice for Unicode data
+-------------------------------
+
+https://www.unicode.org/license.html
+
+Copyright © 1991-2024 Unicode, Inc. All rights reserved.
+Distributed under the Terms of Use in https://www.unicode.org/copyright.html.
+
+Permission is hereby granted, free of charge, to any person obtaining a
+copy of data files and any associated documentation (the "Data Files") or
+software and any associated documentation (the "Software") to deal in the
+Data Files or Software without restriction, including without limitation
+the rights to use, copy, modify, merge, publish, distribute, and/or sell
+copies of the Data Files or Software, and to permit persons to whom the
+Data Files or Software are furnished to do so, provided that either (a)
+this copyright and permission notice appear with all copies of the Data
+Files or Software, or (b) this copyright and permission notice appear in
+associated Documentation.
+
+THE DATA FILES AND SOFTWARE ARE PROVIDED "AS IS", WITHOUT WARRANTY OF ANY
+KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF
+THIRD PARTY RIGHTS.
+
+IN NO EVENT SHALL THE COPYRIGHT HOLDER OR HOLDERS INCLUDED IN THIS NOTICE
+BE LIABLE FOR ANY CLAIM, OR ANY SPECIAL INDIRECT OR CONSEQUENTIAL DAMAGES,
+OR ANY DAMAGES WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS,
+WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION,
+ARISING OUT OF OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THE DATA
+FILES OR SOFTWARE.
+
+Except as contained in this notice, the name of a copyright holder shall
+not be used in advertising or otherwise to promote the sale, use or other
+dealings in these Data Files or Software without prior written
+authorization of the copyright holder.
+
+License notice for zlib-ng
+-----------------------
+
+https://github.com/zlib-ng/zlib-ng/blob/d54e3769be0c522015b784eca2af258b1c026107/LICENSE.md
+
+(C) 1995-2024 Jean-loup Gailly and Mark Adler
+
+This software is provided 'as-is', without any express or implied
+warranty. In no event will the authors be held liable for any damages
+arising from the use of this software.
+
+Permission is granted to anyone to use this software for any purpose,
+including commercial applications, and to alter it and redistribute it
+freely, subject to the following restrictions:
+
+1. The origin of this software must not be misrepresented; you must not
+ claim that you wrote the original software. If you use this software
+ in a product, an acknowledgment in the product documentation would be
+ appreciated but is not required.
+
+2. Altered source versions must be plainly marked as such, and must not be
+ misrepresented as being the original software.
+
+3. This notice may not be removed or altered from any source distribution.
+
+License notice for opentelemetry-dotnet
+---------------------------------------
+
+https://github.com/open-telemetry/opentelemetry-dotnet/blob/805dd6b4abfa18ef2706d04c30d0ed28dbc2955e/LICENSE.TXT#L1
+
+Apache License
+Version 2.0, January 2004
+http://www.apache.org/licenses/
+
+Copyright The OpenTelemetry Authors
+
+
+License notice for LinuxTracepoints
+-----------------------------------
+
+https://github.com/microsoft/LinuxTracepoints/blob/main/LICENSE
+
+Copyright (c) Microsoft Corporation.
+
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE
+
+License notice for Mono
+-------------------------------
+
+http://www.mono-project.com/docs/about-mono/
+
+Copyright (c) .NET Foundation Contributors
+
+MIT License
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the Software), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+License notice for International Organization for Standardization
+-----------------------------------------------------------------
+
+Portions (C) International Organization for Standardization 1986:
+ Permission to copy in any form is granted for use with
+ conforming SGML systems and applications as defined in
+ ISO 8879, provided this notice is included in all copies.
+
+License notice for Intel
+------------------------
+
+"Copyright (c) 2004-2006 Intel Corporation - All Rights Reserved
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright notice,
+this list of conditions and the following disclaimer in the documentation
+and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
+SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
+CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
+OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+License notice for Xamarin and Novell
+-------------------------------------
+
+Copyright (c) 2015 Xamarin, Inc (http://www.xamarin.com)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+Copyright (c) 2011 Novell, Inc (http://www.novell.com)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+Third party notice for W3C
+--------------------------
+
+"W3C SOFTWARE AND DOCUMENT NOTICE AND LICENSE
+Status: This license takes effect 13 May, 2015.
+This work is being provided by the copyright holders under the following license.
+License
+By obtaining and/or copying this work, you (the licensee) agree that you have read, understood, and will comply with the following terms and conditions.
+Permission to copy, modify, and distribute this work, with or without modification, for any purpose and without fee or royalty is hereby granted, provided that you include the following on ALL copies of the work or portions thereof, including modifications:
+The full text of this NOTICE in a location viewable to users of the redistributed or derivative work.
+Any pre-existing intellectual property disclaimers, notices, or terms and conditions. If none exist, the W3C Software and Document Short Notice should be included.
+Notice of any changes or modifications, through a copyright statement on the new code or document such as "This software or document includes material copied from or derived from [title and URI of the W3C document]. Copyright © [YEAR] W3C® (MIT, ERCIM, Keio, Beihang)."
+Disclaimers
+THIS WORK IS PROVIDED "AS IS," AND COPYRIGHT HOLDERS MAKE NO REPRESENTATIONS OR WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO, WARRANTIES OF MERCHANTABILITY OR FITNESS FOR ANY PARTICULAR PURPOSE OR THAT THE USE OF THE SOFTWARE OR DOCUMENT WILL NOT INFRINGE ANY THIRD PARTY PATENTS, COPYRIGHTS, TRADEMARKS OR OTHER RIGHTS.
+COPYRIGHT HOLDERS WILL NOT BE LIABLE FOR ANY DIRECT, INDIRECT, SPECIAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF ANY USE OF THE SOFTWARE OR DOCUMENT.
+The name and trademarks of copyright holders may NOT be used in advertising or publicity pertaining to the work without specific, written prior permission. Title to copyright in this work will at all times remain with copyright holders."
+
+License notice for Bit Twiddling Hacks
+--------------------------------------
+
+Bit Twiddling Hacks
+
+By Sean Eron Anderson
+seander@cs.stanford.edu
+
+Individually, the code snippets here are in the public domain (unless otherwise
+noted) — feel free to use them however you please. The aggregate collection and
+descriptions are © 1997-2005 Sean Eron Anderson. The code and descriptions are
+distributed in the hope that they will be useful, but WITHOUT ANY WARRANTY and
+without even the implied warranty of merchantability or fitness for a particular
+purpose.
+
+License notice for Brotli
+--------------------------------------
+
+Copyright (c) 2009, 2010, 2013-2016 by the Brotli Authors.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+compress_fragment.c:
+Copyright (c) 2011, Google Inc.
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+""AS IS"" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+decode_fuzzer.c:
+Copyright (c) 2015 The Chromium Authors. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+copyright notice, this list of conditions and the following disclaimer
+in the documentation and/or other materials provided with the
+distribution.
+ * Neither the name of Google Inc. nor the names of its
+contributors may be used to endorse or promote products derived from
+this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+""AS IS"" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE."
+
+License notice for Json.NET
+-------------------------------
+
+https://github.com/JamesNK/Newtonsoft.Json/blob/master/LICENSE.md
+
+The MIT License (MIT)
+
+Copyright (c) 2007 James Newton-King
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
+COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
+IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+License notice for vectorized base64 encoding / decoding
+--------------------------------------------------------
+
+Copyright (c) 2005-2007, Nick Galbreath
+Copyright (c) 2013-2017, Alfred Klomp
+Copyright (c) 2015-2017, Wojciech Mula
+Copyright (c) 2016-2017, Matthieu Darbois
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+- Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+
+- Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
+IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
+PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+License notice for vectorized hex parsing
+--------------------------------------------------------
+
+Copyright (c) 2022, Geoff Langdale
+Copyright (c) 2022, Wojciech Mula
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+- Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+
+- Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
+IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
+PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+License notice for RFC 3492
+---------------------------
+
+The punycode implementation is based on the sample code in RFC 3492
+
+Copyright (C) The Internet Society (2003). All Rights Reserved.
+
+This document and translations of it may be copied and furnished to
+others, and derivative works that comment on or otherwise explain it
+or assist in its implementation may be prepared, copied, published
+and distributed, in whole or in part, without restriction of any
+kind, provided that the above copyright notice and this paragraph are
+included on all such copies and derivative works. However, this
+document itself may not be modified in any way, such as by removing
+the copyright notice or references to the Internet Society or other
+Internet organizations, except as needed for the purpose of
+developing Internet standards in which case the procedures for
+copyrights defined in the Internet Standards process must be
+followed, or as required to translate it into languages other than
+English.
+
+The limited permissions granted above are perpetual and will not be
+revoked by the Internet Society or its successors or assigns.
+
+This document and the information contained herein is provided on an
+"AS IS" basis and THE INTERNET SOCIETY AND THE INTERNET ENGINEERING
+TASK FORCE DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING
+BUT NOT LIMITED TO ANY WARRANTY THAT THE USE OF THE INFORMATION
+HEREIN WILL NOT INFRINGE ANY RIGHTS OR ANY IMPLIED WARRANTIES OF
+MERCHANTABILITY OR FITNESS FOR A PARTICULAR PURPOSE.
+
+Copyright(C) The Internet Society 1997. All Rights Reserved.
+
+This document and translations of it may be copied and furnished to others,
+and derivative works that comment on or otherwise explain it or assist in
+its implementation may be prepared, copied, published and distributed, in
+whole or in part, without restriction of any kind, provided that the above
+copyright notice and this paragraph are included on all such copies and
+derivative works.However, this document itself may not be modified in any
+way, such as by removing the copyright notice or references to the Internet
+Society or other Internet organizations, except as needed for the purpose of
+developing Internet standards in which case the procedures for copyrights
+defined in the Internet Standards process must be followed, or as required
+to translate it into languages other than English.
+
+The limited permissions granted above are perpetual and will not be revoked
+by the Internet Society or its successors or assigns.
+
+This document and the information contained herein is provided on an "AS IS"
+basis and THE INTERNET SOCIETY AND THE INTERNET ENGINEERING TASK FORCE
+DISCLAIMS ALL WARRANTIES, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
+ANY WARRANTY THAT THE USE OF THE INFORMATION HEREIN WILL NOT INFRINGE ANY
+RIGHTS OR ANY IMPLIED WARRANTIES OF MERCHANTABILITY OR FITNESS FOR A
+PARTICULAR PURPOSE.
+
+License notice for Algorithm from RFC 4122 -
+A Universally Unique IDentifier (UUID) URN Namespace
+----------------------------------------------------
+
+Copyright (c) 1990- 1993, 1996 Open Software Foundation, Inc.
+Copyright (c) 1989 by Hewlett-Packard Company, Palo Alto, Ca. &
+Digital Equipment Corporation, Maynard, Mass.
+Copyright (c) 1998 Microsoft.
+To anyone who acknowledges that this file is provided "AS IS"
+without any express or implied warranty: permission to use, copy,
+modify, and distribute this file for any purpose is hereby
+granted without fee, provided that the above copyright notices and
+this notice appears in all source code copies, and that none of
+the names of Open Software Foundation, Inc., Hewlett-Packard
+Company, Microsoft, or Digital Equipment Corporation be used in
+advertising or publicity pertaining to distribution of the software
+without specific, written prior permission. Neither Open Software
+Foundation, Inc., Hewlett-Packard Company, Microsoft, nor Digital
+Equipment Corporation makes any representations about the
+suitability of this software for any purpose."
+
+License notice for The LLVM Compiler Infrastructure
+---------------------------------------------------
+
+Developed by:
+
+ LLVM Team
+
+ University of Illinois at Urbana-Champaign
+
+ http://llvm.org
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal with
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
+of the Software, and to permit persons to whom the Software is furnished to do
+so, subject to the following conditions:
+
+ * Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimers.
+
+ * Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimers in the
+ documentation and/or other materials provided with the distribution.
+
+ * Neither the names of the LLVM Team, University of Illinois at
+ Urbana-Champaign, nor the names of its contributors may be used to
+ endorse or promote products derived from this Software without specific
+ prior written permission.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
+FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE
+SOFTWARE.
+
+License notice for Bob Jenkins
+------------------------------
+
+By Bob Jenkins, 1996. bob_jenkins@burtleburtle.net. You may use this
+code any way you wish, private, educational, or commercial. It's free.
+
+License notice for Greg Parker
+------------------------------
+
+Greg Parker gparker@cs.stanford.edu December 2000
+This code is in the public domain and may be copied or modified without
+permission.
+
+License notice for libunwind based code
+----------------------------------------
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+License notice for Printing Floating-Point Numbers (Dragon4)
+------------------------------------------------------------
+
+/******************************************************************************
+ Copyright (c) 2014 Ryan Juckett
+ http://www.ryanjuckett.com/
+
+ This software is provided 'as-is', without any express or implied
+ warranty. In no event will the authors be held liable for any damages
+ arising from the use of this software.
+
+ Permission is granted to anyone to use this software for any purpose,
+ including commercial applications, and to alter it and redistribute it
+ freely, subject to the following restrictions:
+
+ 1. The origin of this software must not be misrepresented; you must not
+ claim that you wrote the original software. If you use this software
+ in a product, an acknowledgment in the product documentation would be
+ appreciated but is not required.
+
+ 2. Altered source versions must be plainly marked as such, and must not be
+ misrepresented as being the original software.
+
+ 3. This notice may not be removed or altered from any source
+ distribution.
+******************************************************************************/
+
+License notice for Printing Floating-point Numbers (Grisu3)
+-----------------------------------------------------------
+
+Copyright 2012 the V8 project authors. All rights reserved.
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following
+ disclaimer in the documentation and/or other materials provided
+ with the distribution.
+ * Neither the name of Google Inc. nor the names of its
+ contributors may be used to endorse or promote products derived
+ from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+License notice for xxHash
+-------------------------
+
+xxHash - Extremely Fast Hash algorithm
+Header File
+Copyright (C) 2012-2021 Yann Collet
+
+BSD 2-Clause License (https://www.opensource.org/licenses/bsd-license.php)
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above
+ copyright notice, this list of conditions and the following disclaimer
+ in the documentation and/or other materials provided with the
+ distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+You can contact the author at:
+ - xxHash homepage: https://www.xxhash.com
+ - xxHash source repository: https://github.com/Cyan4973/xxHash
+
+License notice for Berkeley SoftFloat Release 3e
+------------------------------------------------
+
+https://github.com/ucb-bar/berkeley-softfloat-3
+https://github.com/ucb-bar/berkeley-softfloat-3/blob/master/COPYING.txt
+
+License for Berkeley SoftFloat Release 3e
+
+John R. Hauser
+2018 January 20
+
+The following applies to the whole of SoftFloat Release 3e as well as to
+each source file individually.
+
+Copyright 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018 The Regents of the
+University of California. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+ 1. Redistributions of source code must retain the above copyright notice,
+ this list of conditions, and the following disclaimer.
+
+ 2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions, and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+ 3. Neither the name of the University nor the names of its contributors
+ may be used to endorse or promote products derived from this software
+ without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS "AS IS", AND ANY
+EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE, ARE
+DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY
+DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF
+THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+License notice for xoshiro RNGs
+--------------------------------
+
+Written in 2018 by David Blackman and Sebastiano Vigna (vigna@acm.org)
+
+To the extent possible under law, the author has dedicated all copyright
+and related and neighboring rights to this software to the public domain
+worldwide. This software is distributed without any warranty.
+
+See .
+
+License for fastmod (https://github.com/lemire/fastmod), ibm-fpgen (https://github.com/nigeltao/parse-number-fxx-test-data) and fastrange (https://github.com/lemire/fastrange)
+--------------------------------------
+
+ Copyright 2018 Daniel Lemire
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+License for sse4-strstr (https://github.com/WojciechMula/sse4-strstr)
+--------------------------------------
+
+ Copyright (c) 2008-2016, Wojciech Mula
+ All rights reserved.
+
+ Redistribution and use in source and binary forms, with or without
+ modification, are permitted provided that the following conditions are
+ met:
+
+ 1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+ 2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
+ IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+ TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
+ PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+ HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+ SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+ TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+ PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+ LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+ NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+License notice for The C++ REST SDK
+-----------------------------------
+
+C++ REST SDK
+
+The MIT License (MIT)
+
+Copyright (c) Microsoft Corporation
+
+All rights reserved.
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of
+this software and associated documentation files (the "Software"), to deal in
+the Software without restriction, including without limitation the rights to
+use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
+the Software, and to permit persons to whom the Software is furnished to do so,
+subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+License notice for MessagePack-CSharp
+-------------------------------------
+
+MessagePack for C#
+
+MIT License
+
+Copyright (c) 2017 Yoshifumi Kawai
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+License notice for lz4net
+-------------------------------------
+
+lz4net
+
+Copyright (c) 2013-2017, Milosz Krajewski
+
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification, are permitted provided that the following conditions are met:
+
+Redistributions of source code must retain the above copyright notice, this list of conditions and the following disclaimer.
+
+Redistributions in binary form must reproduce the above copyright notice, this list of conditions and the following disclaimer in the documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+License notice for Nerdbank.Streams
+-----------------------------------
+
+The MIT License (MIT)
+
+Copyright (c) Andrew Arnott
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+License notice for RapidJSON
+----------------------------
+
+Tencent is pleased to support the open source community by making RapidJSON available.
+
+Copyright (C) 2015 THL A29 Limited, a Tencent company, and Milo Yip. All rights reserved.
+
+Licensed under the MIT License (the "License"); you may not use this file except
+in compliance with the License. You may obtain a copy of the License at
+
+http://opensource.org/licenses/MIT
+
+Unless required by applicable law or agreed to in writing, software distributed
+under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR
+CONDITIONS OF ANY KIND, either express or implied. See the License for the
+specific language governing permissions and limitations under the License.
+
+License notice for DirectX Math Library
+---------------------------------------
+
+https://github.com/microsoft/DirectXMath/blob/master/LICENSE
+
+ The MIT License (MIT)
+
+Copyright (c) 2011-2020 Microsoft Corp
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this
+software and associated documentation files (the "Software"), to deal in the Software
+without restriction, including without limitation the rights to use, copy, modify,
+merge, publish, distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to the following
+conditions:
+
+The above copyright notice and this permission notice shall be included in all copies
+or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED,
+INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
+PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
+HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF
+CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE
+OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+License notice for ldap4net
+---------------------------
+
+The MIT License (MIT)
+
+Copyright (c) 2018 Alexander Chermyanin
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+License notice for vectorized sorting code
+------------------------------------------
+
+MIT License
+
+Copyright (c) 2020 Dan Shechter
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+License notice for musl
+-----------------------
+
+musl as a whole is licensed under the following standard MIT license:
+
+Copyright © 2005-2020 Rich Felker, et al.
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
+IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
+CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
+TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
+SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+
+License notice for "Faster Unsigned Division by Constants"
+------------------------------
+
+Reference implementations of computing and using the "magic number" approach to dividing
+by constants, including codegen instructions. The unsigned division incorporates the
+"round down" optimization per ridiculous_fish.
+
+This is free and unencumbered software. Any copyright is dedicated to the Public Domain.
+
+
+License notice for mimalloc
+-----------------------------------
+
+MIT License
+
+Copyright (c) 2019 Microsoft Corporation, Daan Leijen
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+License for remote stack unwind (https://github.com/llvm/llvm-project/blob/main/lldb/source/Symbol/CompactUnwindInfo.cpp)
+--------------------------------------
+
+Copyright 2019 LLVM Project
+
+Licensed under the Apache License, Version 2.0 (the "License") with LLVM Exceptions;
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+
+https://llvm.org/LICENSE.txt
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+
+License notice for Apple header files
+-------------------------------------
+
+Copyright (c) 1980, 1986, 1993
+ The Regents of the University of California. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+3. All advertising materials mentioning features or use of this software
+ must display the following acknowledgement:
+ This product includes software developed by the University of
+ California, Berkeley and its contributors.
+4. Neither the name of the University nor the names of its contributors
+ may be used to endorse or promote products derived from this software
+ without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE REGENTS AND CONTRIBUTORS ``AS IS'' AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+ARE DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE
+FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
+DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS
+OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION)
+HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT
+LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY
+OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGE.
+
+License notice for JavaScript queues
+-------------------------------------
+
+CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER.
+
+Statement of Purpose
+The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work").
+Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others.
+For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights.
+
+1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following:
+the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work;
+moral rights retained by the original author(s) and/or performer(s);
+publicity and privacy rights pertaining to a person's image or likeness depicted in a Work;
+rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below;
+rights protecting the extraction, dissemination, use and reuse of data in a Work;
+database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and
+other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof.
+2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose.
+3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose.
+4. Limitations and Disclaimers.
+a. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document.
+b. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law.
+c. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work.
+d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work.
+
+
+License notice for FastFloat algorithm
+-------------------------------------
+MIT License
+Copyright (c) 2021 csFastFloat authors
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+License notice for MsQuic
+--------------------------------------
+
+Copyright (c) Microsoft Corporation.
+Licensed under the MIT License.
+
+Available at
+https://github.com/microsoft/msquic/blob/main/LICENSE
+
+License notice for m-ou-se/floatconv
+-------------------------------
+
+Copyright (c) 2020 Mara Bos
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are met:
+
+1. Redistributions of source code must retain the above copyright notice, this
+ list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR
+ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND
+ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+License notice for code from The Practice of Programming
+-------------------------------
+
+Copyright (C) 1999 Lucent Technologies
+
+Excerpted from 'The Practice of Programming
+by Brian W. Kernighan and Rob Pike
+
+You may use this code for any purpose, as long as you leave the copyright notice and book citation attached.
+
+Notice for Euclidean Affine Functions and Applications to Calendar
+Algorithms
+-------------------------------
+
+Aspects of Date/Time processing based on algorithm described in "Euclidean Affine Functions and Applications to Calendar
+Algorithms", Cassio Neri and Lorenz Schneider. https://arxiv.org/pdf/2102.06959.pdf
+
+License notice for amd/aocl-libm-ose
+-------------------------------
+
+Copyright (C) 2008-2020 Advanced Micro Devices, Inc. All rights reserved.
+
+Redistribution and use in source and binary forms, with or without modification,
+are permitted provided that the following conditions are met:
+1. Redistributions of source code must retain the above copyright notice,
+ this list of conditions and the following disclaimer.
+2. Redistributions in binary form must reproduce the above copyright notice,
+ this list of conditions and the following disclaimer in the documentation
+ and/or other materials provided with the distribution.
+3. Neither the name of the copyright holder nor the names of its contributors
+ may be used to endorse or promote products derived from this software without
+ specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
+ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
+WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
+IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
+INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
+BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA,
+OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY,
+WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+POSSIBILITY OF SUCH DAMAGE.
+
+License notice for fmtlib/fmt
+-------------------------------
+
+Formatting library for C++
+
+Copyright (c) 2012 - present, Victor Zverovich
+
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of this software and associated documentation files (the
+"Software"), to deal in the Software without restriction, including
+without limitation the rights to use, copy, modify, merge, publish,
+distribute, sublicense, and/or sell copies of the Software, and to
+permit persons to whom the Software is furnished to do so, subject to
+the following conditions:
+
+The above copyright notice and this permission notice shall be
+included in all copies or substantial portions of the Software.
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
+NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
+LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
+OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
+WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+License for Jb Evain
+---------------------
+
+Copyright (c) 2006 Jb Evain (jbevain@gmail.com)
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included
+in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+--- Optional exception to the license ---
+
+As an exception, if, as a result of your compiling your source code, portions
+of this Software are embedded into a machine-executable object form of such
+source code, you may redistribute such embedded portions in such object form
+without including the above copyright and permission notices.
+
+
+License for MurmurHash3
+--------------------------------------
+
+https://github.com/aappleby/smhasher/blob/master/src/MurmurHash3.cpp
+
+MurmurHash3 was written by Austin Appleby, and is placed in the public
+domain. The author hereby disclaims copyright to this source
+
+License for Fast CRC Computation
+--------------------------------------
+
+https://github.com/intel/isa-l/blob/33a2d9484595c2d6516c920ce39a694c144ddf69/crc/crc32_ieee_by4.asm
+https://github.com/intel/isa-l/blob/33a2d9484595c2d6516c920ce39a694c144ddf69/crc/crc64_ecma_norm_by8.asm
+
+Copyright(c) 2011-2015 Intel Corporation All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions
+are met:
+ * Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+ * Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in
+ the documentation and/or other materials provided with the
+ distribution.
+ * Neither the name of Intel Corporation nor the names of its
+ contributors may be used to endorse or promote products derived
+ from this software without specific prior written permission.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+License for C# Implementation of Fast CRC Computation
+-----------------------------------------------------
+
+https://github.com/SixLabors/ImageSharp/blob/f4f689ce67ecbcc35cebddba5aacb603e6d1068a/src/ImageSharp/Formats/Png/Zlib/Crc32.cs
+
+Copyright (c) Six Labors.
+Licensed under the Apache License, Version 2.0.
+
+Available at
+https://github.com/SixLabors/ImageSharp/blob/f4f689ce67ecbcc35cebddba5aacb603e6d1068a/LICENSE
+
+License for the Teddy multi-substring searching implementation
+--------------------------------------
+
+https://github.com/BurntSushi/aho-corasick
+
+The MIT License (MIT)
+
+Copyright (c) 2015 Andrew Gallant
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in
+all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
+THE SOFTWARE.
+
+License notice for Avx512Vbmi base64 encoding / decoding
+--------------------------------------------------------
+
+Copyright (c) 2015-2018, Wojciech Muła
+All rights reserved.
+
+Redistribution and use in source and binary forms, with or without
+modification, are permitted provided that the following conditions are
+met:
+
+1. Redistributions of source code must retain the above copyright
+ notice, this list of conditions and the following disclaimer.
+
+2. Redistributions in binary form must reproduce the above copyright
+ notice, this list of conditions and the following disclaimer in the
+ documentation and/or other materials provided with the distribution.
+
+THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS
+IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED
+TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A
+PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
+HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED
+TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
+PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
+LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
+NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+
+--------------------------------------------------------
+
+Aspects of base64 encoding / decoding are based on algorithm described in "Base64 encoding and decoding at almost the speed of a memory
+copy", Wojciech Muła and Daniel Lemire. https://arxiv.org/pdf/1910.05109.pdf
+
+License for FormatJS Intl.Segmenter grapheme segmentation algorithm
+--------------------------------------------------------------------------
+Available at https://github.com/formatjs/formatjs/blob/58d6a7b398d776ca3d2726d72ae1573b65cc3bef/packages/intl-segmenter/LICENSE.md
+
+MIT License
+
+Copyright (c) 2022 FormatJS
+
+Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+
+License for SharpFuzz and related samples
+--------------------------------------
+
+https://github.com/Metalnem/sharpfuzz
+https://github.com/Metalnem/dotnet-fuzzers
+https://github.com/Metalnem/libfuzzer-dotnet
+
+MIT License
+
+Copyright (c) 2018 Nemanja Mijailovic
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
+
+License for National Institute of Standards and Technology ACVP Data
+--------------------------------------------------------------------
+Available at https://github.com/usnistgov/ACVP-Server/blob/85f8742965b2691862079172982683757d8d91db/README.md#License
+
+NIST-developed software is provided by NIST as a public service. You may use, copy, and distribute copies of the software in any medium, provided that you keep intact this entire notice. You may improve, modify, and create derivative works of the software or any portion of the software, and you may copy and distribute such modifications or works. Modified works should carry a notice stating that you changed the software and should note the date and nature of any such change. Please explicitly acknowledge the National Institute of Standards and Technology as the source of the software.
+
+NIST-developed software is expressly provided "AS IS." NIST MAKES NO WARRANTY OF ANY KIND, EXPRESS, IMPLIED, IN FACT, OR ARISING BY OPERATION OF LAW, INCLUDING, WITHOUT LIMITATION, THE IMPLIED WARRANTY OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE, NON-INFRINGEMENT, AND DATA ACCURACY. NIST NEITHER REPRESENTS NOR WARRANTS THAT THE OPERATION OF THE SOFTWARE WILL BE UNINTERRUPTED OR ERROR-FREE, OR THAT ANY DEFECTS WILL BE CORRECTED. NIST DOES NOT WARRANT OR MAKE ANY REPRESENTATIONS REGARDING THE USE OF THE SOFTWARE OR THE RESULTS THEREOF, INCLUDING BUT NOT LIMITED TO THE CORRECTNESS, ACCURACY, RELIABILITY, OR USEFULNESS OF THE SOFTWARE.
+
+You are solely responsible for determining the appropriateness of using and distributing the software and you assume all risks associated with its use, including but not limited to the risks and costs of program errors, compliance with applicable laws, damage to or loss of data, programs or equipment, and the unavailability or interruption of operation. This software is not intended to be used in any situation where a failure could cause risk of injury or damage to property. The software developed by NIST employees is not subject to copyright protection within the United States.
+
+```
diff --git a/androidApp/app/build.gradle.kts b/androidApp/app/build.gradle.kts
index ef26020..5fe74a9 100644
--- a/androidApp/app/build.gradle.kts
+++ b/androidApp/app/build.gradle.kts
@@ -22,10 +22,17 @@ val hasReleaseSigning = listOf(
releaseKeyPassword,
).all { !it.isNullOrBlank() }
+val licenseAssets = layout.buildDirectory.dir("generated/licenseAssets")
+val syncLicenseAssets = tasks.register("syncLicenseAssets") {
+ from(rootProject.file("LICENSE"), rootProject.file("THIRD_PARTY_LICENSES.md"))
+ into(licenseAssets)
+}
+
android {
namespace = "dev.youximi.appmapper"
compileSdk = 35
buildToolsVersion = "36.0.0"
+ sourceSets.getByName("main").assets.srcDir(licenseAssets.get().asFile)
signingConfigs {
if (hasReleaseSigning) {
@@ -69,6 +76,10 @@ android {
}
}
+tasks.named("preBuild") {
+ dependsOn(syncLicenseAssets)
+}
+
dependencies {
val composeBom = platform("androidx.compose:compose-bom:2024.10.00")
implementation(composeBom)
diff --git a/androidApp/app/src/main/AndroidManifest.xml b/androidApp/app/src/main/AndroidManifest.xml
index b8076dd..37fb926 100644
--- a/androidApp/app/src/main/AndroidManifest.xml
+++ b/androidApp/app/src/main/AndroidManifest.xml
@@ -1,4 +1,5 @@
-
+
@@ -6,7 +7,11 @@
-
+
+
+
+
@@ -22,6 +27,7 @@
android:theme="@style/AppTheme">
@@ -29,6 +35,10 @@
+
+
- Box(
- modifier = Modifier
- .fillMaxSize()
- .padding(padding),
- ) {
- when (selectedTab) {
- MainTab.Home -> HomeScreen(
- pairedComputer = appState.pairedComputer,
- connectionStatus = connectionStatus,
- onPair = { request ->
- coordinator.savePollingMs(draftPollingMs)
- coordinator.pair(request)
- },
- onStart = {
- coordinator.savePollingMs(draftPollingMs)
- coordinator.startService()
+ BackHandler(enabled = rootPage != RootPage.Main) { rootPage = RootPage.Main }
+
+ Crossfade(targetState = rootPage, animationSpec = tween(220), label = "root page") { page ->
+ pageState.SaveableStateProvider(page.name) {
+ if (page == RootPage.Logs) {
+ LogsScreen(coordinator = coordinator, onBack = { rootPage = RootPage.Main })
+ } else if (page == RootPage.Licenses) {
+ OpenSourceLicensesScreen(onBack = { rootPage = RootPage.Main })
+ } else if (page == RootPage.Scanner) {
+ QrScannerScreen(
+ onBack = { rootPage = RootPage.Main },
+ onResult = { contents ->
+ // Crossfade retains the outgoing camera briefly after Back.
+ if (rootPage == RootPage.Scanner) {
+ val request = PairingParser.parseUri(contents)
+ scanError = if (request == null) "二维码无效或版本过旧,请扫描电脑端当前显示的配对二维码。" else ""
+ rootPage = RootPage.Main
+ if (request != null) pair(request)
+ }
},
- onStop = coordinator::stopService,
)
+ } else {
+ BoxWithConstraints(Modifier.fillMaxSize()) {
+ val useRail = maxWidth >= 600.dp
+ val scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior()
+ val tabState = rememberSaveableStateHolder()
- MainTab.Settings -> SettingsScreen(
- hasUsageAccess = appState.hasUsageAccess,
- pollingMs = draftPollingMs,
- onOpenUsageAccess = coordinator::openUsageAccessSettings,
- onPollingSelected = { draftPollingMs = it },
- onOpenLogs = { rootPage = RootPage.Logs },
- pairedComputer = appState.pairedComputer,
- onForgetComputer = coordinator::forgetComputer,
- )
+ Row(Modifier.fillMaxSize()) {
+ if (useRail) {
+ NavigationRail(containerColor = MaterialTheme.colorScheme.surfaceContainer) {
+ MainTab.entries.forEach { tab ->
+ NavigationRailItem(
+ selected = selectedTab == tab,
+ onClick = { selectedTab = tab },
+ icon = { TabIcon(tab, selectedTab) },
+ label = { Text(tab.label) },
+ )
+ }
+ }
+ }
+ // A single Scaffold owns the bars and insets for both main tabs.
+ Scaffold(
+ modifier = Modifier.weight(1f).nestedScroll(scrollBehavior.nestedScrollConnection),
+ topBar = {
+ TopAppBar(
+ title = { Text(if (selectedTab == MainTab.Home) "AppMapper" else "设置") },
+ scrollBehavior = scrollBehavior,
+ )
+ },
+ bottomBar = {
+ if (!useRail) {
+ NavigationBar {
+ MainTab.entries.forEach { tab ->
+ NavigationBarItem(
+ selected = selectedTab == tab,
+ onClick = { selectedTab = tab },
+ icon = { TabIcon(tab, selectedTab) },
+ label = { Text(tab.label) },
+ )
+ }
+ }
+ }
+ },
+ ) { padding ->
+ Box(
+ Modifier.fillMaxSize().padding(padding)
+ .consumeWindowInsets(padding).imePadding(),
+ ) {
+ Crossfade(
+ targetState = selectedTab,
+ animationSpec = tween(220),
+ label = "main tab",
+ ) { tab ->
+ tabState.SaveableStateProvider(tab.name) {
+ when (tab) {
+ MainTab.Home -> HomeScreen(
+ pairedComputer = appState.pairedComputer,
+ connectionStatus = connectionStatus,
+ isSyncRunning = isSyncRunning,
+ scanError = scanError,
+ onScan = {
+ scanError = ""
+ rootPage = RootPage.Scanner
+ },
+ onPair = ::pair,
+ onStart = {
+ notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
+ coordinator.startService()
+ },
+ onStop = coordinator::stopService,
+ )
+ MainTab.Settings -> SettingsScreen(
+ hasUsageAccess = appState.hasUsageAccess,
+ pollingMs = pollingMs,
+ onOpenUsageAccess = coordinator::openUsageAccessSettings,
+ onPollingSelected = {
+ pollingMs = it
+ coordinator.savePollingMs(it)
+ },
+ onOpenLogs = { rootPage = RootPage.Logs },
+ onOpenLicenses = { rootPage = RootPage.Licenses },
+ pairedComputer = appState.pairedComputer,
+ onForgetComputer = coordinator::forgetComputer,
+ )
+ }
+ }
+ }
+ }
+ }
+ }
+ }
}
}
}
}
+@Composable
+private fun TabIcon(tab: MainTab, selectedTab: MainTab) {
+ Icon(if (tab == selectedTab) tab.icon else tab.inactiveIcon, contentDescription = null)
+}
+
@Composable
private fun rememberAppState(coordinator: AppCoordinator, status: String): State {
val state = remember { mutableStateOf(coordinator.loadInitialState()) }
val lifecycleOwner = LocalLifecycleOwner.current
LaunchedEffect(status) { state.value = coordinator.loadInitialState() }
-
DisposableEffect(coordinator, lifecycleOwner) {
val observer = LifecycleEventObserver { _, event ->
- if (event == Lifecycle.Event.ON_RESUME) {
- state.value = coordinator.loadInitialState()
- }
+ if (event == Lifecycle.Event.ON_RESUME) state.value = coordinator.loadInitialState()
}
lifecycleOwner.lifecycle.addObserver(observer)
- onDispose {
- lifecycleOwner.lifecycle.removeObserver(observer)
- }
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
}
-
return state
}
+
+@Composable
+internal fun ScreenContent(content: @Composable ColumnScope.() -> Unit) {
+ Box(Modifier.fillMaxSize(), contentAlignment = Alignment.TopCenter) {
+ Column(
+ modifier = Modifier
+ .widthIn(max = 840.dp)
+ .fillMaxWidth()
+ .verticalScroll(rememberScrollState())
+ .padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(24.dp),
+ content = content,
+ )
+ }
+}
+
+@Composable
+internal fun SectionTitle(text: String) {
+ Text(
+ text = text,
+ modifier = Modifier.padding(horizontal = 16.dp).semantics { heading() },
+ style = MaterialTheme.typography.titleSmall,
+ color = MaterialTheme.colorScheme.primary,
+ )
+}
diff --git a/androidApp/app/src/main/java/dev/youximi/appmapper/HomeScreen.kt b/androidApp/app/src/main/java/dev/youximi/appmapper/HomeScreen.kt
index f0af5c8..7ad989f 100644
--- a/androidApp/app/src/main/java/dev/youximi/appmapper/HomeScreen.kt
+++ b/androidApp/app/src/main/java/dev/youximi/appmapper/HomeScreen.kt
@@ -1,40 +1,83 @@
package dev.youximi.appmapper
import android.Manifest
+import android.content.Intent
+import android.content.pm.PackageManager
+import android.net.Uri
+import android.provider.Settings
import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts
+import androidx.compose.animation.AnimatedVisibility
+import androidx.compose.animation.animateContentSize
+import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.aspectRatio
+import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.layout.widthIn
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
-import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material3.Button
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.FilledTonalButton
-import androidx.compose.material3.HorizontalDivider
-import androidx.compose.material3.ListItem
-import androidx.compose.material3.OutlinedButton
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Scaffold
-import androidx.compose.material3.Switch
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.semantics.LiveRegionMode
+import androidx.compose.ui.semantics.heading
+import androidx.compose.ui.semantics.liveRegion
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.unit.dp
-import com.journeyapps.barcodescanner.ScanContract
-import com.journeyapps.barcodescanner.ScanOptions
+import androidx.compose.ui.viewinterop.AndroidView
+import androidx.core.content.ContextCompat
+import androidx.lifecycle.Lifecycle
+import androidx.lifecycle.LifecycleEventObserver
+import androidx.lifecycle.compose.LocalLifecycleOwner
+import com.google.zxing.BarcodeFormat
+import com.journeyapps.barcodescanner.BarcodeCallback
+import com.journeyapps.barcodescanner.BarcodeResult
+import com.journeyapps.barcodescanner.BarcodeView
+import com.journeyapps.barcodescanner.CameraPreview
+import com.journeyapps.barcodescanner.DefaultDecoderFactory
import dev.youximi.appmapper.data.PairedComputer
-import dev.youximi.appmapper.data.PairingParser
import dev.youximi.appmapper.data.PairingRequest
@OptIn(ExperimentalMaterial3Api::class)
@@ -42,6 +85,9 @@ import dev.youximi.appmapper.data.PairingRequest
internal fun HomeScreen(
pairedComputer: PairedComputer?,
connectionStatus: String,
+ isSyncRunning: Boolean,
+ scanError: String,
+ onScan: () -> Unit,
onPair: (PairingRequest) -> Unit,
onStart: () -> Unit,
onStop: () -> Unit,
@@ -49,69 +95,307 @@ internal fun HomeScreen(
var host by rememberSaveable { mutableStateOf("") }
var port by rememberSaveable { mutableStateOf("8765") }
var code by remember { mutableStateOf("") }
- var rememberDevice by rememberSaveable { mutableStateOf(true) }
- var inputError by rememberSaveable { mutableStateOf("") }
+ var useQrCode by rememberSaveable { mutableStateOf(true) }
+ var showPairing by rememberSaveable(pairedComputer != null) { mutableStateOf(pairedComputer == null) }
+ var submitted by rememberSaveable { mutableStateOf(false) }
+ val hostInvalid = host.isBlank()
+ val portInvalid = port.toIntOrNull()?.let { it !in 1..65535 } ?: true
+ val codeInvalid = code.length != 6
+ fun connectTemporarily() {
+ submitted = true
+ if (!hostInvalid && !portInvalid && !codeInvalid) {
+ onPair(PairingRequest.Temporary(host.trim(), port.toInt(), code))
+ code = ""
+ submitted = false
+ }
+ }
+
+ ScreenContent {
+ ConnectionCard(
+ pairedComputer = pairedComputer,
+ connectionStatus = connectionStatus,
+ isSyncRunning = isSyncRunning,
+ onStart = onStart,
+ onStop = onStop,
+ )
+
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ if (pairedComputer != null) {
+ TextButton(onClick = { showPairing = !showPairing }) {
+ Text(if (showPairing) "收起配对选项" else "连接其他电脑")
+ }
+ } else {
+ SectionTitle("开始连接")
+ }
- val notificationPermission = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {}
- val qrScanner = rememberLauncherForActivityResult(ScanContract()) { result ->
- if (result.contents != null) PairingParser.parseUri(result.contents)?.let { request ->
- inputError = ""
- notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
- onPair(request)
- } ?: run { inputError = "二维码无效或版本过旧" }
+ AnimatedVisibility(visible = showPairing) {
+ Card(
+ shape = MaterialTheme.shapes.extraLarge,
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
+ ) {
+ Column(
+ modifier = Modifier.animateContentSize().padding(16.dp),
+ verticalArrangement = Arrangement.spacedBy(16.dp),
+ ) {
+ Text("手机和电脑需连接同一局域网", style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant)
+ SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
+ listOf("扫码绑定", "临时连接").forEachIndexed { index, label ->
+ SegmentedButton(
+ selected = useQrCode == (index == 0),
+ onClick = {
+ useQrCode = index == 0
+ submitted = false
+ },
+ shape = SegmentedButtonDefaults.itemShape(index, 2),
+ ) { Text(label) }
+ }
+ }
+
+ if (useQrCode) {
+ Text("绑定后记住这台电脑,断线时自动重连。",
+ style = MaterialTheme.typography.bodyLarge)
+ Button(
+ modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
+ onClick = onScan,
+ ) {
+ Icon(painterResource(R.drawable.ic_qr_code), contentDescription = null)
+ Spacer(Modifier.width(8.dp))
+ Text("扫码绑定并开始")
+ }
+ } else {
+ Text("仅连接本次,不会替换已保存的电脑。断线后需重新输入验证码。",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant)
+ OutlinedTextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = host,
+ onValueChange = { host = it },
+ label = { Text("电脑 IP") },
+ placeholder = { Text("例如 192.168.1.10") },
+ singleLine = true,
+ isError = submitted && hostInvalid,
+ supportingText = if (submitted && hostInvalid) ({ Text("请输入电脑 IP") }) else null,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
+ )
+ OutlinedTextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = port,
+ onValueChange = { port = it.filter(Char::isDigit).take(5) },
+ label = { Text("端口") },
+ singleLine = true,
+ isError = submitted && portInvalid,
+ supportingText = if (submitted && portInvalid) ({ Text("端口范围为 1–65535") }) else null,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number, imeAction = ImeAction.Next),
+ )
+ OutlinedTextField(
+ modifier = Modifier.fillMaxWidth(),
+ value = code,
+ onValueChange = { code = it.filter(Char::isDigit).take(6) },
+ label = { Text("6 位验证码") },
+ singleLine = true,
+ isError = submitted && codeInvalid,
+ supportingText = if (submitted && codeInvalid) ({ Text("请输入电脑端显示的 6 位验证码") }) else null,
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword, imeAction = ImeAction.Done),
+ keyboardActions = KeyboardActions(onDone = { connectTemporarily() }),
+ )
+ Button(
+ modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp),
+ onClick = ::connectTemporarily,
+ ) { Text("临时连接并开始") }
+ }
+
+ if (useQrCode && scanError.isNotBlank()) {
+ Surface(
+ shape = MaterialTheme.shapes.medium,
+ color = MaterialTheme.colorScheme.errorContainer,
+ contentColor = MaterialTheme.colorScheme.onErrorContainer,
+ ) {
+ Text(scanError,
+ modifier = Modifier.padding(16.dp).semantics { liveRegion = LiveRegionMode.Polite },
+ style = MaterialTheme.typography.bodyMedium)
+ }
+ }
+ }
+ }
+ }
+ }
}
+}
- Scaffold(topBar = { TopAppBar(title = { Text("配对") }) }) { padding ->
- Column(
- modifier = Modifier.padding(padding).fillMaxSize().verticalScroll(rememberScrollState())
- .padding(horizontal = 16.dp, vertical = 12.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
- ) {
- ListItem(headlineContent = { Text("运行状态") }, supportingContent = { Text(connectionStatus) })
- pairedComputer?.let { computer ->
- ListItem(headlineContent = { Text("已配对电脑") },
- supportingContent = { Text("${computer.name} · ${computer.host}:${computer.port}") })
- Button(modifier = Modifier.fillMaxWidth(), onClick = onStart) { Text("连接已配对电脑") }
+@Composable
+private fun ConnectionCard(
+ pairedComputer: PairedComputer?,
+ connectionStatus: String,
+ isSyncRunning: Boolean,
+ onStart: () -> Unit,
+ onStop: () -> Unit,
+) {
+ Card(
+ modifier = Modifier.fillMaxWidth().animateContentSize(),
+ shape = MaterialTheme.shapes.extraLarge,
+ colors = CardDefaults.cardColors(
+ containerColor = MaterialTheme.colorScheme.primaryContainer,
+ contentColor = MaterialTheme.colorScheme.onPrimaryContainer,
+ ),
+ ) {
+ Column(Modifier.padding(24.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) {
+ Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ Surface(shape = CircleShape, color = MaterialTheme.colorScheme.primary) {
+ Icon(painterResource(R.drawable.ic_computer), contentDescription = null,
+ modifier = Modifier.padding(12.dp).size(24.dp))
+ }
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier.weight(1f)) {
+ Text("连接状态", style = MaterialTheme.typography.labelLarge)
+ Text(connectionStatus,
+ modifier = Modifier.semantics { heading(); liveRegion = LiveRegionMode.Polite },
+ style = MaterialTheme.typography.headlineSmall)
+ }
}
- OutlinedButton(modifier = Modifier.fillMaxWidth(), onClick = onStop) { Text("停止同步") }
- HorizontalDivider()
- Text("首次连接或更换电脑")
- ListItem(headlineContent = { Text("记住设备") },
- supportingContent = { Text(if (rememberDevice) "扫码绑定,断线后可重连" else "手动临时连接,断线后需重新输入验证码") },
- trailingContent = { Switch(checked = rememberDevice, onCheckedChange = {
- rememberDevice = it
- inputError = ""
- }) })
- if (rememberDevice) {
- FilledTonalButton(modifier = Modifier.fillMaxWidth(), onClick = {
- qrScanner.launch(ScanOptions().setDesiredBarcodeFormats(ScanOptions.QR_CODE)
- .setPrompt("扫描 AppMapper 配对二维码").setBeepEnabled(false)
- .setOrientationLocked(false))
- }) { Text("扫码绑定并开始") }
+ if (pairedComputer != null) {
+ Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
+ Text("已保存的电脑", style = MaterialTheme.typography.labelMedium)
+ Text(pairedComputer.name, style = MaterialTheme.typography.titleMedium)
+ Text("${pairedComputer.host}:${pairedComputer.port}", style = MaterialTheme.typography.bodyMedium)
+ }
} else {
- OutlinedTextField(modifier = Modifier.fillMaxWidth(), value = host,
- onValueChange = { host = it }, label = { Text("电脑 IP") }, singleLine = true)
- OutlinedTextField(modifier = Modifier.fillMaxWidth(), value = port,
- onValueChange = { port = it.filter(Char::isDigit) }, label = { Text("端口") },
- singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
- OutlinedTextField(modifier = Modifier.fillMaxWidth(), value = code,
- onValueChange = { code = it.filter(Char::isDigit).take(6) }, label = { Text("6 位验证码") },
- singleLine = true, keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number))
+ Text("将手机上的前台应用同步到电脑,让使用时间自动记录。",
+ style = MaterialTheme.typography.bodyLarge)
+ }
+ if (isSyncRunning) {
+ FilledTonalButton(modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), onClick = onStop) {
+ Text("停止同步")
+ }
+ } else if (pairedComputer != null) {
+ Button(modifier = Modifier.fillMaxWidth().heightIn(min = 48.dp), onClick = onStart) {
+ Text("连接已配对电脑")
+ }
}
- if (inputError.isNotBlank()) Text(inputError)
- if (!rememberDevice) {
- Button(modifier = Modifier.fillMaxWidth(), onClick = {
- val p = port.toIntOrNull()
- if (host.isBlank() || p == null || p !in 1..65535 || code.length != 6) {
- inputError = "请填写地址、端口和 6 位验证码"
- } else {
- inputError = ""
- notificationPermission.launch(Manifest.permission.POST_NOTIFICATIONS)
- onPair(PairingRequest.Temporary(host.trim(), p, code))
- code = ""
+ }
+ }
+}
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun QrScannerScreen(onBack: () -> Unit, onResult: (String) -> Unit) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ fun cameraGranted() = ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == PackageManager.PERMISSION_GRANTED
+ var hasPermission by remember { mutableStateOf(cameraGranted()) }
+ var requestedPermission by rememberSaveable { mutableStateOf(false) }
+ val permissionRequest = rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) {
+ hasPermission = it
+ }
+
+ LaunchedEffect(Unit) {
+ if (!hasPermission && !requestedPermission) {
+ requestedPermission = true
+ permissionRequest.launch(Manifest.permission.CAMERA)
+ }
+ }
+ DisposableEffect(lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ if (event == Lifecycle.Event.ON_RESUME) hasPermission = cameraGranted()
+ }
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose { lifecycleOwner.lifecycle.removeObserver(observer) }
+ }
+
+ Scaffold(topBar = {
+ TopAppBar(
+ title = { Text("扫码绑定") },
+ navigationIcon = {
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回首页")
+ }
+ },
+ )
+ }) { padding ->
+ Box(Modifier.fillMaxSize().padding(padding).consumeWindowInsets(padding)) {
+ ScreenContent {
+ Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
+ Text("扫描电脑上的二维码", style = MaterialTheme.typography.headlineSmall)
+ Text("打开电脑端 AppMapper 的配对页面,将二维码放入取景框。",
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ if (hasPermission) {
+ CameraScanner(onResult = onResult)
+ } else {
+ Surface(shape = MaterialTheme.shapes.extraLarge,
+ color = MaterialTheme.colorScheme.surfaceContainerLow) {
+ Column(Modifier.fillMaxWidth().padding(24.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ Text("需要相机权限", style = MaterialTheme.typography.titleLarge)
+ Text("相机仅用于识别电脑上的配对二维码。你也可以返回首页选择临时连接。",
+ style = MaterialTheme.typography.bodyMedium)
+ Button(onClick = { permissionRequest.launch(Manifest.permission.CAMERA) }) {
+ Text("允许使用相机")
+ }
+ TextButton(onClick = {
+ context.startActivity(Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+ Uri.fromParts("package", context.packageName, null)))
+ }) { Text("打开应用权限设置") }
+ }
}
- }) { Text("临时连接并开始") }
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun CameraScanner(onResult: (String) -> Unit) {
+ val context = LocalContext.current
+ val lifecycleOwner = LocalLifecycleOwner.current
+ var cameraError by remember { mutableStateOf(false) }
+ val barcodeView = remember {
+ BarcodeView(context).apply {
+ decoderFactory = DefaultDecoderFactory(listOf(BarcodeFormat.QR_CODE))
+ addStateListener(object : CameraPreview.StateListener {
+ override fun previewSized() = Unit
+ override fun previewStarted() = Unit
+ override fun previewStopped() = Unit
+ override fun cameraClosed() = Unit
+ override fun cameraError(error: Exception) { cameraError = true }
+ })
+ decodeSingle(object : BarcodeCallback {
+ override fun barcodeResult(result: BarcodeResult) { onResult(result.text) }
+ })
+ }
+ }
+
+ DisposableEffect(barcodeView, lifecycleOwner) {
+ val observer = LifecycleEventObserver { _, event ->
+ when (event) {
+ Lifecycle.Event.ON_RESUME -> barcodeView.resume()
+ Lifecycle.Event.ON_PAUSE -> barcodeView.pause()
+ else -> Unit
}
}
+ lifecycleOwner.lifecycle.addObserver(observer)
+ onDispose {
+ lifecycleOwner.lifecycle.removeObserver(observer)
+ barcodeView.pause()
+ }
+ }
+
+ Column(horizontalAlignment = Alignment.CenterHorizontally,
+ modifier = Modifier.fillMaxWidth(), verticalArrangement = Arrangement.spacedBy(16.dp)) {
+ Box(
+ modifier = Modifier.widthIn(max = 480.dp).fillMaxWidth().aspectRatio(1f)
+ .clip(MaterialTheme.shapes.extraLarge),
+ contentAlignment = Alignment.Center,
+ ) {
+ AndroidView(factory = { barcodeView }, modifier = Modifier.fillMaxSize())
+ Box(Modifier.fillMaxSize(0.8f).border(2.dp, MaterialTheme.colorScheme.primary, MaterialTheme.shapes.large))
+ }
+ if (cameraError) {
+ Text("相机暂时无法启动,请返回后重试,或使用临时连接。",
+ modifier = Modifier.semantics { liveRegion = LiveRegionMode.Polite },
+ style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.error)
+ } else {
+ Text("识别成功后会自动返回并开始连接", style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
}
}
diff --git a/androidApp/app/src/main/java/dev/youximi/appmapper/LogsScreen.kt b/androidApp/app/src/main/java/dev/youximi/appmapper/LogsScreen.kt
index 52952e1..cebdffe 100644
--- a/androidApp/app/src/main/java/dev/youximi/appmapper/LogsScreen.kt
+++ b/androidApp/app/src/main/java/dev/youximi/appmapper/LogsScreen.kt
@@ -1,19 +1,31 @@
package dev.youximi.appmapper
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
import androidx.compose.foundation.text.selection.SelectionContainer
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.outlined.List
+import androidx.compose.material.icons.filled.MoreVert
+import androidx.compose.material.icons.filled.Refresh
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.FilledTonalButton
-import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
@@ -21,72 +33,106 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.unit.dp
@OptIn(ExperimentalMaterial3Api::class)
@Composable
-internal fun LogsScreen(
- coordinator: AppCoordinator,
- onBack: () -> Unit,
-) {
+internal fun LogsScreen(coordinator: AppCoordinator, onBack: () -> Unit) {
var logs by remember { mutableStateOf(coordinator.readLogs()) }
+ var menuExpanded by remember { mutableStateOf(false) }
+ var confirmClear by rememberSaveable { mutableStateOf(false) }
Scaffold(
topBar = {
TopAppBar(
- title = { Text("日志") },
+ title = { Text("连接日志") },
navigationIcon = {
- TextButton(onClick = onBack) {
- Text("返回")
+ IconButton(onClick = onBack) {
+ Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "返回设置")
+ }
+ },
+ actions = {
+ IconButton(onClick = { logs = coordinator.readLogs() }) {
+ Icon(Icons.Filled.Refresh, contentDescription = "刷新日志")
+ }
+ Box {
+ IconButton(onClick = { menuExpanded = true }) {
+ Icon(Icons.Filled.MoreVert, contentDescription = "更多日志操作")
+ }
+ DropdownMenu(expanded = menuExpanded, onDismissRequest = { menuExpanded = false }) {
+ DropdownMenuItem(
+ text = { Text("导出日志") },
+ onClick = { menuExpanded = false; coordinator.exportLogs() },
+ )
+ DropdownMenuItem(
+ text = { Text("清空日志", color = MaterialTheme.colorScheme.error) },
+ enabled = logs.isNotBlank(),
+ onClick = { menuExpanded = false; confirmClear = true },
+ )
+ }
}
},
)
},
) { padding ->
- Column(
- modifier = Modifier
- .padding(padding)
- .fillMaxSize()
- .verticalScroll(rememberScrollState())
- .padding(horizontal = 16.dp, vertical = 12.dp),
- verticalArrangement = Arrangement.spacedBy(12.dp),
+ Box(
+ modifier = Modifier.fillMaxSize().padding(padding).consumeWindowInsets(padding),
+ contentAlignment = Alignment.TopCenter,
) {
- FilledTonalButton(
- modifier = Modifier.fillMaxWidth(),
- onClick = { logs = coordinator.readLogs() },
- ) {
- Text("刷新")
- }
- OutlinedButton(
- modifier = Modifier.fillMaxWidth(),
- onClick = coordinator::exportLogs,
- ) {
- Text("导出")
+ if (logs.isBlank()) {
+ Column(
+ modifier = Modifier.fillMaxSize().padding(32.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp, Alignment.CenterVertically),
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ Icon(Icons.AutoMirrored.Outlined.List, contentDescription = null,
+ modifier = Modifier.size(40.dp), tint = MaterialTheme.colorScheme.primary)
+ Text("暂无日志", style = MaterialTheme.typography.titleLarge)
+ Text("连接活动会记录在这里", style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant)
+ }
+ } else {
+ Column(
+ modifier = Modifier.widthIn(max = 840.dp).fillMaxWidth()
+ .verticalScroll(rememberScrollState()).padding(16.dp),
+ ) {
+ Surface(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.large,
+ color = MaterialTheme.colorScheme.surfaceContainerLow,
+ ) {
+ SelectionContainer {
+ Text(
+ text = logs,
+ modifier = Modifier.padding(16.dp),
+ fontFamily = FontFamily.Monospace,
+ style = MaterialTheme.typography.bodySmall,
+ )
+ }
+ }
+ }
}
- OutlinedButton(
- modifier = Modifier.fillMaxWidth(),
- onClick = {
+ }
+ }
+
+ if (confirmClear) {
+ AlertDialog(
+ onDismissRequest = { confirmClear = false },
+ title = { Text("清空连接日志?") },
+ text = { Text("现有日志将被删除。需要保留时,可以先导出。") },
+ confirmButton = {
+ TextButton(onClick = {
coordinator.clearLogs()
logs = coordinator.readLogs()
- },
- ) {
- Text("清空")
- }
-
- HorizontalDivider()
-
- Text("日志内容", style = MaterialTheme.typography.titleSmall)
- SelectionContainer {
- Text(
- text = logs.ifBlank { "暂无日志" },
- modifier = Modifier.fillMaxWidth(),
- fontFamily = FontFamily.Monospace,
- style = MaterialTheme.typography.bodySmall,
- )
- }
- }
+ confirmClear = false
+ }) { Text("清空", color = MaterialTheme.colorScheme.error) }
+ },
+ dismissButton = { TextButton(onClick = { confirmClear = false }) { Text("取消") } },
+ )
}
}
diff --git a/androidApp/app/src/main/java/dev/youximi/appmapper/MainActivity.kt b/androidApp/app/src/main/java/dev/youximi/appmapper/MainActivity.kt
index c1d0255..a7b8cda 100644
--- a/androidApp/app/src/main/java/dev/youximi/appmapper/MainActivity.kt
+++ b/androidApp/app/src/main/java/dev/youximi/appmapper/MainActivity.kt
@@ -3,6 +3,7 @@ package dev.youximi.appmapper
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
+import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.compose.ui.platform.LocalContext
import dev.youximi.appmapper.data.AppLogger
@@ -12,6 +13,7 @@ import dev.youximi.appmapper.data.UsageAppReader
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
+ enableEdgeToEdge()
AppLogger.write(this, "MainActivity created.")
setContent {
diff --git a/androidApp/app/src/main/java/dev/youximi/appmapper/SettingsScreen.kt b/androidApp/app/src/main/java/dev/youximi/appmapper/SettingsScreen.kt
index d3707e0..964d474 100644
--- a/androidApp/app/src/main/java/dev/youximi/appmapper/SettingsScreen.kt
+++ b/androidApp/app/src/main/java/dev/youximi/appmapper/SettingsScreen.kt
@@ -1,36 +1,63 @@
package dev.youximi.appmapper
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
-import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.widthIn
-import androidx.compose.foundation.rememberScrollState
-import androidx.compose.foundation.verticalScroll
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.lazy.itemsIndexed
+import androidx.compose.foundation.lazy.rememberLazyListState
+import androidx.compose.foundation.shape.CornerSize
+import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
-import androidx.compose.material.icons.filled.ArrowDropDown
-import androidx.compose.material.icons.filled.Check
-import androidx.compose.material3.DropdownMenuItem
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight
+import androidx.compose.material.icons.automirrored.outlined.List
+import androidx.compose.material.icons.outlined.CheckCircle
+import androidx.compose.material.icons.outlined.Info
+import androidx.compose.material.icons.outlined.Warning
+import androidx.compose.material3.AlertDialog
+import androidx.compose.material3.Card
+import androidx.compose.material3.CardDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.ExposedDropdownMenuBox
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
import androidx.compose.material3.ListItem
+import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme
-import androidx.compose.material3.MenuAnchorType
import androidx.compose.material3.Scaffold
+import androidx.compose.material3.SegmentedButton
+import androidx.compose.material3.SegmentedButtonDefaults
+import androidx.compose.material3.SingleChoiceSegmentedButtonRow
+import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.painterResource
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import dev.youximi.appmapper.data.PairedComputer
@@ -42,132 +69,268 @@ internal fun SettingsScreen(
onOpenUsageAccess: () -> Unit,
onPollingSelected: (Long) -> Unit,
onOpenLogs: () -> Unit,
+ onOpenLicenses: () -> Unit,
pairedComputer: PairedComputer?,
onForgetComputer: () -> Unit,
) {
- var selectedPolling by rememberSaveable(pollingMs) { mutableStateOf(pollingMs) }
- var pollingMenuExpanded by rememberSaveable { mutableStateOf(false) }
+ var confirmForget by rememberSaveable { mutableStateOf(false) }
val pollingOptions = listOf(
- PollingOption(name = "快速", value = 500L),
- PollingOption(name = "标准", value = 1000L),
- PollingOption(name = "省电", value = 3000L),
+ PollingOption("快速", 500L, "每 0.5 秒检测一次,响应更及时"),
+ PollingOption("标准", 1000L, "每 1 秒检测一次,兼顾响应与耗电"),
+ PollingOption("省电", 3000L, "每 3 秒检测一次,减少后台查询"),
)
- val selectedPollingOption = pollingOptions.firstOrNull { it.value == selectedPolling }
-
- Scaffold(
- topBar = { TopAppBar(title = { Text("设置") }) },
- ) { padding ->
- Column(
- modifier = Modifier
- .padding(padding)
- .fillMaxSize()
- .verticalScroll(rememberScrollState())
- .padding(horizontal = 16.dp, vertical = 12.dp),
- verticalArrangement = Arrangement.spacedBy(8.dp),
- ) {
- Text("权限", style = MaterialTheme.typography.titleSmall)
- ListItem(
- headlineContent = { Text("权限检测") },
- supportingContent = { Text(if (hasUsageAccess) "使用情况访问权限已授权" else "使用情况访问权限未授权") },
- trailingContent = {
- TextButton(onClick = onOpenUsageAccess) {
- Text("打开")
- }
- },
- )
+ val rowColors = ListItemDefaults.colors(containerColor = Color.Transparent)
+ val groupColors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow)
- HorizontalDivider()
-
- ListItem(
- headlineContent = { Text("查询档位") },
- trailingContent = {
- ExposedDropdownMenuBox(
- expanded = pollingMenuExpanded,
- onExpandedChange = { pollingMenuExpanded = it },
- ) {
- Row(
- modifier = Modifier.menuAnchor(MenuAnchorType.PrimaryNotEditable),
- verticalAlignment = Alignment.CenterVertically,
- ) {
- Text(
- selectedPollingOption?.selectedLabel ?: "${selectedPolling}ms",
- style = MaterialTheme.typography.bodyMedium,
- )
- Icon(
- imageVector = Icons.Filled.ArrowDropDown,
- contentDescription = null,
- )
- }
+ ScreenContent {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ SectionTitle("应用权限")
+ Card(shape = MaterialTheme.shapes.large, colors = groupColors) {
+ ListItem(
+ modifier = Modifier.clickable(role = Role.Button, onClick = onOpenUsageAccess),
+ colors = rowColors,
+ headlineContent = { Text("使用情况访问") },
+ supportingContent = { Text(if (hasUsageAccess) "已授权,可以读取前台应用" else "尚未授权,点击前往设置") },
+ leadingContent = {
+ Icon(if (hasUsageAccess) Icons.Outlined.CheckCircle else Icons.Outlined.Warning,
+ contentDescription = null,
+ tint = if (hasUsageAccess) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.error)
+ },
+ trailingContent = { Icon(Icons.AutoMirrored.Outlined.KeyboardArrowRight, contentDescription = null) },
+ )
+ }
+ }
- ExposedDropdownMenu(
- expanded = pollingMenuExpanded,
- onDismissRequest = { pollingMenuExpanded = false },
- modifier = Modifier.widthIn(min = 140.dp, max = 180.dp),
- matchTextFieldWidth = false,
- ) {
- pollingOptions.forEach { option ->
- val selected = selectedPolling == option.value
- DropdownMenuItem(
- text = { Text(option.menuLabel) },
- trailingIcon = {
- if (selected) {
- Icon(
- imageVector = Icons.Filled.Check,
- contentDescription = null,
- )
- }
- },
- onClick = {
- selectedPolling = option.value
- onPollingSelected(option.value)
- pollingMenuExpanded = false
- },
- )
- }
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ SectionTitle("同步频率")
+ Card(shape = MaterialTheme.shapes.large, colors = groupColors) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text("前台应用检测", style = MaterialTheme.typography.titleMedium)
+ SingleChoiceSegmentedButtonRow(Modifier.fillMaxWidth()) {
+ pollingOptions.forEachIndexed { index, option ->
+ SegmentedButton(
+ selected = pollingMs == option.value,
+ onClick = { onPollingSelected(option.value) },
+ shape = SegmentedButtonDefaults.itemShape(index, pollingOptions.size),
+ ) { Text(option.name) }
}
}
- },
- )
+ Text(
+ pollingOptions.firstOrNull { it.value == pollingMs }?.description ?: "每 ${pollingMs}ms 检测一次",
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
- HorizontalDivider()
+ if (pairedComputer != null) {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ SectionTitle("已保存的电脑")
+ Card(shape = MaterialTheme.shapes.large, colors = groupColors) {
+ ListItem(
+ colors = rowColors,
+ headlineContent = { Text(pairedComputer.name) },
+ supportingContent = { Text("${pairedComputer.host}:${pairedComputer.port}") },
+ leadingContent = { Icon(painterResource(R.drawable.ic_computer), contentDescription = null) },
+ )
+ TextButton(modifier = Modifier.padding(start = 8.dp, bottom = 8.dp), onClick = { confirmForget = true }) {
+ Text("忘记这台电脑", color = MaterialTheme.colorScheme.error)
+ }
+ }
+ }
+ }
- pairedComputer?.let { computer ->
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ SectionTitle("更多")
+ Card(shape = MaterialTheme.shapes.large, colors = groupColors) {
ListItem(
- headlineContent = { Text("已配对:${computer.name}") },
- supportingContent = { Text("忘记后需要重新扫码") },
- trailingContent = { TextButton(onClick = onForgetComputer) { Text("忘记电脑") } },
+ modifier = Modifier.clickable(role = Role.Button, onClick = onOpenLogs),
+ colors = rowColors,
+ headlineContent = { Text("连接日志") },
+ supportingContent = { Text("查看连接记录与排查信息") },
+ leadingContent = { Icon(Icons.AutoMirrored.Outlined.List, contentDescription = null) },
+ trailingContent = { Icon(Icons.AutoMirrored.Outlined.KeyboardArrowRight, contentDescription = null) },
+ )
+ ListItem(
+ colors = rowColors,
+ headlineContent = { Text("AppMapper") },
+ supportingContent = { Text("版本 ${BuildConfig.VERSION_NAME}") },
+ leadingContent = { Icon(Icons.Outlined.Info, contentDescription = null) },
+ )
+ ListItem(
+ modifier = Modifier.clickable(role = Role.Button, onClick = onOpenLicenses),
+ colors = rowColors,
+ headlineContent = { Text("开源许可") },
+ supportingContent = { Text("查看第三方库与开源协议") },
+ leadingContent = { Icon(Icons.AutoMirrored.Outlined.List, contentDescription = null) },
+ trailingContent = { Icon(Icons.AutoMirrored.Outlined.KeyboardArrowRight, contentDescription = null) },
)
- HorizontalDivider()
}
+ }
+ }
+
+ if (confirmForget && pairedComputer != null) {
+ AlertDialog(
+ onDismissRequest = { confirmForget = false },
+ title = { Text("忘记这台电脑?") },
+ text = { Text("将移除与“${pairedComputer.name}”的配对。下次连接需要重新扫码。") },
+ confirmButton = {
+ TextButton(onClick = { confirmForget = false; onForgetComputer() }) {
+ Text("忘记电脑", color = MaterialTheme.colorScheme.error)
+ }
+ },
+ dismissButton = { TextButton(onClick = { confirmForget = false }) { Text("取消") } },
+ )
+ }
+}
- Text("更多", style = MaterialTheme.typography.titleSmall)
- ListItem(
- headlineContent = { Text("日志") },
- supportingContent = { Text("进入独立日志页面") },
- trailingContent = {
- TextButton(onClick = onOpenLogs) {
- Text("进入")
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+internal fun OpenSourceLicensesScreen(onBack: () -> Unit) {
+ val context = LocalContext.current
+ val notices = remember(context) {
+ context.assets.open("THIRD_PARTY_LICENSES.md").bufferedReader(Charsets.UTF_8)
+ .use { it.readText() }.replace("\r\n", "\n")
+ }
+ val projectLicense = remember(context) {
+ context.assets.open("LICENSE").bufferedReader(Charsets.UTF_8)
+ .use { it.readText() }.replace("\r\n", "\n")
+ }
+ val libraries = remember(notices) {
+ // Reuse the Android table in the bundled notices as the single library list.
+ notices.substringAfter("## Android 发行版依赖\n").substringBefore("\n许可来源:")
+ .lineSequence().filter { it.startsWith("| `") }
+ .flatMap { row ->
+ val columns = row.split('|').map { it.trim() }
+ columns[2].split(',').map { artifact ->
+ LicensedLibrary(
+ group = columns[1].trim('`'),
+ artifact = artifact.trim().trim('`'),
+ version = columns[3].trim('`'),
+ license = columns[4],
+ )
+ }
+ }
+ .sortedWith(compareBy({ it.name }, { it.id })).toList()
+ }
+ val licenseTexts = remember(notices) {
+ mapOf("Apache-2.0" to notices.substringAfter("### Apache License, Version 2.0")
+ .substringAfter("```text").substringBefore("```").trim())
+ }
+ var selectedDocument by rememberSaveable { mutableStateOf(null) }
+ val selectedLibrary = libraries.firstOrNull { it.id == selectedDocument }
+ val documentTitle = when (selectedDocument) {
+ "LICENSE" -> "项目许可"
+ "THIRD_PARTY_LICENSES.md" -> "完整第三方声明"
+ else -> selectedLibrary?.name ?: "开源许可"
+ }
+ val documentText = when (selectedDocument) {
+ "LICENSE" -> projectLicense
+ "THIRD_PARTY_LICENSES.md" -> notices
+ else -> selectedLibrary?.let {
+ "${it.id}\n\n版本 ${it.version}\n\n${it.license}\n\n${licenseTexts.getValue(it.license)}"
+ }
+ }
+ val libraryListState = rememberLazyListState()
+ val goBack = { if (selectedDocument != null) selectedDocument = null else onBack() }
+ BackHandler(enabled = selectedDocument != null) { selectedDocument = null }
+
+ Scaffold(
+ topBar = {
+ TopAppBar(
+ title = { Text(documentTitle, maxLines = 1, overflow = TextOverflow.Ellipsis) },
+ navigationIcon = {
+ IconButton(onClick = goBack) {
+ Icon(
+ Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = if (selectedDocument == null) "返回设置" else "返回开源许可",
+ )
}
},
)
- HorizontalDivider()
- ListItem(
- headlineContent = { Text("关于") },
- supportingContent = { Text("AppMapper") },
- )
- HorizontalDivider()
- ListItem(
- headlineContent = { Text("版本信息") },
- supportingContent = { Text("v${BuildConfig.VERSION_NAME}") },
- )
+ },
+ ) { padding ->
+ Box(
+ modifier = Modifier.fillMaxSize().padding(padding).consumeWindowInsets(padding),
+ contentAlignment = Alignment.TopCenter,
+ ) {
+ if (documentText == null) {
+ LazyColumn(
+ modifier = Modifier.widthIn(max = 840.dp).fillMaxSize(),
+ state = libraryListState,
+ contentPadding = PaddingValues(16.dp),
+ ) {
+ item {
+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ SectionTitle("引用库")
+ Card(
+ modifier = Modifier.fillMaxWidth(),
+ shape = MaterialTheme.shapes.extraLarge,
+ colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.surfaceContainerLow),
+ ) {
+ Column(Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
+ Text("AppMapper Android 发行版使用以下开源库。点击条目可查看版本信息和许可全文。")
+ Text("共包含 ${libraries.size} 个库", style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.primary)
+ Row {
+ TextButton(onClick = { selectedDocument = "LICENSE" }) { Text("项目许可") }
+ TextButton(onClick = { selectedDocument = "THIRD_PARTY_LICENSES.md" }) { Text("完整声明") }
+ }
+ }
+ }
+ }
+ }
+ item {
+ Column(Modifier.padding(top = 24.dp, bottom = 12.dp)) { SectionTitle("库列表") }
+ }
+ itemsIndexed(libraries, key = { _, library -> library.id }) { index, library ->
+ val shape = when {
+ libraries.size == 1 -> MaterialTheme.shapes.extraLarge
+ index == 0 -> MaterialTheme.shapes.extraLarge.copy(bottomStart = CornerSize(0.dp), bottomEnd = CornerSize(0.dp))
+ index == libraries.lastIndex -> MaterialTheme.shapes.extraLarge.copy(topStart = CornerSize(0.dp), topEnd = CornerSize(0.dp))
+ else -> RectangleShape
+ }
+ Surface(shape = shape, color = MaterialTheme.colorScheme.surfaceContainerLow) {
+ Column {
+ ListItem(
+ modifier = Modifier.clickable(role = Role.Button) { selectedDocument = library.id }
+ .padding(vertical = 8.dp),
+ colors = ListItemDefaults.colors(containerColor = Color.Transparent),
+ headlineContent = { Text(library.name, maxLines = 1, overflow = TextOverflow.Ellipsis) },
+ supportingContent = { Text("${library.version}\n${library.license}") },
+ leadingContent = { Icon(Icons.Outlined.Info, contentDescription = null) },
+ )
+ if (index != libraries.lastIndex) HorizontalDivider(Modifier.padding(horizontal = 16.dp))
+ }
+ }
+ }
+ }
+ } else {
+ key(selectedDocument) {
+ val paragraphs = remember(documentText) { documentText.split("\n\n") }
+ LazyColumn(
+ modifier = Modifier.widthIn(max = 840.dp).fillMaxSize(),
+ contentPadding = PaddingValues(16.dp),
+ verticalArrangement = Arrangement.spacedBy(12.dp),
+ ) {
+ items(paragraphs) { paragraph ->
+ SelectionContainer {
+ Text(paragraph, style = MaterialTheme.typography.bodyMedium)
+ }
+ }
+ }
+ }
+ }
}
}
}
-private data class PollingOption(
- val name: String,
- val value: Long,
-) {
- val menuLabel: String = "$name(${value}ms)"
- val selectedLabel: String = "$name ${value}ms"
+private data class LicensedLibrary(val group: String, val artifact: String, val version: String, val license: String) {
+ val id = "$group:$artifact"
+ val name = artifact.removeSuffix("-android").split('-').joinToString(" ") {
+ if (it in setOf("ui", "ktx", "jvm")) it.uppercase() else it.replaceFirstChar(Char::uppercase)
+ }
}
+
+private data class PollingOption(val name: String, val value: Long, val description: String)
diff --git a/androidApp/app/src/main/java/dev/youximi/appmapper/service/ForegroundSyncService.kt b/androidApp/app/src/main/java/dev/youximi/appmapper/service/ForegroundSyncService.kt
index 3eb0415..6de2f90 100644
--- a/androidApp/app/src/main/java/dev/youximi/appmapper/service/ForegroundSyncService.kt
+++ b/androidApp/app/src/main/java/dev/youximi/appmapper/service/ForegroundSyncService.kt
@@ -39,6 +39,7 @@ import java.io.IOException
object SyncStatus {
val text = MutableStateFlow("未连接")
+ val isRunning = MutableStateFlow(false)
}
class ForegroundSyncService : Service() {
@@ -68,6 +69,7 @@ class ForegroundSyncService : Service() {
.setOngoing(true)
.build(),
)
+ SyncStatus.isRunning.value = true
}
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
@@ -122,6 +124,7 @@ class ForegroundSyncService : Service() {
}
override fun onDestroy() {
+ SyncStatus.isRunning.value = false
syncJob?.cancel()
client.close()
activeSession = null
diff --git a/androidApp/app/src/main/res/drawable/ic_computer.xml b/androidApp/app/src/main/res/drawable/ic_computer.xml
new file mode 100644
index 0000000..5cabc23
--- /dev/null
+++ b/androidApp/app/src/main/res/drawable/ic_computer.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/androidApp/app/src/main/res/drawable/ic_qr_code.xml b/androidApp/app/src/main/res/drawable/ic_qr_code.xml
new file mode 100644
index 0000000..cf39f60
--- /dev/null
+++ b/androidApp/app/src/main/res/drawable/ic_qr_code.xml
@@ -0,0 +1,6 @@
+
+
+
diff --git a/androidApp/app/src/main/res/values-night/styles.xml b/androidApp/app/src/main/res/values-night/styles.xml
new file mode 100644
index 0000000..edee820
--- /dev/null
+++ b/androidApp/app/src/main/res/values-night/styles.xml
@@ -0,0 +1,10 @@
+
+
+
+
diff --git a/androidApp/app/src/main/res/values/styles.xml b/androidApp/app/src/main/res/values/styles.xml
index 8df7fa3..b2cf9be 100644
--- a/androidApp/app/src/main/res/values/styles.xml
+++ b/androidApp/app/src/main/res/values/styles.xml
@@ -2,5 +2,8 @@