Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
60 commits
Select commit Hold shift + click to select a range
a921b5b
fix(android): avoid cross-drive Kotlin cache failures
Nov 3, 2026
179f212
refactor(ios): drop compatibility below iOS 16
Nov 3, 2026
1994c53
refactor(ui): introduce six destinations and a shared single-column s…
Nov 3, 2026
995ad8a
feat(connect): select readable nodes without startup probes and remem…
Nov 3, 2026
716a79f
refactor(connect): adopt single-column plans and guarded JSON editors
Nov 3, 2026
a590d96
fix(connect): keep target choices usable above keyboards and label Ra…
Nov 3, 2026
8292e5d
refactor(ui): move server resources and routing editors into single-c…
Nov 3, 2026
d263c7e
refactor(ui): align tunnel and Xray tools with the shared page layout
Nov 3, 2026
991e760
feat(settings): separate connection-resource cleanup from full data r…
Nov 3, 2026
df6cb27
fix(routing): isolate damaged unused routes from connection and serve…
Nov 3, 2026
489c84f
test(ui): align theme and tray regressions with the approved contracts
Nov 3, 2026
80bdb07
fix(navigation): preserve pending editor stacks and serialize incomin…
Nov 3, 2026
1baf49d
fix(ui): avoid router ancestor loops in imperative preview page bars
Nov 3, 2026
951edad
fix(ui): expose enabled switch semantics and truthful import feedback
Nov 3, 2026
f76dff0
test(ui): record native acceptance and final refactor checks
Nov 3, 2026
9762d7e
style(theme): align shared controls and palette with the prototype
Nov 3, 2026
0dbdd37
style(ui): align shared navigation and cards with prototype
Nov 3, 2026
09b0659
style(ui): align pages with approved prototype across layouts
Nov 3, 2026
e152faf
fix(ui): close visual gaps found in native comparison
Nov 3, 2026
e27716b
fix(ui): unify form cards and responsive page actions
Nov 3, 2026
30f3ff2
fix(ui): finish detail layouts and prevent action label clipping
Nov 4, 2026
0b13122
fix(ui): align routing and resource pages with approved layouts
Nov 4, 2026
d51b93d
style(ui): match prototype entry selectors and control radii
Nov 4, 2026
2d0c51c
style(ui): apply shared input tokens to subscription fields
Nov 4, 2026
3f9ac6d
fix(ui): align remaining tools and report partial imports accurately
Nov 4, 2026
41c2d60
fix(ui): keep modal actions visible when content expands
Nov 4, 2026
9c24f7e
fix(ui): size ordering cards to their content
Nov 4, 2026
f3689df
fix(a11y): expose setting rows as independent actions
Nov 4, 2026
b4553db
fix(a11y): label the log follow action as a button
Nov 4, 2026
a886429
fix(share): export legacy SOCKS endpoints without changing stored data
Nov 4, 2026
f772c7d
docs(share): describe the SOCKS standard-link projection
Nov 4, 2026
e65249e
docs(license): limit provider exception to dedicated VPN clients
Nov 4, 2026
3f24b07
refactor(ui): use standard shared app bars
Nov 4, 2026
05ba291
refactor(setup): present initialization as full pages
Nov 4, 2026
12ac17f
fix(servers): restore manual node ordering entry
Nov 4, 2026
7cf7f52
fix(ui): preserve contextual help with standard app bars
Nov 4, 2026
48f56db
fix(theme): align wide page chrome with the page background
Nov 4, 2026
b90908a
refactor(navigation): use the standard mobile bar and remove wide pag…
Nov 4, 2026
c532108
fix(navigation): use the requested BottomNavigationBar widget
Nov 4, 2026
ce999ce
revert(navigation): restore the custom mobile bar and keep wide surfa…
Nov 4, 2026
62c88b4
refactor(json): fill editor workspaces and preserve a minimum height
Nov 4, 2026
8e0364f
fix(json): reveal the caret in short editor viewports
Nov 4, 2026
c1d4e83
fix(android): open the app when long-pressing the VPN tile
Nov 4, 2026
b656d38
fix(vpn): clear managed logs before each admitted Core start
Nov 4, 2026
c032ae1
feat(ios): add explicit Control Center VPN actions
Nov 4, 2026
07e1f8d
fix(logs): reset the viewer when a new VPN session starts
Nov 4, 2026
bf94e3f
test(logs): isolate the viewer settings test coordinator
Nov 4, 2026
df01a36
feat(ios): run saved VPN actions from widgets in the background
Nov 4, 2026
1ce3da1
feat(ios): replace VPN controls with one state-aware button
Nov 4, 2026
9ec56fb
fix(ios): preserve later widget toggles during status queries
Nov 4, 2026
9377817
feat(ios): combine home widget VPN actions into one button
Nov 4, 2026
6cd9375
refactor(ios): redesign widgets around a single power control
Nov 4, 2026
94e043b
docs(ios): describe background widget actions and validation
Nov 4, 2026
f37c3c5
refactor(ios): remove widget observation time
Nov 4, 2026
a06b469
docs(license): require attribution for the provider exception
Nov 4, 2026
3ea6101
refactor(ui): restore resource actions and remove retired flows
Nov 4, 2026
1a65598
fix(ui): unify Raw creation and repair JSON editor borders
Nov 5, 2026
3eb98a9
fix(ui): highlight target categories and clarify configuration types
Nov 5, 2026
c1f8586
fix(ci): format managed log cleanup test
Nov 5, 2026
e0edc49
fix(test): account for wrapped JSON editor tools on resize
Nov 5, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 26 additions & 8 deletions LICENSE-EXCEPTION
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
OneXray VPN Provider Exception
Version 1.0
Version 1.2

