@@ -109,12 +109,76 @@ fun parseHysteria2(url: String): HysteriaBean {
109109 link.queryParameter(" obfs-max-packet-size" )?.toIntOrNull()?.also {
110110 geckoMaxPacketSize = it
111111 }
112+ queryInt(link, " initStreamReceiveWindow" , " init_stream_receive_window" , " init-stream-receive-window" )?.also {
113+ hy2InitialStreamReceiveWindow = it
114+ }
115+ queryInt(link, " maxStreamReceiveWindow" , " max_stream_receive_window" , " max-stream-receive-window" )?.also {
116+ hy2MaxStreamReceiveWindow = it
117+ }
118+ queryInt(link, " initConnReceiveWindow" , " init_conn_receive_window" , " init-conn-receive-window" )?.also {
119+ hy2InitialConnectionReceiveWindow = it
120+ }
121+ queryInt(link, " maxConnReceiveWindow" , " max_conn_receive_window" , " max-conn-receive-window" )?.also {
122+ hy2MaxConnectionReceiveWindow = it
123+ }
124+ querySeconds(link, " maxIdleTimeout" , " max_idle_timeout" , " max-idle-timeout" )?.also {
125+ hy2MaxIdleTimeout = it
126+ }
127+ querySeconds(link, " keepAlivePeriod" , " keep_alive_period" , " keep-alive-period" )?.also {
128+ hy2KeepAlivePeriod = it
129+ }
130+ queryBool(link, " disablePathMTUDiscovery" , " disable_path_mtu_discovery" , " disable-path-mtu-discovery" )?.also {
131+ disableMtuDiscovery = it
132+ }
133+ querySeconds(link, " hopInterval" , " hop_interval" , " hop-interval" )?.also {
134+ hopInterval = it
135+ }
136+ querySeconds(link, " minHopInterval" , " min_hop_interval" , " min-hop-interval" )?.also {
137+ hy2MinHopInterval = it
138+ }
139+ querySeconds(link, " maxHopInterval" , " max_hop_interval" , " max-hop-interval" )?.also {
140+ hy2MaxHopInterval = it
141+ }
112142// link.queryParameter("pinSHA256")?.also {
113143// // TODO your box do not support it
114144// }
115145 }
116146}
117147
148+ private fun queryInt (link : okhttp3.HttpUrl , vararg names : String ): Int? {
149+ return names.firstNotNullOfOrNull { link.queryParameter(it)?.toIntOrNull() }
150+ }
151+
152+ private fun queryBool (link : okhttp3.HttpUrl , vararg names : String ): Boolean? {
153+ return names.firstNotNullOfOrNull { name ->
154+ link.queryParameter(name)?.let { it == " 1" || it.equals(" true" , ignoreCase = true ) }
155+ }
156+ }
157+
158+ private fun querySeconds (link : okhttp3.HttpUrl , vararg names : String ): Int? {
159+ return names.firstNotNullOfOrNull { name ->
160+ parseDurationSeconds(link.queryParameter(name))
161+ }
162+ }
163+
164+ private fun parseDurationSeconds (value : String? ): Int? {
165+ val raw = value?.trim()?.lowercase()?.takeIf { it.isNotEmpty() } ? : return null
166+ val (number, multiplier, roundUpDivisor) = when {
167+ raw.endsWith(" ms" ) -> Triple (raw.removeSuffix(" ms" ), 1L , 1000L )
168+ raw.endsWith(" s" ) -> Triple (raw.removeSuffix(" s" ), 1L , 1L )
169+ raw.endsWith(" m" ) -> Triple (raw.removeSuffix(" m" ), 60L , 1L )
170+ raw.endsWith(" h" ) -> Triple (raw.removeSuffix(" h" ), 3600L , 1L )
171+ else -> Triple (raw, 1L , 1L )
172+ }
173+ val base = number.toLongOrNull() ? : return null
174+ val seconds = if (roundUpDivisor > 1 ) {
175+ (base + roundUpDivisor - 1 ) / roundUpDivisor
176+ } else {
177+ base * multiplier
178+ }
179+ return seconds.coerceIn(0 , Int .MAX_VALUE .toLong()).toInt()
180+ }
181+
118182fun HysteriaBean.toUri (): String {
119183 var un = " "
120184 var pw = " "
@@ -185,6 +249,33 @@ fun HysteriaBean.toUri(): String {
185249 }
186250 }
187251 }
252+ if (hy2InitialStreamReceiveWindow > 0 ) {
253+ builder.addQueryParameter(" initStreamReceiveWindow" , hy2InitialStreamReceiveWindow.toString())
254+ }
255+ if (hy2MaxStreamReceiveWindow > 0 ) {
256+ builder.addQueryParameter(" maxStreamReceiveWindow" , hy2MaxStreamReceiveWindow.toString())
257+ }
258+ if (hy2InitialConnectionReceiveWindow > 0 ) {
259+ builder.addQueryParameter(" initConnReceiveWindow" , hy2InitialConnectionReceiveWindow.toString())
260+ }
261+ if (hy2MaxConnectionReceiveWindow > 0 ) {
262+ builder.addQueryParameter(" maxConnReceiveWindow" , hy2MaxConnectionReceiveWindow.toString())
263+ }
264+ if (hy2MaxIdleTimeout > 0 ) {
265+ builder.addQueryParameter(" maxIdleTimeout" , " ${hy2MaxIdleTimeout} s" )
266+ }
267+ if (hy2KeepAlivePeriod > 0 ) {
268+ builder.addQueryParameter(" keepAlivePeriod" , " ${hy2KeepAlivePeriod} s" )
269+ }
270+ if (disableMtuDiscovery) {
271+ builder.addQueryParameter(" disablePathMTUDiscovery" , " 1" )
272+ }
273+ if (hy2MinHopInterval > 0 || hy2MaxHopInterval > 0 ) {
274+ if (hy2MinHopInterval > 0 ) builder.addQueryParameter(" minHopInterval" , " ${hy2MinHopInterval} s" )
275+ if (hy2MaxHopInterval > 0 ) builder.addQueryParameter(" maxHopInterval" , " ${hy2MaxHopInterval} s" )
276+ } else if (hopInterval != 10 ) {
277+ builder.addQueryParameter(" hopInterval" , " ${hopInterval} s" )
278+ }
188279 }
189280 return builder.toLink(if (protocolVersion == 2 ) " hy2" else " hysteria" )
190281}
@@ -289,16 +380,34 @@ fun isMultiPort(hyAddr: String): Boolean {
289380}
290381
291382fun getFirstPort (portStr : String ): Int {
292- return portStr.substringBefore(" : " ).substringBefore(" , " ).toIntOrNull() ? : 443
383+ return portStr.substringBefore(" , " ).substringBefore(" - " ).substringBefore( " : " ).toIntOrNull() ? : 443
293384}
294385
295386fun HysteriaBean.canUseSingBox (): Boolean {
296387 if (protocol != HysteriaBean .PROTOCOL_UDP ) return false
388+ if (protocolVersion == 2 && hasAdvancedHysteria2Options()) {
389+ // The bundled sing-box Hysteria2 option model supports Gecko obfs and fixed
390+ // port hopping, but not Hysteria's advanced QUIC knobs or random hop
391+ // intervals. Use the Hysteria2 sidecar when those fields are configured.
392+ return false
393+ }
297394 // Gecko obfs is now supported natively by this fork's sing-box core (via the
298- // hawkff/sing-quic gecko backport), so it no longer needs the sidecar.
395+ // hawkff/sing-quic gecko backport), so default HY2 profiles do not need the sidecar.
299396 return true
300397}
301398
399+ fun HysteriaBean.hasAdvancedHysteria2Options (): Boolean {
400+ if (protocolVersion != 2 ) return false
401+ return hy2InitialStreamReceiveWindow > 0 ||
402+ hy2MaxStreamReceiveWindow > 0 ||
403+ hy2InitialConnectionReceiveWindow > 0 ||
404+ hy2MaxConnectionReceiveWindow > 0 ||
405+ hy2MaxIdleTimeout > 0 ||
406+ hy2KeepAlivePeriod > 0 ||
407+ disableMtuDiscovery ||
408+ (isMultiPort(displayAddress()) && (hy2MinHopInterval > 0 || hy2MaxHopInterval > 0 ))
409+ }
410+
302411fun buildSingBoxOutboundHysteriaBean (bean : HysteriaBean ): SingBoxOptions .SingBoxOption {
303412 return when (bean.protocolVersion) {
304413 1 -> SingBoxOptions .Outbound_HysteriaOptions ().apply {
@@ -413,9 +522,9 @@ fun hopPortsToSingboxList(s: String): List<String> {
413522/* *
414523 * Builds a config for the bundled official apernet/hysteria client binary (sidecar).
415524 *
416- * NOTE: Hysteria2 (including Gecko obfs) now runs natively in the sing-box core, so this
417- * sidecar path is no longer used for Gecko. This builder is retained for the bundled
418- * hysteria2 binary infrastructure and potential fallback use .
525+ * NOTE: Default Hysteria2 (including Gecko obfs and fixed port hopping) runs natively in
526+ * the sing-box core. This sidecar path is used when HY2 advanced QUIC options or random
527+ * hop intervals are configured, because those fields are not exposed by the pinned core .
419528 *
420529 * Emitted as JSON (hysteria uses viper, which detects format by the .json extension).
421530 * The client's upstream QUIC sockets are protected from the VPN via
@@ -471,7 +580,30 @@ fun HysteriaBean.buildHysteria2SidecarConfig(
471580 if (downloadMbps > 0 ) put(" down" , " $downloadMbps mbps" )
472581 })
473582 }
583+ if (isMultiPort(displayAddress())) {
584+ put(" transport" , JSONObject ().apply {
585+ put(" type" , " udp" )
586+ put(" udp" , JSONObject ().apply {
587+ if (hy2MinHopInterval > 0 || hy2MaxHopInterval > 0 ) {
588+ val rawMin = hy2MinHopInterval.takeIf { it > 0 } ? : hy2MaxHopInterval
589+ val min = rawMin.coerceAtLeast(5 )
590+ val max = (hy2MaxHopInterval.takeIf { it > 0 } ? : min).coerceAtLeast(min)
591+ put(" minHopInterval" , " ${min} s" )
592+ put(" maxHopInterval" , " ${max} s" )
593+ } else if (hopInterval > 0 ) {
594+ put(" hopInterval" , " ${hopInterval.coerceAtLeast(5 )} s" )
595+ }
596+ })
597+ })
598+ }
474599 put(" quic" , JSONObject ().apply {
600+ if (hy2InitialStreamReceiveWindow > 0 ) put(" initStreamReceiveWindow" , hy2InitialStreamReceiveWindow)
601+ if (hy2MaxStreamReceiveWindow > 0 ) put(" maxStreamReceiveWindow" , hy2MaxStreamReceiveWindow)
602+ if (hy2InitialConnectionReceiveWindow > 0 ) put(" initConnReceiveWindow" , hy2InitialConnectionReceiveWindow)
603+ if (hy2MaxConnectionReceiveWindow > 0 ) put(" maxConnReceiveWindow" , hy2MaxConnectionReceiveWindow)
604+ if (hy2MaxIdleTimeout > 0 ) put(" maxIdleTimeout" , " ${hy2MaxIdleTimeout} s" )
605+ if (hy2KeepAlivePeriod > 0 ) put(" keepAlivePeriod" , " ${hy2KeepAlivePeriod} s" )
606+ if (disableMtuDiscovery) put(" disablePathMTUDiscovery" , true )
475607 put(" sockopts" , JSONObject ().apply {
476608 put(" fdControlUnixSocket" , protectPath)
477609 })
0 commit comments