android: Routes rules can be deleted with one tap and undone: each rule row gets a trash button, the rule disappears at once and is really removed when the «Undo» snackbar goes away or the page is left (so undoing a package pattern does not reconnect the tunnel twice); tapping a rule opens a sheet with its on/off switch, the direction (direct / through the VPN, changed in place keeping the id) and delete; rows say «single address», «network», the number of resolved addresses or matching apps, long values wrap; the Routes tab shows what the rules do («1 via VPN · 2 direct · 1 off») and the hero no longer counts switched-off rules; mockup F_Rules, F_RuleEdit, F_RuleDeleted, F_Routing and their dark pairs

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
DenozordecandClaude Fable 5.2 committed 2026-11-11 09:18:44 +07:00
1 parent 26b4edee66
commit 25754b5cc4
22 files changed
+755 -134

No files matched your search

@@ -509,6 +509,12 @@ class RoutingManager private constructor(private val context: Context)
} }
} }
/** Sends an existing site or address rule the other way, keeping its id and on/off state. */
fun setRuleDirect(id: String, direct: Boolean)
{
updateConfigNoReconnect { withRuleDirect(it, id, direct) }
}
/** /**
* Resolves the site rules again and pushes the result to the engine. * Resolves the site rules again and pushes the result to the engine.
* Called when a rule is added and when the tunnel comes up; repeats every * Called when a rule is added and when the tunnel comes up; repeats every
@@ -852,6 +858,21 @@ class RoutingManager private constructor(private val context: Context)
/** Config schema: 1 = rule sets, 2 = direct apps + categories + patterns. */ /** Config schema: 1 = rule sets, 2 = direct apps + categories + patterns. */
const val CONFIG_VERSION = 2 const val CONFIG_VERSION = 2
/** [config] with the site or address rule [id] routed [direct] (or through the VPN). */
fun withRuleDirect(config: RoutingConfig, id: String, direct: Boolean): RoutingConfig
{
val (from, to) = if (direct) config.forceTunnelPrefixes to config.directPrefixes else config.directPrefixes to config.forceTunnelPrefixes
val moved = from.firstOrNull { it.id == id }
val newFrom = from.filter { it.id != id }
/* the same address may already be on the other side: the moved entry replaces it */
val newTo = if (moved == null) to else to.filter { it.prefix != moved.prefix } + moved
return config.copy(
siteRules = config.siteRules.map { if (it.id == id) it.copy(direct = direct) else it },
forceTunnelPrefixes = if (direct) newFrom else newTo,
directPrefixes = if (direct) newTo else newFrom,
)
}
/** Validates and canonicalizes a CIDR/address; null if invalid. */ /** Validates and canonicalizes a CIDR/address; null if invalid. */
fun normalizePrefix(cidr: String): String? = try fun normalizePrefix(cidr: String): String? = try
{ {
@@ -109,7 +109,9 @@ import org.strongswan.android.ui.compose.components.ShxSnackbarHost
import org.strongswan.android.ui.compose.routing.DeadHostsPage import org.strongswan.android.ui.compose.routing.DeadHostsPage
import org.strongswan.android.ui.compose.routing.DirectAppsScreen import org.strongswan.android.ui.compose.routing.DirectAppsScreen
import org.strongswan.android.ui.compose.routing.RecentAppsPage import org.strongswan.android.ui.compose.routing.RecentAppsPage
import org.strongswan.android.ui.compose.routing.RuleRow
import org.strongswan.android.ui.compose.routing.RulesPage import org.strongswan.android.ui.compose.routing.RulesPage
import org.strongswan.android.ui.compose.routing.buildRuleRows
import org.strongswan.android.ui.compose.routing.routingSummary import org.strongswan.android.ui.compose.routing.routingSummary
import org.strongswan.android.ui.compose.theme.LocalShxStatusColors import org.strongswan.android.ui.compose.theme.LocalShxStatusColors
import org.strongswan.android.ui.compose.theme.ShxSpacing import org.strongswan.android.ui.compose.theme.ShxSpacing
@@ -259,14 +261,14 @@ private fun RoutingPageContent(
snackbarHostState.showSnackbar(message) snackbarHostState.showSnackbar(message)
} }
} }
val ruleCount = config.siteRules.size + config.forceTunnelPrefixes.size + config.directPrefixes.size + config.packagePatterns.size val rules = remember(config) { buildRuleRows(config, emptyMap()) }
/* the hero says what bypasses the VPN: only the "direct" rules count there */ /* the hero says what bypasses the VPN: only the working "direct" rules count there */
val directRuleCount = config.siteRules.count { it.direct } + config.directPrefixes.size + config.packagePatterns.size val directRuleCount = rules.count { it.direct && it.enabled }
when (page) when (page)
{ {
RoutingPage.APPS -> DirectAppsScreen(onBack = onBack) RoutingPage.APPS -> DirectAppsScreen(onBack = onBack)
RoutingPage.RULES -> RulesPage(onBack = onBack) RoutingPage.RULES -> RulesPage(onBack = onBack, snackbarHostState = snackbarHostState)
RoutingPage.RECENT -> RecentAppsPage( RoutingPage.RECENT -> RecentAppsPage(
deadTargets = deadTargets, deadTargets = deadTargets,
onForceTunnel = { dst -> forceTunnel(manager, dst) }, onForceTunnel = { dst -> forceTunnel(manager, dst) },
@@ -282,7 +284,7 @@ private fun RoutingPageContent(
enabled = config.enabled, enabled = config.enabled,
ruState = ruState, ruState = ruState,
directPackages = directPackages, directPackages = directPackages,
ruleCount = ruleCount, rules = rules,
directRuleCount = directRuleCount, directRuleCount = directRuleCount,
deadCount = deadTargets.count { !it.viaTunnel }, deadCount = deadTargets.count { !it.viaTunnel },
split = split, split = split,
@@ -309,7 +311,7 @@ private fun MainPage(
enabled: Boolean, enabled: Boolean,
ruState: org.strongswan.android.logic.routing.RuIpListState, ruState: org.strongswan.android.logic.routing.RuIpListState,
directPackages: List<String>, directPackages: List<String>,
ruleCount: Int, rules: List<RuleRow>,
directRuleCount: Int, directRuleCount: Int,
deadCount: Int, deadCount: Int,
split: Pair<Long, Long>?, split: Pair<Long, Long>?,
@@ -350,7 +352,7 @@ private fun MainPage(
ShxSectionHeader(stringResource(R.string.routing_section_exceptions)) ShxSectionHeader(stringResource(R.string.routing_section_exceptions))
ExceptionsPanel( ExceptionsPanel(
directPackages = directPackages, directPackages = directPackages,
ruleCount = ruleCount, rules = rules,
onApps = onOpenApps, onApps = onOpenApps,
onRules = { onOpen(RoutingPage.RULES) }, onRules = { onOpen(RoutingPage.RULES) },
) )
@@ -174,7 +174,7 @@ internal fun RuSitesPanel(state: RuIpListState, onToggle: (Boolean) -> Unit, onM
/** «Exceptions» card: direct apps (with an icon stack) and rules (with a count). */ /** «Exceptions» card: direct apps (with an icon stack) and rules (with a count). */
@Composable @Composable
internal fun ExceptionsPanel(directPackages: List<String>, ruleCount: Int, onApps: () -> Unit, onRules: () -> Unit) internal fun ExceptionsPanel(directPackages: List<String>, rules: List<RuleRow>, onApps: () -> Unit, onRules: () -> Unit)
{ {
val c = LocalShxColors.current val c = LocalShxColors.current
ShxPanel() ShxPanel()
@@ -235,17 +235,30 @@ internal fun ExceptionsPanel(directPackages: List<String>, ruleCount: Int, onApp
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{ {
Text(stringResource(R.string.rules_title), style = ShxType.rowTitle, color = c.text) Text(stringResource(R.string.rules_title), style = ShxType.rowTitle, color = c.text)
Text(stringResource(R.string.routing_rules_sub), style = ShxType.sub, color = c.textSub) Text(rulesBreakdown(rules), style = ShxType.sub, color = c.textSub)
} }
if (ruleCount > 0) if (rules.isNotEmpty())
{ {
Text(ruleCount.toString(), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.textSub) Text(rules.size.toString(), style = ShxType.body.copy(fontWeight = FontWeight.Bold), color = c.textSub)
} }
Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp)) Icon(ShxIcons.Chevron, contentDescription = null, tint = c.textSub, modifier = Modifier.size(18.dp))
} }
} }
} }
/** "2 via VPN · 3 direct · 1 off": what the user rules do right now. */
@Composable
private fun rulesBreakdown(rules: List<RuleRow>): String
{
if (rules.isEmpty())
{
return stringResource(R.string.routing_rules_sub)
}
val on = stringResource(R.string.routing_rules_breakdown, rules.count { it.enabled && !it.direct }, rules.count { it.enabled && it.direct })
val off = rules.count { !it.enabled }
return if (off > 0) stringResource(R.string.routing_rules_breakdown_off, on, off) else on
}
/** One app of the «Recent apps» preview. */ /** One app of the «Recent apps» preview. */
data class RecentPreview(val packageName: String, val label: String, val direct: Boolean, val failing: Boolean) data class RecentPreview(val packageName: String, val label: String, val direct: Boolean, val failing: Boolean)
@@ -17,7 +17,22 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.animation.core.animateDpAsState
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.SnackbarDuration
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.produceState
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.rememberUpdatedState
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import org.strongswan.android.logic.routing.PackagePatterns
import org.strongswan.android.ui.compose.design.ShxTag
import org.strongswan.android.ui.compose.design.ShxToggleRow
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.navigationBarsPadding import androidx.compose.foundation.layout.navigationBarsPadding
@@ -105,18 +120,54 @@ fun buildRuleRows(config: RoutingConfig, resolved: Map<String, List<String>>): L
/** «Rules» (F_Rules / F_RulesEmpty): sites, addresses and package templates that override the RU base. */ /** «Rules» (F_Rules / F_RulesEmpty): sites, addresses and package templates that override the RU base. */
@Composable @Composable
internal fun RulesPage(onBack: () -> Unit) internal fun RulesPage(onBack: () -> Unit, snackbarHostState: SnackbarHostState)
{ {
val context = LocalContext.current val context = LocalContext.current
val c = LocalShxColors.current val c = LocalShxColors.current
val manager = remember { RoutingManager.getInstance(context) } val manager = remember { RoutingManager.getInstance(context) }
val scope = rememberCoroutineScope()
val state by manager.state.collectAsStateWithLifecycle() val state by manager.state.collectAsStateWithLifecycle()
val resolved by manager.resolvedSites.collectAsStateWithLifecycle() val resolved by manager.resolvedSites.collectAsStateWithLifecycle()
val rows = remember(state.config, resolved) { buildRuleRows(state.config, resolved) } /* a deleted rule only disappears from the list at first; it is really removed when the
* «Undo» snackbar goes away or the page is left, so undoing a package pattern does not
* reconnect the tunnel twice */
var pending by remember { mutableStateOf<RuleRow?>(null) }
val pendingNow = rememberUpdatedState(pending)
DisposableEffect(Unit) { onDispose { pendingNow.value?.let { delete(manager, it) } } }
val rows = remember(state.config, resolved, pending) {
buildRuleRows(state.config, resolved).filterNot { it.sameRule(pending) }
}
val installed by produceState(emptySet<String>()) {
value = withContext(Dispatchers.IO) { RoutingManager.loadInternetPackages(context.packageManager) }
}
var filter by rememberSaveable { mutableStateOf<RuleKind?>(null) } var filter by rememberSaveable { mutableStateOf<RuleKind?>(null) }
var adding by remember { mutableStateOf(false) } var adding by remember { mutableStateOf(false) }
var actionsFor by remember { mutableStateOf<RuleRow?>(null) } var actionsFor by remember { mutableStateOf<RuleRow?>(null) }
val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) } val rulesActive = remember { CharonVpnService.isUserspaceRoutingEnabled(context) }
val fabLift by animateDpAsState(if (snackbarHostState.currentSnackbarData != null) 96.dp else 24.dp, label = "fabLift")
fun deleteWithUndo(row: RuleRow)
{
pending?.let { delete(manager, it) }
pending = row
scope.launch {
snackbarHostState.currentSnackbarData?.dismiss()
val result = snackbarHostState.showSnackbar(
message = context.getString(R.string.rules_deleted, row.value),
actionLabel = context.getString(R.string.action_undo),
duration = SnackbarDuration.Long,
)
/* a newer deletion has already committed this one */
if (row.sameRule(pending))
{
if (result != SnackbarResult.ActionPerformed)
{
delete(manager, row)
}
pending = null
}
}
}
Box(Modifier.fillMaxSize().background(c.background)) Box(Modifier.fillMaxSize().background(c.background))
{ {
@@ -143,7 +194,11 @@ internal fun RulesPage(onBack: () -> Unit)
{ {
ShxChip(stringResource(R.string.rules_filter_all, rows.size), filter == null, { filter = null }) ShxChip(stringResource(R.string.rules_filter_all, rows.size), filter == null, { filter = null })
RuleKind.entries.forEach { kind -> RuleKind.entries.forEach { kind ->
ShxChip(stringResource(kind.pluralTitleRes()), filter == kind, { filter = kind }) val count = rows.count { it.kind == kind }
if (count > 0 || filter == kind)
{
ShxChip(stringResource(kind.pluralTitleRes()) + " · " + count, filter == kind, { filter = kind })
}
} }
} }
val shown = rows.filter { filter == null || it.kind == filter } val shown = rows.filter { filter == null || it.kind == filter }
@@ -165,7 +220,13 @@ internal fun RulesPage(onBack: () -> Unit)
ShxPanel() ShxPanel()
{ {
group.forEachIndexed { i, row -> group.forEachIndexed { i, row ->
RuleRowItem(row, onToggle = { toggle(manager, row, it) }, onClick = { actionsFor = row }) RuleRowItem(
row,
apps = if (row.kind == RuleKind.PACKAGE && installed.isNotEmpty()) installed.count { PackagePatterns.matches(row.value, it) } else -1,
onToggle = { toggle(manager, row, it) },
onClick = { actionsFor = row },
onDelete = { deleteWithUndo(row) },
)
if (i < group.lastIndex) if (i < group.lastIndex)
{ {
Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider)) Box(Modifier.fillMaxWidth().height(1.dp).background(c.divider))
@@ -180,33 +241,94 @@ internal fun RulesPage(onBack: () -> Unit)
} }
if (rows.isNotEmpty()) if (rows.isNotEmpty())
{ {
AddRuleFab(Modifier.align(Alignment.BottomEnd).navigationBarsPadding().padding(end = 16.dp, bottom = 24.dp)) { adding = true } AddRuleFab(Modifier.align(Alignment.BottomEnd).navigationBarsPadding().padding(end = 16.dp, bottom = fabLift)) { adding = true }
} }
} }
if (adding) if (adding)
{ {
RuleAddSheet(manager = manager, rulesActive = rulesActive, onDismiss = { adding = false }) RuleAddSheet(manager = manager, rulesActive = rulesActive, onDismiss = { adding = false })
} }
actionsFor?.let { row -> /* the sheet follows the live rule: its switch and direction change it in place */
ShxSheet(onDismiss = { actionsFor = null }) actionsFor?.let { opened -> rows.firstOrNull { it.sameRule(opened) } }?.let { row ->
RuleEditSheet(
row = row,
addresses = state.config.siteRules.firstOrNull { it.id == row.id }?.let { resolved[it.domain] }.orEmpty(),
onToggle = { toggle(manager, row, it) },
onDirect = { manager.setRuleDirect(row.id, it) },
onDelete = {
actionsFor = null
deleteWithUndo(row)
},
onDismiss = { actionsFor = null },
)
}
}
/** Same rule, whatever its on/off state and direction are by now. */
private fun RuleRow.sameRule(other: RuleRow?) = other != null && kind == other.kind && id == other.id
/** «Rule» sheet (F_RuleEdit): on/off, direction, delete. */
@Composable
private fun RuleEditSheet(
row: RuleRow,
addresses: List<String>,
onToggle: (Boolean) -> Unit,
onDirect: (Boolean) -> Unit,
onDelete: () -> Unit,
onDismiss: () -> Unit,
)
{
val c = LocalShxColors.current
val (tagBg, tagFg) = row.kind.tagColors()
ShxSheet(onDismiss = onDismiss)
{
Column(verticalArrangement = Arrangement.spacedBy(6.dp), modifier = Modifier.padding(start = 4.dp, end = 4.dp, top = 4.dp))
{ {
ShxSheetTitle(row.value) ShxTag(stringResource(row.kind.tagRes()).uppercase(), tagBg, tagFg)
Text( Text(row.value, style = ShxType.mono.copy(fontSize = 22.sp, fontWeight = FontWeight.ExtraBold), color = c.text)
stringResource(if (row.direct) R.string.rules_to_direct else R.string.rules_to_vpn) + " · " + stringResource(row.kind.tagRes()), }
style = ShxType.note.copy(fontSize = 14.sp), ShxPanel()
color = c.textSub, {
modifier = Modifier.padding(horizontal = 4.dp), ShxToggleRow(stringResource(R.string.rules_enabled), row.enabled, onToggle, subtitle = stringResource(R.string.rules_enabled_sub))
) }
ShxButton( if (row.kind != RuleKind.PACKAGE)
stringResource(R.string.rules_delete), {
{ Text(stringResource(R.string.rules_where), style = ShxType.sub.copy(fontWeight = FontWeight.Bold), color = c.textSoft, modifier = Modifier.padding(horizontal = 4.dp))
delete(manager, row) Row(horizontalArrangement = Arrangement.spacedBy(8.dp))
actionsFor = null {
}, ShxChoiceCard(
style = ShxButtonStyle.DANGER, title = stringResource(R.string.rules_to_direct),
modifier = Modifier.fillMaxWidth(), subtitle = stringResource(R.string.rules_direct_short),
icon = ShxIcons.Trash, selected = row.direct,
) onClick = { onDirect(true) },
tone = ShxChoiceTone.SUCCESS,
modifier = Modifier.weight(1f),
)
ShxChoiceCard(
title = stringResource(R.string.rules_to_vpn),
subtitle = stringResource(if (row.kind == RuleKind.SITE) R.string.rules_vpn_short_site else R.string.rules_vpn_short),
selected = !row.direct,
onClick = { onDirect(false) },
modifier = Modifier.weight(1f),
)
}
}
ShxNote(
when
{
row.kind == RuleKind.PACKAGE -> stringResource(R.string.rules_package_note)
row.kind == RuleKind.ADDRESS -> stringResource(if (row.direct) R.string.rules_direct_note else R.string.rules_vpn_note)
addresses.isEmpty() -> stringResource(R.string.rules_site_note)
else -> stringResource(
R.string.rules_site_now,
addresses.take(4).joinToString(", ") { it.substringBefore('/') } + if (addresses.size > 4) ", …" else "",
)
}
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp))
{
ShxButton(stringResource(R.string.routing_force_delete), onDelete, style = ShxButtonStyle.DANGER, modifier = Modifier.weight(1f), icon = ShxIcons.Trash)
ShxButton(stringResource(R.string.action_done), onDismiss, modifier = Modifier.weight(1f))
} }
} }
} }
@@ -246,53 +368,76 @@ private fun RuleKind.pluralTitleRes(): Int = when (this)
RuleKind.PACKAGE -> R.string.rules_filter_packages RuleKind.PACKAGE -> R.string.rules_filter_packages
} }
/** Row of the mockup: tag, monospace value with a sub line, switch; 64dp. */
@Composable @Composable
private fun RuleRowItem(row: RuleRow, onToggle: (Boolean) -> Unit, onClick: () -> Unit) private fun RuleKind.tagColors(): Pair<Color, Color>
{ {
val c = LocalShxColors.current val c = LocalShxColors.current
val (tagBg, tagFg) = when (row.kind) return when (this)
{ {
RuleKind.SITE -> c.infoContainer to c.onInfo RuleKind.SITE -> c.infoContainer to c.onInfo
RuleKind.ADDRESS -> c.tile to c.textSoft RuleKind.ADDRESS -> c.tile to c.textSoft
RuleKind.PACKAGE -> c.successContainer to c.onSuccess RuleKind.PACKAGE -> c.successContainer to c.onSuccess
} }
Row( }
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), /** A single host ("1.2.3.4", "1.2.3.4/32", "::1/128") rather than a network. */
modifier = Modifier.fillMaxWidth().height(64.dp).clickable(role = Role.Button, onClick = onClick).padding(horizontal = 14.dp), internal fun isSingleAddress(prefix: String): Boolean =
) prefix.substringAfter('/', "") in setOf("", if (prefix.contains(':')) "128" else "32")
/** Row of the mockup: tag, monospace value with a sub line (opens the rule), switch, delete; 64dp. */
@Composable
private fun RuleRowItem(row: RuleRow, apps: Int, onToggle: (Boolean) -> Unit, onClick: () -> Unit, onDelete: () -> Unit)
{
val c = LocalShxColors.current
val (tagBg, tagFg) = row.kind.tagColors()
val what = when (row.kind)
{ {
Text( RuleKind.SITE -> if (row.addresses >= 0) pluralStringResource(R.plurals.rules_site_addresses, row.addresses, row.addresses)
stringResource(row.kind.tagRes()).uppercase(), else stringResource(R.string.rules_site_unresolved)
style = ShxType.tag.copy(fontSize = 10.sp), RuleKind.ADDRESS -> stringResource(if (isSingleAddress(row.value)) R.string.rules_address_one else R.string.rules_address_net)
color = tagFg, RuleKind.PACKAGE -> if (apps >= 0) pluralStringResource(R.plurals.routing_apps_n, apps, apps) else null
modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(tagBg).padding(horizontal = 7.dp, vertical = 3.dp), }
Row(verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth().padding(end = 6.dp))
{
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
modifier = Modifier.weight(1f).heightIn(min = 64.dp).clickable(role = Role.Button, onClick = onClick).padding(start = 14.dp, end = 10.dp, top = 8.dp, bottom = 8.dp),
) )
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{ {
Text( Text(
row.value, stringResource(row.kind.tagRes()).uppercase(),
style = ShxType.mono.copy(fontWeight = FontWeight.Bold), style = ShxType.tag.copy(fontSize = 10.sp),
color = if (row.enabled) c.text else c.textSub, color = tagFg,
maxLines = 1, modifier = Modifier.clip(RoundedCornerShape(8.dp)).background(tagBg).padding(horizontal = 7.dp, vertical = 3.dp),
overflow = TextOverflow.Ellipsis,
)
Text(
when (row.kind)
{
RuleKind.SITE -> if (row.addresses >= 0) pluralStringResource(R.plurals.rules_site_addresses, row.addresses, row.addresses)
else stringResource(R.string.rules_site_unresolved)
RuleKind.ADDRESS -> stringResource(if (row.direct) R.string.rules_address_direct else R.string.rules_address_vpn)
RuleKind.PACKAGE -> stringResource(R.string.rules_package_sub)
},
style = ShxType.meta.copy(fontWeight = FontWeight.Normal),
color = c.textSub,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
) )
Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp))
{
/* a long address wraps on a narrow screen instead of being cut; "/32" says nothing next to «single address» */
Text(
if (row.kind == RuleKind.ADDRESS && isSingleAddress(row.value)) row.value.substringBefore('/') else row.value,
style = ShxType.mono.copy(fontWeight = FontWeight.Bold),
color = if (row.enabled) c.text else c.textSub,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
Text(
listOfNotNull(what, if (row.enabled) null else stringResource(R.string.rules_off)).joinToString(" · "),
style = ShxType.meta.copy(fontWeight = FontWeight.Normal),
color = c.textSub,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
} }
ShxSwitch(checked = row.enabled, onCheckedChange = onToggle) ShxSwitch(checked = row.enabled, onCheckedChange = onToggle)
Box(
contentAlignment = Alignment.Center,
modifier = Modifier.padding(start = 4.dp).size(44.dp).clip(CircleShape).clickable(role = Role.Button, onClick = onDelete),
)
{
Icon(ShxIcons.Trash, contentDescription = stringResource(R.string.rules_delete), tint = c.textSub, modifier = Modifier.size(20.dp))
}
} }
} }
@@ -638,7 +638,7 @@
<string name="route_reason_app_default">Nicht bei den direkten Apps: über das VPN.</string> <string name="route_reason_app_default">Nicht bei den direkten Apps: über das VPN.</string>
<string name="route_rules_need_flexible">Regeln für Websites und Adressen wirken, wenn „Russische Websites direkt“ im Modus „Flexibel“ an ist.</string> <string name="route_rules_need_flexible">Regeln für Websites und Adressen wirken, wenn „Russische Websites direkt“ im Modus „Flexibel“ an ist.</string>
<string name="rules_title">Regeln</string> <string name="rules_title">Regeln</string>
<string name="rules_hint">Wohin Websites, Adressen und Apps gehen. Eine Regel hat Vorrang vor der russischen Datenbank.</string> <string name="rules_hint">Wohin Websites, Adressen und Apps gehen. Eine Regel hat Vorrang vor der russischen Datenbank. Tippen Sie auf eine Regel, um sie zu ändern.</string>
<string name="rules_filter_all">Alle · %1$d</string> <string name="rules_filter_all">Alle · %1$d</string>
<string name="rules_filter_sites">Websites</string> <string name="rules_filter_sites">Websites</string>
<string name="rules_filter_addresses">Adressen</string> <string name="rules_filter_addresses">Adressen</string>
@@ -650,10 +650,14 @@
<string name="rules_tag_address">Adresse</string> <string name="rules_tag_address">Adresse</string>
<string name="rules_tag_package">Paket</string> <string name="rules_tag_package">Paket</string>
<string name="rules_site_unresolved">Adressen noch nicht aufgelöst</string> <string name="rules_site_unresolved">Adressen noch nicht aufgelöst</string>
<string name="rules_address_direct">immer direkt</string> <string name="rules_address_one">eine Adresse</string>
<string name="rules_address_vpn">immer über das VPN, auch in der russischen Datenbank</string> <string name="rules_address_net">Netz</string>
<string name="rules_package_sub">Apps mit passendem Paket gehen direkt</string> <string name="rules_off">aus</string>
<string name="rules_delete">Regel löschen</string> <string name="rules_delete">Regel löschen</string>
<string name="rules_deleted">Regel %1$s gelöscht</string>
<string name="rules_enabled">Regel aktiv</string>
<string name="rules_enabled_sub">Eine deaktivierte Regel bleibt in der Liste</string>
<string name="rules_site_now">Aktuelle Adressen der Website: %1$s. Sie werden beim Verbinden und alle 30 Minuten aktualisiert.</string>
<string name="rules_empty_title">Noch keine Regeln, und das ist in Ordnung</string> <string name="rules_empty_title">Noch keine Regeln, und das ist in Ordnung</string>
<string name="rules_empty_text">Russische Websites und die gewählten Apps gehen schon direkt. Eine Regel braucht es nur, wenn eine Website den falschen Weg nimmt.</string> <string name="rules_empty_text">Russische Websites und die gewählten Apps gehen schon direkt. Eine Regel braucht es nur, wenn eine Website den falschen Weg nimmt.</string>
<string name="rules_ideas">Oft hinzugefügt</string> <string name="rules_ideas">Oft hinzugefügt</string>
@@ -697,6 +701,8 @@
<string name="recent_access_later">Nicht jetzt</string> <string name="recent_access_later">Nicht jetzt</string>
<string name="routing_section_exceptions">Ausnahmen</string> <string name="routing_section_exceptions">Ausnahmen</string>
<string name="routing_rules_sub">Websites, Adressen und Paketvorlagen</string> <string name="routing_rules_sub">Websites, Adressen und Paketvorlagen</string>
<string name="routing_rules_breakdown">%1$d über VPN · %2$d direkt</string>
<string name="routing_rules_breakdown_off">%1$s · %2$d aus</string>
<string name="routing_section_check">Prüfung</string> <string name="routing_section_check">Prüfung</string>
<string name="routing_check_row">Wie eine Website, IP oder App läuft</string> <string name="routing_check_row">Wie eine Website, IP oder App läuft</string>
<string name="routing_recent_sub">Wer das Netz genutzt hat und auf welchem Weg</string> <string name="routing_recent_sub">Wer das Netz genutzt hat und auf welchem Weg</string>
@@ -528,7 +528,7 @@
<string name="route_reason_app_default">Nie ma wśród aplikacji bezpośrednich: przez VPN.</string> <string name="route_reason_app_default">Nie ma wśród aplikacji bezpośrednich: przez VPN.</string>
<string name="route_rules_need_flexible">Reguły dla stron i adresów działają, gdy „Rosyjskie strony bezpośrednio” są włączone w trybie „Elastyczny”.</string> <string name="route_rules_need_flexible">Reguły dla stron i adresów działają, gdy „Rosyjskie strony bezpośrednio” są włączone w trybie „Elastyczny”.</string>
<string name="rules_title">Reguły</string> <string name="rules_title">Reguły</string>
<string name="rules_hint">Dokąd idą strony, adresy i aplikacje. Reguła ma pierwszeństwo przed bazą rosyjską.</string> <string name="rules_hint">Dokąd idą strony, adresy i aplikacje. Reguła ma pierwszeństwo przed bazą rosyjską. Dotknij reguły, aby ją zmienić.</string>
<string name="rules_filter_all">Wszystkie · %1$d</string> <string name="rules_filter_all">Wszystkie · %1$d</string>
<string name="rules_filter_sites">Strony</string> <string name="rules_filter_sites">Strony</string>
<string name="rules_filter_addresses">Adresy</string> <string name="rules_filter_addresses">Adresy</string>
@@ -540,10 +540,14 @@
<string name="rules_tag_address">Adres</string> <string name="rules_tag_address">Adres</string>
<string name="rules_tag_package">Pakiet</string> <string name="rules_tag_package">Pakiet</string>
<string name="rules_site_unresolved">adresy jeszcze nieustalone</string> <string name="rules_site_unresolved">adresy jeszcze nieustalone</string>
<string name="rules_address_direct">zawsze bezpośrednio</string> <string name="rules_address_one">jeden adres</string>
<string name="rules_address_vpn">zawsze przez VPN, nawet jeśli w bazie rosyjskiej</string> <string name="rules_address_net">sieć</string>
<string name="rules_package_sub">aplikacje z takim pakietem — bezpośrednio</string> <string name="rules_off">wyłączona</string>
<string name="rules_delete">Usuń regułę</string> <string name="rules_delete">Usuń regułę</string>
<string name="rules_deleted">Reguła %1$s usunięta</string>
<string name="rules_enabled">Reguła włączona</string>
<string name="rules_enabled_sub">Wyłączona reguła zostaje na liście</string>
<string name="rules_site_now">Adresy strony teraz: %1$s. Odświeżane przy połączeniu i co 30 minut.</string>
<string name="rules_empty_title">Jeszcze brak reguł — i to w porządku</string> <string name="rules_empty_title">Jeszcze brak reguł — i to w porządku</string>
<string name="rules_empty_text">Rosyjskie strony i wybrane aplikacje już idą bezpośrednio. Reguła jest potrzebna tylko, gdy jakaś strona idzie złą drogą.</string> <string name="rules_empty_text">Rosyjskie strony i wybrane aplikacje już idą bezpośrednio. Reguła jest potrzebna tylko, gdy jakaś strona idzie złą drogą.</string>
<string name="rules_ideas">Często dodawane</string> <string name="rules_ideas">Często dodawane</string>
@@ -587,6 +591,8 @@
<string name="recent_access_later">Nie teraz</string> <string name="recent_access_later">Nie teraz</string>
<string name="routing_section_exceptions">Wyjątki</string> <string name="routing_section_exceptions">Wyjątki</string>
<string name="routing_rules_sub">Strony, adresy i szablony pakietów</string> <string name="routing_rules_sub">Strony, adresy i szablony pakietów</string>
<string name="routing_rules_breakdown">%1$d przez VPN · %2$d bezpośrednio</string>
<string name="routing_rules_breakdown_off">%1$s · %2$d wyłączone</string>
<string name="routing_section_check">Sprawdzanie</string> <string name="routing_section_check">Sprawdzanie</string>
<string name="routing_check_row">Jak idzie strona, IP lub aplikacja</string> <string name="routing_check_row">Jak idzie strona, IP lub aplikacja</string>
<string name="routing_recent_sub">Kto korzystał z sieci i jaką drogą</string> <string name="routing_recent_sub">Kto korzystał z sieci i jaką drogą</string>
@@ -658,7 +658,7 @@
<string name="route_rules_need_flexible">Правила для сайтов и адресов работают, когда «Сайты РФ напрямую» включены в режиме «Гибкий».</string> <string name="route_rules_need_flexible">Правила для сайтов и адресов работают, когда «Сайты РФ напрямую» включены в режиме «Гибкий».</string>
<!-- Routing: rules, recent apps, sections --> <!-- Routing: rules, recent apps, sections -->
<string name="rules_title">Правила</string> <string name="rules_title">Правила</string>
<string name="rules_hint">Куда идут сайты, адреса и приложения. Правило важнее базы РФ.</string> <string name="rules_hint">Куда идут сайты, адреса и приложения. Правило важнее базы РФ. Нажмите на правило, чтобы изменить его.</string>
<string name="rules_filter_all">Все · %1$d</string> <string name="rules_filter_all">Все · %1$d</string>
<string name="rules_filter_sites">Сайты</string> <string name="rules_filter_sites">Сайты</string>
<string name="rules_filter_addresses">Адреса</string> <string name="rules_filter_addresses">Адреса</string>
@@ -670,10 +670,14 @@
<string name="rules_tag_address">Адрес</string> <string name="rules_tag_address">Адрес</string>
<string name="rules_tag_package">Пакет</string> <string name="rules_tag_package">Пакет</string>
<string name="rules_site_unresolved">адреса ещё не определены</string> <string name="rules_site_unresolved">адреса ещё не определены</string>
<string name="rules_address_direct">всегда напрямую</string> <string name="rules_address_one">один адрес</string>
<string name="rules_address_vpn">всегда через VPN, даже если в базе РФ</string> <string name="rules_address_net">сеть</string>
<string name="rules_package_sub">приложения с таким пакетом — напрямую</string> <string name="rules_off">выключено</string>
<string name="rules_delete">Удалить правило</string> <string name="rules_delete">Удалить правило</string>
<string name="rules_deleted">Правило %1$s удалено</string>
<string name="rules_enabled">Правило включено</string>
<string name="rules_enabled_sub">Выключенное остаётся в списке</string>
<string name="rules_site_now">Адреса сайта сейчас: %1$s. Обновляются при подключении и раз в 30 минут.</string>
<string name="rules_empty_title">Правил пока нет — и это нормально</string> <string name="rules_empty_title">Правил пока нет — и это нормально</string>
<string name="rules_empty_text">Сайты РФ и выбранные приложения уже идут напрямую. Правило нужно, только если какой-то сайт идёт не тем путём.</string> <string name="rules_empty_text">Сайты РФ и выбранные приложения уже идут напрямую. Правило нужно, только если какой-то сайт идёт не тем путём.</string>
<string name="rules_ideas">Часто добавляют</string> <string name="rules_ideas">Часто добавляют</string>
@@ -717,6 +721,8 @@
<string name="recent_access_later">Не сейчас</string> <string name="recent_access_later">Не сейчас</string>
<string name="routing_section_exceptions">Исключения</string> <string name="routing_section_exceptions">Исключения</string>
<string name="routing_rules_sub">Сайты, адреса и шаблоны пакетов</string> <string name="routing_rules_sub">Сайты, адреса и шаблоны пакетов</string>
<string name="routing_rules_breakdown">%1$d через VPN · %2$d напрямую</string>
<string name="routing_rules_breakdown_off">%1$s · %2$d выключено</string>
<string name="routing_section_check">Проверка</string> <string name="routing_section_check">Проверка</string>
<string name="routing_check_row">Как ходит сайт, IP или приложение</string> <string name="routing_check_row">Как ходит сайт, IP или приложение</string>
<string name="routing_check_row_sub">Через VPN или напрямую — и почему</string> <string name="routing_check_row_sub">Через VPN или напрямую — и почему</string>
@@ -538,7 +538,7 @@
<string name="route_reason_app_default">Не в списку «напряму» — через VPN.</string> <string name="route_reason_app_default">Не в списку «напряму» — через VPN.</string>
<string name="route_rules_need_flexible">Правила для сайтів і адрес працюють, коли «Сайти РФ напряму» увімкнено в режимі «Гнучкий».</string> <string name="route_rules_need_flexible">Правила для сайтів і адрес працюють, коли «Сайти РФ напряму» увімкнено в режимі «Гнучкий».</string>
<string name="rules_title">Правила</string> <string name="rules_title">Правила</string>
<string name="rules_hint">Куди йдуть сайти, адреси й застосунки. Правило важливіше за базу РФ.</string> <string name="rules_hint">Куди йдуть сайти, адреси й застосунки. Правило важливіше за базу РФ. Натисніть на правило, щоб змінити його.</string>
<string name="rules_filter_all">Усі · %1$d</string> <string name="rules_filter_all">Усі · %1$d</string>
<string name="rules_filter_sites">Сайти</string> <string name="rules_filter_sites">Сайти</string>
<string name="rules_filter_addresses">Адреси</string> <string name="rules_filter_addresses">Адреси</string>
@@ -550,10 +550,14 @@
<string name="rules_tag_address">Адреса</string> <string name="rules_tag_address">Адреса</string>
<string name="rules_tag_package">Пакет</string> <string name="rules_tag_package">Пакет</string>
<string name="rules_site_unresolved">адреси ще не визначено</string> <string name="rules_site_unresolved">адреси ще не визначено</string>
<string name="rules_address_direct">завжди напряму</string> <string name="rules_address_one">одна адреса</string>
<string name="rules_address_vpn">завжди через VPN, навіть якщо в базі РФ</string> <string name="rules_address_net">мережа</string>
<string name="rules_package_sub">застосунки з таким пакетом — напряму</string> <string name="rules_off">вимкнено</string>
<string name="rules_delete">Видалити правило</string> <string name="rules_delete">Видалити правило</string>
<string name="rules_deleted">Правило %1$s видалено</string>
<string name="rules_enabled">Правило ввімкнено</string>
<string name="rules_enabled_sub">Вимкнене залишається у списку</string>
<string name="rules_site_now">Адреси сайту зараз: %1$s. Оновлюються під час підключення та кожні 30 хвилин.</string>
<string name="rules_empty_title">Правил поки немає — і це нормально</string> <string name="rules_empty_title">Правил поки немає — і це нормально</string>
<string name="rules_empty_text">Сайти РФ і вибрані застосунки вже йдуть напряму. Правило потрібне, лише якщо якийсь сайт іде не тим шляхом.</string> <string name="rules_empty_text">Сайти РФ і вибрані застосунки вже йдуть напряму. Правило потрібне, лише якщо якийсь сайт іде не тим шляхом.</string>
<string name="rules_ideas">Часто додають</string> <string name="rules_ideas">Часто додають</string>
@@ -597,6 +601,8 @@
<string name="recent_access_later">Не зараз</string> <string name="recent_access_later">Не зараз</string>
<string name="routing_section_exceptions">Винятки</string> <string name="routing_section_exceptions">Винятки</string>
<string name="routing_rules_sub">Сайти, адреси та шаблони пакетів</string> <string name="routing_rules_sub">Сайти, адреси та шаблони пакетів</string>
<string name="routing_rules_breakdown">%1$d через VPN · %2$d напряму</string>
<string name="routing_rules_breakdown_off">%1$s · %2$d вимкнено</string>
<string name="routing_section_check">Перевірка</string> <string name="routing_section_check">Перевірка</string>
<string name="routing_check_row">Як іде сайт, IP або застосунок</string> <string name="routing_check_row">Як іде сайт, IP або застосунок</string>
<string name="routing_recent_sub">Хто виходив у мережу і яким шляхом</string> <string name="routing_recent_sub">Хто виходив у мережу і яким шляхом</string>
@@ -537,7 +537,7 @@
<string name="route_reason_app_default">不在直连应用中:经 VPN。</string> <string name="route_reason_app_default">不在直连应用中:经 VPN。</string>
<string name="route_rules_need_flexible">网站和地址规则仅在“灵活”模式下开启“俄罗斯网站直连”时生效。</string> <string name="route_rules_need_flexible">网站和地址规则仅在“灵活”模式下开启“俄罗斯网站直连”时生效。</string>
<string name="rules_title">规则</string> <string name="rules_title">规则</string>
<string name="rules_hint">网站、地址和应用的去向。规则优先于俄罗斯地址库。</string> <string name="rules_hint">网站、地址和应用的去向。规则优先于俄罗斯地址库。点按规则即可修改。</string>
<string name="rules_filter_all">全部 · %1$d</string> <string name="rules_filter_all">全部 · %1$d</string>
<string name="rules_filter_sites">网站</string> <string name="rules_filter_sites">网站</string>
<string name="rules_filter_addresses">地址</string> <string name="rules_filter_addresses">地址</string>
@@ -549,10 +549,14 @@
<string name="rules_tag_address">地址</string> <string name="rules_tag_address">地址</string>
<string name="rules_tag_package">包名</string> <string name="rules_tag_package">包名</string>
<string name="rules_site_unresolved">地址尚未解析</string> <string name="rules_site_unresolved">地址尚未解析</string>
<string name="rules_address_direct">始终直连</string> <string name="rules_address_one">单个地址</string>
<string name="rules_address_vpn">始终经 VPN,即使在俄罗斯地址库中</string> <string name="rules_address_net">网段</string>
<string name="rules_package_sub">包名匹配的应用直连</string> <string name="rules_off">已关闭</string>
<string name="rules_delete">删除规则</string> <string name="rules_delete">删除规则</string>
<string name="rules_deleted">规则 %1$s 已删除</string>
<string name="rules_enabled">规则已启用</string>
<string name="rules_enabled_sub">关闭的规则仍保留在列表中</string>
<string name="rules_site_now">网站当前地址:%1$s。连接时及每 30 分钟刷新一次。</string>
<string name="rules_empty_title">暂无规则,这很正常</string> <string name="rules_empty_title">暂无规则,这很正常</string>
<string name="rules_empty_text">俄罗斯网站和所选应用已经直连。只有某个网站走错路径时才需要规则。</string> <string name="rules_empty_text">俄罗斯网站和所选应用已经直连。只有某个网站走错路径时才需要规则。</string>
<string name="rules_ideas">常用</string> <string name="rules_ideas">常用</string>
@@ -596,6 +600,8 @@
<string name="recent_access_later">暂不</string> <string name="recent_access_later">暂不</string>
<string name="routing_section_exceptions">例外</string> <string name="routing_section_exceptions">例外</string>
<string name="routing_rules_sub">网站、地址和包名模板</string> <string name="routing_rules_sub">网站、地址和包名模板</string>
<string name="routing_rules_breakdown">%1$d 条经 VPN · %2$d 条直连</string>
<string name="routing_rules_breakdown_off">%1$s · %2$d 条已关闭</string>
<string name="routing_section_check">检查</string> <string name="routing_section_check">检查</string>
<string name="routing_check_row">网站、IP 或应用的走向</string> <string name="routing_check_row">网站、IP 或应用的走向</string>
<string name="routing_recent_sub">谁联网过以及走哪条路径</string> <string name="routing_recent_sub">谁联网过以及走哪条路径</string>
@@ -522,7 +522,7 @@
<string name="route_reason_app_default">不在直連應用程式中:經 VPN。</string> <string name="route_reason_app_default">不在直連應用程式中:經 VPN。</string>
<string name="route_rules_need_flexible">網站與位址規則僅在「靈活」模式下開啟「俄羅斯網站直連」時生效。</string> <string name="route_rules_need_flexible">網站與位址規則僅在「靈活」模式下開啟「俄羅斯網站直連」時生效。</string>
<string name="rules_title">規則</string> <string name="rules_title">規則</string>
<string name="rules_hint">網站、位址和應用程式的去向。規則優先於俄羅斯位址庫。</string> <string name="rules_hint">網站、位址和應用程式的去向。規則優先於俄羅斯位址庫。輕觸規則即可修改。</string>
<string name="rules_filter_all">全部 · %1$d</string> <string name="rules_filter_all">全部 · %1$d</string>
<string name="rules_filter_sites">網站</string> <string name="rules_filter_sites">網站</string>
<string name="rules_filter_addresses">位址</string> <string name="rules_filter_addresses">位址</string>
@@ -534,10 +534,14 @@
<string name="rules_tag_address">位址</string> <string name="rules_tag_address">位址</string>
<string name="rules_tag_package">套件</string> <string name="rules_tag_package">套件</string>
<string name="rules_site_unresolved">位址尚未解析</string> <string name="rules_site_unresolved">位址尚未解析</string>
<string name="rules_address_direct">一律直連</string> <string name="rules_address_one">單一位址</string>
<string name="rules_address_vpn">一律經 VPN,即使在俄羅斯位址庫中</string> <string name="rules_address_net">網段</string>
<string name="rules_package_sub">套件名稱相符的應用程式直連</string> <string name="rules_off">已關閉</string>
<string name="rules_delete">刪除規則</string> <string name="rules_delete">刪除規則</string>
<string name="rules_deleted">規則 %1$s 已刪除</string>
<string name="rules_enabled">規則已啟用</string>
<string name="rules_enabled_sub">關閉的規則仍保留在清單中</string>
<string name="rules_site_now">網站目前位址:%1$s。連線時及每 30 分鐘重新整理一次。</string>
<string name="rules_empty_title">暫無規則,這很正常</string> <string name="rules_empty_title">暫無規則,這很正常</string>
<string name="rules_empty_text">俄羅斯網站與所選應用程式已經直連。只有某個網站走錯路徑時才需要規則。</string> <string name="rules_empty_text">俄羅斯網站與所選應用程式已經直連。只有某個網站走錯路徑時才需要規則。</string>
<string name="rules_ideas">常用</string> <string name="rules_ideas">常用</string>
@@ -581,6 +585,8 @@
<string name="recent_access_later">暫不</string> <string name="recent_access_later">暫不</string>
<string name="routing_section_exceptions">例外</string> <string name="routing_section_exceptions">例外</string>
<string name="routing_rules_sub">網站、位址與套件範本</string> <string name="routing_rules_sub">網站、位址與套件範本</string>
<string name="routing_rules_breakdown">%1$d 條經 VPN · %2$d 條直連</string>
<string name="routing_rules_breakdown_off">%1$s · %2$d 條已關閉</string>
<string name="routing_section_check">檢查</string> <string name="routing_section_check">檢查</string>
<string name="routing_check_row">網站、IP 或應用程式的走向</string> <string name="routing_check_row">網站、IP 或應用程式的走向</string>
<string name="routing_recent_sub">誰連網過以及走哪條路徑</string> <string name="routing_recent_sub">誰連網過以及走哪條路徑</string>
@@ -655,7 +655,7 @@
<string name="route_rules_need_flexible">Rules for sites and addresses work when «Russian sites direct» is on in the «Flexible» mode.</string> <string name="route_rules_need_flexible">Rules for sites and addresses work when «Russian sites direct» is on in the «Flexible» mode.</string>
<!-- Routing: rules, recent apps, sections --> <!-- Routing: rules, recent apps, sections -->
<string name="rules_title">Rules</string> <string name="rules_title">Rules</string>
<string name="rules_hint">Where sites, addresses and apps go. A rule wins over the RU base.</string> <string name="rules_hint">Where sites, addresses and apps go. A rule wins over the RU base. Tap a rule to change it.</string>
<string name="rules_filter_all">All · %1$d</string> <string name="rules_filter_all">All · %1$d</string>
<string name="rules_filter_sites">Sites</string> <string name="rules_filter_sites">Sites</string>
<string name="rules_filter_addresses">Addresses</string> <string name="rules_filter_addresses">Addresses</string>
@@ -667,10 +667,14 @@
<string name="rules_tag_address">Address</string> <string name="rules_tag_address">Address</string>
<string name="rules_tag_package">Package</string> <string name="rules_tag_package">Package</string>
<string name="rules_site_unresolved">addresses not resolved yet</string> <string name="rules_site_unresolved">addresses not resolved yet</string>
<string name="rules_address_direct">always direct</string> <string name="rules_address_one">single address</string>
<string name="rules_address_vpn">always through the VPN, even inside the RU base</string> <string name="rules_address_net">network</string>
<string name="rules_package_sub">apps whose package matches go direct</string> <string name="rules_off">off</string>
<string name="rules_delete">Delete rule</string> <string name="rules_delete">Delete rule</string>
<string name="rules_deleted">Rule %1$s deleted</string>
<string name="rules_enabled">Rule enabled</string>
<string name="rules_enabled_sub">A disabled rule stays in the list</string>
<string name="rules_site_now">Site addresses now: %1$s. They are refreshed on connect and every 30 minutes.</string>
<string name="rules_empty_title">No rules yet, and that is fine</string> <string name="rules_empty_title">No rules yet, and that is fine</string>
<string name="rules_empty_text">Russian sites and the selected apps already go direct. A rule is only needed when some site takes the wrong way.</string> <string name="rules_empty_text">Russian sites and the selected apps already go direct. A rule is only needed when some site takes the wrong way.</string>
<string name="rules_ideas">Often added</string> <string name="rules_ideas">Often added</string>
@@ -714,6 +718,8 @@
<string name="recent_access_later">Not now</string> <string name="recent_access_later">Not now</string>
<string name="routing_section_exceptions">Exceptions</string> <string name="routing_section_exceptions">Exceptions</string>
<string name="routing_rules_sub">Sites, addresses and package templates</string> <string name="routing_rules_sub">Sites, addresses and package templates</string>
<string name="routing_rules_breakdown">%1$d via VPN · %2$d direct</string>
<string name="routing_rules_breakdown_off">%1$s · %2$d off</string>
<string name="routing_section_check">Check</string> <string name="routing_section_check">Check</string>
<string name="routing_check_row">How a site, IP or app goes</string> <string name="routing_check_row">How a site, IP or app goes</string>
<string name="routing_check_row_sub">Through the VPN or direct, and why</string> <string name="routing_check_row_sub">Through the VPN or direct, and why</string>
@@ -20,6 +20,7 @@ import org.strongswan.android.ui.compose.DeadTargetUi
import org.strongswan.android.ui.compose.routing.RuleKind import org.strongswan.android.ui.compose.routing.RuleKind
import org.strongswan.android.ui.compose.routing.buildRecentApps import org.strongswan.android.ui.compose.routing.buildRecentApps
import org.strongswan.android.ui.compose.routing.buildRuleRows import org.strongswan.android.ui.compose.routing.buildRuleRows
import org.strongswan.android.ui.compose.routing.isSingleAddress
import org.strongswan.android.utils.IPRange import org.strongswan.android.utils.IPRange
import java.net.InetAddress import java.net.InetAddress
@@ -82,6 +83,31 @@ class RoutingRulesTest
assertThat(rows.first { it.value == "2.2.2.0/24" }.enabled).isFalse() assertThat(rows.first { it.value == "2.2.2.0/24" }.enabled).isFalse()
} }
@Test
fun ruleChangesDirectionInPlace()
{
val config = RoutingConfig(
forceTunnelPrefixes = listOf(ForceTunnelPrefix("f", "1.1.1.1/32", enabled = false), ForceTunnelPrefix("keep", "3.3.3.3/32")),
directPrefixes = listOf(ForceTunnelPrefix("dup", "1.1.1.1/32")),
siteRules = listOf(SiteRule("s", "chatgpt.com", direct = false, enabled = false)),
)
val moved = RoutingManager.withRuleDirect(config, "f", direct = true)
assertThat(moved.forceTunnelPrefixes.map { it.id }).containsExactly("keep")
/* same id and on/off state; the duplicate on the other side is replaced */
assertThat(moved.directPrefixes).containsExactly(ForceTunnelPrefix("f", "1.1.1.1/32", enabled = false))
assertThat(RoutingManager.withRuleDirect(moved, "f", direct = false).forceTunnelPrefixes.map { it.id }).containsExactly("keep", "f")
assertThat(RoutingManager.withRuleDirect(config, "s", direct = true).siteRules)
.containsExactly(SiteRule("s", "chatgpt.com", direct = true, enabled = false))
assertThat(RoutingManager.withRuleDirect(config, "f", direct = false)).isEqualTo(config)
}
@Test
fun singleAddressOrNetwork()
{
assertThat(listOf("1.2.3.4", "1.2.3.4/32", "2001:db8::1/128").all(::isSingleAddress)).isTrue()
assertThat(listOf("10.8.0.0/24", "2001:db8::/32").none(::isSingleAddress)).isTrue()
}
@Test @Test
fun recentAppsPutFailuresFirst() fun recentAppsPutFailuresFirst()
{ {
+4 -4
View File
@@ -3,7 +3,7 @@
Утверждённый дизайн SHX VPN. Все экраны приложения сделаны строго по нему. Утверждённый дизайн SHX VPN. Все экраны приложения сделаны строго по нему.
- **Живой холст (смотреть и править):** https://claude.ai/artifact/UCC88C9hkU3hDU2YwMck1a. Это артефакт типа «Design» в Claude, доступ только у владельца. - **Живой холст (смотреть и править):** https://claude.ai/artifact/UCC88C9hkU3hDU2YwMck1a. Это артефакт типа «Design» в Claude, доступ только у владельца.
- **Копия в репозитории:** `final-2026/` — исходники артбордов и раскладка холста (`canvas.json`) на 2026-10-13, версия 2.18.0. - **Копия в репозитории:** `final-2026/` — исходники артбордов и раскладка холста (`canvas.json`) на 2026-10-13, версия 2.18.0; раздел «Правила» (`F_Rules`, `F_RuleEdit`, `F_RuleDeleted`, `F_Routing`) — на 2026-11-10.
## Как устроены файлы ## Как устроены файлы
@@ -17,8 +17,8 @@
| Префикс | Страница | Что это | | Префикс | Страница | Что это |
|---|---|---| |---|---|---|
| `O_*` | Обзор | Анализ, фичи по приоритетам, решения по дизайну, карта экранов | | `O_*` | Обзор | Анализ, фичи по приоритетам, решения по дизайну, карта экранов |
| `F_*` | Финал 2026 | 57 артбордов светлой темы — **эталон** | | `F_*` | Финал 2026 | 59 артбордов светлой темы — **эталон** |
| `D_*` | Финал 2026 · тёмная | 54 артборда тёмной темы, те же экраны | | `D_*` | Финал 2026 · тёмная | 56 артбордов тёмной темы, те же экраны |
| остальные | Исследование | Ранние варианты (Главная 2.0, простой режим, «Для опытных»). Не реализуются, оставлены для истории | | остальные | Исследование | Ранние варианты (Главная 2.0, простой режим, «Для опытных»). Не реализуются, оставлены для истории |
`D_*` не рисуются вручную: они получаются из `F_*` заменой цветов светлой палитры на тёмную (`LightShxColors` → `DarkShxColors` в `ShxDesign.kt`). Исключение — `D_QsTile`, он правился руками. Когда меняется `F_*`, обновите и парный `D_*`. `D_*` не рисуются вручную: они получаются из `F_*` заменой цветов светлой палитры на тёмную (`LightShxColors` → `DarkShxColors` в `ShxDesign.kt`). Исключение — `D_QsTile`, он правился руками. Когда меняется `F_*`, обновите и парный `D_*`.
@@ -45,7 +45,7 @@
| `F_Check` | Как ходит трафик | `ui/compose/RouteCheckScreen.kt` | | `F_Check` | Как ходит трафик | `ui/compose/RouteCheckScreen.kt` |
| `F_Routing` | Маршруты | `ui/compose/routing/RoutingHub.kt` | | `F_Routing` | Маршруты | `ui/compose/routing/RoutingHub.kt` |
| `F_Apps`, `F_AppPicker` | Приложения напрямую | `ui/compose/routing/DirectAppsScreen.kt` | | `F_Apps`, `F_AppPicker` | Приложения напрямую | `ui/compose/routing/DirectAppsScreen.kt` |
| `F_Rules`, `F_RulesEmpty`, `F_RuleAdd` | Правила | `ui/compose/routing/RulesScreen.kt` | | `F_Rules`, `F_RulesEmpty`, `F_RuleAdd`, `F_RuleEdit`, `F_RuleDeleted` | Правила | `ui/compose/routing/RulesScreen.kt` |
| `F_Recent`, `F_RecentEmpty` | Недавние приложения | `ui/compose/routing/RecentAppsScreen.kt` | | `F_Recent`, `F_RecentEmpty` | Недавние приложения | `ui/compose/routing/RecentAppsScreen.kt` |
| `F_Automation`, `F_ScenarioAdd` | Сценарии | `ui/ScenariosActivity.kt`, `logic/scenarios/Scenarios.kt` | | `F_Automation`, `F_ScenarioAdd` | Сценарии | `ui/ScenariosActivity.kt`, `logic/scenarios/Scenarios.kt` |
| `F_Settings` | Настройки | `ui/compose/SettingsScreen.kt` | | `F_Settings` | Настройки | `ui/compose/SettingsScreen.kt` |
@@ -78,9 +78,9 @@ a{color:#B69DFF}a:hover{color:#D0C2FF}
</span> </span>
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px"> <span style="flex: 1; display: flex; flex-direction: column; gap: 2px">
<span style="font-size: 16px; font-weight: 700">Правила</span> <span style="font-size: 16px; font-weight: 700">Правила</span>
<span style="font-size: 13px; color: #A9A3BF">Домены, адреса и шаблоны пакетов</span> <span style="font-size: 13px; color: #A9A3BF">2 через VPN · 3 напрямую · 1 выключено</span>
</span> </span>
<span style="font-size: 14px; font-weight: 700; color: #A9A3BF">5</span> <span style="font-size: 14px; font-weight: 700; color: #A9A3BF">6</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#A9A3BF" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#A9A3BF" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
</a> </a>
</section> </section>
@@ -0,0 +1,97 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Тёмная · Правило удалено — можно отменить</title>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&amp;display=swap">
<style>
body{margin:0}
a{color:#B69DFF}a:hover{color:#D0C2FF}
</style>
</helmet>
<div style="position: relative; width: 390px; height: 900px; box-sizing: border-box; background: #141318; font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; font-variant-numeric: tabular-nums">
<header style="display: flex; align-items: center; gap: 8px; padding: 16px 12px 8px">
<a href="D_Routing.dc.html" aria-label="Назад" style="width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: #E5E1E9">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
</a>
<h1 style="margin: 0; flex: 1; font-size: 22px; font-weight: 800">Правила</h1>
</header>
<div style="padding: 0 16px; display: flex; flex-direction: column; gap: 12px">
<p style="margin: 0 4px; font-size: 14px; line-height: 1.45; color: #CAC4D0">Куда идут сайты, адреса и приложения. Правило важнее базы РФ. Нажмите на правило, чтобы изменить его.</p>
<div style="display: flex; gap: 8px">
<button aria-pressed="true" style="height: 36px; padding: 0 14px; border-radius: 12px; border: 0; background: #3B2C88; color: #FFFFFF; font: 700 13px 'Manrope', sans-serif">Все · 5</button>
<button style="height: 36px; padding: 0 14px; border-radius: 12px; border: 1px solid #48454E; background: transparent; color: #E5E1E9; font: 600 13px 'Manrope', sans-serif">Сайты · 1</button>
<button style="height: 36px; padding: 0 14px; border-radius: 12px; border: 1px solid #48454E; background: transparent; color: #E5E1E9; font: 600 13px 'Manrope', sans-serif">Адреса · 2</button>
<button style="height: 36px; padding: 0 14px; border-radius: 12px; border: 1px solid #48454E; background: transparent; color: #E5E1E9; font: 600 13px 'Manrope', sans-serif">Пакеты · 2</button>
</div>
<sc-for list="{{groups}}" as="g" hint-placeholder-count="2">
<section style="display: flex; flex-direction: column; gap: 6px">
<h2 style="margin: 6px 6px 0; display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 800; color: #B69DFF; letter-spacing: 0.04em"><span style="width: 8px; height: 8px; border-radius: 4px; background: {{g.dot}}"></span>{{g.title}}</h2>
<div style="border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; overflow: hidden">
<sc-for list="{{g.rows}}" as="r" hint-placeholder-count="3">
<div style="display: flex; align-items: center; gap: 12px; padding: 0 6px 0 14px; height: 64px; border-bottom: {{r.divider}}">
<a href="D_RuleEdit.dc.html" style="flex: 1; min-width: 0; height: 64px; display: flex; align-items: center; gap: 12px; text-decoration: none; color: {{r.fg}}">
<span style="flex: none; padding: 3px 7px; border-radius: 8px; background: {{r.tagBg}}; color: {{r.tagFg}}; font-size: 10px; font-weight: 800">{{r.kind}}</span>
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0">
<span style="font-size: 15px; font-weight: 700; font-family: ui-monospace, 'Cascadia Mono', Menlo, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">{{r.value}}</span>
<span style="font-size: 12px; color: #A9A3BF; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">{{r.sub}}</span>
</span>
</a>
<button aria-pressed="{{r.on}}" aria-label="Включить правило" style="flex: none; width: 52px; height: 32px; border: 0; border-radius: 16px; background: {{r.track}}; padding: 4px; display: flex; justify-content: {{r.align}}"><span style="width: 24px; height: 24px; border-radius: 12px; background: #1F1D24"></span></button>
<a href="D_RuleDeleted.dc.html" aria-label="Удалить правило" style="flex: none; margin-left: -8px; width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: #A9A3BF">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 7h14M10 11v6M14 11v6M7 7l1 13h8l1-13M9 7V4h6v3"/></svg>
</a>
</div>
</sc-for>
</div>
</section>
</sc-for>
</div>
<a href="D_RuleAdd.dc.html" style="position: absolute; right: 16px; bottom: 96px; height: 60px; padding: 0 22px; border: 0; border-radius: 20px; background: #6D4AFF; color: #FFFFFF; font-size: 16px; font-weight: 800; text-decoration: none; display: flex; align-items: center; gap: 10px; box-shadow: 0 6px 18px rgba(109,74,255,0.35)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>Правило
</a>
<div role="status" style="position: absolute; left: 16px; right: 16px; bottom: 24px; min-height: 56px; box-sizing: border-box; padding: 0 6px 0 16px; border-radius: 16px; background: #E5E1E9; color: #313036; display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 600; box-shadow: 0 6px 18px rgba(28,27,32,0.25)">
<span style="flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">Правило chatgpt.com удалено</span>
<a href="D_Rules.dc.html" style="flex: none; height: 44px; padding: 0 12px; border-radius: 22px; display: flex; align-items: center; text-decoration: none; font-size: 14px; font-weight: 800; color: #6D4AFF">Отменить</a>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":900}}'>
class Component extends DCLogic {
renderVals() {
var tag = { 'САЙТ': ['#1E2A44', '#A8C7FF'], 'АДРЕС': ['#2B2930', '#CAC4D0'], 'ПАКЕТ': ['#12321F', '#7CE3B0'] };
var grp = function (title, dot, rows) {
return { title: title, dot: dot, rows: rows.map(function (r, i) {
return { kind: r[0], tagBg: tag[r[0]][0], tagFg: tag[r[0]][1], value: r[1], sub: r[2],
on: r[3] ? 'true' : 'false', track: r[3] ? '#6D4AFF' : '#48454E', align: r[3] ? 'flex-end' : 'flex-start',
fg: r[3] ? '#E5E1E9' : '#A9A3BF',
divider: i < rows.length - 1 ? '1px solid #2B2930' : '0' };
}) };
};
return {
groups: [
grp('ВСЕГДА ЧЕРЕЗ VPN', '#6D4AFF', [
['АДРЕС', '130.49.213.176', 'один адрес', true],
['АДРЕС', '10.8.0.0/24', 'сеть · выключено', false]
]),
grp('ВСЕГДА НАПРЯМУЮ', '#3FBF7F', [
['ПАКЕТ', 'ru.*', '14 приложений', true],
['ПАКЕТ', '*yandex*', '6 приложений', true],
['САЙТ', 'mos.ru', '2 адреса', true]
])
]
};
}
}
</script>
</body>
</html>
@@ -0,0 +1,65 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Тёмная · Правило: изменить или удалить</title>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&amp;display=swap">
<style>
body{margin:0}
a{color:#B69DFF}a:hover{color:#D0C2FF}
</style>
</helmet>
<div style="position: relative; width: 390px; height: 844px; box-sizing: border-box; background: rgba(28,27,32,0.5); font-family: 'Manrope', system-ui, sans-serif; color: #E5E1E9; overflow: hidden; display: flex; flex-direction: column; justify-content: flex-end">
<section role="dialog" aria-label="Правило" style="border-radius: 32px 32px 0 0; background: #141318; padding: 12px 16px 28px; display: flex; flex-direction: column; gap: 14px">
<span style="align-self: center; width: 36px; height: 4px; border-radius: 2px; background: #48454E"></span>
<div style="margin: 4px 4px 0; display: flex; flex-direction: column; gap: 6px">
<span style="align-self: flex-start; padding: 3px 7px; border-radius: 8px; background: #1E2A44; color: #A8C7FF; font-size: 10px; font-weight: 800">САЙТ</span>
<h1 style="margin: 0; font: 800 22px ui-monospace, 'Cascadia Mono', Menlo, monospace; letter-spacing: -0.02em; overflow-wrap: anywhere">chatgpt.com</h1>
</div>
<div style="border-radius: 22px; background: #1F1D24; border: 1px solid #2E2B35; display: flex; align-items: center; gap: 12px; padding: 0 14px 0 16px; height: 60px">
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px">
<span style="font-size: 15px; font-weight: 700">Правило включено</span>
<span style="font-size: 12px; color: #A9A3BF">Выключенное остаётся в списке</span>
</span>
<button aria-pressed="true" aria-label="Правило включено" style="flex: none; width: 52px; height: 32px; border: 0; border-radius: 16px; background: #6D4AFF; padding: 4px; display: flex; justify-content: flex-end"><span style="width: 24px; height: 24px; border-radius: 12px; background: #1F1D24"></span></button>
</div>
<span style="padding: 0 4px; font-size: 13px; font-weight: 700; color: #CAC4D0">Куда направлять</span>
<div role="radiogroup" aria-label="Куда" style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px">
<button style="padding: 14px; border-radius: 18px; border: 1px solid #48454E; background: #1F1D24; text-align: left; font-family: 'Manrope', sans-serif; color: #E5E1E9; display: flex; flex-direction: column; gap: 4px">
<span style="font-size: 15px; font-weight: 800">Напрямую</span>
<span style="font-size: 12px; line-height: 1.35; color: #CAC4D0">Мимо VPN</span>
</button>
<button aria-pressed="true" style="padding: 14px; border-radius: 18px; border: 2px solid #6D4AFF; background: #2A2343; text-align: left; font-family: 'Manrope', sans-serif; color: #E5E1E9; display: flex; flex-direction: column; gap: 4px">
<span style="font-size: 15px; font-weight: 800">Через VPN</span>
<span style="font-size: 12px; line-height: 1.35; color: #CAC4D0">Даже если сайт в базе РФ</span>
</button>
</div>
<div style="padding: 12px 14px; border-radius: 18px; background: #2A2343; font-size: 13px; line-height: 1.45; color: #E0DAF5">Сейчас у сайта 3 адреса: <span style="font-family: ui-monospace, 'Cascadia Mono', Menlo, monospace">104.18.32.47, 172.64.155.209, 2606:4700::6812:202f</span>. Они обновляются при подключении и раз в 30 минут.</div>
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px">
<a href="D_RuleDeleted.dc.html" style="height: 56px; border-radius: 18px; background: #4A1C1C; color: #FFB4AB; text-decoration: none; display: flex; align-items: center; justify-content: center; gap: 8px; font-size: 15px; font-weight: 800">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 7h14M10 11v6M14 11v6M7 7l1 13h8l1-13M9 7V4h6v3"/></svg>Удалить
</a>
<a href="D_Rules.dc.html" style="height: 56px; border-radius: 18px; background: #6D4AFF; color: #FFFFFF; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 800">Готово</a>
</div>
</section>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":844}}'>
class Component extends DCLogic {
renderVals() {
return {};
}
}
</script>
</body>
</html>
@@ -23,13 +23,13 @@ a{color:#B69DFF}a:hover{color:#D0C2FF}
</header> </header>
<div style="padding: 0 16px; display: flex; flex-direction: column; gap: 12px"> <div style="padding: 0 16px; display: flex; flex-direction: column; gap: 12px">
<p style="margin: 0 4px; font-size: 14px; line-height: 1.45; color: #CAC4D0">Куда идут сайты, адреса и приложения. Правило важнее базы РФ.</p> <p style="margin: 0 4px; font-size: 14px; line-height: 1.45; color: #CAC4D0">Куда идут сайты, адреса и приложения. Правило важнее базы РФ. Нажмите на правило, чтобы изменить его.</p>
<div style="display: flex; gap: 8px"> <div style="display: flex; gap: 8px">
<button aria-pressed="true" style="height: 36px; padding: 0 14px; border-radius: 12px; border: 0; background: #3B2C88; color: #FFFFFF; font: 700 13px 'Manrope', sans-serif">Все · 6</button> <button aria-pressed="true" style="height: 36px; padding: 0 14px; border-radius: 12px; border: 0; background: #3B2C88; color: #FFFFFF; font: 700 13px 'Manrope', sans-serif">Все · 6</button>
<button style="height: 36px; padding: 0 14px; border-radius: 12px; border: 1px solid #48454E; background: transparent; color: #E5E1E9; font: 600 13px 'Manrope', sans-serif">Сайты</button> <button style="height: 36px; padding: 0 14px; border-radius: 12px; border: 1px solid #48454E; background: transparent; color: #E5E1E9; font: 600 13px 'Manrope', sans-serif">Сайты · 2</button>
<button style="height: 36px; padding: 0 14px; border-radius: 12px; border: 1px solid #48454E; background: transparent; color: #E5E1E9; font: 600 13px 'Manrope', sans-serif">Адреса</button> <button style="height: 36px; padding: 0 14px; border-radius: 12px; border: 1px solid #48454E; background: transparent; color: #E5E1E9; font: 600 13px 'Manrope', sans-serif">Адреса · 2</button>
<button style="height: 36px; padding: 0 14px; border-radius: 12px; border: 1px solid #48454E; background: transparent; color: #E5E1E9; font: 600 13px 'Manrope', sans-serif">Пакеты</button> <button style="height: 36px; padding: 0 14px; border-radius: 12px; border: 1px solid #48454E; background: transparent; color: #E5E1E9; font: 600 13px 'Manrope', sans-serif">Пакеты · 2</button>
</div> </div>
<sc-for list="{{groups}}" as="g" hint-placeholder-count="2"> <sc-for list="{{groups}}" as="g" hint-placeholder-count="2">
@@ -37,13 +37,18 @@ a{color:#B69DFF}a:hover{color:#D0C2FF}
<h2 style="margin: 6px 6px 0; display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 800; color: #B69DFF; letter-spacing: 0.04em"><span style="width: 8px; height: 8px; border-radius: 4px; background: {{g.dot}}"></span>{{g.title}}</h2> <h2 style="margin: 6px 6px 0; display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 800; color: #B69DFF; letter-spacing: 0.04em"><span style="width: 8px; height: 8px; border-radius: 4px; background: {{g.dot}}"></span>{{g.title}}</h2>
<div style="border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; overflow: hidden"> <div style="border-radius: 24px; background: #1F1D24; border: 1px solid #2E2B35; overflow: hidden">
<sc-for list="{{g.rows}}" as="r" hint-placeholder-count="3"> <sc-for list="{{g.rows}}" as="r" hint-placeholder-count="3">
<div style="display: flex; align-items: center; gap: 12px; padding: 0 14px; height: 64px; border-bottom: {{r.divider}}"> <div style="display: flex; align-items: center; gap: 12px; padding: 0 6px 0 14px; height: 64px; border-bottom: {{r.divider}}">
<span style="flex: none; padding: 3px 7px; border-radius: 8px; background: {{r.tagBg}}; color: {{r.tagFg}}; font-size: 10px; font-weight: 800">{{r.kind}}</span> <a href="D_RuleEdit.dc.html" style="flex: 1; min-width: 0; height: 64px; display: flex; align-items: center; gap: 12px; text-decoration: none; color: {{r.fg}}">
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0"> <span style="flex: none; padding: 3px 7px; border-radius: 8px; background: {{r.tagBg}}; color: {{r.tagFg}}; font-size: 10px; font-weight: 800">{{r.kind}}</span>
<span style="font-size: 15px; font-weight: 700; font-family: ui-monospace, 'Cascadia Mono', Menlo, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">{{r.value}}</span> <span style="flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0">
<span style="font-size: 12px; color: #A9A3BF">{{r.sub}}</span> <span style="font-size: 15px; font-weight: 700; font-family: ui-monospace, 'Cascadia Mono', Menlo, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">{{r.value}}</span>
</span> <span style="font-size: 12px; color: #A9A3BF; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">{{r.sub}}</span>
</span>
</a>
<button aria-pressed="{{r.on}}" aria-label="Включить правило" style="flex: none; width: 52px; height: 32px; border: 0; border-radius: 16px; background: {{r.track}}; padding: 4px; display: flex; justify-content: {{r.align}}"><span style="width: 24px; height: 24px; border-radius: 12px; background: #1F1D24"></span></button> <button aria-pressed="{{r.on}}" aria-label="Включить правило" style="flex: none; width: 52px; height: 32px; border: 0; border-radius: 16px; background: {{r.track}}; padding: 4px; display: flex; justify-content: {{r.align}}"><span style="width: 24px; height: 24px; border-radius: 12px; background: #1F1D24"></span></button>
<a href="D_RuleDeleted.dc.html" aria-label="Удалить правило" style="flex: none; margin-left: -8px; width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: #A9A3BF">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 7h14M10 11v6M14 11v6M7 7l1 13h8l1-13M9 7V4h6v3"/></svg>
</a>
</div> </div>
</sc-for> </sc-for>
</div> </div>
@@ -64,20 +69,21 @@ class Component extends DCLogic {
return { title: title, dot: dot, rows: rows.map(function (r, i) { return { title: title, dot: dot, rows: rows.map(function (r, i) {
return { kind: r[0], tagBg: tag[r[0]][0], tagFg: tag[r[0]][1], value: r[1], sub: r[2], return { kind: r[0], tagBg: tag[r[0]][0], tagFg: tag[r[0]][1], value: r[1], sub: r[2],
on: r[3] ? 'true' : 'false', track: r[3] ? '#6D4AFF' : '#48454E', align: r[3] ? 'flex-end' : 'flex-start', on: r[3] ? 'true' : 'false', track: r[3] ? '#6D4AFF' : '#48454E', align: r[3] ? 'flex-end' : 'flex-start',
fg: r[3] ? '#E5E1E9' : '#A9A3BF',
divider: i < rows.length - 1 ? '1px solid #2B2930' : '0' }; divider: i < rows.length - 1 ? '1px solid #2B2930' : '0' };
}) }; }) };
}; };
return { return {
groups: [ groups: [
grp('ВСЕГДА ЧЕРЕЗ VPN', '#6D4AFF', [ grp('ВСЕГДА ЧЕРЕЗ VPN', '#6D4AFF', [
['САЙТ', 'chatgpt.com', 'и поддомены', true], ['САЙТ', 'chatgpt.com', '3 адреса', true],
['АДРЕС', '130.49.213.176', 'один адрес', true], ['АДРЕС', '130.49.213.176', 'один адрес', true],
['АДРЕС', '10.8.0.0/24', 'подсеть', false] ['АДРЕС', '10.8.0.0/24', 'сеть · выключено', false]
]), ]),
grp('ВСЕГДА НАПРЯМУЮ', '#3FBF7F', [ grp('ВСЕГДА НАПРЯМУЮ', '#3FBF7F', [
['ПАКЕТ', 'ru.*', '14 приложений', true], ['ПАКЕТ', 'ru.*', '14 приложений', true],
['ПАКЕТ', '*yandex*', '6 приложений · 1 исключение', true], ['ПАКЕТ', '*yandex*', '6 приложений', true],
['САЙТ', '*.mos.ru', 'и поддомены', true] ['САЙТ', 'mos.ru', '2 адреса', true]
]) ])
] ]
}; };
@@ -78,9 +78,9 @@ a{color:#6D4AFF}a:hover{color:#4B2FD1}
</span> </span>
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px"> <span style="flex: 1; display: flex; flex-direction: column; gap: 2px">
<span style="font-size: 16px; font-weight: 700">Правила</span> <span style="font-size: 16px; font-weight: 700">Правила</span>
<span style="font-size: 13px; color: #5E5A7D">Домены, адреса и шаблоны пакетов</span> <span style="font-size: 13px; color: #5E5A7D">2 через VPN · 3 напрямую · 1 выключено</span>
</span> </span>
<span style="font-size: 14px; font-weight: 700; color: #5E5A7D">5</span> <span style="font-size: 14px; font-weight: 700; color: #5E5A7D">6</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#5E5A7D" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="#5E5A7D" stroke-width="2" stroke-linecap="round" aria-hidden="true"><path d="M9 6l6 6-6 6"/></svg>
</a> </a>
</section> </section>
@@ -0,0 +1,97 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Правило удалено — можно отменить</title>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&amp;display=swap">
<style>
body{margin:0}
a{color:#6D4AFF}a:hover{color:#4B2FD1}
</style>
</helmet>
<div style="position: relative; width: 390px; height: 900px; box-sizing: border-box; background: #FCF8FF; font-family: 'Manrope', system-ui, sans-serif; color: #1C1B20; overflow: hidden; font-variant-numeric: tabular-nums">
<header style="display: flex; align-items: center; gap: 8px; padding: 16px 12px 8px">
<a href="F_Routing.dc.html" aria-label="Назад" style="width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: #1C1B20">
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 6l-6 6 6 6"/></svg>
</a>
<h1 style="margin: 0; flex: 1; font-size: 22px; font-weight: 800">Правила</h1>
</header>
<div style="padding: 0 16px; display: flex; flex-direction: column; gap: 12px">
<p style="margin: 0 4px; font-size: 14px; line-height: 1.45; color: #49454F">Куда идут сайты, адреса и приложения. Правило важнее базы РФ. Нажмите на правило, чтобы изменить его.</p>
<div style="display: flex; gap: 8px">
<button aria-pressed="true" style="height: 36px; padding: 0 14px; border-radius: 12px; border: 0; background: #3B2C88; color: #FFFFFF; font: 700 13px 'Manrope', sans-serif">Все · 5</button>
<button style="height: 36px; padding: 0 14px; border-radius: 12px; border: 1px solid #C9C5D0; background: transparent; color: #1C1B20; font: 600 13px 'Manrope', sans-serif">Сайты · 1</button>
<button style="height: 36px; padding: 0 14px; border-radius: 12px; border: 1px solid #C9C5D0; background: transparent; color: #1C1B20; font: 600 13px 'Manrope', sans-serif">Адреса · 2</button>
<button style="height: 36px; padding: 0 14px; border-radius: 12px; border: 1px solid #C9C5D0; background: transparent; color: #1C1B20; font: 600 13px 'Manrope', sans-serif">Пакеты · 2</button>
</div>
<sc-for list="{{groups}}" as="g" hint-placeholder-count="2">
<section style="display: flex; flex-direction: column; gap: 6px">
<h2 style="margin: 6px 6px 0; display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 800; color: #6D4AFF; letter-spacing: 0.04em"><span style="width: 8px; height: 8px; border-radius: 4px; background: {{g.dot}}"></span>{{g.title}}</h2>
<div style="border-radius: 24px; background: #FFFFFF; border: 1px solid #EDE8F2; overflow: hidden">
<sc-for list="{{g.rows}}" as="r" hint-placeholder-count="3">
<div style="display: flex; align-items: center; gap: 12px; padding: 0 6px 0 14px; height: 64px; border-bottom: {{r.divider}}">
<a href="F_RuleEdit.dc.html" style="flex: 1; min-width: 0; height: 64px; display: flex; align-items: center; gap: 12px; text-decoration: none; color: {{r.fg}}">
<span style="flex: none; padding: 3px 7px; border-radius: 8px; background: {{r.tagBg}}; color: {{r.tagFg}}; font-size: 10px; font-weight: 800">{{r.kind}}</span>
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0">
<span style="font-size: 15px; font-weight: 700; font-family: ui-monospace, 'Cascadia Mono', Menlo, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">{{r.value}}</span>
<span style="font-size: 12px; color: #5E5A7D; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">{{r.sub}}</span>
</span>
</a>
<button aria-pressed="{{r.on}}" aria-label="Включить правило" style="flex: none; width: 52px; height: 32px; border: 0; border-radius: 16px; background: {{r.track}}; padding: 4px; display: flex; justify-content: {{r.align}}"><span style="width: 24px; height: 24px; border-radius: 12px; background: #FFFFFF"></span></button>
<a href="F_RuleDeleted.dc.html" aria-label="Удалить правило" style="flex: none; margin-left: -8px; width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: #5E5A7D">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 7h14M10 11v6M14 11v6M7 7l1 13h8l1-13M9 7V4h6v3"/></svg>
</a>
</div>
</sc-for>
</div>
</section>
</sc-for>
</div>
<a href="F_RuleAdd.dc.html" style="position: absolute; right: 16px; bottom: 96px; height: 60px; padding: 0 22px; border: 0; border-radius: 20px; background: #6D4AFF; color: #FFFFFF; font-size: 16px; font-weight: 800; text-decoration: none; display: flex; align-items: center; gap: 10px; box-shadow: 0 6px 18px rgba(109,74,255,0.35)">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14"/></svg>Правило
</a>
<div role="status" style="position: absolute; left: 16px; right: 16px; bottom: 24px; min-height: 56px; box-sizing: border-box; padding: 0 6px 0 16px; border-radius: 16px; background: #313036; color: #F3EFF7; display: flex; align-items: center; gap: 8px; font-size: 14px; font-weight: 600; box-shadow: 0 6px 18px rgba(28,27,32,0.25)">
<span style="flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">Правило chatgpt.com удалено</span>
<a href="F_Rules.dc.html" style="flex: none; height: 44px; padding: 0 12px; border-radius: 22px; display: flex; align-items: center; text-decoration: none; font-size: 14px; font-weight: 800; color: #B69DFF">Отменить</a>
</div>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":900}}'>
class Component extends DCLogic {
renderVals() {
var tag = { 'САЙТ': ['#E3ECFF', '#1F4FA8'], 'АДРЕС': ['#F0ECF4', '#49454F'], 'ПАКЕТ': ['#DDF3E6', '#0E6B35'] };
var grp = function (title, dot, rows) {
return { title: title, dot: dot, rows: rows.map(function (r, i) {
return { kind: r[0], tagBg: tag[r[0]][0], tagFg: tag[r[0]][1], value: r[1], sub: r[2],
on: r[3] ? 'true' : 'false', track: r[3] ? '#6D4AFF' : '#C9C5D0', align: r[3] ? 'flex-end' : 'flex-start',
fg: r[3] ? '#1C1B20' : '#5E5A7D',
divider: i < rows.length - 1 ? '1px solid #F0ECF4' : '0' };
}) };
};
return {
groups: [
grp('ВСЕГДА ЧЕРЕЗ VPN', '#6D4AFF', [
['АДРЕС', '130.49.213.176', 'один адрес', true],
['АДРЕС', '10.8.0.0/24', 'сеть · выключено', false]
]),
grp('ВСЕГДА НАПРЯМУЮ', '#3FBF7F', [
['ПАКЕТ', 'ru.*', '14 приложений', true],
['ПАКЕТ', '*yandex*', '6 приложений', true],
['САЙТ', 'mos.ru', '2 адреса', true]
])
]
};
}
}
</script>
</body>
</html>
@@ -0,0 +1,65 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>Правило: изменить или удалить</title>
<script src="./support.js"></script>
</head>
<body>
<x-dc>
<helmet>
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&amp;display=swap">
<style>
body{margin:0}
a{color:#6D4AFF}a:hover{color:#4B2FD1}
</style>
</helmet>
<div style="position: relative; width: 390px; height: 844px; box-sizing: border-box; background: rgba(28,27,32,0.5); font-family: 'Manrope', system-ui, sans-serif; color: #1C1B20; overflow: hidden; display: flex; flex-direction: column; justify-content: flex-end">
<section role="dialog" aria-label="Правило" style="border-radius: 32px 32px 0 0; background: #FCF8FF; padding: 12px 16px 28px; display: flex; flex-direction: column; gap: 14px">
<span style="align-self: center; width: 36px; height: 4px; border-radius: 2px; background: #C9C5D0"></span>
<div style="margin: 4px 4px 0; display: flex; flex-direction: column; gap: 6px">
<span style="align-self: flex-start; padding: 3px 7px; border-radius: 8px; background: #E3ECFF; color: #1F4FA8; font-size: 10px; font-weight: 800">САЙТ</span>
<h1 style="margin: 0; font: 800 22px ui-monospace, 'Cascadia Mono', Menlo, monospace; letter-spacing: -0.02em; overflow-wrap: anywhere">chatgpt.com</h1>
</div>
<div style="border-radius: 22px; background: #FFFFFF; border: 1px solid #EDE8F2; display: flex; align-items: center; gap: 12px; padding: 0 14px 0 16px; height: 60px">
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px">
<span style="font-size: 15px; font-weight: 700">Правило включено</span>
<span style="font-size: 12px; color: #5E5A7D">Выключенное остаётся в списке</span>
</span>
<button aria-pressed="true" aria-label="Правило включено" style="flex: none; width: 52px; height: 32px; border: 0; border-radius: 16px; background: #6D4AFF; padding: 4px; display: flex; justify-content: flex-end"><span style="width: 24px; height: 24px; border-radius: 12px; background: #FFFFFF"></span></button>
</div>
<span style="padding: 0 4px; font-size: 13px; font-weight: 700; color: #49454F">Куда направлять</span>
<div role="radiogroup" aria-label="Куда" style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px">
<button style="padding: 14px; border-radius: 18px; border: 1px solid #C9C5D0; background: #FFFFFF; text-align: left; font-family: 'Manrope', sans-serif; color: #1C1B20; display: flex; flex-direction: column; gap: 4px">
<span style="font-size: 15px; font-weight: 800">Напрямую</span>
<span style="font-size: 12px; line-height: 1.35; color: #49454F">Мимо VPN</span>
</button>
<button aria-pressed="true" style="padding: 14px; border-radius: 18px; border: 2px solid #6D4AFF; background: #F3EEFF; text-align: left; font-family: 'Manrope', sans-serif; color: #1C1B20; display: flex; flex-direction: column; gap: 4px">
<span style="font-size: 15px; font-weight: 800">Через VPN</span>
<span style="font-size: 12px; line-height: 1.35; color: #49454F">Даже если сайт в базе РФ</span>
</button>
</div>
<div style="padding: 12px 14px; border-radius: 18px; background: #F3EEFF; font-size: 13px; line-height: 1.45; color: #2A2640">Сейчас у сайта 3 адреса: <span style="font-family: ui-monospace, 'Cascadia Mono', Menlo, monospace">104.18.32.47, 172.64.155.209, 2606:4700::6812:202f</span>. Они обновляются при подключении и раз в 30 минут.</div>
<div style="display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px">
<a href="F_RuleDeleted.dc.html" style="height: 56px; border-radius: 18px; background: #FFDAD8; color: #8C1D24; text-decoration: none; display: flex; align-items: center; justify-content: center; gap: 8px; font-size: 15px; font-weight: 800">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 7h14M10 11v6M14 11v6M7 7l1 13h8l1-13M9 7V4h6v3"/></svg>Удалить
</a>
<a href="F_Rules.dc.html" style="height: 56px; border-radius: 18px; background: #6D4AFF; color: #FFFFFF; text-decoration: none; display: flex; align-items: center; justify-content: center; font-size: 15px; font-weight: 800">Готово</a>
</div>
</section>
</div>
</x-dc>
<script type="text/x-dc" data-dc-script data-props='{"$preview":{"width":390,"height":844}}'>
class Component extends DCLogic {
renderVals() {
return {};
}
}
</script>
</body>
</html>
@@ -23,13 +23,13 @@ a{color:#6D4AFF}a:hover{color:#4B2FD1}
</header> </header>
<div style="padding: 0 16px; display: flex; flex-direction: column; gap: 12px"> <div style="padding: 0 16px; display: flex; flex-direction: column; gap: 12px">
<p style="margin: 0 4px; font-size: 14px; line-height: 1.45; color: #49454F">Куда идут сайты, адреса и приложения. Правило важнее базы РФ.</p> <p style="margin: 0 4px; font-size: 14px; line-height: 1.45; color: #49454F">Куда идут сайты, адреса и приложения. Правило важнее базы РФ. Нажмите на правило, чтобы изменить его.</p>
<div style="display: flex; gap: 8px"> <div style="display: flex; gap: 8px">
<button aria-pressed="true" style="height: 36px; padding: 0 14px; border-radius: 12px; border: 0; background: #3B2C88; color: #FFFFFF; font: 700 13px 'Manrope', sans-serif">Все · 6</button> <button aria-pressed="true" style="height: 36px; padding: 0 14px; border-radius: 12px; border: 0; background: #3B2C88; color: #FFFFFF; font: 700 13px 'Manrope', sans-serif">Все · 6</button>
<button style="height: 36px; padding: 0 14px; border-radius: 12px; border: 1px solid #C9C5D0; background: transparent; color: #1C1B20; font: 600 13px 'Manrope', sans-serif">Сайты</button> <button style="height: 36px; padding: 0 14px; border-radius: 12px; border: 1px solid #C9C5D0; background: transparent; color: #1C1B20; font: 600 13px 'Manrope', sans-serif">Сайты · 2</button>
<button style="height: 36px; padding: 0 14px; border-radius: 12px; border: 1px solid #C9C5D0; background: transparent; color: #1C1B20; font: 600 13px 'Manrope', sans-serif">Адреса</button> <button style="height: 36px; padding: 0 14px; border-radius: 12px; border: 1px solid #C9C5D0; background: transparent; color: #1C1B20; font: 600 13px 'Manrope', sans-serif">Адреса · 2</button>
<button style="height: 36px; padding: 0 14px; border-radius: 12px; border: 1px solid #C9C5D0; background: transparent; color: #1C1B20; font: 600 13px 'Manrope', sans-serif">Пакеты</button> <button style="height: 36px; padding: 0 14px; border-radius: 12px; border: 1px solid #C9C5D0; background: transparent; color: #1C1B20; font: 600 13px 'Manrope', sans-serif">Пакеты · 2</button>
</div> </div>
<sc-for list="{{groups}}" as="g" hint-placeholder-count="2"> <sc-for list="{{groups}}" as="g" hint-placeholder-count="2">
@@ -37,13 +37,18 @@ a{color:#6D4AFF}a:hover{color:#4B2FD1}
<h2 style="margin: 6px 6px 0; display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 800; color: #6D4AFF; letter-spacing: 0.04em"><span style="width: 8px; height: 8px; border-radius: 4px; background: {{g.dot}}"></span>{{g.title}}</h2> <h2 style="margin: 6px 6px 0; display: flex; align-items: center; gap: 8px; font-size: 13px; font-weight: 800; color: #6D4AFF; letter-spacing: 0.04em"><span style="width: 8px; height: 8px; border-radius: 4px; background: {{g.dot}}"></span>{{g.title}}</h2>
<div style="border-radius: 24px; background: #FFFFFF; border: 1px solid #EDE8F2; overflow: hidden"> <div style="border-radius: 24px; background: #FFFFFF; border: 1px solid #EDE8F2; overflow: hidden">
<sc-for list="{{g.rows}}" as="r" hint-placeholder-count="3"> <sc-for list="{{g.rows}}" as="r" hint-placeholder-count="3">
<div style="display: flex; align-items: center; gap: 12px; padding: 0 14px; height: 64px; border-bottom: {{r.divider}}"> <div style="display: flex; align-items: center; gap: 12px; padding: 0 6px 0 14px; height: 64px; border-bottom: {{r.divider}}">
<span style="flex: none; padding: 3px 7px; border-radius: 8px; background: {{r.tagBg}}; color: {{r.tagFg}}; font-size: 10px; font-weight: 800">{{r.kind}}</span> <a href="F_RuleEdit.dc.html" style="flex: 1; min-width: 0; height: 64px; display: flex; align-items: center; gap: 12px; text-decoration: none; color: {{r.fg}}">
<span style="flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0"> <span style="flex: none; padding: 3px 7px; border-radius: 8px; background: {{r.tagBg}}; color: {{r.tagFg}}; font-size: 10px; font-weight: 800">{{r.kind}}</span>
<span style="font-size: 15px; font-weight: 700; font-family: ui-monospace, 'Cascadia Mono', Menlo, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">{{r.value}}</span> <span style="flex: 1; display: flex; flex-direction: column; gap: 2px; min-width: 0">
<span style="font-size: 12px; color: #5E5A7D">{{r.sub}}</span> <span style="font-size: 15px; font-weight: 700; font-family: ui-monospace, 'Cascadia Mono', Menlo, monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">{{r.value}}</span>
</span> <span style="font-size: 12px; color: #5E5A7D; white-space: nowrap; overflow: hidden; text-overflow: ellipsis">{{r.sub}}</span>
</span>
</a>
<button aria-pressed="{{r.on}}" aria-label="Включить правило" style="flex: none; width: 52px; height: 32px; border: 0; border-radius: 16px; background: {{r.track}}; padding: 4px; display: flex; justify-content: {{r.align}}"><span style="width: 24px; height: 24px; border-radius: 12px; background: #FFFFFF"></span></button> <button aria-pressed="{{r.on}}" aria-label="Включить правило" style="flex: none; width: 52px; height: 32px; border: 0; border-radius: 16px; background: {{r.track}}; padding: 4px; display: flex; justify-content: {{r.align}}"><span style="width: 24px; height: 24px; border-radius: 12px; background: #FFFFFF"></span></button>
<a href="F_RuleDeleted.dc.html" aria-label="Удалить правило" style="flex: none; margin-left: -8px; width: 44px; height: 44px; border-radius: 22px; display: flex; align-items: center; justify-content: center; color: #5E5A7D">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 7h14M10 11v6M14 11v6M7 7l1 13h8l1-13M9 7V4h6v3"/></svg>
</a>
</div> </div>
</sc-for> </sc-for>
</div> </div>
@@ -64,20 +69,21 @@ class Component extends DCLogic {
return { title: title, dot: dot, rows: rows.map(function (r, i) { return { title: title, dot: dot, rows: rows.map(function (r, i) {
return { kind: r[0], tagBg: tag[r[0]][0], tagFg: tag[r[0]][1], value: r[1], sub: r[2], return { kind: r[0], tagBg: tag[r[0]][0], tagFg: tag[r[0]][1], value: r[1], sub: r[2],
on: r[3] ? 'true' : 'false', track: r[3] ? '#6D4AFF' : '#C9C5D0', align: r[3] ? 'flex-end' : 'flex-start', on: r[3] ? 'true' : 'false', track: r[3] ? '#6D4AFF' : '#C9C5D0', align: r[3] ? 'flex-end' : 'flex-start',
fg: r[3] ? '#1C1B20' : '#5E5A7D',
divider: i < rows.length - 1 ? '1px solid #F0ECF4' : '0' }; divider: i < rows.length - 1 ? '1px solid #F0ECF4' : '0' };
}) }; }) };
}; };
return { return {
groups: [ groups: [
grp('ВСЕГДА ЧЕРЕЗ VPN', '#6D4AFF', [ grp('ВСЕГДА ЧЕРЕЗ VPN', '#6D4AFF', [
['САЙТ', 'chatgpt.com', 'и поддомены', true], ['САЙТ', 'chatgpt.com', '3 адреса', true],
['АДРЕС', '130.49.213.176', 'один адрес', true], ['АДРЕС', '130.49.213.176', 'один адрес', true],
['АДРЕС', '10.8.0.0/24', 'подсеть', false] ['АДРЕС', '10.8.0.0/24', 'сеть · выключено', false]
]), ]),
grp('ВСЕГДА НАПРЯМУЮ', '#3FBF7F', [ grp('ВСЕГДА НАПРЯМУЮ', '#3FBF7F', [
['ПАКЕТ', 'ru.*', '14 приложений', true], ['ПАКЕТ', 'ru.*', '14 приложений', true],
['ПАКЕТ', '*yandex*', '6 приложений · 1 исключение', true], ['ПАКЕТ', '*yandex*', '6 приложений', true],
['САЙТ', '*.mos.ru', 'и поддомены', true] ['САЙТ', 'mos.ru', '2 адреса', true]
]) ])
] ]
}; };
@@ -1137,6 +1137,38 @@
"h": 1000, "h": 1000,
"title": "Виджеты на рабочем столе", "title": "Виджеты на рабочем столе",
"page": "final_dark" "page": "final_dark"
},
"F_RuleEdit.dc.html": {
"x": 2350,
"y": 1540,
"w": 390,
"h": 844,
"page": "final",
"title": "Правило: изменить или удалить"
},
"F_RuleDeleted.dc.html": {
"x": 2820,
"y": 1540,
"w": 390,
"h": 900,
"page": "final",
"title": "Правило удалено — «Отменить»"
},
"D_RuleEdit.dc.html": {
"x": 2350,
"y": 1540,
"w": 390,
"h": 844,
"page": "final_dark",
"title": "Правило: изменить или удалить"
},
"D_RuleDeleted.dc.html": {
"x": 2820,
"y": 1540,
"w": 390,
"h": 900,
"page": "final_dark",
"title": "Правило удалено — «Отменить»"
} }
}, },
"createdOnFiles": { "createdOnFiles": {
@@ -1162,7 +1194,7 @@
"fB": { "fB": {
"color": "green", "color": "green",
"kind": "title1", "kind": "title1",
"maxW": 2270, "maxW": 3210,
"page": "final", "page": "final",
"text": "Финал 2026 · Маршруты", "text": "Финал 2026 · Маршруты",
"w": 240, "w": 240,
@@ -1339,7 +1371,7 @@
"dfB": { "dfB": {
"color": "purple", "color": "purple",
"kind": "title1", "kind": "title1",
"maxW": 2270, "maxW": 3210,
"page": "final_dark", "page": "final_dark",
"text": "Тёмная тема · Маршруты", "text": "Тёмная тема · Маршруты",
"w": 240, "w": 240,
@@ -1589,7 +1621,11 @@
"D_HomeOffline.dc.html", "D_HomeOffline.dc.html",
"D_HomeUnreachable.dc.html", "D_HomeUnreachable.dc.html",
"D_QsTile.dc.html", "D_QsTile.dc.html",
"D_Widgets.dc.html" "D_Widgets.dc.html",
"F_RuleEdit.dc.html",
"F_RuleDeleted.dc.html",
"D_RuleEdit.dc.html",
"D_RuleDeleted.dc.html"
], ],
"pages": [ "pages": [
{ {