1. Scope

Expand All @@ -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.

Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down Expand Up @@ -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.

Expand Down
4 changes: 4 additions & 0 deletions android/app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.service.quicksettings.action.QS_TILE_PREFERENCES" />
<category android:name="android.intent.category.DEFAULT" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
Expand Down
101 changes: 101 additions & 0 deletions android/app/src/main/kotlin/net/yuandev/onexray/pigeon/Messages.g.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>) -> Unit)
Expand Down Expand Up @@ -1013,6 +1014,11 @@ interface BridgeHostApi {
interface AppleWidgetHostApi {
fun takeAction(callback: (Result<String?>) -> Unit)
fun setLanguage(languageCode: String, callback: (Result<Unit>) -> Unit)
fun beginPreparation(callback: (Result<String>) -> Unit)
fun finishPreparation(token: String, callback: (Result<Unit>) -> Unit)
fun startPreparedVpn(token: String, requestJson: String, callback: (Result<NativeVpnCommandResult>) -> Unit)
fun connectedAtMilliseconds(callback: (Result<Long?>) -> Unit)
fun setStartBlocked(blocked: Boolean, callback: (Result<Unit>) -> Unit)

companion object {
/** The codec used by AppleWidgetHostApi. */
Expand Down Expand Up @@ -1060,6 +1066,101 @@ interface AppleWidgetHostApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.onexray.AppleWidgetHostApi.beginPreparation$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.beginPreparation{ result: Result<String> ->
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<Any?>(binaryMessenger, "dev.flutter.pigeon.onexray.AppleWidgetHostApi.finishPreparation$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val tokenArg = args[0] as String
api.finishPreparation(tokenArg) { result: Result<Unit> ->
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<Any?>(binaryMessenger, "dev.flutter.pigeon.onexray.AppleWidgetHostApi.startPreparedVpn$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val tokenArg = args[0] as String
val requestJsonArg = args[1] as String
api.startPreparedVpn(tokenArg, requestJsonArg) { result: Result<NativeVpnCommandResult> ->
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<Any?>(binaryMessenger, "dev.flutter.pigeon.onexray.AppleWidgetHostApi.connectedAtMilliseconds$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { _, reply ->
api.connectedAtMilliseconds{ result: Result<Long?> ->
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<Any?>(binaryMessenger, "dev.flutter.pigeon.onexray.AppleWidgetHostApi.setStartBlocked$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val blockedArg = args[0] as Boolean
api.setStartBlocked(blockedArg) { result: Result<Unit> ->
val error = result.exceptionOrNull()
if (error != null) {
reply.reply(MessagesPigeonUtils.wrapError(error))
} else {
reply.reply(MessagesPigeonUtils.wrapResult(null))
}
}
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
}
Original file line number Diff line number Diff line change
@@ -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())
}
}
3 changes: 3 additions & 0 deletions android/gradle.properties
Original file line number Diff line number Diff line change
@@ -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
Expand Down
Loading