diff --git a/LICENSE-EXCEPTION b/LICENSE-EXCEPTION
index d00db33e5..cd6b10b5c 100644
--- a/LICENSE-EXCEPTION
+++ b/LICENSE-EXCEPTION
@@ -1,5 +1,5 @@
OneXray VPN Provider Exception
-Version 1.0
+Version 1.2
1. Scope
@@ -13,22 +13,40 @@ Exception. This Exception applies only to those rights.
2. Eligibility and permission
-You may rely on this Exception only if both of the following apply:
+You may rely on this Exception only if all of the following apply:
a) You are a VPN service provider.
- b) You use the Covered Code to create a customized client for your own
- VPN service.
+ b) You use the Covered Code to create a dedicated, customized client
+ that connects exclusively to your own VPN service.
+ c) The customized client does not include general-purpose
+ custom-configuration or subscription features, including the
+ ability to import or use arbitrary third-party VPN or proxy
+ configurations or subscription URLs.
+ d) Each customized client you distribute under closed-source terms
+ includes a clear, reasonably accessible acknowledgment in its user
+ interface that it is based on the OneXray project, together with a
+ link to https://github.com/YuanDevTeam/OneXray. An About, Credits or
+ Open-source Notices screen may be used for this acknowledgment.
-For that purpose, you may use, copy, modify, build and distribute the
-Covered Code and the resulting customized client under terms of your
-choice, including proprietary, closed-source terms. You need not comply
+Configurations and subscriptions that you supply solely for access to
+your own VPN service are permitted. Branding the client for your service,
+distributing it only to your subscribers, or making your service the
+default does not satisfy these conditions if the client can also connect
+to third-party services.
+
+For that purpose and subject to the conditions above, you may use, copy,
+modify, build and distribute the Covered Code and the resulting
+customized client under terms of your choice, including proprietary,
+closed-source terms. You need not comply
with GPLv3's conditions for the Covered Code in that permitted use,
including its requirements to license the client or your modifications
under GPLv3, disclose Corresponding Source or supply Installation
Information.
This permission does not extend to closed-source clients for VPN services
-provided by others. It does not restrict anyone's rights under GPLv3.
+provided by others, including general-purpose clients that support both
+your own service and third-party services. It does not restrict anyone's
+rights under GPLv3.
Uses outside this Exception remain subject to GPLv3 or another permission
from the applicable copyright holders.
diff --git a/README.md b/README.md
index 3e2ab0104..463f7bbdb 100644
--- a/README.md
+++ b/README.md
@@ -56,7 +56,7 @@
Choose the official store for your device:
-- **iPhone / iPad:** [App Store](https://apps.apple.com/us/app/onexray/id6745748773) — iOS / iPadOS 15+.
+- **iPhone / iPad:** [App Store](https://apps.apple.com/us/app/onexray/id6745748773) — iOS / iPadOS 16+.
- **macOS:** [Mac App Store](https://apps.apple.com/us/app/onexray/id6745748773) — macOS 13+, Apple silicon or Intel.
- **Android phones / tablets:** [Google Play](https://play.google.com/store/apps/details?id=net.yuandev.onexray) — Android 10+, arm64-v8a or x86_64.
- **Windows:** [Microsoft Store](https://apps.microsoft.com/detail/9NJ0MVHW215D) — Windows 10 20H2+ (x64) or Windows 11 (ARM64).
@@ -106,7 +106,13 @@ Code, translations and documentation contributions are welcome. Start with the [
OneXray is licensed under the [GNU General Public License v3.0](./LICENSE), with the [OneXray VPN Provider Exception](./LICENSE-EXCEPTION).
-VPN service providers may build and distribute customized, closed-source clients for their own VPN services without complying with the repository's GPL requirements for the code covered by the exception. Other uses remain subject to the GPL or another applicable permission.
+VPN service providers may build and distribute dedicated, customized closed-source clients that connect exclusively to their own VPN services without complying with the repository's GPL requirements for the code covered by the exception.
+
+To qualify, the client must not include general-purpose custom-configuration or subscription features, including the ability to import or use arbitrary third-party VPN or proxy configurations or subscription URLs. Configurations and subscriptions supplied by the provider solely for access to its own VPN service are permitted. Branding the client for the provider, distributing it only to the provider's subscribers, or making the provider's service the default is not sufficient if the client can also connect to third-party services.
+
+Each closed-source client distributed under this exception must clearly acknowledge that it is based on the OneXray project and include a link to [OneXray](https://github.com/YuanDevTeam/OneXray) in a reasonably accessible part of its user interface, such as an About, Credits or Open-source Notices screen.
+
+These conditions apply to eligibility for the exception. Other uses remain subject to the GPL or another applicable permission.
Yuan Dev Team guarantees that it will not introduce direct third-party dependencies that require a qualifying customized client as a whole to be GPL-licensed or to disclose its source code under the GPL. Third-party licenses and their component-specific obligations still apply; the exception only covers rights Yuan Dev Team owns or is authorized to license.
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
index e05361101..5db4c137b 100644
--- a/android/app/src/main/AndroidManifest.xml
+++ b/android/app/src/main/AndroidManifest.xml
@@ -50,6 +50,10 @@
+
+
+
+
diff --git a/android/app/src/main/kotlin/net/yuandev/onexray/pigeon/Messages.g.kt b/android/app/src/main/kotlin/net/yuandev/onexray/pigeon/Messages.g.kt
index fd9f35016..55d742840 100644
--- a/android/app/src/main/kotlin/net/yuandev/onexray/pigeon/Messages.g.kt
+++ b/android/app/src/main/kotlin/net/yuandev/onexray/pigeon/Messages.g.kt
@@ -682,6 +682,7 @@ private open class MessagesPigeonCodec : StandardMessageCodec() {
}
}
+
/** Generated interface from Pigeon that represents a handler of messages from Flutter. */
interface BridgeHostApi {
fun getTunFilesDir(callback: (Result) -> Unit)
@@ -1013,6 +1014,11 @@ interface BridgeHostApi {
interface AppleWidgetHostApi {
fun takeAction(callback: (Result) -> Unit)
fun setLanguage(languageCode: String, callback: (Result) -> Unit)
+ fun beginPreparation(callback: (Result) -> Unit)
+ fun finishPreparation(token: String, callback: (Result) -> Unit)
+ fun startPreparedVpn(token: String, requestJson: String, callback: (Result) -> Unit)
+ fun connectedAtMilliseconds(callback: (Result) -> Unit)
+ fun setStartBlocked(blocked: Boolean, callback: (Result) -> Unit)
companion object {
/** The codec used by AppleWidgetHostApi. */
@@ -1060,6 +1066,101 @@ interface AppleWidgetHostApi {
channel.setMessageHandler(null)
}
}
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.onexray.AppleWidgetHostApi.beginPreparation$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ api.beginPreparation{ result: Result ->
+ val error = result.exceptionOrNull()
+ if (error != null) {
+ reply.reply(MessagesPigeonUtils.wrapError(error))
+ } else {
+ val data = result.getOrNull()
+ reply.reply(MessagesPigeonUtils.wrapResult(data))
+ }
+ }
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.onexray.AppleWidgetHostApi.finishPreparation$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val tokenArg = args[0] as String
+ api.finishPreparation(tokenArg) { result: Result ->
+ val error = result.exceptionOrNull()
+ if (error != null) {
+ reply.reply(MessagesPigeonUtils.wrapError(error))
+ } else {
+ reply.reply(MessagesPigeonUtils.wrapResult(null))
+ }
+ }
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.onexray.AppleWidgetHostApi.startPreparedVpn$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val tokenArg = args[0] as String
+ val requestJsonArg = args[1] as String
+ api.startPreparedVpn(tokenArg, requestJsonArg) { result: Result ->
+ val error = result.exceptionOrNull()
+ if (error != null) {
+ reply.reply(MessagesPigeonUtils.wrapError(error))
+ } else {
+ val data = result.getOrNull()
+ reply.reply(MessagesPigeonUtils.wrapResult(data))
+ }
+ }
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.onexray.AppleWidgetHostApi.connectedAtMilliseconds$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { _, reply ->
+ api.connectedAtMilliseconds{ result: Result ->
+ val error = result.exceptionOrNull()
+ if (error != null) {
+ reply.reply(MessagesPigeonUtils.wrapError(error))
+ } else {
+ val data = result.getOrNull()
+ reply.reply(MessagesPigeonUtils.wrapResult(data))
+ }
+ }
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
+ run {
+ val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.onexray.AppleWidgetHostApi.setStartBlocked$separatedMessageChannelSuffix", codec)
+ if (api != null) {
+ channel.setMessageHandler { message, reply ->
+ val args = message as List
+ val blockedArg = args[0] as Boolean
+ api.setStartBlocked(blockedArg) { result: Result ->
+ val error = result.exceptionOrNull()
+ if (error != null) {
+ reply.reply(MessagesPigeonUtils.wrapError(error))
+ } else {
+ reply.reply(MessagesPigeonUtils.wrapResult(null))
+ }
+ }
+ }
+ } else {
+ channel.setMessageHandler(null)
+ }
+ }
}
}
}
diff --git a/android/app/src/main/kotlin/net/yuandev/onexray/vpn/OneVpnService.kt b/android/app/src/main/kotlin/net/yuandev/onexray/vpn/OneVpnService.kt
index 10dffadd8..be89e4703 100644
--- a/android/app/src/main/kotlin/net/yuandev/onexray/vpn/OneVpnService.kt
+++ b/android/app/src/main/kotlin/net/yuandev/onexray/vpn/OneVpnService.kt
@@ -27,6 +27,9 @@ import kotlinx.coroutines.cancel
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.booleanOrNull
+import kotlinx.serialization.json.jsonObject
+import kotlinx.serialization.json.jsonPrimitive
import libXray.DialerController
import libXray.LibXray
import net.yuandev.onexray.MainActivity
@@ -41,6 +44,7 @@ import net.yuandev.onexray.pigeon.PerAppVPNMode
import net.yuandev.onexray.pigeon.StartVpnRequest
import net.yuandev.onexray.pigeon.TunJson
import net.yuandev.onexray.pigeon.VpnStatus
+import java.io.File
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import java.util.concurrent.atomic.AtomicReference
@@ -68,6 +72,7 @@ class OneVpnService : VpnService() {
// Core retains protection callbacks for the lifetime of :native.
private val controller = VPNController()
private var controllerInit = false
+ private val coreLifecycleLock = Any()
@Synchronized
private fun initController() {
@@ -510,7 +515,15 @@ class OneVpnService : VpnService() {
if (generation != startGeneration.get() || tunnel !== establishedTunnel) {
return@launch
}
- val result = LibXray.invoke(patchRuntimeEnv(coreInvokeText, establishedTunnel.fd))
+ val requestJson = patchRuntimeEnv(coreInvokeText, establishedTunnel.fd)
+ val result = synchronized(coreLifecycleLock) {
+ if (generation != startGeneration.get() || tunnel !== establishedTunnel) {
+ return@launch
+ }
+ check(!isXrayRunning()) { "The previous Xray instance is still running" }
+ RuntimeLogFiles.clear(File(filesDir, "run"))
+ LibXray.invoke(requestJson)
+ }
validateRunXrayResult(result)
if (generation != startGeneration.get() || tunnel !== establishedTunnel) {
XLog.d("OneVpnService: stale runXray result ignored")
@@ -547,8 +560,19 @@ class OneVpnService : VpnService() {
}
private fun stopXray() {
- val request = LibXrayInvokeRequest(method = LibXrayMethod.STOP_XRAY)
- LibXray.invoke(JsonTool.json.encodeToString(request))
+ synchronized(coreLifecycleLock) {
+ val request = LibXrayInvokeRequest(method = LibXrayMethod.STOP_XRAY)
+ validateRunXrayResult(LibXray.invoke(JsonTool.json.encodeToString(request)))
+ }
+ }
+
+ private fun isXrayRunning(): Boolean {
+ val request = LibXrayInvokeRequest(method = LibXrayMethod.GET_XRAY_STATE)
+ val result = LibXray.invoke(JsonTool.json.encodeToString(request))
+ validateRunXrayResult(result)
+ return JsonTool.json.parseToJsonElement(result).jsonObject["data"]
+ ?.jsonObject?.get("running")?.jsonPrimitive?.booleanOrNull
+ ?: throw IllegalStateException("Invalid Xray state response")
}
private fun patchRuntimeEnv(requestJson: String, fd: Int): String {
diff --git a/android/app/src/main/kotlin/net/yuandev/onexray/vpn/RuntimeLogFiles.kt b/android/app/src/main/kotlin/net/yuandev/onexray/vpn/RuntimeLogFiles.kt
new file mode 100644
index 000000000..3a9aa8e22
--- /dev/null
+++ b/android/app/src/main/kotlin/net/yuandev/onexray/vpn/RuntimeLogFiles.kt
@@ -0,0 +1,31 @@
+package net.yuandev.onexray.vpn
+
+import java.io.File
+import java.io.IOException
+import java.nio.file.Files
+import java.nio.file.LinkOption
+import java.nio.file.NoSuchFileException
+import java.nio.file.Path
+import java.nio.file.attribute.BasicFileAttributes
+
+/** Called only for an admitted new Core start, after the previous Core has stopped. */
+internal object RuntimeLogFiles {
+ fun clear(directory: File) {
+ val root = directory.toPath()
+ val rootAttributes = attributes(root) ?: return
+ if (!rootAttributes.isDirectory) throw IOException("Invalid runtime log directory: $root")
+ val files = listOf("access.log", "error.log").map { root.resolve(it) }.filter { path ->
+ val attributes = attributes(path) ?: return@filter false
+ if (!attributes.isRegularFile) throw IOException("Invalid runtime log file: $path")
+ true
+ }
+ // Validate both before removing either, without enumerating the directory.
+ files.forEach { Files.delete(it) }
+ }
+
+ private fun attributes(path: Path): BasicFileAttributes? = try {
+ Files.readAttributes(path, BasicFileAttributes::class.java, LinkOption.NOFOLLOW_LINKS)
+ } catch (_: NoSuchFileException) {
+ null
+ }
+}
diff --git a/android/app/src/test/kotlin/net/yuandev/onexray/vpn/RuntimeLogFilesTest.kt b/android/app/src/test/kotlin/net/yuandev/onexray/vpn/RuntimeLogFilesTest.kt
new file mode 100644
index 000000000..feb67e071
--- /dev/null
+++ b/android/app/src/test/kotlin/net/yuandev/onexray/vpn/RuntimeLogFilesTest.kt
@@ -0,0 +1,58 @@
+package net.yuandev.onexray.vpn
+
+import java.io.File
+import java.io.IOException
+import java.nio.file.Files
+import org.junit.After
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertFalse
+import org.junit.Assert.assertThrows
+import org.junit.Before
+import org.junit.Test
+
+class RuntimeLogFilesTest {
+ private lateinit var root: File
+
+ @Before fun prepare() {
+ val fixtures = File("../../../references/onexray-tests/android/runtime-logs").absoluteFile
+ check(fixtures.mkdirs() || fixtures.isDirectory)
+ root = Files.createTempDirectory(fixtures.toPath(), "logs-").toFile()
+ }
+
+ @After fun cleanup() { root.deleteRecursively() }
+
+ @Test fun clearsOnlyManagedLogsAndAllowsMissingFiles() {
+ val run = File(root, "run")
+ RuntimeLogFiles.clear(run)
+ check(run.mkdir())
+ for (name in listOf("access.log", "error.log", "start.json", "export.log")) {
+ File(run, name).writeText(name)
+ }
+ RuntimeLogFiles.clear(run)
+ RuntimeLogFiles.clear(run)
+ assertFalse(File(run, "access.log").exists())
+ assertFalse(File(run, "error.log").exists())
+ assertEquals("start.json", File(run, "start.json").readText())
+ assertEquals("export.log", File(run, "export.log").readText())
+ }
+
+ @Test fun rejectsDirectoryLogBeforeClearingEither() {
+ File(root, "access.log").writeText("old access")
+ check(File(root, "error.log").mkdir())
+ assertThrows(IOException::class.java) { RuntimeLogFiles.clear(root) }
+ assertEquals("old access", File(root, "access.log").readText())
+ }
+
+ @Test fun rejectsLinkedLogAndRuntimeDirectory() {
+ val access = File(root, "access.log").apply { writeText("old access") }
+ val outside = File(root, "outside.log").apply { writeText("outside") }
+ Files.createSymbolicLink(File(root, "error.log").toPath(), outside.toPath())
+ assertThrows(IOException::class.java) { RuntimeLogFiles.clear(root) }
+ assertEquals("old access", access.readText())
+ assertEquals("outside", outside.readText())
+ val linkedRun = File(root, "linked-run")
+ Files.createSymbolicLink(linkedRun.toPath(), root.toPath())
+ assertThrows(IOException::class.java) { RuntimeLogFiles.clear(linkedRun) }
+ assertEquals("old access", access.readText())
+ }
+}
diff --git a/android/gradle.properties b/android/gradle.properties
index 0399e24fd..211d2814d 100644
--- a/android/gradle.properties
+++ b/android/gradle.properties
@@ -1,6 +1,9 @@
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
android.useAndroidX=true
android.enableJetifier=true
+# Avoid cross-drive cache failures when the Windows Pub cache and project
+# are on different drives.
+kotlin.incremental=false
# Preserve the app-level 64-bit ABI list instead of Flutter's default list.
disable-abi-filtering=true
# This builtInKotlin flag was added automatically by Flutter migrator
diff --git a/docs/app.md b/docs/app.md
index 86f5ff225..a84fbca93 100644
--- a/docs/app.md
+++ b/docs/app.md
@@ -5,14 +5,23 @@
## 导航与交互
-- 四个根 Tab 依次为连接、服务器、高级、设置,独立保留导航栈。紧凑布局使用底部导航,
- 宽布局使用侧栏;完整业务页面均可在当前 Tab 内作为二级页面打开,包括其他根页面。
+- 六个根目的地为连接、服务器、路由、VPN 隧道、Xray、设置,独立保留导航栈。
+ 手机根页使用内缩的圆角卡片底栏,包含前五项入口,选中项为蓝底白字;设置由顶部按钮进入。
+ 设置页和二级页面隐藏底部根导航。平板和桌面宽布局保留带文字的左侧导航,
+ 连接、服务器和路由归入“工作区”,VPN 隧道和 Xray 归入“系统与内核”,设置位于侧栏底部;
+ 不再提供“高级”的二级 Tab 容器。侧栏选中项使用无边框卡片底色与强调色文字。
+ 完整业务页面均可在当前分支内作为二级页面打开,包括其他根页面。
页面入口使用 `pushScoped`,不因打开服务器或设置而切换根 Tab。
-- 移动端二级页面隐藏底部根导航;宽布局侧栏允许主动切换 Tab,并保留各栈。Setup 独立于业务导航。
- 页面保留默认转场和 iOS 侧滑返回,标题及高级页 TabBar 固定,不随内容滚动。
+- 宽布局侧栏允许主动切换分支,并保留各栈。Setup 独立于业务导航。
+ 页面保留默认转场和 iOS 侧滑返回,标题固定,不随内容滚动。
+- 业务内容在手机、平板和桌面均沿一条纵向内容流呈现,不使用左右主从详情或宽屏双列工作区。
+ 服务器、规则和资源列表进入独立全宽详情;同一会话的并列统计值与紧凑动作不构成第二内容面。
- 宽布局先避让横向安全区,再分配侧栏内容宽度。侧栏高度不足时可滚动访问全部入口与更新提醒,
品牌保持单行;横竖屏切换不重置当前页面或各 Tab 的导航栈。
+ 业务区直接填充侧栏旁的剩余空间,不附加整区圆角裁切、装饰外框或外层留白;页面自身内容边距仍由页面管理。
- 完整页面的共享 `PageActionBar` 固定在底部,保留安全区、键盘避让和动作换行;弹窗保留自己的操作区。
+ 宽屏按钮按文字自然取宽,macOS 最低42、iPad最低44;手机动作等宽且最低48。
+ 手机草稿页显示取消与保存或完成,已有高级模板的删除独立一行,沿用原有退出及删除确认。
添加、编辑、分享和更新提醒保持原有页面或弹窗形式,均留在当前 Tab。
- 异步操作使用按钮内 loading,只限制冲突操作,不全局禁用交互。保存成功使用共享 toast;
失败、取消不显示成功提示。恢复默认只更新草稿,仍需保存;导航、选择和草稿增删不重复提示。
@@ -23,8 +32,15 @@
颜色、文字角色、尺寸和间距分别由 `lib/pages/theme/` 的公共配置管理,Material 与 Shad 共用语义角色。
App 不在运行时读取原型配置;优先调整共享主题,页面不重复定义字体或 AppBar 样式。
-字号为不小于 12 的整数、字重为整百值,不额外设置行高、字距或 `fontVariations`。
-代码字体使用已打包的 GeistMono;Windows 中文回退到 Microsoft YaHei UI / Microsoft YaHei。
+页面通过共享 `PageAppBar` 使用标准 Flutter `AppBar`:页面标题、返回及页面操作位于同一工具栏,
+样式由公共主题管理,不另设工具栏下方的固定页标题区。AppBar 不固定展示 logo、App 名称或工作区路径,
+不提供主题快捷按钮;主题在设置中选择。手机服务器主页使用紧凑图标动作,
+长标题和系统大字号下仍保留返回、设置及主要动作的可达性,不通过缩小文字适配。
+平板与桌面宽布局的共享 AppBar、`PageActionBar` 背景均与 `Scaffold` 背景一致,滚动时不额外着色或增加抬升效果;
+手机 AppBar 沿用 Material 默认表现。业务卡片和弹窗继续使用各自的背景、边框与圆角。
+字号为整数,正文和一般辅助文字不小于 12;侧栏分组名及手机底栏辅助标签使用共享的 11px 导航角色。
+字重为整百值,正文共享原型的 1.45 行高,不额外设置字距或 `fontVariations`。
+界面复用 shadcn 内置 Geist,代码使用 GeistMono;其余字形使用系统回退,Windows 保留 Microsoft YaHei UI / Microsoft YaHei。不新增自定义字体资源。
主题、语言默认跟随系统,已保存的选择优先;未支持的语言回退英语。
简体中文、繁体中文、英语、俄语、波斯语复用已批准文案。Linux ARM64 对 CJK 请求实际回退英语,
@@ -32,13 +48,17 @@ App 不在运行时读取原型配置;优先调整共享主题,页面不重
隐私正文使用 HTTPS 链接,App 不内置隐私正文,不收集匿名分析数据。
`ResponsiveContent` 只限制内容宽度,不叠加页面内边距;公共按钮保留系统文字缩放。
+`SectionView` 的标题、默认位置的说明与正文共用同一张圆角卡片,不在标题与正文间绘制通栏分隔线;
+`descriptionBelow` 指定的说明仍位于卡片外。`SettingSection` 默认不绘制行间分隔线,需要时显式启用 `separated`。
+导航和卡片共用现有字体家族与文字角色;导航辅助标签使用专属角色,不改写正文等已有角色。
共享触控下限按平台应用:iOS 为 44×44 pt,Android 为 48×48 dp,不依赖窗口宽度。
+共享开关保留紧凑轨道,以实际点击层补足触控下限;轨道及外围点击均只回调一次,桌面不扩大轨道。
弹窗关闭、返回及设置动作可保留较小的视觉图标,通过实际点击区域满足下限;横屏不切换为桌面触控尺寸。
入口数量选择器沿用相同触控下限,桌面保持紧凑。订阅可编辑字段关联本地化名称,
填写内容后仍保留字段身份、编辑动作和错误语义;Age 密钥默认遮蔽。
macOS 原生输入桥接不公开字段名称,因此保留相邻可见标题供辅助阅读;这不替代真实可编辑节点。
选中背景上的小号强调正文和提示使用独立的不透明文字色,浅深色实际组合均满足 4.5:1 对比。
-高级页标签按实际文字宽度切换横向滚动并适应字号,隧道状态和只读值在空间不足时改为上下排列。
+资源类别标签按实际文字宽度滚动并适应字号,隧道状态和只读值在空间不足时改为上下排列。
UI-only 改动保持字段、语义、平台可见性、持久化和验证不变。
页面优先显示操作、当前值和状态,不重复解释标题与按钮。配置原理、平台边界和较长的使用说明
@@ -48,8 +68,9 @@ UI-only 改动保持字段、语义、平台可见性、持久化和验证不变
### 桌面窗口
-窗口框架位于 Router 外。macOS / macOS SE 保留原生红黄绿按钮,侧栏呈现原生材质,正文和表单保持不透明。
-材质跟随最终主题,初始化失败使用纯色窗口;不新增外观开关或强制活跃状态。
+窗口框架位于 Router 外。macOS / macOS SE 保留原生红黄绿按钮、标题栏几何与安全区;
+Flutter 窗口背景和侧栏分别使用不透明的主题背景色与侧栏色,正文和表单保持不透明。
+原生窗口外观跟随最终主题,初始化失败使用纯色窗口;不新增外观开关或强制活跃状态。
标题栏高度在缩放和全屏事件后更新,不定时轮询;LTR 宽布局让侧栏避让按钮,RTL 正文仍避让物理左侧按钮。
窄窗口、Setup 和独立页面保留顶部安全区。
@@ -68,15 +89,36 @@ macOS 应用菜单“设置…”及 ⌘, 恢复并聚焦既有主窗口,进
### 连接
App 管理的普通模式选择自动、订阅、全部手动节点或固定节点,以及智能路由、全部使用 VPN 或自定义路由。
-提供方方式逐项选择完整配置,不需要开启专家模式;连接页显示“提供方配置”和路由摘要,
-App 流量方式切换不可用,Smart/Custom 偏好继续保存。模式改变或缓存隐藏后,运行名称仍来自 metadata 快照。
-专家模式用 Raw JSON 区域替换整个服务器区域,保留普通选择但不混用。
-仅打开 Raw 浏览区、尚未选择 Raw 时,不立即修改持久化连接目标;无关路由或平台偏好更新保留该本地浏览状态。
-其他页面或托盘保存新的专家模式、Raw 或服务器选择后,连接页同步对应视图,避免旧 Raw 浏览状态阻挡普通节点或提供方配置连接。
+连接页按状态与主操作、连接计划、实际运行、本次会话纵向呈现。连接计划的服务器行打开专用选择器,
+按自动范围、固定节点、完整配置分类浏览;切换标签和搜索不提交选择,点击资源才申请变更。
+选中按资源种类和 ID 判断,不以同名资源或相同数字 ID 混淆来源、节点与 Raw;管理入口另开服务器页。
+提供方完整配置与个人 Raw 同处完整配置类别,不要求先切换专家浏览开关。
+完整配置使用自带路由,连接页相应行只读;普通 Smart/Custom 偏好继续保存,独立路由页可编辑备用偏好。
+计划与实际运行身份分开:运行名称、入口、最终出口、Raw 和提供方名称来自 metadata 快照,
+不因资产重命名、来源方式改变、缓存隐藏或新的浏览选择而改写。自动跨范围回退单独提示实际结果。
+固定提供方完整配置前的普通选择由本机可选记忆保留,与选择一起事务提交;提供方之间切换不覆盖旧普通意图。
+返回普通计划先读取并确认,取消不修改选择或记忆;旧用户无记忆、引用缺失或来源方式不兼容时回到自动并要求重新选择。
+普通路由偏好继续保留;记忆不进入资产备份,恢复/清理连接资产时清空。Raw 沿用原普通选择保留行为。
+选择器的标签和搜索属于本地浏览状态,取消不修改持久化目标或运行输入;无关偏好更新不代替一次资源选择。
+其他页面或托盘保存新的 Raw、节点或提供方选择后,连接页同步连接计划。
+提交前复核所选资产与活动来源方式;等待确认期间被删除、替换或切换为停用缓存的资源不能继续按旧列表提交。
配置类型、数量、节点链路和规则语义见 [Xray 配置](xray-configuration.md)。
-页面展示原生连接状态、实时速率和本次上下行;流量卡片无点击入口,不展示累计流量或单出口 IP。
+页面展示原生连接状态、实时速率、本次上下行与连接时长;未知指标显示未知,不伪造零速率、历史图或单出口 IP。
+本次会话为只读摘要,空库且没有进行中的连接时直接提供导入和恢复入口。
+“为什么这样连接”只解释实际运行快照,不预测尚未启动的计划。正常断开时不提供入口;
+仍可断开的失败状态如有运行快照,继续按该会话的入口、最终出口和配置类型解释,不读取当前待用计划替代。
启停期间立即显示操作等待,不提前宣称已连接/断开;准备或连接阶段可取消,避免重复提交。
编辑正在使用的配置经确认重连;未启用资产或仅改名称不重连。
+启动不等待连接前测速,也不要求已有成功延迟;活动、可读取的候选足量即可进入准备。
+已有成功延迟优先,未测、失败和超时排后按 ID 稳定选取;结构、数量、最终出口、权限与停止确认仍须通过。
+测速与运行中的最低延迟观察继续独立保留,选择与回退边界见 [普通配置编译](xray-configuration.md#普通配置编译)。
+
+### 路由
+
+路由有独立首页,展示智能路由、全部使用 VPN 以及常规/高级自定义资产;使用与编辑为不同动作。
+Raw 或提供方完整图生效时,这里只保存普通路由偏好,不覆盖运行图或要求无效重连。
+自定义资产共用三份配额,删除、有效变更比较、依赖校验与确认重连继续交给生产服务;
+损坏资产逐项隔离,不能阻断智能/全部模式和其他路由资产的管理。Geodata 入口复用 Xray 的同一资源库。
智能与常规路由复用 DNS、嗅探和代理选路表单。DNS 主代理/直连 resolver 与各自有序备用独立保存,
每项只编辑地址;全局缓存开关直接显示,默认开启缓存,其余 resolver 参数沿用 Core 默认值。
@@ -91,12 +133,15 @@ FakeDNS 开启时不能关闭嗅探,先关闭 FakeDNS 才能停用。
普通表单不提供域名/IP 排除,高级/Raw 原生配置与 JSON 字段提示保留。
所有设置只改草稿,经保存沿用现有校验和已连接确认重连流程;取消不持久化。
-智能路由按直连与拦截、代理连接、DNS 和流量嗅探分组;直连 DNS 开关归入 DNS,
-FakeDNS 归入流量嗅探。内容宽度至少 720 时,平板和桌面共用双列:左侧直连与拦截及 DNS,
-右侧代理连接、嗅探及预览。窄窗口按上述分组顺序单列显示,整页统一滚动。
-常规路由的规则列表、FakeDNS/Fragment、DNS 和嗅探使用同级卡片,避免重复嵌套外框。
-规则区未打开编辑器时占满内容宽度;打开后,内容宽度至少 876 时规则列表与编辑器等宽并排,否则上下排列。
-下方设置区独立适配:内容宽度至少 720 时 DNS 居左,FakeDNS/Fragment 与嗅探居右;窄窗口保留单列顺序。
+智能路由依次展示直连与拦截、代理连接、DNS、流量嗅探和预览,整页始终单列滚动。
+直连地区最多选择一个,也可显式清空,候选来自已安装索引;重复选择同项不取消选择。
+底层 `directRegions` 仍为 List 以读取历史配置,选择页仅投影首项;打开/取消不裁剪原列表。
+地区页“完成”只返回单项或空列表至父级草稿,整个 Smart 保存后才持久化;取消地区页或 Smart 编辑不提交更改。
+直连 DNS 开关归入 DNS,FakeDNS 归入流量嗅探。
+常规路由依次展示名称、有序规则、代理连接、DNS 与嗅探;导入、导出与分享工具位于页末独立卡片。
+Fragment 归入代理连接,FakeDNS 归入嗅探,不重复嵌套外框。
+所有宽度均从有序规则列表进入独立规则页,不保留常驻右侧编辑器;取消规则页不修改父草稿。
+FakeDNS/Fragment、DNS 和嗅探沿同一内容流排列,保留规则新增、删除、拖动和上下移动。
调整窗口宽度保留各设置区的输入草稿、焦点、选区和展开状态。
路由分组标题保持相同最小高度;数量、规则条件与动作控件统一绘制前景外框,保留圆角与分隔线。
@@ -109,6 +154,13 @@ FakeDNS 归入流量嗅探。内容宽度至少 720 时,平板和桌面共用
### 服务器
+首页按节点、完整配置、订阅三类资源浏览。节点与完整配置使用单列列表,普通节点、个人 Raw 和提供方配置
+保留明确类型;订阅来源有独立详情,展示当前方式、套餐、缓存状态及更新、编辑和分享入口。
+浏览资源或打开详情不修改连接目标;详情中的“使用”才提交选择,编辑、测速、分享和删除分别执行各自动作。
+完整配置指以整份 Xray JSON 运行的连接资源,不使用普通节点的路由偏好。
+类别包含个人 Raw JSON 和订阅下发的提供方配置:个人 Raw JSON 可编辑;提供方配置只读、随订阅更新,
+需要编辑时先复制为个人 Raw JSON。原图查看、复制编辑与选择不互相替代;完整配置工具区提供按需查看的定义与类型说明。
+
服务器的订阅按 ID 升序,普通手动节点可整理为一层自定义分组;未分组固定第一,空自定义组保留。
支持新建、重命名和通过节点菜单移组;解散分组保留节点,删除分组及节点需确认数量与运行影响。
自定义组仅用于浏览和管理,不创建连接范围;全部手动节点入口仍覆盖所有组,节点可以单独选择。
@@ -116,19 +168,35 @@ Raw 与提供方完整配置不参与手动分组,不提供节点位置 Tab、
服务器列表及分组详情提供默认、名称和延迟排序,默认顺序不因测速结果更新而变化。
选择作为本机偏好立即保存,同步服务器浏览、最终出口选择与桌面托盘;搜索和收藏筛选保留所选顺序。
搜索非空时提供清除按钮,零匹配使用通用结果提示并提供清除搜索操作,不改写排序或收藏偏好。
-移动端根页保留固定的来源管理入口,无需滚动到列表末尾,空列表及空手动组不影响入口可用。
+订阅标签位于列表上方,来源管理不依赖滚动到节点末尾;空手动组不影响来源入口可用。
自定义分组和组内节点分别进入整理页面,手机长按手柄、桌面拖动手柄,也可用上下移动按钮。
-整理使用独立草稿,保存后提交,取消或返回不写入;搜索期间不可整理,不支持跨组拖放。
+主节点列表在搜索为空、未启用收藏筛选且来源为全部来源或手动添加时提供“整理节点顺序”;
+只有一个非空手动组时直接进入该组,多个非空组先选择完整分组范围,空组、单节点组及忙碌组不可整理。
+订阅来源筛选不提供手动整理。整理使用独立草稿,保存后提交,取消或返回不写入;不支持跨组拖放。
整理页使用手动顺序,退出保留原显示排序偏好;默认排序展示保存的位置。成员变化时需重新加载草稿,测速和收藏更新不打断整理。
-显示排序不改变自动选择最快可用接入节点的规则,具体比较与刷新边界见 [数据管理](data-management.md#数据显示顺序)。
+名称或延迟排序不阻止进入整理,也不因保存位置而改写为默认排序;整理始终针对完整组,不将筛选结果写为组顺序。
+显示排序不改变连接候选规则,未测或测速失败不禁用节点与最终出口选择;具体比较与刷新边界见
+[数据管理](data-management.md#数据显示顺序)及 [普通配置编译](xray-configuration.md#普通配置编译)。
普通节点显示 type=outbound、协议/传输/安全和延迟标签;成功延迟 ≤500 ms 为快、≤1000 ms 为慢,其余为可用。
-提供方行显示 type=Remnawave,不展示普通延迟、不参加单 outbound 测速;来源组保持浏览及逐项固定选择入口。
+未测与测速失败使用独立状态文案,不把失败诊断当作延迟数值或不可连接结论。
+提供方行显示 type=Remnawave,不展示普通延迟、不参加单 outbound 测速;来源详情保持浏览及逐项固定选择入口。
原有自动选择在普通候选不足时可运行一份提供方配置,连接页按运行快照显示实际名称与提供方管理的路由;
断开后仍保留原选择意图,具体策略见 [配置合同](xray-configuration.md#普通配置编译)。
-完整配置可选择、收藏、查看原 JSON、分享、删除或复制为个人 Raw;原配置只读,副本受 Raw 三份配额限制。
-普通节点仍用 JSON 编辑。连接 Tab 内打开的服务器页不改变服务器根 Tab,选择后保留当前页面并按打开顺序返回。
-独立节点 JSON 编辑按已加载或成功保存的原文判断未保存修改;光标移动不算修改,手动还原原文恢复干净状态。
-已修改时,关闭、取消、返回、遮罩和 Escape 共用继续编辑/放弃更改确认;未修改直接关闭,保存中禁止退出。
+提供方完整配置可选择、收藏/取消收藏、查看原 JSON、分享、删除或复制为个人 Raw;原配置只读,副本受 Raw 三份配额限制。
+完整配置列表提供独立收藏筛选,列表与详情都可修改提供方收藏;个人 Raw 不支持收藏,不因切换资源标签继承普通节点的收藏筛选。
+普通节点仍用 JSON 编辑。连接分支内打开的服务器页不改变服务器根分支,选择后保留当前页面并按打开顺序返回。
+资源详情按真实协议、来源与延迟展示信息,并提供有界、可滚动的只读 JSON 预览;完整配置保留存储原文,读取失败显示错误,不使用示例或空配置回退。
+新建资源统一从“添加服务器”页面选择:普通节点手动 JSON 与完整 Raw JSON 为独立方式,Raw 复用原有完整配置编辑器、校验和配额。
+连接空状态只保留添加服务器主入口,完整配置列表及空状态同样先进入添加方式页,不再直接新建 Raw。
+取消 Raw 新建返回添加方式页,不写入或改变选择;保存成功后结束添加流程。已有 Raw 详情的编辑仍按原 ID 打开,不经过添加方式页。
+导入方式集中在一张卡片内,扫码等入口仍按平台支持显示;手动分组保留固定在前的未分组项和独立整理操作。
+节点、手动添加、Raw 与高级 JSON 复用全宽编辑工作区和原有 `re_editor`:编辑器占满 AppBar 与固定操作区之间扣除名称、工具和必要提示后的剩余宽高,不设置固定编辑器高度或正文最大宽度,编辑器保留 160 逻辑像素的最小高度。
+名称与工具、提示可分别滚动;正常空间仅在代码编辑器内部滚动,横屏、短屏或键盘压缩到最小布局以下时,工作区整体允许滚动,保存区仍固定。
+短视口中的自动滚动按实际光标行定位,避免将整个编辑框底对齐而隐藏当前输入;诊断位置、补全与撤销保持不变。
+按已加载或成功保存的原文判断未保存修改,Raw/高级同时比较名称;光标移动不算修改,手动还原原文和名称恢复干净状态。
+已修改时,关闭、取消、系统返回和 Escape 共用继续编辑/放弃更改确认;未修改直接关闭,保存中禁止退出。
+侧滑受同一可退出状态约束,干净稿保留平台手势,不能越过未保存或保存中的退出限制。
+旧弹窗入口的遮罩关闭同样遵守节点编辑守卫,全宽页面不另提供遮罩退出路径。
明确放弃不写库,成功保存直接返回;保存期间产生的新草稿和迟到结果保护保持。订阅向导继续沿用原有草稿保留流程。
确认框按钮在文字无法完整横排时改为纵向全宽,保留系统字号、原有确认行为及可滚动正文。
@@ -140,15 +208,20 @@ Raw 与提供方完整配置不参与手动分组,不提供节点位置 Tab、
订阅导入和刷新不预览、不二次确认;文件直接使用系统选择器,扫码仅 iOS/Android 显示。
套餐摘要和详情无数据时隐藏,不限制节点或连接;导入、双缓存、分享及套餐见 [订阅合同](subscriptions-and-sharing.md)。
-测速不锁定菜单;批量按钮可取消当前分组手动任务,不影响其它任务,队列边界见 [数据管理](data-management.md)。
+测速不锁定菜单;手动组与普通订阅来源详情的批量操作依次展示测速、取消、正在取消,
+同一组在途任务不重复提交。取消只作用于本组手动任务,不停止 VPN、不影响其它组任务,队列边界见 [数据管理](data-management.md)。
手动单节点或 App 管理订阅分组在 VPN 已连接时提醒请求可能经过当前 VPN、延迟可能不准确,提供取消/继续;
每次操作只确认一次,多批次不重复。连接或断开中及仍可断开的失败状态同样提醒,普通准备/已断开直接测速。
等待确认不入队、不增加共享 loading,连续点击不叠弹窗;取消、关弹窗或离开页面不提交。
继续测速不改变 VPN;自动测速不弹窗,同样受当前 VPN 路径影响。
-### 高级
+### VPN 隧道
-第三个根入口分为 VPN 隧道和 Xray,平台配置打开详情页:
+VPN 隧道有独立主入口,公共参数与平台策略沿一条内容流呈现,平台配置打开全宽详情。
+公共页和子页继续使用同一策略草稿、字段诊断和固定操作栏;恢复默认不保存,取消不写入,
+只有当前平台的有效变化经原服务确认后才重连。
+首页以系统接管状态与连接参数摘要组织导航,DNS 和 IPv6 在各自详情编辑并保存。
+恢复全部隧道默认仍作用于完整策略草稿,尚未提交时才显示根页保存操作;它不等同于只重置 DNS。
- 隧道页展示只读地址,可编辑 IPv4/IPv6 DNS 和 Apple DoT 服务器域名,IPv6 使用独立开关。
Windows/Linux 才显示出口网卡,必须明确选择。
@@ -163,37 +236,52 @@ Raw 与提供方完整配置不参与手动分组,不提供节点位置 Tab、
广播自动化在独立详情页配置,立即保存,不重连,见 [外部接口](external-interfaces.md)。
- Windows 系统 VPN 入口仅 MSIX 显示,提供自动连接、局域网绕过及排除网段。
EXE 不应用或校验停用的 MSIX 策略,两种模式均保留公共设置和出口网卡。
-- Xray 提供路由数据、更新周期、测速 URL/超时、服务器拨号、日志、局域网代理共享和只读实际运行配置。
- 路由数据、数据更新、测速与服务器拨号集中为紧凑的设置导航,保留当前值摘要、加载反馈及原有详情入口。
- 服务器拨号在独立页面编辑域名解析策略与 Happy Eyeballs,默认系统解析、开关关闭;较长原理说明放在帮助中。
- Happy Eyeballs 不适用时显示原因并保留原开关偏好。保存复用有效变更的确认重连流程,
- 停用配置只保存偏好,作用模式与 DNS 边界见 [服务器拨号](xray-configuration.md#服务器拨号)。
- 共享开关在 Xray 区域,端口在独立页面;保存只保留偏好,下次 App 编译连接才生效,不弹重连。
- 说明 HTTP/SOCKS5、SOCKS5 UDP、可信网络无认证使用,以及个人 Raw/提供方方式不应用共享,
- 后台 saved-start 和平台限制见 [LAN 共享](xray-configuration.md#局域网代理共享)。
- 桌面端另有本地 HTTP API;移动端不注册该入口或路由。
- System Extension 隐藏整个日志区和对应保存/恢复按钮,不提供原生日志读取;其他入口不受影响。
- 日志导出不设容量上限,先按开始时的文件长度流式复制到临时副本,再通过文件路径交给系统保存;
- 保存或取消后清理副本,保留原日志供内核继续写入。日志查看仍按有界分块读取。
+
+### Xray
+
+Xray 有独立主入口,分为运行状态、检查与诊断、连接与资源、共享与接口四张卡,不与隧道共用二级标签。
+核心名称与真实版本同行;当前连接身份仅来自活动运行快照,无运行时不以准备中的连接计划替代。
+运行时长仅在已连接且实际可用时显示;日志与最近运行配置入口归入检查与诊断。
+只读诊断页显示当前阶段、原错误、版本、运行时长及实际运行 JSON/日志入口;刷新只重新读取状态,
+不启停 VPN、不发起网络测试,也不清除或宣称解决连接错误。无运行记录使用空状态,不能用部分规则预览代替实际 Core 输入。
+
+路由数据、数据更新、节点延迟设置与服务器拨号集中为紧凑的设置导航,保留当前值摘要、加载反馈及原有详情入口。
+服务器拨号在独立页面编辑域名解析策略与 Happy Eyeballs,默认系统解析、开关关闭;较长原理说明放在帮助中。
+Happy Eyeballs 不适用时显示原因并保留原开关偏好。保存复用有效变更的确认重连流程,
+停用配置只保存偏好,作用模式与 DNS 边界见 [服务器拨号](xray-configuration.md#服务器拨号)。
+共享开关在 Xray 区域,端口在独立页面;保存只保留偏好,下次 App 编译连接才生效,不弹重连。
+说明 HTTP/SOCKS5、SOCKS5 UDP、可信网络无认证使用,以及个人 Raw/提供方方式不应用共享,
+后台 saved-start 和平台限制见 [LAN 共享](xray-configuration.md#局域网代理共享)。
+桌面端另有本地 HTTP API,继续沿用即时开关、端口提交与 Token 重置语义;移动端不注册该入口或路由,
+服务合同见 [外部接口](external-interfaces.md)。
+日志偏好与恢复默认位于独立日志设置页,草稿保存后才生效;Xray 根页不显示日志表单或固定保存按钮。System Extension 隐藏日志入口,独立页面、诊断和深链同样限制日志访问;其他入口不受影响。
+日志导出不设容量上限,先按开始时的文件长度流式复制到临时副本,再通过文件路径交给系统保存;
+保存或取消后清理副本,保留原日志供内核继续写入。日志查看仍按有界分块读取。
+日志页在运行身份变化或进入新连接时重置内存缓冲和读取位置,丢弃旧会话在途读取;断开本身保留本轮诊断。
数据更新页仅管理订阅与 Geodata 的自动更新开关和周期。User-Agent 在每个订阅表单单独管理,默认填入兼容 UA,
用户可自定义;清空后仍使用订阅默认值,规则见 [订阅合同](subscriptions-and-sharing.md)。
-高级页没有 iOS Debug Proxy 开关;模拟器适配属于 Swift,见 [Raw 运行边界](xray-configuration.md#raw-json)。
+App 没有 iOS Debug Proxy 开关;模拟器适配属于 Swift,见 [Raw 运行边界](xray-configuration.md#raw-json)。
测速设置只提供延迟 URL 和超时,并说明 VPN 运行时结果可能不准确、建议关闭 VPN 后测速。
保存影响后续手动和自动任务,无需重连;取消不保存,正在执行的任务使用开始时的快照。
-出口地区探测与位置缓存已移除,不影响目标 GeoIP/Geosite、Smart 直连地区或 Setup 地区配置。
+服务器出口地区探测、地区筛选/选择范围与位置缓存已移除,不恢复地区字段;这不影响目标 GeoIP/Geosite、Smart 直连地区或 Setup 地区配置。
### 设置
-设置管理外观、语言、Apple 图标、启动行为、连接配置备份、清理与关于。
-首页主题选项按当前字号、标签实际宽度和可用空间选择横排或纵排,不按语言硬编码,也不缩小系统字号。
+设置以单列分组管理外观、语言、Apple 图标、启动行为、连接配置备份、清理与关于。
+首页按外观与语言、启动与系统、数据、关于分组;启动偏好进入独立页面,沿用即时保存并在返回后刷新摘要。
+备份入口实时显示当前绑定目标或未设置状态,长目标名称最多显示两行,进入详情查看完整位置。
+首页主题选项按当前字号、标签实际宽度和可用空间自然换行,不按语言硬编码,也不缩小系统字号。
选中状态具有语义标记,点击后立即保存;Apple 图标行空间不足时将当前值移至标题下方自然换行。
+语言及图标详情使用单列选择控件,图标保留预览;独立表单仍在保存前保留草稿,取消不写入。
Apple、Android、Windows 提供备份详情页,Linux 不显示,协议与恢复见 [备份](backup.md)。
自动备份开关位于分区标题旁,只显示一次标题,并保留开关名称、状态及操作中的禁用和进度反馈。
周期仍在同一分区编辑,Apple 位置选择保留 iCloud 主动作,敏感数据提醒及恢复、覆盖确认保持原规则。
关于展示 App/Xray-core 版本;有新版本时入口显示圆点,桌面侧栏保留更新提醒。
-清理成功留在业务页面,不重新进入 Setup;清理失败阶段与数据保护见 [数据管理](data-management.md)。
+更新说明有独立入口打开项目发行记录,原有版本检查与更新弹窗继续保留。
+清理连接资源与清理全部数据是不同操作;成功留在业务页面,不重新进入 Setup,
+保留范围、失败阶段与数据保护见 [清理数据](data-management.md#清理数据)。
## 启动与首次初始化
@@ -206,11 +294,18 @@ Apple、Android、Windows 提供备份详情页,Linux 不显示,协议与恢
4. 辅助通知、托盘、本地 API 等各自捕获失败,不统一阻断首页;关键准备失败显示重试并打开主窗口。
待下载 Geodata、云端错误或后台测速不阻断初始化,恢复边界见 [数据管理](data-management.md)。
-Setup 地区复用智能路由单选页:识别仅预选候选,清空经“完成”提交,取消不改草稿。
+Setup 地区复用智能路由单选页:识别仅预选候选,单选替换或显式清空经“完成”返回父稿,取消不改草稿。
+历史列表只在选择界面投影首项,不在打开/取消时改写已有配置。
Windows/Linux 网卡不默认选第一张,必须存在;其他平台隐藏。
“进入首页”将地区与网卡一起保存,成功才写完成标记;失败保留草稿。
已完成标记只决定入口,不跳过已进入 Setup 的必要检查。
+Welcome、准备配置、隐私说明和选择子页均为完整页面,使用标准 AppBar、顶部开始的单列正文与固定底部操作,
+正文在短屏或大字号下独立滚动,不以居中卡片或弹窗承载初始化主流程。
+Setup 根页不直接退出;配置页的返回回到 Welcome,不撤销已保存的隐私同意。
+隐私页返回不代表同意,地区选择取消不改父稿,网卡选择只先返回父稿;错误保留在当前页并提供适用的重试。
+App 页面不替代系统 VPN、局域网或系统扩展授权面板。
+
隐私/Setup 和冷启动快捷操作抑制本次偏好自动连接,并显示所需界面,不改写偏好。
自动连接只执行一次,经同一连接协调器处理;初始化、托盘刷新或自动连接失败不被隐藏启动遮蔽。
@@ -257,29 +352,48 @@ EXE/ZIP/Linux 的 Quit 先停止 VPN。需要停止的退出失败时保持 App
Android 设置页可请求固定 `home_widget`,不把请求完成当作已添加;Widget 展示状态、本次速率和流量,
标题/数据打开 App,图标启停 VPN。Widget/Tile 直接复用已保存运行输入的边界见
[运行协调与统计](xray-configuration.md#运行协调与统计)。外部自动化的授权与失败行为见 [外部接口](external-interfaces.md)。
+Android 快捷设置磁贴单击沿用启停;长按通过 `QS_TILE_PREFERENCES` 打开现有主 Activity,恢复当前页面。
+长按不提交快捷启动命令;普通冷启动仍尊重既有“启动时连接”偏好。
### iOS/iPadOS Widget
-iOS/iPadOS 15 起提供连接 Widget,支持 `systemSmall`、`systemMedium`;16 起增加
+iOS/iPadOS 最低支持 16.0,连接 Widget 支持 `systemSmall`、`systemMedium`、
`accessoryCircular`、`accessoryRectangular`,可添加位置由系统版本与设备决定。
-iOS/iPadOS 15–16 静态展示状态,点击打开连接页;
-17 起主屏幕小号/中号 Widget 增加显式“启动 VPN”“停止 VPN”,通过前台 App Intent 打开 App。
-锁屏两种 accessory 只导航。当前仅接入 Widget,不发布可发现的 Siri/App Shortcuts,
-也不提供 Control Widget 或 Live Activity。
+主屏幕 Widget 保留 kind `net.yuandev.onexray.connection`;17 起小号/中号均提供一个启停按钮,
+锁屏两种 accessory 始终只导航。18 起控制中心提供一个 kind 为 `net.yuandev.onexray.vpn` 的 VPN 按钮。
+
+| 系统版本 | 执行方式 |
+| --- | --- |
+| iOS/iPadOS 16 | 静态展示,点击打开连接页。 |
+| 17–25 | 主屏幕 Widget 默认在主 App 进程后台启停,控制中心从 18 起使用同一路径;缺少可用输入或需要授权时才请求前台继续。 |
+| 26 | 保留主 App 执行路径,显式声明后台和动态前台模式。 |
+| 27 及以上 | 进一步用 `.main` 明确限定主 App 执行;启停与按需前台继续的行为相同。 |
+
+主屏使用 `Button(intent: ToggleVpnWidgetIntent())`,控制中心使用 `ControlWidgetButton` 和同一切换 Intent,
+不采用持久布尔开关或 `SetValueIntent`。主屏未连接时表达启动操作,已连接或正在连接时表达停止操作;
+未知或过期时主屏显示“未知”、控制中心显示“待确认”,并保持可操作。点击时重新读取系统状态及本进程进行中的操作:
+已连接或正在连接时停止,已断开时启动,正在断开时不追加启动。展示快照不决定动作,不对其直接取反。
+控制中心动作名称跟随 App 有效语言,语言变化后请求 Controls 刷新。当前不发布可发现的 Siri/App Shortcuts 或 Live Activity。
主屏组件跟随系统浅深色,配色复用 App 色板,由 Widget 命名颜色资源统一管理;
-系统配置界面通过同一资源目录中的强调色和背景色匹配主题。小号居中展示状态,底部并排显示简短启停文案;
-中号将状态摘要与操作分列,摘要和旧系统的“打开 App”均进入连接页。
-启动使用蓝色,停止使用红色,已连接用绿色盾牌标识,未连接使用中性色。
-按钮强调跟随展示快照,两种操作始终可用,不因可能过期的状态禁用。
-操作保留 44pt 点击范围,最小尺寸与大字模式精简重复品牌、装饰及可见按钮文字,保留完整无障碍名称。
-
-启停复用 App 的 `ConnectionCoordinator` 与当前选择、权限检查和错误反馈;不读取 saved-start,
-Widget 扩展不在后台直接启停 VPN。已到达的冷启动输入先于偏好自动连接处理,Shell 就绪后执行;
-重复同类操作合并;停止取消已有启动,等待必要的准备写入排空,不等待已提交启动的连接状态确认。公开链接仅导航,见
-[Widget 动作与链接](external-interfaces.md#ios-widget-前台动作与链接)。
-
-Widget 展示最近一次原生状态快照及观测时间,非实时状态。真机“已连接”只来自 NetworkExtension 状态观测,
+系统配置界面通过同一资源目录中的强调色和背景色匹配主题。小号顶部显示 OneXray,中心为圆形电源按钮,
+底部显示状态;大字或高度不足时收起品牌。
+中号左侧为品牌与状态摘要,右侧固定窄区放圆形电源按钮及简短操作标签;摘要进入连接页。Widget 不展示观测时间。
+启动沿用蓝色、停止沿用红色,已连接状态使用绿色;配色与字体沿用现有资源和系统字号。
+圆形电源承载唯一启停操作,按钮强调与短标签跟随展示状态,不因可能过期的快照禁用;动作仍由新读取的原生状态决定。
+操作保留 44pt 点击范围,大字模式精简品牌及可见短标签,完整启停名称通过无障碍标签保留。
+
+启停由主 App 原生服务复用最近生成的 `run/start.json` 和已有系统 VPN 配置;不追随后来修改的 DB 选择,
+不经 Dart 重新编译配置。停止不依赖启动文件。缺输入或需要授权时只打开连接页供用户处理,
+不会排队按当前选择自动连接;各版本的前台继续均遵守此边界。
+原生动作抑制本次启动自动连接,并只取消匹配的旧 Dart 准备,不重复执行已经处理的原生停止。
+正常 App 连接仍经协调器准备,再由原生原子提交;协议与维护阻断见
+[Widget 动作与链接](external-interfaces.md#ios-widget-动作与链接)。
+
+App 恢复运行展示时用系统 `connectedDate` 校准内存中的会话起始时间,不改写保存请求或配置缓存。
+
+Widget 展示最近一次原生状态快照,非实时状态;`observedAt` 继续用于内部过期判断,不向用户显示。
+真机“已连接”只来自 NetworkExtension 状态观测,
提交启动请求或 Core 构造成功不代表已连接。快照超过 15 分钟、缺失、损坏或版本未知时按“未知”处理;
WidgetKit 负责刷新调度,不承诺 App 退出后持续更新。展示不包含配置名称、订阅、服务器地址或流量。
语言跟随 App 有效语言,支持英语、简体中文、繁体中文、俄语、波斯语;系统语言偏好先按 App 规则匹配,
diff --git a/docs/backup.md b/docs/backup.md
index 44a08c7d2..c4ea43337 100644
--- a/docs/backup.md
+++ b/docs/backup.md
@@ -79,7 +79,7 @@ iOS 与 macOS 独立匹配,macOS SE 使用 `macos`;Windows 各分发使用 `
最终出口若是本地节点,仍作为普通节点保存。
- 自定义 Geodata 只保存名称/类型/HTTPS URL,包含待下载声明,省略默认组;不保存任何资源文件。
- `data` 保留数据库 Base64 原字符串,不通过分享格式或普通模型裁剪 Raw/高级内容。
- 不保存数据库 ID、收藏、测速缓存、首页选择、专家模式、App 偏好、API 或 Android 自动化授权;
+ 不保存数据库 ID、收藏、测速缓存、首页选择、普通计划记忆、专家模式、App 偏好、API 或 Android 自动化授权;
服务器拨号随当前平台分区保存,显示排序仍为本机设置。
订阅完整配置与派生行不因活动方式改变而纳入本地资产备份;复制出的个人 Raw 按普通 Raw 保存。
@@ -101,7 +101,7 @@ Dart isolate 不能消除插件元数据主线程风险,云占位文件和线
新资产生成新 ID,不重置自增序列,旧编辑/回包不能覆盖新资产。
同平台 VPN 设置与连接资产在同一事务中恢复;无匹配分区时保留原平台策略。
保持目标设备的物理网卡、其它平台草稿、偏好、授权和备份设置;恢复为普通模式、自动节点、智能路由,
-清空最终出口及 Raw/Custom 当前选择。完成后不自动连接、不立即覆盖云备份,也不追加订阅刷新或测速。
+清空最终出口、Raw/Custom 当前选择及本机普通计划记忆。完成后不自动连接、不立即覆盖云备份,也不追加订阅刷新或测速。
恢复完全离线:默认资源使用当前有效文件或启动准备的内置资源;
只有同名、同类型、同 URL 且有效的自定义文件才复用,其余登记待下载。
diff --git a/docs/data-management.md b/docs/data-management.md
index 0332fbe37..1b1252b8c 100644
--- a/docs/data-management.md
+++ b/docs/data-management.md
@@ -13,7 +13,8 @@ v1–v2 创建新结构时不新增退休地区列,v3–v6 删除已有列,
手动分组独立存储名称与顺序,普通手动节点保存可空的分组引用和组内顺序;
未分组为虚拟分组,空自定义组保留。旧节点升级后归入未分组,同序时按 ID 保持原有次序,不改写配置原文。
未知连接选择 kind 在普通读取和运行 metadata 中统一回退 automatic,不为旧地区选择增加专门迁移或改写原文。
-目标 GeoIP/Geosite、Smart 直连地区、Setup 地区与语言国家代码仍属于有效功能。
+服务器地区筛选/选择范围随节点出口地区列退役,不恢复该字段;它与目标 GeoIP/Geosite、Smart 直连地区、Setup 地区及语言国家代码不同。
+后者仍为有效功能;Smart/Setup UI 保持单选/清空,底层历史 `directRegions` List 不迁移或因打开/取消页面而裁剪。
迁移不联网、不生成标识、不更新订阅,不在运行时复制全表验证或制作数据库快照。
`StoragePreparation` 在首次业务访问前打开数据库并完成迁移,不依赖 VPN 或 Setup。
@@ -55,7 +56,8 @@ v1–v2 创建新结构时不新增退休地区列,v3–v6 删除已有列,
名称、延迟等显示排序是设备偏好,保存后通过共享事件同步,重启沿用;缺失或未知值回到默认。
显示排序不改写手动顺序,切回默认时使用已保存的位置。手动分组和顺序进入资产备份,显示排序偏好不进入备份。
订阅刷新仍沿用正常替换与保护合同,新节点依据当前保存后的 ID/名称/延迟重新参与排序,
-不维护跨刷新节点身份或手工位置。显示顺序与连接候选解析分离,自动接入仍按成功延迟、ID 选择,
+不维护跨刷新节点身份或手工位置。显示顺序与连接候选解析分离,自动接入成功延迟优先,其余统一按 ID 排后;
+测速不构成启动条件,连接不发起或等待测速,活动可读候选足量即选取。
已运行连接不随排序或测速更新迁移。偏好不进入备份;清理全部数据恢复默认顺序。
### 测速结果
@@ -131,15 +133,21 @@ macOS System Extension 因跨容器传输是唯一例外:清单纳入根目录
## 清理数据
-`AppDataCleanupService` 只协调清理与备份恢复:先暂停接收相关模块新操作,再等待必要在途操作;
+`AppDataCleanupService` 协调清理连接资源、清理全部数据与备份恢复:先暂停接收相关模块新操作,再等待必要在途操作;
订阅导入不继续下一来源,测速完成当前原生批次后保存并取消后续请求,不等待全部排队测速。
取得 Geodata 文件队列后确认原生 VPN 停止,再替换数据;停止失败不继续删除,旧回包不能在替换后写回。
新连接命令在临界区拒绝,未开始命令取消,不在结束后自动执行旧保存/启动。成功或失败都恢复模块受理。
+清理连接资源只清节点/Raw、订阅及派生缓存、手动分组、常规/高级路由、自定义 Geodata 来源与文件及旧运行输入/日志。
+选择与普通计划记忆清空,恢复普通模式、自动节点和智能路由;平台 VPN/拨号/网卡策略、本机偏好、登录项、默认 Geodata、
+API/Android 自动化授权和 Token、备份目标及外部文件均保留,不重连、不更新订阅、不测速或立即回写备份。
+该操作复用 Geodata 局部恢复日志与数据库事务,运行目录同盘暂存到事务完成;文件或提交失败回滚旧资产与运行文件。
+成功后清除会话视图及旧测速错误,默认资源不重建,剩余 API/后台服务恢复受理。
+
清理全部数据取消登录项、清空数据库/运行文件/用户偏好并重建默认资源,不重跑 Setup;
登录项取消失败在删除前停止,具体窗口与退出边界见 [App](app.md)。
备份恢复替换连接资产及匹配平台的 VPN 设置,保留本机绑定、授权和偏好,见 [备份](backup.md#恢复与缺失资源)。
-两者都保留 Android 跨进程 START 准入保护,见 [外部接口](external-interfaces.md#设备授权与数据操作)。
-清理移除本机备份绑定,不删除云文件。
+三者都保留 Android 跨进程 START 准入保护,见 [外部接口](external-interfaces.md#设备授权与数据操作)。
+仅清理全部数据移除本机备份绑定,所有清理均不删除外部云文件。
平台验证与数据隔离遵循 [验证边界](validation.md);真实旧安装包、文件中断恢复和 SE 传输/按需启动须在对应环境核对。
diff --git a/docs/external-interfaces.md b/docs/external-interfaces.md
index 1f682024d..261f71946 100644
--- a/docs/external-interfaces.md
+++ b/docs/external-interfaces.md
@@ -124,14 +124,35 @@ Widget/Tile 的缺输入打开 App 回退不用于广播。
异常退出遗留标记在下次 `ServiceManager` 存储/协调器就绪后解除,不依赖 Setup、不自动连接,
也不把此标记扩展成 VPN 状态缓存或普通保存/测速锁。
-## iOS Widget 前台动作与链接
-
-iOS/iPadOS 17 起 Widget 的启动/停止使用不公开发现的前台 App Intent;App 内存接收动作,
-经原生桥交给既有快捷入口和连接协调器。冷启动与回前台均读取待处理动作,Shell 未就绪时保留最后一个动作,
-并抑制本次偏好自动连接;消费后清除,同类在途动作合并。较晚到达的停止会取消待执行启动,
-iOS 原生先排空配置准备写入再停止,不等待已提交启动的连接状态确认。
-输入仅限启动/停止,不接受配置参数、节点选择或通用 invoke,也不形成跨进程持久命令队列。
-它使用当前 App 选择,不复用 Android Widget/Tile 或 Apple 按需连接的 saved-start 路径。
+## iOS Widget 动作与链接
+
+主屏幕 Widget 自 iOS/iPadOS 17 使用单个 `Button(intent: ToggleVpnWidgetIntent())`;控制中心自 18 提供单个 VPN 按钮,
+kind 为 `net.yuandev.onexray.vpn`,使用 `ControlWidgetButton` 和同一无参数切换动作,不采用 `SetValueIntent`。
+主屏未连接时显示启动,已连接或连接中显示停止;未知/过期时主屏显示未知、控制中心显示待确认,并保持可操作。iOS 16 与锁屏 accessory 仍只导航。
+这些 Intent 不公开为 Siri/App Shortcuts,`openAppWhenRun=false`;主屏幕自 17、控制中心自 18 起默认后台启停。
+Runner-only 的 `ForegroundContinuableIntent` conformance 提供主 App 进程执行路径;该协议自 16.4 可用,
+26 弃用但未移除,Widget 扩展不编译此 conformance。26 起声明 `supportedModes=[.background,.foreground(.dynamic)]`,
+27 起再用 `allowedExecutionTargets=.main` 明确限定主 App 执行。17–25 缺输入或需要授权时使用旧前台继续方法,
+26 起使用 `continueInForeground`;只有系统接受继续后才导航。API 版本决定兼容路径,设备未测范围单独记录,
+不以 27 或真机覆盖情况作为后台能力的额外门槛。
+
+原生动作每次查所属系统 VPN 配置和实际状态,并结合本进程待启动/停止状态决定操作:已连接或连接中停止,
+已断开时启动,断开中不追加启动。展示快照即使未知或过期也不决定启停目标,不对快照直接取反。
+并发 Toggle 查询不能使较后的停止意图被较早的查询或启动吞掉。启动复用最近生成的完整 `run/start.json` 与 App Group 资源,
+并恢复保存请求中的系统 profile 路由/按需策略;不读取 DB 重新选节点,不重复配置验证、端口或依赖扫描。
+只检查保存输入可读取及启动必需字段,实际 Core/资源错误由既有启动链返回;停止不依赖启动文件。
+缺 profile、输入或需要授权时只请求前台处理,前台继续获系统接受后才导航连接页,不再排队启动当前 App 选择。
+
+正常 App 连接在 Dart 准备前向 Runner 登记 preparation token,原生 `startPreparedVpn` 在同一 MainActor 临界段
+检查并消费 token、原子写入请求、准入启动;iOS 不再由 Dart 先写启动文件。Stop 淘汰旧 token,
+过期提交使用 `apple-preparation-cancelled` 类型化错误返回;Dart 不重复停止已由原生处理的连接,也不影响之后的新启动。
+清理/恢复复用现有维护入口建立原生持久 START 阻断,失败不进入数据替换;正常启动就绪后解除异常遗留标记。
+
+原生到 Dart 的 `takeAction` 返回消费一次的 JSON envelope:`version:1`、`suppressAutoConnect:bool`、
+`openConnect:bool`、`invalidatedPreparations:[String]`。它只累积启动抑制、导航和旧准备失效信息,
+不重送 start/stop,不形成持久命令队列。Dart 先抑制本次偏好自动连接,再仅取消匹配当前 token 的旧任务,
+最后处理导航;冷启动和回前台均读取,迟到旧 token 不取消新任务。系统 `connectedDate` 只校准内存运行时间,
+不改写保存输入或缓存。
Widget 唯一公开 URL 为 `onexray://widget/connect`,只打开连接页,不启停 VPN。
带查询、fragment、端口、用户信息或其他路径的地址不进入 Widget 动作入口;启停不通过 URL 参数开放。
diff --git a/docs/subscriptions-and-sharing.md b/docs/subscriptions-and-sharing.md
index 0d215c793..a2aa81288 100644
--- a/docs/subscriptions-and-sharing.md
+++ b/docs/subscriptions-and-sharing.md
@@ -23,6 +23,10 @@ App 下载统一默认使用 `v2rayN/ <完整 OneXray UA>`,包
更新保护准备中、待启动、运行中、固定选择、最终出口及收藏原行,保留它们的 ID 与配置原文,并插入全部新结果;
同名新旧条目允许并存,保护行不计入本轮导入数。保护解除后后续更新清理旧行,不原地覆盖运行配置。
普通活动节点进入自动测速队列,不等待测速;提供方完整配置不进入单 outbound 测速。
+普通节点和提供方完整配置均可在资源列表及详情收藏/取消收藏;完整配置的收藏筛选只显示提供方配置,不包含个人 Raw。
+收藏继续参与更新保护,取消后在下一次更新按既有引用规则清理,不修改当前运行或自动连接。
+订阅详情的批量测速显示本来源的进行/取消状态,同来源在途请求不重复提交;取消只停止本来源后续批次,
+当前原生批次结束前保持等待,不停止 VPN、不取消其它来源或后台任务。
### 面板与配置使用方式
@@ -127,7 +131,10 @@ Age App Link 只携带算法,不带公私钥;接收端生成新密钥对,
不能把它称为完整配置或任意 outbound 无损导入。手写节点 JSON 不经普通表单重建;
完整订阅 JSON 使用上述独立路径,标准格式的接受/拒绝仍由 libXray 决定。
- 名称使用 `tag`;仅键缺失时兼容旧 `name`,无名以协议补齐。`sendThrough` 保留绑定语义,不参与命名。
- KCP seed/header、废弃 `allowInsecure` 不在标准分享内,App 不另校验协议算法或补写规范字段。
+- SOCKS 原始链接在独立导出副本上兼容旧 `settings.servers`:仅转换单服务器、至多一个用户,已有非 null 的 flat 地址按 Core 的优先级处理。
+ 生成结果须有有效主机和显式有效端口;歧义配置或无效结果不提供原始链接分享和二维码,仍可使用完整 OneXray 链接。
+ 此投影不修改数据库、节点原文、运行配置、备份或 OneXray 链接。
+- KCP seed/header、废弃 `allowInsecure` 不在标准分享内,App 不另校验协议算法或补写规范字段。
- 文件直接使用系统选择器,扫码仅 iOS/Android。失败保留输入,成功 toast 后提交测速,不等待测速完成。
## App Link
diff --git a/docs/validation.md b/docs/validation.md
index 39ff94a1f..2fd078001 100644
--- a/docs/validation.md
+++ b/docs/validation.md
@@ -11,6 +11,7 @@
| 场景 | 主要结果 |
| --- | --- |
| 启动与恢复 | 服务就绪后首页可用;缺库或默认资源丢失可离线恢复 |
+| 导航与浏览 | 六目的地独立保栈,手机五底栏和顶部设置、宽屏文字侧栏与底部设置可达;业务内容单列 |
| 导入与选择 | 普通节点与完整数组入库、活动方式计数、列表 Stream、普通测速与完整配置选择符合类型 |
| 订阅更新 | 双缓存事务更新,保留准备/待启动/运行/固定/收藏原行与来源身份;切换不热重连 |
| 配置与连接 | 普通/高级/Raw/提供方配置的保存、适配、分享与编译;先停止旧连接再启动 |
@@ -36,14 +37,29 @@
按[界面合同](app.md)选择代表性窗口、语言和系统字号,不以字号缩小或控件存在代替实际可操作性。
-- 短高横屏叠加侧边和底部安全区,检查侧栏品牌、全部导航入口和更新提醒可见或可滚动到达;
- 横竖屏往返保留当前详情及不同 Tab 的导航栈。大字和 RTL 选择代表性组合,不重复全部状态矩阵。
+- 代表性手机、平板和桌面窗口分别核对连接/服务器/路由/隧道/Xray/设置六个目的地。
+ 手机根页保留前五项底栏和顶部设置,设置页及二级页面隐藏底栏;宽屏保留文字侧栏、置底设置和更新提醒。
+ 手机底栏核对内缩圆角卡片、蓝底白字选中项、五项标签与选中语义;切换保留分支栈,进入详情隐藏、返回恢复。
+ 短高横屏叠加侧边和底部安全区,所有入口仍可见或可滚动到达;横竖屏往返保留当前详情及各分支导航栈。
+ 内容始终单列,打开资源、规则或诊断详情不生成右侧工作区。大字和 RTL 选择代表性组合,不重复全部状态矩阵。
+ 平板与桌面宽布局业务区直接填满侧栏旁的剩余空间,不保留整区圆角裁切、装饰外框或外层留白;
+ 页面内容边距、单栏、横向安全区、macOS 标题栏和 RTL 原生按钮避让仍须正确。
+- 页面标题、返回及页面操作位于共享标准 Flutter AppBar,不固定展示 logo、App 名称或工作区路径,也不在下方添加重复页标题区。
+ AppBar 没有主题快捷按钮,主题仍可由设置修改;检查长标题、窄宽度和大字号下返回与主要操作可达。
+ 平板与桌面宽布局在明暗主题下分别检查 AppBar、`PageActionBar` 与 `Scaffold` 背景一致,
+ 滚动前后没有额外表面着色或抬升效果;手机 AppBar 沿用 Material 默认表现,业务卡片与弹窗样式保持原合同。
+- Welcome、准备配置、隐私和选择子页检查完整页面布局:正文上对齐单列滚动,底部动作独立固定,
+ 短屏、键盘、大字号及 768 宽 RTL 下不以居中卡片或弹窗承载主流程,操作不随正文滚走。
+ 隐私需明确同意,配置返回不撤销同意;地区建议不直接入库,跳过、清空和子页取消分别保持原草稿语义。
+ Windows/Linux 必选有效网卡,保存失败保留地区与网卡草稿,成功保存后才写完成标记。
+ 本地准备失败可在页内重试;Setup 不导入节点、不测速、不启动 VPN,系统授权面板仍按平台验证。
- iOS 44×44 pt、Android 48×48 dp 的实际点击区域分别检查,宽横屏仍使用对应平台下限。
点击小图标外围应触发正确动作且只执行一次;关闭、返回、确认及禁用行为不变,键盘和安全区不遮挡动作。
入口数量选择器覆盖三项边缘命中、选中/禁用、端点圆角及 RTL,桌面维持紧凑尺寸。
- 智能与常规路由检查同级卡片、标题高度、宽屏详情右对齐,以及规则条件、动作、建议列表和拖拽行的圆角;选中背景不覆盖外框。
- 常规路由分别检查规则区和设置区的列断点;未打开规则时不预留空编辑列,缩放窗口保留 DNS 焦点、选区与嗅探展开状态。
- 小屏最大辅助字号下,高级页标签保持完整且不覆盖正文,状态和 TUN 值可换行,DNS 输入与底部操作可滚动到达。
+ 智能与常规路由检查同级卡片、标题高度,以及规则条件、动作、建议列表和拖拽行的圆角;选中背景不覆盖外框。
+ 各宽度按直连/拦截、代理、DNS、嗅探、预览保持阅读顺序;规则始终进入独立全宽页,取消不改父稿。
+ 缩放窗口保留 DNS 焦点、选区与嗅探展开状态,不预留空编辑列。
+ 小屏最大辅助字号下,资源类别标签保持完整且不覆盖正文,状态和 TUN 值可换行,DNS 输入与底部操作可滚动到达。
- 订阅名称、URL、User-Agent 和 Age 输入在空值/已填/错误/禁用时保留真实可编辑语义名称,
显示/隐藏密钥不丢角色和值保护。分别记录 Widget Semantics、原生 AX 与实际 VoiceOver 播报,不能相互代替。
macOS 检查相邻可见标题仍可遍历,不把这项回退当作原生 AXTextField 命名已修复。
@@ -54,8 +70,18 @@
- 连接阶段依次覆盖断开、准备、连接、已连接、断开过程及失败;静态 VPN 名称和图标不预告成功,
文案与状态色随真实阶段变化。明暗主题分别核对正常小字对比度,运行文本在普通及运行背景上均满足 4.5:1。
智能路由预览正文与多入口提示计算实际颜色、背景及透明度组合,浅深色均达到 4.5:1。
-- 独立节点 JSON 编辑覆盖干净/修改/手动还原、关闭/取消/返回/遮罩/Escape、继续/明确放弃、
- 重复确认、保存中退出、保存失败、加载期间输入及旧稿保存后的新草稿;明确放弃不写库,成功保存不循环提示。
+- 连接页检查计划与实际运行分离、Raw/provider 名称及入口/最终出口快照、回退来源与实际目标说明,
+ 重命名、隐藏缓存或新计划不改写运行身份;未知速率/流量/时长不填假值。空库首屏的导入、恢复和完整配置入口可达。
+ 目标选择器覆盖三标签、搜索、长列表按需构建、管理返回与取消不提交;来源/节点同数字 ID、Raw/provider 同名不串选。
+ 三个分类标签在明暗主题下有清晰的实色选中态与可读文字;分类切换和完整配置定义帮助只用于浏览,不提交连接选择。
+ 重连确认取消保留持久化选择和运行快照;确认前资产被删或来源方式改变时不得按旧列表提交。
+- 节点、手动添加、Raw 和高级 JSON 编辑检查编辑器随可用正文宽高伸缩,填满名称、工具与提示之外的剩余区域;
+ 正常空间不保留固定代码框高度或整页外层滚动。横屏、短屏和键盘下编辑器至少 160 逻辑像素,空间不足时工作区整体可滚动,名称/工具/必要提示仍可到达,保存区固定。
+ 编辑页 AppBar 仅保留返回与有效页面动作,不另放重复关闭按钮;明暗主题下检查四边和圆角,行号、代码及双向滚动条不遮盖边框。
+ 节点、Raw 和高级 JSON 全宽编辑覆盖干净/修改/手动还原、关闭/取消/系统返回/Escape、继续/明确放弃、
+ 重复确认、保存中退出、保存失败、加载期间输入及旧稿保存后的新草稿;光标移动不算修改,名称与原文还原恢复干净。
+ 旧节点弹窗另覆盖遮罩关闭,全宽页不增加遮罩路径;明确放弃不写库,成功保存不循环提示。
+ iOS 干净稿保留原侧滑,未保存或保存中的稿不能经手势绕过退出守卫,不以 Widget 返回替代真机手势验收。
375×667 最大辅助字号下,中俄确认动作完整显示并可点击;按钮纵排时正文仍可滚动。
- 隧道 DNS 覆盖首错离焦/提交、修正、无关修改、IPv6/DoT 生效开关、逐项修正及原始错误复制。
聚焦和滚动仅针对当前页仍有效的错误;没有对应字段的子页保留页级诊断,存储失败不伪装成本地字段错误。
@@ -65,7 +91,20 @@
弹窗首帧前及窗口恢复期间同样拦截;后台分支弹窗不阻挡当前分支,后台路由变化不覆盖根弹窗状态。
使用真实布尔偏好关闭启动自动连接,现场始终不启动 VPN、不截图,以菜单/AX/窗口状态和日志记录。
- 搜索清除和零结果重置恢复列表,保留排序、收藏偏好;移动端空列表、空手动组和长列表均可进入来源管理。
+ 服务器节点/完整配置/订阅三类计数和类型准确;打开资源或来源详情仅浏览,显式“使用”才提交连接选择。
+ 提供方完整配置在列表与详情均可收藏/取消,旧收藏可见,完整配置收藏筛选与节点筛选独立;Raw 不增加收藏能力。
+ 普通订阅详情覆盖测速、取消、正在取消及完成恢复;同来源不重复提交,取消不影响其它组请求或 VPN。
节点测速错误初始折叠,展开后可选择、复制原始诊断,后续成功移除错误,折叠不删除诊断。
+ 主节点列表在无搜索、无收藏筛选且来源为全部来源或手动添加时可整理位置;订阅过滤下不提供手动整理。
+ 单个非空手动组直接进入,多个非空组先选择完整范围;空组、单节点组和忙碌组不可选为整理范围。
+ 名称与延迟排序下均可进入整理,草稿按手动位置加载;保存只调整所选完整组,取消或返回不写入,原显示排序偏好保持不变。
+- 独立路由首页分别验证使用、编辑和删除,常规/高级共用配额;一项坏资产不阻断其他模式和资源管理。
+ 完整图运行时保存普通路由偏好不覆盖当前图或触发无效重连。Smart 地区验证单选替换、重复选择幂等和显式清空,
+ 候选仅取已安装索引;历史 List 不迁移,选择页投影首项,打开/取消不裁剪原值。
+ 地区 Done 只改父稿,父页保存才提交,取消不写入;Setup 使用同一地区选择行为。服务器地区筛选/选择范围不恢复。
+- VPN 隧道与 Xray 分别进入独立分支,平台权限、能力、MSIX 和移动/桌面入口限制仍由真实平台决定。
+ 只读诊断刷新保留 phase 与原错误,不启停、不测速、不假报检查通过;无运行输入有空状态,运行 JSON 来自实际 Core 输入。
+ SE 不显示日志区,诊断与直接路由也不能绕过日志能力限制;LAN 下次启动生效、本地 API 即时开关和 Token 语义不随页面迁移改变。
- 主题标签在普通、大字和受限空间下正确切换横排或纵排,选中语义与即时保存一致;
App 图标的标题与当前值不因半宽布局碎裂或丢失,窄布局中的值自然换行。
自动备份标题不重复,开关保留可访问名称、状态、忙态与禁用条件,周期编辑及 iCloud 主动作不变,
@@ -112,8 +151,10 @@
桌面不携带来源旧 FD。编辑语义比较使用当前隧道 DNS 设置,与运行补充一致,不改写保存原文。
- 连接页显示 provider 名称快照与完整路由说明,不伪装成 App 多入口或单节点延迟;
App Smart/Custom/LAN 偏好保存不影响 provider 会话,平台策略有效变更保持原重连流程。
-- 自动选择覆盖纯普通、纯提供方、混合、接入数量不足及健康不足;提供方按 ID 稳定选取且限定活动缓存和来源范围。
- 读取/测速异常、取消、编译或启动失败不尝试其它完整配置;保存意图与运行 profile 分离,重启仍显示原自动选择。
+- 自动选择覆盖纯普通、纯提供方、混合及活动候选数量不足;提供方按 ID 稳定选取且限定活动缓存和来源范围。
+ 未测、测速失败和超时均不构成启动门槛,挂起的手动/后台测速不能阻塞准备,不因缺少成功延迟触发 provider/global 回退。
+ 成功延迟优先,其他候选排后按 ID 稳定选取;仍保留活动类型、JSON 可读取、数量、最终出口和取消检查。
+ 资产读取异常、取消、编译或启动失败不尝试其它完整配置;保存意图与运行 profile 分离,重启仍显示原自动选择。
删除活动最后一项、仍有提供方可重连、模式切换及非活动缓存分别检查;固定选择和个人 Raw 不获得自动兜底。
- 备份 panel/mode 往返、旧文件缺省与未知字段容忍;订阅缓存排除,恢复不联网、不自动连接,正常刷新重新获取。
`platformSettings` 分别覆盖 iOS/macOS/Android/Windows/Linux 的同平台恢复与跨平台保留,iOS/macOS 不交叉恢复。
@@ -143,13 +184,15 @@ iOS Simulator 不能通过绕过它来验证刷新。设备验收需要检查 VP
显示顺序验证默认顺序在延迟更新后稳定、名称与延迟顺序的确定性、混合普通节点/完整配置,
以及浏览、分组、筛选、最终出口和托盘的一致性。保存/重启/未知值/清理覆盖本机偏好;
-自动接入仍按真实成功延迟选取,不能用当前显示顺序替代连接候选。刷新继续沿用资产替换与保护流程。
+自动接入按成功延迟优先、未测/失败/超时按 ID 排后选取,不等待测速;不能用当前显示顺序替代连接候选。
+固定节点与最终出口同样允许未知或失败延迟,排除入口、自引用和无效资产的非测速约束保留;
+运行中的 leastPing 仍只观察实际多条完整链,单链退化和高级 direct fallback 例外不变。刷新继续沿用资产替换与保护流程。
手动分组覆盖空组、未分组、创建/重命名/移组、解散保留节点与整组删除;旧库升级保留原顺序。
默认模式共享组序和组内顺序,名称/延迟显示偏好不改写它们;全部手动节点仍是 source:0,文件夹不创建连接范围。
整理草稿保存、取消、返回与重启分别核对;延迟/收藏更新不打断草稿,成员变化使旧草稿保存失败,重新加载后可重试。
空组及组织顺序随备份往返,恢复映射新数据库 ID,旧 v1 恢复为未分组;清理和恢复不遗留旧组。
-代表性长列表检查按需构建、首尾拖动、边缘滚动和桌面双栏独立滚动;手机长按与桌面鼠标拖动在各自平台验收。
+代表性长列表检查按需构建、首尾拖动、边缘滚动及全宽单列详情;手机长按与桌面鼠标拖动在各自平台验收。
拨号验证旧 JSON 的 AsIs 缺省、策略往返、IPv6 关闭后的有效值与偏好保留、有效变更的确认/取消、
同效或停用配置只保存、备份排除,以及等待连接队列后再次核对配置与重连授权。
@@ -202,10 +245,14 @@ Pigeon、FFI、Drift 或本地化变更先生成对应输出;只改测试时
- Apple 文件备份:`bash tool/test_backup_file_store.sh` 编译生产文件存储实现,验证首字节内容探测、绑定不覆盖、替换写入、
新实例重新访问、解绑保留文件及大小限制。命令行使用普通 bookmark,持久沙盒授权需在签名 App 中核对。
- iOS Widget 原生:`bash tool/test_apple_widget.sh` 以纯 Swift 编译生产快照与动作收件箱,
- 验证状态/语言独立原子更新、五语言归一化、缺失/损坏/未知版本与过期状态、早到动作只消费一次及保留最后动作。
- Dart 定向测试位于 `test/service/shared/apple_widget/` 与 `test/service/shared/menu/short_cut/`,
- 覆盖冷启动抑制自动连接、运行中停止不被启动阻塞、重复动作、回前台读取、未知输入及导航 URL 边界。
- 这些测试不执行实际 App Intent、不证明 WidgetKit 展示或 NetworkExtension 状态。
+ 检查状态/语言独立原子更新、五语言归一化、缺失/损坏/未知版本与过期状态,以及 envelope 消费、真实状态动作分流、
+ 重复启动、Stop 淘汰 token、维护阻断与恢复。它使用可控原生状态回复,不执行实际 App Intent 或真实 VPN。
+ Dart 定向测试位于 `test/service/shared/apple_widget/`、`test/service/shared/menu/short_cut/`、
+ `test/service/connect/runtime_host_test.dart`、`test/service/connect/coordinator_test.dart` 和
+ `test/service/settings/data_cleanup_test.dart`;检查冷启动抑制顺序、匹配 token 取消、登记回包前失效、
+ 暂停准备/原生提交后的 Stop、新启动不受旧通知影响、类型化取消不重复停止、iOS 不先写文件、
+ 系统连接时间只更新内存,以及旧快捷入口与公开导航 URL 边界。
+ 这些自动化测试不证明 WidgetKit 展示、Intent 实际执行宿主或 NetworkExtension 状态。
- macOS SE 激活:`bash tool/test_system_extension_activation.sh` 编译生产 Swift,
验证待批准时重试、已安装时跳过、强制替换及并发请求合并;使用可控系统回复,不申请真实扩展授权或启动 VPN。
页面测试核对授权未完成时不连接、批准说明持续显示、取消与打开设置,以及设置打开失败的反馈。
@@ -213,6 +260,10 @@ Pigeon、FFI、Drift 或本地化变更先生成对应输出;只改测试时
Robolectric 调用正式 Receiver、保存配置、Widget 资源与 VPN 状态桥;
状态桥回归使用真实 command/changed 与可控 Binder 重演停止确认后旧事件迟到,
并区分当前错误/退出、旧绑定回调和外部启动;不替代真实 VPN 与桌面验收。
+- 启动日志清理:Dart FFI 与 `bash tool/test_runtime_env.sh`、Android `RuntimeLogFilesTest` 覆盖两份托管文件、
+ 缺失幂等、符号链接/目录拒绝及旁侧文件保留;启动回归检查停止失败、旧 Core 残留和进程查询失败均保留旧日志。
+ 文件测试不替代各平台真实启动与原生 saved-start 验收。
+ 日志查看器另覆盖同路径新文件大小等于或大于旧偏移、旧在途数据/错误回包、新连接边沿、同会话追加以及断开保留诊断。
- Android Manifest/网络 XML 变更补跑 `./gradlew :app:lintVitalRelease`;
发布相关修复还需本地 Release AAB 构建,不调用会上传商店的部署脚本。
- 原生桥合同变更验证实际序列化/消费方与对应平台构建;
@@ -222,21 +273,37 @@ Pigeon、FFI、Drift 或本地化变更先生成对应输出;只改测试时
Android 模拟器直接使用当前分支构建覆盖已经安装的 OneXray,保留已有数据,不另建测试包或 applicationId。
验证使用脱敏输入或临时响应替身,调用真实原生解析;完成后只清理本轮新增的测试数据。
+iOS 同样沿用正常 bundle ID、App Group 和已有真机,使用签名包覆盖安装;不另建验收 App 或新模拟器。
- Android UI 用模拟器验证;系统导入、导出和扫码可记录跳过。Widget 检查 4×2 最小尺寸、
缩放、旋转、主题、语言与启动/停止。通知和真实代理请求需运行 VPN 后验证。
连接生命周期修改还须在同一 `:native` PID 内连续重连,逐次确认受控 HTTP 响应和服务端收据;
仅显示已连接或杀进程后的成功不能代替此项。
+- Android 磁贴分别检查单击启停和真实长按打开 App;冷启动、子页热恢复、锁屏取消与启动连接偏好分别核对,
+ 长按不得进入 App Info 或重放 `startVpn` 快捷动作。
+- 日志在连续连接、配置重连和原生快捷启动后仅保留本轮内容;关闭日志后启动仍清旧文件,
+ 重复启动运行实例不清当前日志。编译、权限、停止或清理失败以及取消路径保留适当诊断,节点与设置不变。
- 完整配置适配在 Android 验证 Raw/提供方真实 VPN 启动、DNS 查询、受控网页响应、连续重连和隧道 DNS 设置变更。
iOS 模拟器只验证配置与 SOCKS 路径,不能验收真机 DNS、DoT 或 includeAllNetworks;这些项目需要 Apple 真机。
-- iOS/iPadOS Widget 最低部署版本为 15;15 验证主屏幕小号/中号静态导航,16 起增加两种锁屏 accessory。
- 在系统图库与实际主屏幕/锁屏验证系统支持的 family;
- 17 起验证主屏幕小号/中号启动、停止实际前台打开 App,并使用当前选择而非最近生成的 saved-start。
- 覆盖冷启动、App 已运行/后台、重复点击和启动中停止,确认连接协调器、权限失败与页面/通知反馈。
+- iOS/iPadOS Widget 最低部署版本为 16.0;16 验证主屏幕小号/中号静态导航及两种锁屏 accessory。
+ 在系统图库与实际主屏幕/锁屏验证系统支持的 family。17 起主屏幕单个启停按钮、18 起单个控制中心 VPN 按钮
+ 均验证主 App 进程后台执行;17–25 验证 Runner-only `ForegroundContinuableIntent` 及旧前台继续方法,
+ 26 验证后台/动态前台模式与 `continueInForeground`,27 起额外验证 `.main` 执行目标。
+ 正常启停均复用最近保存输入;故意让 DB 选择与已保存输入不同,确认不自动重编译当前选择,前台修复回退只导航。
+ 覆盖 App 冷态、前台、后台,重复点击、连接中停止、断开中再点、缺输入/权限和实际启动失败;
+ 主屏未连接表达启动,已连接/连接中表达停止;未知/过期时主屏显示未知、控制中心显示待确认,且仍可点击。
+ 两类按钮均新读系统状态和进行中操作,不从展示快照推断切换目标,断开中不追加启动。
+ 两次 Toggle 查询重叠时,较后的关闭不能被较早的查询或启动吞掉。
+ 已有 App Start 准备/提交时发原生 Stop,确认旧 Dart 工作不再提交或重复停止;随后新 Start 正常且不受迟到通知影响。
+ 检查 Stop 后再次启动恢复保存的 profile 路由/按需策略,App 回前台会话时间与系统连接时间一致。
+ 逐次确认真实代理 HTTP 响应和服务端收据;按钮动画、Widget 快照和系统已连接均不能代替网络证据。
核对五语言及系统语言匹配、波斯语 RTL、大字、明暗主题、可访问名称,以及未知/过期状态不会继续展示已连接,
Widget 不泄露配置名、订阅、地址或流量。签名安装包须检查 Widget 扩展嵌入、独立 provisioning profile、
App/隧道扩展/Widget 的共享 App Group 权限。模拟器与无签名构建不能证明真机 NE 授权、锁屏交互或后台刷新,
- App Intent 前台执行、WidgetKit 调度与系统限制须在对应真机签名渠道单独验收。
+ App Intent 执行、WidgetKit 调度与系统限制须在对应真机签名渠道单独验收。
+ 后台路径按官方 API availability 启用,未测范围不改变功能版本门槛。各阶段的设备结果、未测项、截图、网络收据和
+ 临时数据恢复记录保存在工作区 `references/`;分别记录自动化、构建和各版本设备结果,不以旧 UI 或新版本编译通过
+ 替代当前布局及旧系统的设备验收。
- Android 自动化需普通第三方 UID 的后台启停、冷进程、Token 撤销和清理/恢复阻断,
至少联调一款真实工具;shell 广播不等于工具联调。
- macOS 桌面 UI 使用 Flutter Debug 验证;VPN、Apple 授权、按需连接、SE 跨容器交接
diff --git a/docs/xray-configuration.md b/docs/xray-configuration.md
index 2edab899b..abfc54d34 100644
--- a/docs/xray-configuration.md
+++ b/docs/xray-configuration.md
@@ -21,11 +21,15 @@ App 保留自身名称、资产数量/交换格式、文件链接安全、事务
## 普通配置编译
-自动或来源选择先按路由要求选择 1–3 个健康的普通接入节点;固定节点及全部使用 VPN 使用一个。
-普通节点按延迟、ID 选择;数量不足或无法取得足量健康节点时,在同一范围内按 ID 升序选择首份可读取的活动提供方完整配置。
-只有候选缺少、数量不足或健康不足可触发该选择;取消、读取、测速服务异常及配置、编译、权限或启动错误直接报告,不重试其它提供方。
+自动或来源选择先按路由要求选择 1–3 个活动、可读取的普通接入节点;固定节点及全部使用 VPN 使用一个。
+已有成功延迟按耗时、ID 优先,全部未测、失败、超时或其它非成功结果统一排后按 ID 选择,不以测速结果限制启动。
+连接前不发起、等待或重试节点测速,独立手动/后台测速不阻塞解析;当前快照足量即进入编译与启动。
+只有候选缺少或结构数量不足时,才在同一范围内按 ID 升序选择首份可读取的活动提供方完整配置。
+空范围允许沿原规则重试全局自动,数量不足不重试全局;固定普通节点的重试不引入提供方。
+固定资源缺失、来源 mode 不兼容、最终出口缺失/自引用、取消、读取、配置、编译、权限或启动错误直接报告,不绕过前置检查或重试其它提供方。
完整配置不进入普通节点组合、最终出口或单 outbound 测速,也不与普通节点比较延迟。
自动或来源意图仍保存在设置中,实际选中的完整配置只进入运行快照,不改成固定选择。
+全局回退仅在成功启动后提交自动选择;本次运行 metadata 保留原选择、来源名称快照和回退原因,旧 metadata 缺字段仍可读取。
已运行节点不因后台测速或订阅更新热替换;节点命名见 [服务器及分享](subscriptions-and-sharing.md),
显示排序独立于自动候选选择,见 [数据管理](data-management.md#数据显示顺序)。
@@ -46,7 +50,8 @@ Raw 用户字段不适用此删减。
智能路由把直连域名/IP 分别去重合并为一条规则,无条件时省略对应规则。
顺序为广告阻断、Windows 开关对应的 GitHub 代理、合并域名直连、合并 IP 直连。
Windows 服务直连使用 Microsoft/Bing 分类,同时优先代理 GitHub;该开关在所有平台显示。
-直连地区来自安装的官方分类与随包映射。广告、FakeDNS、Fragment 默认关闭,其余智能开关默认开启;保留已保存值。
+直连地区来自安装的官方分类与随包映射,Smart/Setup UI 保持单选/显式清空;底层历史 List 仍可读取和编译,不在本轮迁移中删字段或改写历史配置。
+这项直连规则配置与已退役的服务器地区筛选、节点出口地区缓存无关。广告、FakeDNS、Fragment 默认关闭,其余智能开关默认开启;保留已保存值。
### DNS
@@ -448,6 +453,16 @@ run/start.json 是唯一原生启动请求:coreInvokeText 为实际输入,me
停止失败不继续准备;启停已开始后的失败尽力停止本次运行并进入 failed,不恢复旧连接/输入。
不能确认停止时仍以原生状态为准,缺少 metrics 或描述不等于断开。
+每次实际启动新的 Core 前,运行拥有者在旧 Core 已停止、输入准备完成后清空固定 `run/access.log` 和 `run/error.log`,
+日志关闭时也处理旧文件;文件不存在可继续,目录或符号链接占位及删除失败会阻止本次启动。
+只处理这两个托管日志,不清理节点、路由、运行请求、Geodata、用户导出或启动诊断 `.error`。
+普通 Apple NE、SE 扩展容器、模拟器、Android 原生 saved-start 和桌面 App 启动分别在实际 Core 启动入口处理;
+MSIX 先停止系统会话并确认旧 Core 进程退出,外部宿主自主恢复仍由宿主负责。
+预览、保存、校验、状态刷新和已运行时重复启动不清日志;开始本轮后失败保留本轮日志供诊断。
+清理发生在下一轮启动,单次持续运行期间不进行大小或年龄轮换。
+Apple 隧道扩展在后台串行队列内检查 Core 状态、复核启动代次、清日志并启动;停止先使旧代次失效再排队关闭 Core,
+迟到的准备、启动结果和 SE 等待超时不清日志、不重启已停止的隧道,也不覆盖新一代状态。
+
桌面启动前在旧 Core 停止后清理 core-inputs,生成本次唯一输入和 error 文件,通过 -error-file 获取加载/构造/启动原因,
无诊断才用通用错误,不再运行 testXray。发布需包含支持该参数的 Core。
MSIX snapshotToken 只验证 Vole 宿主会话,不是可删的 App 快照。
diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj
index a20e27350..a88adbba8 100644
--- a/ios/Runner.xcodeproj/project.pbxproj
+++ b/ios/Runner.xcodeproj/project.pbxproj
@@ -7,10 +7,10 @@
objects = {
/* Begin PBXBuildFile section */
+ EB2401000000000000000002 /* AppleWidgetVpnActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB2401000000000000000001 /* AppleWidgetVpnActions.swift */; };
1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; };
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 74858FAE1ED2DC5600515810 /* AppDelegate.swift */; };
- EB3100000000000000000001 /* FileExportApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB3100000000000000000002 /* FileExportApi.swift */; };
7884E8682EC3CC0700C636F2 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */; };
78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; };
97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; };
@@ -30,6 +30,7 @@
EB240000000000000000010A /* AppleWidgetApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB2400000000000000000014 /* AppleWidgetApi.swift */; };
EB240000000000000000010B /* Widget.strings in Resources */ = {isa = PBXBuildFile; fileRef = EB2400000000000000000020 /* Widget.strings */; };
EB240000000000000000010C /* AppleVpnStartAdmission.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB2400000000000000000016 /* AppleVpnStartAdmission.swift */; };
+ EB3100000000000000000001 /* FileExportApi.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB3100000000000000000002 /* FileExportApi.swift */; };
EB67F754308F0B400096B893 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB67F753308F0B400096B893 /* WidgetKit.framework */; };
EB67F756308F0B400096B893 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = EB67F755308F0B400096B893 /* SwiftUI.framework */; };
EB67F767308F0B400096B893 /* widgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = EB67F752308F0B400096B893 /* widgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
@@ -108,13 +109,13 @@
/* End PBXCopyFilesBuildPhase section */
/* Begin PBXFileReference section */
+ EB2401000000000000000001 /* AppleWidgetVpnActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = AppleWidgetVpnActions.swift; path = ../swift/iOS/AppleWidgetVpnActions.swift; sourceTree = SOURCE_ROOT; };
1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; };
1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; };
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; };
54C0E75B86BC41128CD6F654 /* BackupApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BackupApi.swift; sourceTree = ""; };
74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; };
74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; };
- EB3100000000000000000002 /* FileExportApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExportApi.swift; sourceTree = ""; };
7884E8672EC3CC0400C636F2 /* SceneDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; };
78E0A7A72DC9AD7400C4905E /* FlutterGeneratedPluginSwiftPackage */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = FlutterGeneratedPluginSwiftPackage; path = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; sourceTree = ""; };
7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; };
@@ -139,6 +140,7 @@
EB2400000000000000000023 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "widget/zh-Hant.lproj/Widget.strings"; sourceTree = SOURCE_ROOT; };
EB2400000000000000000024 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = widget/ru.lproj/Widget.strings; sourceTree = SOURCE_ROOT; };
EB2400000000000000000025 /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = widget/fa.lproj/Widget.strings; sourceTree = SOURCE_ROOT; };
+ EB3100000000000000000002 /* FileExportApi.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileExportApi.swift; sourceTree = ""; };
EB390D812EC84AF400B741BA /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = fa.lproj/Main.strings; sourceTree = ""; };
EB390D822EC84AF400B741BA /* fa */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fa; path = fa.lproj/LaunchScreen.strings; sourceTree = ""; };
EB67F752308F0B400096B893 /* widgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = widgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; };
@@ -292,6 +294,7 @@
EB2400000000000000000011 /* AppleWidgetPublisher.swift */,
EB2400000000000000000012 /* AppleWidgetActionInbox.swift */,
EB2400000000000000000013 /* AppleWidgetIntents.swift */,
+ EB2401000000000000000001 /* AppleWidgetVpnActions.swift */,
EB2400000000000000000014 /* AppleWidgetApi.swift */,
EB2400000000000000000016 /* AppleVpnStartAdmission.swift */,
EB2400000000000000000020 /* Widget.strings */,
@@ -565,6 +568,7 @@
EB2400000000000000000104 /* AppleWidgetPublisher.swift in Sources */,
EB2400000000000000000106 /* AppleWidgetActionInbox.swift in Sources */,
EB2400000000000000000108 /* AppleWidgetIntents.swift in Sources */,
+ EB2401000000000000000002 /* AppleWidgetVpnActions.swift in Sources */,
EB240000000000000000010A /* AppleWidgetApi.swift in Sources */,
EB240000000000000000010C /* AppleVpnStartAdmission.swift in Sources */,
74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */,
@@ -713,7 +717,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -740,7 +744,7 @@
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OneXray;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
- IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -805,7 +809,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = YES;
ONLY_ACTIVE_ARCH = YES;
SDKROOT = iphoneos;
@@ -860,7 +864,7 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
- IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
MTL_ENABLE_DEBUG_INFO = NO;
SDKROOT = iphoneos;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
@@ -889,7 +893,7 @@
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OneXray;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
- IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -922,7 +926,7 @@
INFOPLIST_FILE = Runner/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OneXray;
INFOPLIST_KEY_LSApplicationCategoryType = "public.app-category.utilities";
- IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -959,7 +963,7 @@
INFOPLIST_FILE = widget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OneXray;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
- IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -974,7 +978,7 @@
REGISTER_APP_GROUPS = YES;
SKIP_INSTALL = YES;
STRING_CATALOG_GENERATE_SYMBOLS = YES;
- SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)";
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited) ONEXRAY_WIDGET_EXTENSION";
SWIFT_APPROACHABLE_CONCURRENCY = YES;
SWIFT_EMIT_LOC_STRINGS = YES;
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
@@ -1009,7 +1013,7 @@
INFOPLIST_FILE = widget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OneXray;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
- IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1031,6 +1035,7 @@
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) ONEXRAY_WIDGET_EXTENSION";
};
name = Release;
};
@@ -1056,7 +1061,7 @@
INFOPLIST_FILE = widget/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = OneXray;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
- IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1076,6 +1081,7 @@
SWIFT_VERSION = 5.0;
TARGETED_DEVICE_FAMILY = "1,2";
VERSIONING_SYSTEM = "apple-generic";
+ SWIFT_ACTIVE_COMPILATION_CONDITIONS = "$(inherited) ONEXRAY_WIDGET_EXTENSION";
};
name = Profile;
};
@@ -1099,7 +1105,7 @@
INFOPLIST_FILE = tunnel/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = tunnel;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
- IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1144,7 +1150,7 @@
INFOPLIST_FILE = tunnel/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = tunnel;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
- IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
@@ -1185,7 +1191,7 @@
INFOPLIST_FILE = tunnel/Info.plist;
INFOPLIST_KEY_CFBundleDisplayName = tunnel;
INFOPLIST_KEY_NSHumanReadableCopyright = "";
- IPHONEOS_DEPLOYMENT_TARGET = 15.0;
+ IPHONEOS_DEPLOYMENT_TARGET = 16.0;
LD_RUNPATH_SEARCH_PATHS = (
"$(inherited)",
"@executable_path/Frameworks",
diff --git a/ios/Runner/AppleWidgetApi.swift b/ios/Runner/AppleWidgetApi.swift
index b4b746d21..494c2ab3e 100644
--- a/ios/Runner/AppleWidgetApi.swift
+++ b/ios/Runner/AppleWidgetApi.swift
@@ -33,4 +33,44 @@ final class AppleWidgetApi: NSObject, @preconcurrency AppleWidgetHostApi {
completion(.failure(error))
}
}
+
+ func beginPreparation(completion: @escaping (Result) -> Void) {
+ do {
+ completion(.success(try VPNManager.shared.beginDartPreparation()))
+ } catch is CancellationError {
+ completion(.failure(PigeonError(code: "apple-preparation-cancelled", message: nil, details: nil)))
+ } catch {
+ completion(.failure(error))
+ }
+ }
+
+ func finishPreparation(token: String, completion: @escaping (Result) -> Void) {
+ VPNManager.shared.finishDartPreparation(token: token)
+ completion(.success(()))
+ }
+
+ func startPreparedVpn(token: String, requestJson: String, completion: @escaping (Result) -> Void) {
+ Task { @MainActor in
+ do {
+ completion(.success(try await VPNManager.shared.startPreparedVpn(token: token, requestJson: requestJson)))
+ } catch is CancellationError {
+ completion(.failure(PigeonError(code: "apple-preparation-cancelled", message: nil, details: nil)))
+ } catch {
+ completion(.failure(error))
+ }
+ }
+ }
+
+ func connectedAtMilliseconds(completion: @escaping (Result) -> Void) {
+ completion(.success(VPNManager.shared.connectedAtMilliseconds()))
+ }
+
+ func setStartBlocked(blocked: Bool, completion: @escaping (Result) -> Void) {
+ do {
+ try VPNManager.shared.setStartBlocked(blocked)
+ completion(.success(()))
+ } catch {
+ completion(.failure(error))
+ }
+ }
}
diff --git a/ios/widget/en.lproj/Widget.strings b/ios/widget/en.lproj/Widget.strings
index e8b09e1d2..0df7aa056 100644
--- a/ios/widget/en.lproj/Widget.strings
+++ b/ios/widget/en.lproj/Widget.strings
@@ -1,8 +1,7 @@
"Connection" = "Connection";
"VPN status and actions that open OneXray." = "VPN status and actions that open OneXray.";
+"Open OneXray to perform this action." = "Open OneXray to perform this action.";
"Open app" = "Open app";
-"Open app to refresh" = "Open app to refresh";
-"Observed" = "Observed";
"Start VPN" = "Start VPN";
"Stop VPN" = "Stop VPN";
"Disconnected" = "Disconnected";
@@ -12,3 +11,16 @@
"Unknown" = "Unknown";
"Start" = "Start";
"Stop" = "Stop";
+"VPN" = "VPN";
+"VPN status and background actions using the last prepared configuration." = "VPN status and background actions using the last prepared configuration.";
+"Start or stop VPN in the background using the last prepared configuration." = "Start or stop VPN in the background using the last prepared configuration.";
+"Start or stop VPN" = "Start or stop VPN";
+"Status pending" = "Status pending";
+"Open OneXray to finish setup." = "Open OneXray to finish setup.";
+"This VPN action must run in OneXray." = "This VPN action must run in OneXray.";
+"OneXray cannot open from this system control right now." = "OneXray cannot open from this system control right now.";
+"VPN action failed." = "VPN action failed.";
+"The VPN start was cancelled." = "The VPN start was cancelled.";
+"VPN start is temporarily unavailable while app data is being updated." = "VPN start is temporarily unavailable while app data is being updated.";
+"The VPN is still stopping." = "The VPN is still stopping.";
+"Unable to confirm the current VPN status." = "Unable to confirm the current VPN status.";
diff --git a/ios/widget/fa.lproj/Widget.strings b/ios/widget/fa.lproj/Widget.strings
index cb9f60b2a..6ad6dccfb 100644
--- a/ios/widget/fa.lproj/Widget.strings
+++ b/ios/widget/fa.lproj/Widget.strings
@@ -1,8 +1,7 @@
"Connection" = "اتصال";
"VPN status and actions that open OneXray." = "وضعیت VPN و اقداماتی که OneXray را باز میکنند.";
+"Open OneXray to perform this action." = "برای انجام این کار OneXray را باز کنید.";
"Open app" = "باز کردن برنامه";
-"Open app to refresh" = "برای تازهسازی برنامه را باز کنید";
-"Observed" = "زمان مشاهده";
"Start VPN" = "شروع VPN";
"Stop VPN" = "توقف VPN";
"Disconnected" = "قطع است";
@@ -12,3 +11,16 @@
"Unknown" = "نامشخص";
"Start" = "شروع";
"Stop" = "توقف";
+"VPN" = "VPN";
+"VPN status and background actions using the last prepared configuration." = "وضعیت VPN و اقدامات پسزمینه با آخرین پیکربندی آمادهشده.";
+"Start or stop VPN in the background using the last prepared configuration." = "شروع یا توقف VPN در پسزمینه با آخرین پیکربندی آمادهشده.";
+"Start or stop VPN" = "شروع یا توقف VPN";
+"Status pending" = "در انتظار تأیید وضعیت";
+"Open OneXray to finish setup." = "برای تکمیل تنظیمات، OneXray را باز کنید.";
+"This VPN action must run in OneXray." = "این اقدام VPN باید در OneXray اجرا شود.";
+"OneXray cannot open from this system control right now." = "فعلاً نمیتوان OneXray را از این کنترل سیستمی باز کرد.";
+"VPN action failed." = "اقدام VPN انجام نشد.";
+"The VPN start was cancelled." = "راهاندازی VPN لغو شد.";
+"VPN start is temporarily unavailable while app data is being updated." = "تا پایان بهروزرسانی دادههای برنامه، راهاندازی VPN موقتاً در دسترس نیست.";
+"The VPN is still stopping." = "VPN هنوز در حال توقف است.";
+"Unable to confirm the current VPN status." = "امکان تأیید وضعیت فعلی VPN وجود ندارد.";
diff --git a/ios/widget/ru.lproj/Widget.strings b/ios/widget/ru.lproj/Widget.strings
index 161c2c752..4589f90a9 100644
--- a/ios/widget/ru.lproj/Widget.strings
+++ b/ios/widget/ru.lproj/Widget.strings
@@ -1,8 +1,7 @@
"Connection" = "Подключение";
"VPN status and actions that open OneXray." = "Статус VPN и действия с открытием OneXray.";
+"Open OneXray to perform this action." = "Откройте OneXray для выполнения этого действия.";
"Open app" = "Открыть приложение";
-"Open app to refresh" = "Откройте для обновления";
-"Observed" = "Обновлено";
"Start VPN" = "Старт VPN";
"Stop VPN" = "Стоп VPN";
"Disconnected" = "Не подключено";
@@ -12,3 +11,16 @@
"Unknown" = "Неизвестно";
"Start" = "Старт";
"Stop" = "Стоп";
+"VPN" = "VPN";
+"VPN status and background actions using the last prepared configuration." = "Состояние VPN и фоновые действия с последней подготовленной конфигурацией.";
+"Start or stop VPN in the background using the last prepared configuration." = "Фоновый запуск или остановка VPN с последней подготовленной конфигурацией.";
+"Start or stop VPN" = "Запустить или остановить VPN";
+"Status pending" = "Состояние требует проверки";
+"Open OneXray to finish setup." = "Откройте OneXray для завершения настройки.";
+"This VPN action must run in OneXray." = "Это действие VPN должно выполняться в OneXray.";
+"OneXray cannot open from this system control right now." = "Сейчас невозможно открыть OneXray из этого системного элемента управления.";
+"VPN action failed." = "Не удалось выполнить действие VPN.";
+"The VPN start was cancelled." = "Запуск VPN отменён.";
+"VPN start is temporarily unavailable while app data is being updated." = "Запуск VPN временно недоступен, пока обновляются данные приложения.";
+"The VPN is still stopping." = "VPN всё ещё отключается.";
+"Unable to confirm the current VPN status." = "Не удалось определить текущее состояние VPN.";
diff --git a/ios/widget/widget.swift b/ios/widget/widget.swift
index 420989b93..afd072547 100644
--- a/ios/widget/widget.swift
+++ b/ios/widget/widget.swift
@@ -4,12 +4,9 @@ import AppIntents
private let connectionURL = URL(string: "onexray://widget/connect")!
-private var connectionWidgetFamilies: [WidgetFamily] {
- if #available(iOS 16.0, *) {
- return [.systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular]
- }
- return [.systemSmall, .systemMedium]
-}
+private let connectionWidgetFamilies: [WidgetFamily] = [
+ .systemSmall, .systemMedium, .accessoryCircular, .accessoryRectangular,
+]
struct ConnectionWidgetEntry: TimelineEntry {
let date: Date
@@ -57,25 +54,120 @@ struct ConnectionWidget: Widget {
.widgetURL(connectionURL)
}
.configurationDisplayName(Text(verbatim: widgetText("Connection", language)))
- .description(Text(verbatim: widgetText("VPN status and actions that open OneXray.", language)))
+ .description(Text(verbatim: widgetText(
+ AppleWidgetExecutionPolicy.usesBackgroundActions
+ ? "VPN status and background actions using the last prepared configuration."
+ : "VPN status and actions that open OneXray.",
+ language
+ )))
.supportedFamilies(connectionWidgetFamilies)
}
}
+@available(iOS 18.0, *)
+private struct VpnControlValue {
+ let status: AppleWidgetStatus
+ let languageCode: String
+}
+
+@available(iOS 18.0, *)
+private struct VpnControlValueProvider: ControlValueProvider {
+ var previewValue: VpnControlValue {
+ VpnControlValue(status: .unknown, languageCode: AppleWidgetStore.shared?.languageCode() ?? "en")
+ }
+
+ func currentValue() async throws -> VpnControlValue {
+ let store = AppleWidgetStore.shared
+ // Providers stay in the Widget extension. Re-evaluate expiry whenever
+ // the system requests a value; this is not a live NetworkExtension read.
+ return VpnControlValue(
+ status: store?.snapshot()?.displayedStatus(at: Date()) ?? .unknown,
+ languageCode: store?.languageCode() ?? "en"
+ )
+ }
+}
+
+@available(iOS 18.0, *)
+struct VpnControl: ControlWidget {
+ var body: some ControlWidgetConfiguration {
+ let language = AppleWidgetStore.shared?.languageCode() ?? "en"
+ return StaticControlConfiguration(kind: AppleWidgetStore.controlKind, provider: VpnControlValueProvider()) { value in
+ ControlWidgetButton(action: ToggleVpnWidgetIntent()) {
+ Label {
+ Text("VPN")
+ } icon: {
+ Image(systemName: controlSymbol(value.status))
+ }
+ .environment(\.locale, Locale(identifier: value.languageCode))
+ .environment(\.layoutDirection, value.languageCode == "fa" ? .rightToLeft : .leftToRight)
+ .controlWidgetStatus(Text(verbatim: widgetText(controlStatusKey(value.status), value.languageCode)))
+ .controlWidgetActionHint(Text(verbatim: widgetText("Start or stop VPN", value.languageCode)))
+ .accessibilityLabel(Text(verbatim: "VPN, \(widgetText(controlStatusKey(value.status), value.languageCode))"))
+ }
+ // A button remains actionable for unknown and expired observations.
+ // The intent decides from current native state, never this snapshot.
+ .tint(value.status == .connected ? Color("WidgetSuccess") : nil)
+ }
+ .displayName(LocalizedStringResource("VPN", table: "Widget", locale: Locale(identifier: language)))
+ .description(controlDescription(language: language))
+ }
+
+ private func controlDescription(language: String) -> LocalizedStringResource {
+ if AppleWidgetExecutionPolicy.usesBackgroundActions {
+ return LocalizedStringResource(
+ "Start or stop VPN in the background using the last prepared configuration.",
+ table: "Widget", locale: Locale(identifier: language)
+ )
+ }
+ return LocalizedStringResource(
+ "Open OneXray to perform this action.", table: "Widget", locale: Locale(identifier: language)
+ )
+ }
+
+ private func controlStatusKey(_ status: AppleWidgetStatus) -> String {
+ switch status {
+ case .unknown: "Status pending"
+ case .disconnected: "Disconnected"
+ case .connecting: "Connecting"
+ case .connected: "Connected"
+ case .disconnecting: "Disconnecting"
+ }
+ }
+
+ private func controlSymbol(_ status: AppleWidgetStatus) -> String {
+ switch status {
+ case .unknown: "questionmark.circle"
+ case .disconnected: "shield"
+ case .connecting, .disconnecting: "arrow.triangle.2.circlepath"
+ case .connected: "checkmark.shield"
+ }
+ }
+}
+
private struct ConnectionWidgetView: View {
@Environment(\.widgetFamily) private var family
@Environment(\.dynamicTypeSize) private var dynamicTypeSize
+ @ScaledMetric(relativeTo: .headline) private var statusLineHeight: CGFloat = 22
+ @ScaledMetric(relativeTo: .caption) private var brandLineHeight: CGFloat = 14
let entry: ConnectionWidgetEntry
private let palette = ConnectionWidgetPalette()
private var largeText: Bool { dynamicTypeSize >= .xxxLarge }
- private var emphasizeStop: Bool { entry.status == .connected || entry.status == .connecting }
- private var isAccessory: Bool {
- if #available(iOS 16.0, *) {
- return family == .accessoryCircular || family == .accessoryRectangular
+ private var toggleAction: ConnectionWidgetAction {
+ switch entry.status {
+ case .connected, .connecting: .stop
+ case .disconnected: .start
+ case .unknown, .disconnecting: .toggle
}
- return false
+ }
+
+ private var toggleLabelKey: String {
+ entry.status == .disconnecting ? "Disconnecting" : toggleAction.labelKey
+ }
+
+ private var isAccessory: Bool {
+ family == .accessoryCircular || family == .accessoryRectangular
}
var body: some View {
@@ -97,14 +189,14 @@ private struct ConnectionWidgetView: View {
}
@ViewBuilder private var content: some View {
- if #available(iOS 16.0, *), family == .accessoryCircular {
+ if family == .accessoryCircular {
ZStack {
AccessoryWidgetBackground()
Image(systemName: statusIcon).font(.title2)
}
.privacySensitive()
.accessibilityLabel("OneXray, \(statusText)")
- } else if #available(iOS 16.0, *), family == .accessoryRectangular {
+ } else if family == .accessoryRectangular {
VStack(alignment: .leading, spacing: 3) {
Text("OneXray").font(.headline)
Label(statusText, systemImage: statusIcon).font(.caption).privacySensitive()
@@ -119,22 +211,18 @@ private struct ConnectionWidgetView: View {
private var smallContent: some View {
GeometryReader { geometry in
- let compactHeight = geometry.size.height < 124
- VStack(alignment: .leading, spacing: 0) {
- if !largeText && !compactHeight { brand }
- Spacer(minLength: 3)
- VStack(spacing: 2) {
- if !largeText { statusSymbol(size: geometry.size.height >= 136 ? 24 : 20) }
- statusLabel(lines: 1)
- observation(showPrefix: !largeText)
- }
- .frame(maxWidth: .infinity)
+ let showBrand = !largeText && geometry.size.height >= 44 + statusLineHeight + brandLineHeight + 12
+ let reservedHeight = statusLineHeight + 10
+ + (showBrand ? brandLineHeight + 4 : 0)
+ let diameter = min(64, max(44, geometry.size.height - reservedHeight))
+
+ VStack(spacing: 0) {
+ if showBrand { brand }
+ Spacer(minLength: 4)
+ powerControl(diameter: diameter)
Spacer(minLength: 6)
- if #available(iOS 17.0, *) {
- actions(compact: true, iconsOnly: compactHeight && dynamicTypeSize >= .xxLarge)
- } else {
- openApp(compact: true)
- }
+ statusLabel(lines: 1)
+ .frame(maxWidth: .infinity)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
@@ -142,30 +230,38 @@ private struct ConnectionWidgetView: View {
private var mediumContent: some View {
GeometryReader { geometry in
- let actionWidth = largeText ? 64 : geometry.size.width * 0.44
+ let actionWidth: CGFloat = geometry.size.width < 300 ? 88 : 104
+ let diameter = min(72, max(44, geometry.size.height - (largeText ? 0 : 36)))
HStack(spacing: 16) {
Link(destination: connectionURL) {
- VStack(alignment: .leading, spacing: 12) {
+ VStack(alignment: .leading, spacing: 10) {
if !largeText { brand }
- VStack(alignment: .leading, spacing: 4) {
- HStack(spacing: 8) {
- if !largeText { statusSymbol(size: geometry.size.width < 310 ? 24 : 32) }
- statusLabel(lines: 1)
- .frame(maxWidth: .infinity, alignment: .leading)
- }
- observation(showPrefix: !largeText)
+ HStack(spacing: 8) {
+ Circle()
+ .fill(statusColor)
+ .frame(width: 6, height: 6)
+ .privacySensitive()
+ .accessibilityHidden(true)
+ statusLabel(font: .title2.weight(.semibold), lines: 2)
+ .frame(maxWidth: .infinity, alignment: .leading)
}
}
- .frame(maxWidth: .infinity, minHeight: 96, alignment: .topLeading)
+ .frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.buttonStyle(.plain)
.accessibilityHint(text("Open app"))
- Group {
- if #available(iOS 17.0, *) {
- actions(compact: false)
- } else {
- openApp(compact: false)
+
+ VStack(spacing: 8) {
+ powerControl(diameter: diameter)
+ if !largeText {
+ Text(text(powerCaptionKey))
+ .font(.caption.weight(.medium))
+ .foregroundStyle(palette.secondary)
+ .multilineTextAlignment(.center)
+ .lineLimit(2)
+ .minimumScaleFactor(0.75)
+ .accessibilityHidden(true)
}
}
.frame(width: actionWidth)
@@ -181,94 +277,43 @@ private struct ConnectionWidgetView: View {
.lineLimit(1)
}
- private func statusLabel(lines: Int) -> some View {
+ private func statusLabel(font: Font = .headline, lines: Int) -> some View {
Text(statusText)
- .font(.headline)
- .foregroundStyle(palette.foreground)
+ .font(font)
+ .foregroundStyle(entry.status == .connected ? palette.success : palette.foreground)
.lineLimit(lines)
.minimumScaleFactor(largeText ? 0.4 : 0.75)
.privacySensitive()
}
- private func statusSymbol(size: CGFloat) -> some View {
- Image(systemName: statusIcon)
- .font(.system(size: size, weight: .regular))
- .foregroundStyle(statusColor)
- .privacySensitive()
- .accessibilityHidden(true)
- }
-
- private func observation(showPrefix: Bool) -> some View {
- Group {
- if let snapshot = entry.snapshot {
- HStack(spacing: 3) {
- if showPrefix { Text(text("Observed")).lineLimit(1) }
- Text(snapshot.observedAt, style: .time)
- .lineLimit(1)
- .fixedSize(horizontal: true, vertical: false)
- .layoutPriority(1)
- }
- .accessibilityElement(children: .combine)
- .accessibilityHint(showPrefix ? "" : text("Observed"))
- } else {
- Text(text("Open app to refresh"))
- .lineLimit(1)
- }
- }
- .font(.caption2)
- .dynamicTypeSize(...DynamicTypeSize.xxxLarge)
- .foregroundStyle(palette.secondary)
- .privacySensitive()
- }
-
- private func openApp(compact: Bool) -> some View {
- Link(destination: connectionURL) {
- actionLabel(.open, compact: compact)
- }
- .buttonStyle(ConnectionWidgetButtonStyle(palette: palette, action: .open, prominent: true))
- .accessibilityLabel(text("Open app"))
- }
-
- @available(iOS 17.0, *)
- @ViewBuilder private func actions(compact: Bool, iconsOnly: Bool = false) -> some View {
- if compact {
- HStack(spacing: 8) {
- startButton(compact: true, iconsOnly: iconsOnly)
- stopButton(compact: true, iconsOnly: iconsOnly)
+ @ViewBuilder private func powerControl(diameter: CGFloat) -> some View {
+ if #available(iOS 17.0, *) {
+ // Snapshot state only changes presentation. Native execution reads
+ // current status, and disconnecting never queues a new connection.
+ Button(intent: ToggleVpnWidgetIntent()) {
+ powerSymbol(diameter: diameter)
}
+ .buttonStyle(ConnectionWidgetPowerButtonStyle(palette: palette, action: toggleAction, diameter: diameter))
+ .accessibilityLabel(text(toggleLabelKey))
+ .accessibilityValue(statusText)
} else {
- VStack(spacing: 8) {
- startButton(compact: false, iconsOnly: iconsOnly)
- stopButton(compact: false, iconsOnly: iconsOnly)
- }
+ Link(destination: connectionURL) { powerSymbol(diameter: diameter) }
+ .buttonStyle(ConnectionWidgetPowerButtonStyle(palette: palette, action: .open, diameter: diameter))
+ .accessibilityLabel(text("Open app"))
}
}
- @available(iOS 17.0, *)
- private func startButton(compact: Bool, iconsOnly: Bool) -> some View {
- Button(intent: StartVpnWidgetIntent()) { actionLabel(.start, compact: compact, iconsOnly: iconsOnly) }
- .buttonStyle(ConnectionWidgetButtonStyle(palette: palette, action: .start, prominent: !emphasizeStop))
- .accessibilityLabel(text("Start VPN"))
- }
-
- @available(iOS 17.0, *)
- private func stopButton(compact: Bool, iconsOnly: Bool) -> some View {
- Button(intent: StopVpnWidgetIntent()) { actionLabel(.stop, compact: compact, iconsOnly: iconsOnly) }
- .buttonStyle(ConnectionWidgetButtonStyle(palette: palette, action: .stop, prominent: emphasizeStop))
- .accessibilityLabel(text("Stop VPN"))
+ private func powerSymbol(diameter: CGFloat) -> some View {
+ Image(systemName: "power")
+ .font(.system(size: diameter * 0.4, weight: .medium))
+ .accessibilityHidden(true)
}
- private func actionLabel(_ action: ConnectionWidgetAction, compact: Bool, iconsOnly: Bool = false) -> some View {
- HStack(spacing: compact ? 3 : 4) {
- Image(systemName: action.symbol)
- .font(.system(size: largeText || iconsOnly ? 18 : 12, weight: .semibold))
- if !largeText && !iconsOnly {
- Text(text(compact ? action.compactKey : action.labelKey))
- .lineLimit(compact && action != .open ? 1 : 2)
- .minimumScaleFactor(0.92)
- }
+ private var powerCaptionKey: String {
+ if #available(iOS 17.0, *) {
+ return entry.status == .disconnecting ? "Disconnecting" : toggleAction.compactKey
}
- .padding(.horizontal, compact ? 2 : 8)
+ return "Open app"
}
private var statusText: String {
@@ -303,20 +348,13 @@ private struct ConnectionWidgetView: View {
}
private enum ConnectionWidgetAction {
- case start, stop, open
-
- var symbol: String {
- switch self {
- case .start: "play.fill"
- case .stop: "stop.fill"
- case .open: "arrow.up.forward"
- }
- }
+ case start, stop, toggle, open
var labelKey: String {
switch self {
case .start: "Start VPN"
case .stop: "Stop VPN"
+ case .toggle: "Start or stop VPN"
case .open: "Open app"
}
}
@@ -325,43 +363,45 @@ private enum ConnectionWidgetAction {
switch self {
case .start: "Start"
case .stop: "Stop"
+ case .toggle: "Start or stop VPN"
case .open: "Open app"
}
}
}
-private struct ConnectionWidgetButtonStyle: ButtonStyle {
+private struct ConnectionWidgetPowerButtonStyle: ButtonStyle {
let palette: ConnectionWidgetPalette
let action: ConnectionWidgetAction
- let prominent: Bool
+ let diameter: CGFloat
private var foreground: Color {
- prominent ? .white : (action == .stop ? palette.destructive : palette.primaryText)
+ switch action {
+ case .stop: palette.destructive
+ case .toggle: palette.secondary
+ case .start, .open: palette.primaryText
+ }
}
- private var fill: Color {
- if prominent { return action == .stop ? palette.destructiveSolid : palette.primarySolid }
- return action == .stop ? .clear : palette.accent
+ private var tint: Color {
+ switch action {
+ case .stop: palette.destructiveSolid
+ case .toggle: palette.secondary
+ case .start, .open: palette.primarySolid
+ }
}
func makeBody(configuration: Configuration) -> some View {
configuration.label
- .font(.caption.weight(.semibold))
.foregroundStyle(foreground)
- .frame(maxWidth: .infinity, minHeight: 44)
+ .frame(width: diameter, height: diameter)
.background {
- if #available(iOS 16.0, *) {
- RoundedRectangle(cornerRadius: 8).fill(fill).widgetAccentable()
- } else {
- RoundedRectangle(cornerRadius: 8).fill(fill)
- }
+ Circle().fill(tint.opacity(0.12)).widgetAccentable()
}
.overlay {
- RoundedRectangle(cornerRadius: 8)
- .strokeBorder(prominent ? .clear : palette.border, lineWidth: 1)
+ Circle().strokeBorder(tint.opacity(0.35), lineWidth: 1)
}
- .contentShape(RoundedRectangle(cornerRadius: 8))
- .opacity(configuration.isPressed ? 0.75 : 1)
+ .contentShape(Circle())
+ .opacity(configuration.isPressed ? 0.7 : 1)
}
}
@@ -381,9 +421,7 @@ private struct ConnectionWidgetPalette {
}
private func widgetText(_ key: String, _ language: String) -> String {
- let path = Bundle.main.path(forResource: AppleWidgetLanguage.normalized(language), ofType: "lproj")
- let bundle = path.flatMap(Bundle.init(path:)) ?? Bundle.main
- return bundle.localizedString(forKey: key, value: key, table: "Widget")
+ AppleWidgetLocalization.text(key, languageCode: language)
}
struct ConnectionWidgetPreviews: PreviewProvider {
diff --git a/ios/widget/widgetBundle.swift b/ios/widget/widgetBundle.swift
index be5d84ef4..ace4607dc 100644
--- a/ios/widget/widgetBundle.swift
+++ b/ios/widget/widgetBundle.swift
@@ -12,5 +12,8 @@ import SwiftUI
struct widgetBundle: WidgetBundle {
var body: some Widget {
ConnectionWidget()
+ if #available(iOS 18.0, *) {
+ VpnControl()
+ }
}
}
diff --git a/ios/widget/zh-Hans.lproj/Widget.strings b/ios/widget/zh-Hans.lproj/Widget.strings
index 1f8db2c98..88bea1959 100644
--- a/ios/widget/zh-Hans.lproj/Widget.strings
+++ b/ios/widget/zh-Hans.lproj/Widget.strings
@@ -1,8 +1,7 @@
"Connection" = "连接";
"VPN status and actions that open OneXray." = "查看 VPN 状态,打开 OneXray 执行操作。";
+"Open OneXray to perform this action." = "打开 OneXray 执行此操作。";
"Open app" = "打开 App";
-"Open app to refresh" = "打开 App 刷新";
-"Observed" = "观测时间";
"Start VPN" = "启动 VPN";
"Stop VPN" = "停止 VPN";
"Disconnected" = "未连接";
@@ -12,3 +11,16 @@
"Unknown" = "未知";
"Start" = "启动";
"Stop" = "停止";
+"VPN" = "VPN";
+"VPN status and background actions using the last prepared configuration." = "查看 VPN 状态,使用上次已准备的配置在后台执行操作。";
+"Start or stop VPN in the background using the last prepared configuration." = "使用上次已准备的配置在后台启动或停止 VPN。";
+"Start or stop VPN" = "启动或停止 VPN";
+"Status pending" = "状态待确认";
+"Open OneXray to finish setup." = "打开 OneXray 完成配置。";
+"This VPN action must run in OneXray." = "此 VPN 操作必须在 OneXray 中执行。";
+"OneXray cannot open from this system control right now." = "当前无法从此系统控件打开 OneXray。";
+"VPN action failed." = "VPN 操作失败。";
+"The VPN start was cancelled." = "VPN 启动已取消。";
+"VPN start is temporarily unavailable while app data is being updated." = "App 数据更新期间暂时无法启动 VPN。";
+"The VPN is still stopping." = "VPN 仍在停止中。";
+"Unable to confirm the current VPN status." = "无法确认当前 VPN 状态。";
diff --git a/ios/widget/zh-Hant.lproj/Widget.strings b/ios/widget/zh-Hant.lproj/Widget.strings
index 6ae3dfdb2..95003f77b 100644
--- a/ios/widget/zh-Hant.lproj/Widget.strings
+++ b/ios/widget/zh-Hant.lproj/Widget.strings
@@ -1,8 +1,7 @@
"Connection" = "連線";
"VPN status and actions that open OneXray." = "查看 VPN 狀態,開啟 OneXray 執行操作。";
+"Open OneXray to perform this action." = "開啟 OneXray 執行此操作。";
"Open app" = "開啟 App";
-"Open app to refresh" = "開啟 App 重新整理";
-"Observed" = "觀測時間";
"Start VPN" = "啓動 VPN";
"Stop VPN" = "停止 VPN";
"Disconnected" = "未連線";
@@ -12,3 +11,16 @@
"Unknown" = "未知";
"Start" = "啟動";
"Stop" = "停止";
+"VPN" = "VPN";
+"VPN status and background actions using the last prepared configuration." = "查看 VPN 狀態,使用上次已準備的設定在背景執行操作。";
+"Start or stop VPN in the background using the last prepared configuration." = "使用上次已準備的設定在背景啟動或停止 VPN。";
+"Start or stop VPN" = "啟動或停止 VPN";
+"Status pending" = "狀態待確認";
+"Open OneXray to finish setup." = "開啟 OneXray 完成設定。";
+"This VPN action must run in OneXray." = "此 VPN 操作必須在 OneXray 中執行。";
+"OneXray cannot open from this system control right now." = "目前無法從此系統控制項開啟 OneXray。";
+"VPN action failed." = "VPN 操作失敗。";
+"The VPN start was cancelled." = "VPN 啟動已取消。";
+"VPN start is temporarily unavailable while app data is being updated." = "App 資料更新期間暫時無法啟動 VPN。";
+"The VPN is still stopping." = "VPN 仍在停止中。";
+"Unable to confirm the current VPN status." = "無法確認目前的 VPN 狀態。";
diff --git a/lib/core/ffi/base_ffi_api.dart b/lib/core/ffi/base_ffi_api.dart
index 8b16c7d79..7b4cb3f0e 100644
--- a/lib/core/ffi/base_ffi_api.dart
+++ b/lib/core/ffi/base_ffi_api.dart
@@ -79,6 +79,31 @@ abstract class BaseFfiApi {
return LibXrayRunConfig.fromInvokeText(request.coreInvokeText!);
}
+ /// Only call after the previous Core has stopped and before launching its replacement.
+ @protected
+ Future clearRuntimeLogs() async {
+ final directory = p.join(await getTunFilesDir(), 'run');
+ final type = await FileSystemEntity.type(directory, followLinks: false);
+ if (type == FileSystemEntityType.notFound) return;
+ if (type != FileSystemEntityType.directory) {
+ throw FileSystemException('Invalid runtime log directory', directory);
+ }
+ final files = [];
+ for (final name in ['access.log', 'error.log']) {
+ final file = File(p.join(directory, name));
+ final type = await FileSystemEntity.type(file.path, followLinks: false);
+ if (type == FileSystemEntityType.notFound) continue;
+ if (type != FileSystemEntityType.file) {
+ throw FileSystemException('Invalid runtime log file', file.path);
+ }
+ files.add(file);
+ }
+ // Validate both before removing either; never traverse the runtime directory.
+ for (final file in files) {
+ await file.delete();
+ }
+ }
+
Future materializeRunXrayConfig(LibXrayRunConfig request) async {
final runPath = p.join(await getTunFilesDir(), 'run');
final root = Directory(p.join(runPath, 'core-inputs'));
diff --git a/lib/core/ffi/linux_ffi_api.dart b/lib/core/ffi/linux_ffi_api.dart
index b53abde66..92c4c336e 100644
--- a/lib/core/ffi/linux_ffi_api.dart
+++ b/lib/core/ffi/linux_ffi_api.dart
@@ -154,15 +154,14 @@ class LinuxFfiApi extends BaseFfiApi {
}
final errorFile = desktopCoreErrorFile(inputs);
await errorFile.writeAsString('', flush: true);
- final process = await _startProcess(
- corePath,
- desktopCoreRunArguments(
- dns: tun?.tunDnsIPv4 ?? '',
- interfaceName: tun?.autoOutboundsInterface ?? '',
- configPath: inputs,
- errorFile: errorFile.path,
- ),
+ final arguments = desktopCoreRunArguments(
+ dns: tun?.tunDnsIPv4 ?? '',
+ interfaceName: tun?.autoOutboundsInterface ?? '',
+ configPath: inputs,
+ errorFile: errorFile.path,
);
+ await clearRuntimeLogs();
+ final process = await _startProcess(corePath, arguments);
_coreProcess = process;
_bindProcess(process);
unawaited(_monitor.waitForExit(process.pid));
diff --git a/lib/core/ffi/windows/exe_ffi_api.dart b/lib/core/ffi/windows/exe_ffi_api.dart
index 9c20a7b85..538cecc28 100644
--- a/lib/core/ffi/windows/exe_ffi_api.dart
+++ b/lib/core/ffi/windows/exe_ffi_api.dart
@@ -107,16 +107,15 @@ class WindowsExeFfiApi extends WindowsFfiApi {
// Create as the App user before Windows starts an elevated Core.
final errorFile = desktopCoreErrorFile(config);
await errorFile.writeAsString('', flush: true);
- launchAttempted = true;
- final pid = await _process.start(
- _corePath,
- desktopCoreRunArguments(
- dns: request.tun?.tunDnsIPv4 ?? '',
- interfaceName: request.tun?.autoOutboundsInterface ?? '',
- configPath: config,
- errorFile: errorFile.path,
- ),
+ final arguments = desktopCoreRunArguments(
+ dns: request.tun?.tunDnsIPv4 ?? '',
+ interfaceName: request.tun?.autoOutboundsInterface ?? '',
+ configPath: config,
+ errorFile: errorFile.path,
);
+ await clearRuntimeLogs();
+ launchAttempted = true;
+ final pid = await _process.start(_corePath, arguments);
await Future.delayed(const Duration(seconds: 1));
if (!(await _monitor.readPids()).contains(pid)) {
throw StateError(
diff --git a/lib/core/ffi/windows/msix_ffi_api.dart b/lib/core/ffi/windows/msix_ffi_api.dart
index 1ae92ce34..70d7596ef 100644
--- a/lib/core/ffi/windows/msix_ffi_api.dart
+++ b/lib/core/ffi/windows/msix_ffi_api.dart
@@ -2,6 +2,7 @@ import 'dart:async';
import 'package:onexray/core/ffi/base_ffi_api.dart';
import 'package:onexray/core/ffi/windows/ffi_api.dart';
+import 'package:onexray/core/ffi/windows/core_process.dart';
import 'package:onexray/core/ffi/windows/model.dart';
import 'package:onexray/core/ffi/windows/native_api.dart';
import 'package:onexray/core/pigeon/messages.g.dart';
@@ -14,6 +15,7 @@ import 'package:onexray/core/tools/logger.dart';
class WindowsMsixFfiApi extends WindowsFfiApi {
WindowsMsixFfiApi({
WindowsNativeApi? native,
+ WindowsCoreProcess? coreProcess,
Future Function()? readRequest,
Future Function(VpnStatus)? notify,
void Function(Object)? notifyError,
@@ -22,6 +24,7 @@ class WindowsMsixFfiApi extends WindowsFfiApi {
this.startTimeout = const Duration(seconds: 30),
this.stopTimeout = const Duration(seconds: 15),
}) : _native = native ?? WindowsNativeApi(),
+ _coreProcess = coreProcess ?? WindowsCoreProcess(),
_readRequest = readRequest ?? StartVpnRequestReader.readFromStartFile,
_notify = notify ?? AppFlutterApi().vpnStatusChanged,
_notifyError =
@@ -31,6 +34,7 @@ class WindowsMsixFfiApi extends WindowsFfiApi {
static const _coreRelativePath = 'OneXrayCore.exe';
final WindowsNativeApi _native;
+ final WindowsCoreProcess _coreProcess;
final Future Function() _readRequest;
final Future Function(VpnStatus) _notify;
final void Function(Object) _notifyError;
@@ -152,9 +156,16 @@ class WindowsMsixFfiApi extends WindowsFfiApi {
_commandActive = true;
var providerStartInvoked = false;
+ var previousStopped = false;
String? coreConfig;
try {
final request = await _readRequest();
+ final stopped = await _native.stopVpn();
+ await _confirm(WindowsVpnStatus.disconnected, stopped.status);
+ if ((await _coreProcess.findPids()).isNotEmpty) {
+ throw StateError('The previous Windows Core process is still running.');
+ }
+ previousStopped = true;
coreConfig = await _publishCoreConfig(readRunXrayRequest(request));
final errorFile = desktopCoreErrorFile(coreConfig);
await errorFile.writeAsString('', flush: true);
@@ -172,6 +183,7 @@ class WindowsMsixFfiApi extends WindowsFfiApi {
],
);
+ await clearRuntimeLogs();
await _emitWindowsStatus(WindowsVpnStatus.connecting);
providerStartInvoked = true;
final state = await _native.startVpn(
@@ -190,7 +202,7 @@ class WindowsMsixFfiApi extends WindowsFfiApi {
return commandSuccess(status: VpnStatus.connected);
} catch (error, stackTrace) {
ygLogger('start Windows VPN failed: $error\n$stackTrace');
- await _cleanupFailedStart(providerStartInvoked);
+ if (previousStopped) await _cleanupFailedStart(providerStartInvoked);
return commandFailed(
providerStartInvoked && coreConfig != null
? await readDesktopCoreStartError(coreConfig, error.toString())
diff --git a/lib/core/pigeon/messages.g.dart b/lib/core/pigeon/messages.g.dart
index c4f71f81b..6390481da 100644
--- a/lib/core/pigeon/messages.g.dart
+++ b/lib/core/pigeon/messages.g.dart
@@ -960,6 +960,108 @@ class AppleWidgetHostApi {
isNullValid: true,
);
}
+
+ Future beginPreparation() async {
+ final pigeonVar_channelName =
+ 'dev.flutter.pigeon.onexray.AppleWidgetHostApi.beginPreparation$pigeonVar_messageChannelSuffix';
+ final pigeonVar_channel = BasicMessageChannel