Add proxy routing and sing-box support
This commit is contained in:
@@ -18,3 +18,5 @@ plans/
|
|||||||
.temp/
|
.temp/
|
||||||
certs/
|
certs/
|
||||||
codenomad/
|
codenomad/
|
||||||
|
native/go_mobile_runtime/.work/
|
||||||
|
native/go_mobile_runtime/build/
|
||||||
|
|||||||
@@ -24,6 +24,8 @@ if (flutterVersionName == null) {
|
|||||||
flutterVersionName = "1.0"
|
flutterVersionName = "1.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def weblibreGoMobileAar = file("../../../../native/go_mobile_runtime/build/weblibre-go.aar")
|
||||||
|
|
||||||
android {
|
android {
|
||||||
namespace = "eu.weblibre.gecko"
|
namespace = "eu.weblibre.gecko"
|
||||||
compileSdk = 36
|
compileSdk = 36
|
||||||
@@ -64,7 +66,19 @@ android {
|
|||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
// implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
// implementation "org.jetbrains.kotlin:kotlin-stdlib-jdk7:$kotlin_version"
|
||||||
implementation 'androidx.glance:glance-appwidget:1.1.1'
|
if (!weblibreGoMobileAar.exists()) {
|
||||||
|
throw new GradleException(
|
||||||
|
"Missing combined gomobile runtime AAR: ${weblibreGoMobileAar}. " +
|
||||||
|
"Run native/go_mobile_runtime/scripts/build-android.sh from the repository root."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
implementation files(weblibreGoMobileAar)
|
||||||
|
implementation('androidx.glance:glance-appwidget') {
|
||||||
|
version {
|
||||||
|
strictly '1.1.1'
|
||||||
|
}
|
||||||
|
because 'home_widget uses 1.+ and newer alpha releases require AGP 9.1 and compileSdk 37'
|
||||||
|
}
|
||||||
implementation 'com.google.android.material:material:1.13.0'
|
implementation 'com.google.android.material:material:1.13.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,622 @@
|
|||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"description": "This file contains a serialized version of schema entities for drift.",
|
||||||
|
"version": "1.3.0"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"store_date_time_values_as_text": false
|
||||||
|
},
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "setting",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "key",
|
||||||
|
"getter_name": "key",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "PRIMARY KEY NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"primary-key"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "partition_key",
|
||||||
|
"getter_name": "partitionKey",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "value",
|
||||||
|
"getter_name": "value",
|
||||||
|
"moor_type": "any",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": [],
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "proxy_profile",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"getter_name": "id",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL PRIMARY KEY",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"primary-key"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"getter_name": "name",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "type",
|
||||||
|
"getter_name": "type",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [],
|
||||||
|
"type_converter": {
|
||||||
|
"dart_expr": "const EnumNameConverter<SingboxProxyProfileType>(SingboxProxyProfileType.values)",
|
||||||
|
"dart_type_name": "SingboxProxyProfileType"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "config_json",
|
||||||
|
"getter_name": "configJson",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"getter_name": "createdAt",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||||
|
"default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at",
|
||||||
|
"getter_name": "updatedAt",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||||
|
"default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"references": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"type": "index",
|
||||||
|
"data": {
|
||||||
|
"on": 1,
|
||||||
|
"name": "idx_proxy_profile_updated_at",
|
||||||
|
"sql": "CREATE INDEX idx_proxy_profile_updated_at ON proxy_profile(updated_at);",
|
||||||
|
"unique": false,
|
||||||
|
"columns": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "proxy_routing_setting",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"getter_name": "id",
|
||||||
|
"moor_type": "int",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL PRIMARY KEY CHECK (id = 1)",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"primary-key"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "regular_tabs_mode",
|
||||||
|
"getter_name": "regularTabsMode",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [],
|
||||||
|
"type_converter": {
|
||||||
|
"dart_expr": "const EnumNameConverter<ProxyRegularTabRoutingMode>(ProxyRegularTabRoutingMode.values)",
|
||||||
|
"dart_type_name": "ProxyRegularTabRoutingMode"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "regular_tabs_proxy_connection_id",
|
||||||
|
"getter_name": "regularTabsProxyConnectionId",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "private_tabs_proxy_connection_id",
|
||||||
|
"getter_name": "privateTabsProxyConnectionId",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "icon_cache",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "origin",
|
||||||
|
"getter_name": "origin",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "PRIMARY KEY NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"primary-key"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "icon_data",
|
||||||
|
"getter_name": "iconData",
|
||||||
|
"moor_type": "blob",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fetch_date",
|
||||||
|
"getter_name": "fetchDate",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "onboarding",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "revision",
|
||||||
|
"getter_name": "revision",
|
||||||
|
"moor_type": "int",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "completion_date",
|
||||||
|
"getter_name": "completionDate",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "riverpod",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "key",
|
||||||
|
"getter_name": "key",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "PRIMARY KEY NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"primary-key"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "json",
|
||||||
|
"getter_name": "json",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "expireAt",
|
||||||
|
"getter_name": "expireAt",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "destroyKey",
|
||||||
|
"getter_name": "destroyKey",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": true,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "toolbar_button_configs",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "button_id",
|
||||||
|
"getter_name": "buttonId",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL PRIMARY KEY",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"primary-key"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "order_key",
|
||||||
|
"getter_name": "orderKey",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "is_visible",
|
||||||
|
"getter_name": "isVisible",
|
||||||
|
"moor_type": "bool",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL DEFAULT TRUE",
|
||||||
|
"default_dart": "const CustomExpression('TRUE')",
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fallback_id",
|
||||||
|
"getter_name": "fallbackId",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "REFERENCES toolbar_button_configs(button_id)ON DELETE SET NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"references": [
|
||||||
|
7
|
||||||
|
],
|
||||||
|
"type": "index",
|
||||||
|
"data": {
|
||||||
|
"on": 7,
|
||||||
|
"name": "idx_toolbar_order_key",
|
||||||
|
"sql": "CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs(order_key);",
|
||||||
|
"unique": false,
|
||||||
|
"columns": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "search_tokens",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"getter_name": "id",
|
||||||
|
"moor_type": "int",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "PRIMARY KEY AUTOINCREMENT",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"auto-increment"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "token",
|
||||||
|
"getter_name": "token",
|
||||||
|
"moor_type": "blob",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "inserted_at",
|
||||||
|
"getter_name": "insertedAt",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "issuer_key_version",
|
||||||
|
"getter_name": "issuerKeyVersion",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "reserved_at",
|
||||||
|
"getter_name": "reservedAt",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"references": [
|
||||||
|
9
|
||||||
|
],
|
||||||
|
"type": "index",
|
||||||
|
"data": {
|
||||||
|
"on": 9,
|
||||||
|
"name": "idx_search_tokens_inserted_at",
|
||||||
|
"sql": "CREATE INDEX idx_search_tokens_inserted_at ON search_tokens(inserted_at);",
|
||||||
|
"unique": false,
|
||||||
|
"columns": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"references": [
|
||||||
|
9
|
||||||
|
],
|
||||||
|
"type": "index",
|
||||||
|
"data": {
|
||||||
|
"on": 9,
|
||||||
|
"name": "idx_search_tokens_reserved_at",
|
||||||
|
"sql": "CREATE INDEX idx_search_tokens_reserved_at ON search_tokens(reserved_at);",
|
||||||
|
"unique": false,
|
||||||
|
"columns": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fixed_sql": [
|
||||||
|
{
|
||||||
|
"name": "setting",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"setting\" (\"key\" TEXT PRIMARY KEY NOT NULL, \"partition_key\" TEXT, \"value\" ANY) STRICT;"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "proxy_profile",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"proxy_profile\" (\"id\" TEXT NOT NULL PRIMARY KEY, \"name\" TEXT NOT NULL, \"type\" TEXT NOT NULL, \"config_json\" TEXT NOT NULL, \"created_at\" INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP, \"updated_at\" INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP);"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_proxy_profile_updated_at",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE INDEX idx_proxy_profile_updated_at ON proxy_profile (updated_at)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "proxy_routing_setting",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"proxy_routing_setting\" (\"id\" INTEGER NOT NULL PRIMARY KEY CHECK (id = 1), \"regular_tabs_mode\" TEXT NOT NULL, \"regular_tabs_proxy_connection_id\" TEXT, \"private_tabs_proxy_connection_id\" TEXT);"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "icon_cache",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"icon_cache\" (\"origin\" TEXT PRIMARY KEY NOT NULL, \"icon_data\" BLOB NOT NULL, \"fetch_date\" INTEGER NOT NULL);"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "onboarding",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"onboarding\" (\"revision\" INTEGER NOT NULL, \"completion_date\" INTEGER NOT NULL);"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "riverpod",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"riverpod\" (\"key\" TEXT PRIMARY KEY NOT NULL, \"json\" TEXT NOT NULL, \"expireAt\" INTEGER, \"destroyKey\" TEXT) WITHOUT ROWID;"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "toolbar_button_configs",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"toolbar_button_configs\" (\"button_id\" TEXT NOT NULL PRIMARY KEY, \"order_key\" TEXT NOT NULL, \"is_visible\" INTEGER NOT NULL DEFAULT TRUE, \"fallback_id\" TEXT REFERENCES toolbar_button_configs(button_id)ON DELETE SET NULL);"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_toolbar_order_key",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs (order_key)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "search_tokens",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"search_tokens\" (\"id\" INTEGER PRIMARY KEY AUTOINCREMENT, \"token\" BLOB NOT NULL, \"inserted_at\" INTEGER NOT NULL, \"issuer_key_version\" TEXT NOT NULL, \"reserved_at\" INTEGER);"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_search_tokens_inserted_at",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE INDEX idx_search_tokens_inserted_at ON search_tokens (inserted_at)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_search_tokens_reserved_at",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE INDEX idx_search_tokens_reserved_at ON search_tokens (reserved_at)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,632 @@
|
|||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"description": "This file contains a serialized version of schema entities for drift.",
|
||||||
|
"version": "1.3.0"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"store_date_time_values_as_text": false
|
||||||
|
},
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "setting",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "key",
|
||||||
|
"getter_name": "key",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "PRIMARY KEY NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"primary-key"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "partition_key",
|
||||||
|
"getter_name": "partitionKey",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "value",
|
||||||
|
"getter_name": "value",
|
||||||
|
"moor_type": "any",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": [],
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "proxy_profile",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"getter_name": "id",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL PRIMARY KEY",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"primary-key"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"getter_name": "name",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "type",
|
||||||
|
"getter_name": "type",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [],
|
||||||
|
"type_converter": {
|
||||||
|
"dart_expr": "const EnumNameConverter<SingboxProxyProfileType>(SingboxProxyProfileType.values)",
|
||||||
|
"dart_type_name": "SingboxProxyProfileType"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "config_json",
|
||||||
|
"getter_name": "configJson",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dns_override_json",
|
||||||
|
"getter_name": "dnsOverrideJson",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"getter_name": "createdAt",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||||
|
"default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at",
|
||||||
|
"getter_name": "updatedAt",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||||
|
"default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"references": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"type": "index",
|
||||||
|
"data": {
|
||||||
|
"on": 1,
|
||||||
|
"name": "idx_proxy_profile_updated_at",
|
||||||
|
"sql": "CREATE INDEX idx_proxy_profile_updated_at ON proxy_profile(updated_at);",
|
||||||
|
"unique": false,
|
||||||
|
"columns": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "proxy_routing_setting",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"getter_name": "id",
|
||||||
|
"moor_type": "int",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL PRIMARY KEY CHECK (id = 1)",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"primary-key"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "regular_tabs_mode",
|
||||||
|
"getter_name": "regularTabsMode",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [],
|
||||||
|
"type_converter": {
|
||||||
|
"dart_expr": "const EnumNameConverter<ProxyRegularTabRoutingMode>(ProxyRegularTabRoutingMode.values)",
|
||||||
|
"dart_type_name": "ProxyRegularTabRoutingMode"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "regular_tabs_proxy_connection_id",
|
||||||
|
"getter_name": "regularTabsProxyConnectionId",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "private_tabs_proxy_connection_id",
|
||||||
|
"getter_name": "privateTabsProxyConnectionId",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "icon_cache",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "origin",
|
||||||
|
"getter_name": "origin",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "PRIMARY KEY NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"primary-key"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "icon_data",
|
||||||
|
"getter_name": "iconData",
|
||||||
|
"moor_type": "blob",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fetch_date",
|
||||||
|
"getter_name": "fetchDate",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "onboarding",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "revision",
|
||||||
|
"getter_name": "revision",
|
||||||
|
"moor_type": "int",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "completion_date",
|
||||||
|
"getter_name": "completionDate",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "riverpod",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "key",
|
||||||
|
"getter_name": "key",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "PRIMARY KEY NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"primary-key"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "json",
|
||||||
|
"getter_name": "json",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "expireAt",
|
||||||
|
"getter_name": "expireAt",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "destroyKey",
|
||||||
|
"getter_name": "destroyKey",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": true,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "toolbar_button_configs",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "button_id",
|
||||||
|
"getter_name": "buttonId",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL PRIMARY KEY",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"primary-key"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "order_key",
|
||||||
|
"getter_name": "orderKey",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "is_visible",
|
||||||
|
"getter_name": "isVisible",
|
||||||
|
"moor_type": "bool",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL DEFAULT TRUE",
|
||||||
|
"default_dart": "const CustomExpression('TRUE')",
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fallback_id",
|
||||||
|
"getter_name": "fallbackId",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "REFERENCES toolbar_button_configs(button_id)ON DELETE SET NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"references": [
|
||||||
|
7
|
||||||
|
],
|
||||||
|
"type": "index",
|
||||||
|
"data": {
|
||||||
|
"on": 7,
|
||||||
|
"name": "idx_toolbar_order_key",
|
||||||
|
"sql": "CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs(order_key);",
|
||||||
|
"unique": false,
|
||||||
|
"columns": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "search_tokens",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"getter_name": "id",
|
||||||
|
"moor_type": "int",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "PRIMARY KEY AUTOINCREMENT",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"auto-increment"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "token",
|
||||||
|
"getter_name": "token",
|
||||||
|
"moor_type": "blob",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "inserted_at",
|
||||||
|
"getter_name": "insertedAt",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "issuer_key_version",
|
||||||
|
"getter_name": "issuerKeyVersion",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "reserved_at",
|
||||||
|
"getter_name": "reservedAt",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"references": [
|
||||||
|
9
|
||||||
|
],
|
||||||
|
"type": "index",
|
||||||
|
"data": {
|
||||||
|
"on": 9,
|
||||||
|
"name": "idx_search_tokens_inserted_at",
|
||||||
|
"sql": "CREATE INDEX idx_search_tokens_inserted_at ON search_tokens(inserted_at);",
|
||||||
|
"unique": false,
|
||||||
|
"columns": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 11,
|
||||||
|
"references": [
|
||||||
|
9
|
||||||
|
],
|
||||||
|
"type": "index",
|
||||||
|
"data": {
|
||||||
|
"on": 9,
|
||||||
|
"name": "idx_search_tokens_reserved_at",
|
||||||
|
"sql": "CREATE INDEX idx_search_tokens_reserved_at ON search_tokens(reserved_at);",
|
||||||
|
"unique": false,
|
||||||
|
"columns": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fixed_sql": [
|
||||||
|
{
|
||||||
|
"name": "setting",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"setting\" (\"key\" TEXT PRIMARY KEY NOT NULL, \"partition_key\" TEXT, \"value\" ANY) STRICT;"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "proxy_profile",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"proxy_profile\" (\"id\" TEXT NOT NULL PRIMARY KEY, \"name\" TEXT NOT NULL, \"type\" TEXT NOT NULL, \"config_json\" TEXT NOT NULL, \"dns_override_json\" TEXT, \"created_at\" INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP, \"updated_at\" INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP);"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_proxy_profile_updated_at",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE INDEX idx_proxy_profile_updated_at ON proxy_profile (updated_at)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "proxy_routing_setting",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"proxy_routing_setting\" (\"id\" INTEGER NOT NULL PRIMARY KEY CHECK (id = 1), \"regular_tabs_mode\" TEXT NOT NULL, \"regular_tabs_proxy_connection_id\" TEXT, \"private_tabs_proxy_connection_id\" TEXT);"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "icon_cache",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"icon_cache\" (\"origin\" TEXT PRIMARY KEY NOT NULL, \"icon_data\" BLOB NOT NULL, \"fetch_date\" INTEGER NOT NULL);"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "onboarding",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"onboarding\" (\"revision\" INTEGER NOT NULL, \"completion_date\" INTEGER NOT NULL);"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "riverpod",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"riverpod\" (\"key\" TEXT PRIMARY KEY NOT NULL, \"json\" TEXT NOT NULL, \"expireAt\" INTEGER, \"destroyKey\" TEXT) WITHOUT ROWID;"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "toolbar_button_configs",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"toolbar_button_configs\" (\"button_id\" TEXT NOT NULL PRIMARY KEY, \"order_key\" TEXT NOT NULL, \"is_visible\" INTEGER NOT NULL DEFAULT TRUE, \"fallback_id\" TEXT REFERENCES toolbar_button_configs(button_id)ON DELETE SET NULL);"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_toolbar_order_key",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs (order_key)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "search_tokens",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"search_tokens\" (\"id\" INTEGER PRIMARY KEY AUTOINCREMENT, \"token\" BLOB NOT NULL, \"inserted_at\" INTEGER NOT NULL, \"issuer_key_version\" TEXT NOT NULL, \"reserved_at\" INTEGER);"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_search_tokens_inserted_at",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE INDEX idx_search_tokens_inserted_at ON search_tokens (inserted_at)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_search_tokens_reserved_at",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE INDEX idx_search_tokens_reserved_at ON search_tokens (reserved_at)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,563 @@
|
|||||||
|
{
|
||||||
|
"_meta": {
|
||||||
|
"description": "This file contains a serialized version of schema entities for drift.",
|
||||||
|
"version": "1.3.0"
|
||||||
|
},
|
||||||
|
"options": {
|
||||||
|
"store_date_time_values_as_text": false
|
||||||
|
},
|
||||||
|
"entities": [
|
||||||
|
{
|
||||||
|
"id": 0,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "setting",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "key",
|
||||||
|
"getter_name": "key",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "PRIMARY KEY NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"primary-key"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "partition_key",
|
||||||
|
"getter_name": "partitionKey",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "value",
|
||||||
|
"getter_name": "value",
|
||||||
|
"moor_type": "any",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": [],
|
||||||
|
"strict": true
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 1,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "proxy_profile",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"getter_name": "id",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL PRIMARY KEY",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"primary-key"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "name",
|
||||||
|
"getter_name": "name",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "type",
|
||||||
|
"getter_name": "type",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [],
|
||||||
|
"type_converter": {
|
||||||
|
"dart_expr": "const EnumNameConverter<SingboxProxyProfileType>(SingboxProxyProfileType.values)",
|
||||||
|
"dart_type_name": "SingboxProxyProfileType"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "config_json",
|
||||||
|
"getter_name": "configJson",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "dns_override_json",
|
||||||
|
"getter_name": "dnsOverrideJson",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "created_at",
|
||||||
|
"getter_name": "createdAt",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||||
|
"default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "updated_at",
|
||||||
|
"getter_name": "updatedAt",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL DEFAULT CURRENT_TIMESTAMP",
|
||||||
|
"default_dart": "const CustomExpression('CURRENT_TIMESTAMP')",
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 2,
|
||||||
|
"references": [
|
||||||
|
1
|
||||||
|
],
|
||||||
|
"type": "index",
|
||||||
|
"data": {
|
||||||
|
"on": 1,
|
||||||
|
"name": "idx_proxy_profile_updated_at",
|
||||||
|
"sql": "CREATE INDEX idx_proxy_profile_updated_at ON proxy_profile(updated_at);",
|
||||||
|
"unique": false,
|
||||||
|
"columns": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 3,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "icon_cache",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "origin",
|
||||||
|
"getter_name": "origin",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "PRIMARY KEY NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"primary-key"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "icon_data",
|
||||||
|
"getter_name": "iconData",
|
||||||
|
"moor_type": "blob",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fetch_date",
|
||||||
|
"getter_name": "fetchDate",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 4,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "onboarding",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "revision",
|
||||||
|
"getter_name": "revision",
|
||||||
|
"moor_type": "int",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "completion_date",
|
||||||
|
"getter_name": "completionDate",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 5,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "riverpod",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "key",
|
||||||
|
"getter_name": "key",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "PRIMARY KEY NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"primary-key"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "json",
|
||||||
|
"getter_name": "json",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "expireAt",
|
||||||
|
"getter_name": "expireAt",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "destroyKey",
|
||||||
|
"getter_name": "destroyKey",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": true,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 6,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "toolbar_button_configs",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "button_id",
|
||||||
|
"getter_name": "buttonId",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL PRIMARY KEY",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"primary-key"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "order_key",
|
||||||
|
"getter_name": "orderKey",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "is_visible",
|
||||||
|
"getter_name": "isVisible",
|
||||||
|
"moor_type": "bool",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL DEFAULT TRUE",
|
||||||
|
"default_dart": "const CustomExpression('TRUE')",
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "fallback_id",
|
||||||
|
"getter_name": "fallbackId",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "REFERENCES toolbar_button_configs(button_id)ON DELETE SET NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 7,
|
||||||
|
"references": [
|
||||||
|
6
|
||||||
|
],
|
||||||
|
"type": "index",
|
||||||
|
"data": {
|
||||||
|
"on": 6,
|
||||||
|
"name": "idx_toolbar_order_key",
|
||||||
|
"sql": "CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs(order_key);",
|
||||||
|
"unique": false,
|
||||||
|
"columns": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 8,
|
||||||
|
"references": [],
|
||||||
|
"type": "table",
|
||||||
|
"data": {
|
||||||
|
"name": "search_tokens",
|
||||||
|
"was_declared_in_moor": true,
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"name": "id",
|
||||||
|
"getter_name": "id",
|
||||||
|
"moor_type": "int",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "PRIMARY KEY AUTOINCREMENT",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": [
|
||||||
|
"auto-increment"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "token",
|
||||||
|
"getter_name": "token",
|
||||||
|
"moor_type": "blob",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "inserted_at",
|
||||||
|
"getter_name": "insertedAt",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "issuer_key_version",
|
||||||
|
"getter_name": "issuerKeyVersion",
|
||||||
|
"moor_type": "string",
|
||||||
|
"nullable": false,
|
||||||
|
"customConstraints": "NOT NULL",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "reserved_at",
|
||||||
|
"getter_name": "reservedAt",
|
||||||
|
"moor_type": "dateTime",
|
||||||
|
"nullable": true,
|
||||||
|
"customConstraints": "",
|
||||||
|
"default_dart": null,
|
||||||
|
"default_client_dart": null,
|
||||||
|
"dsl_features": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"is_virtual": false,
|
||||||
|
"without_rowid": false,
|
||||||
|
"constraints": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 9,
|
||||||
|
"references": [
|
||||||
|
8
|
||||||
|
],
|
||||||
|
"type": "index",
|
||||||
|
"data": {
|
||||||
|
"on": 8,
|
||||||
|
"name": "idx_search_tokens_inserted_at",
|
||||||
|
"sql": "CREATE INDEX idx_search_tokens_inserted_at ON search_tokens(inserted_at);",
|
||||||
|
"unique": false,
|
||||||
|
"columns": []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": 10,
|
||||||
|
"references": [
|
||||||
|
8
|
||||||
|
],
|
||||||
|
"type": "index",
|
||||||
|
"data": {
|
||||||
|
"on": 8,
|
||||||
|
"name": "idx_search_tokens_reserved_at",
|
||||||
|
"sql": "CREATE INDEX idx_search_tokens_reserved_at ON search_tokens(reserved_at);",
|
||||||
|
"unique": false,
|
||||||
|
"columns": []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"fixed_sql": [
|
||||||
|
{
|
||||||
|
"name": "setting",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"setting\" (\"key\" TEXT PRIMARY KEY NOT NULL, \"partition_key\" TEXT, \"value\" ANY) STRICT;"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "proxy_profile",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"proxy_profile\" (\"id\" TEXT NOT NULL PRIMARY KEY, \"name\" TEXT NOT NULL, \"type\" TEXT NOT NULL, \"config_json\" TEXT NOT NULL, \"dns_override_json\" TEXT, \"created_at\" INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP, \"updated_at\" INTEGER NOT NULL DEFAULT CURRENT_TIMESTAMP);"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_proxy_profile_updated_at",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE INDEX idx_proxy_profile_updated_at ON proxy_profile (updated_at)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "icon_cache",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"icon_cache\" (\"origin\" TEXT PRIMARY KEY NOT NULL, \"icon_data\" BLOB NOT NULL, \"fetch_date\" INTEGER NOT NULL);"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "onboarding",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"onboarding\" (\"revision\" INTEGER NOT NULL, \"completion_date\" INTEGER NOT NULL);"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "riverpod",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"riverpod\" (\"key\" TEXT PRIMARY KEY NOT NULL, \"json\" TEXT NOT NULL, \"expireAt\" INTEGER, \"destroyKey\" TEXT) WITHOUT ROWID;"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "toolbar_button_configs",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"toolbar_button_configs\" (\"button_id\" TEXT NOT NULL PRIMARY KEY, \"order_key\" TEXT NOT NULL, \"is_visible\" INTEGER NOT NULL DEFAULT TRUE, \"fallback_id\" TEXT REFERENCES toolbar_button_configs(button_id)ON DELETE SET NULL);"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_toolbar_order_key",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE INDEX idx_toolbar_order_key ON toolbar_button_configs (order_key)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "search_tokens",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE TABLE IF NOT EXISTS \"search_tokens\" (\"id\" INTEGER PRIMARY KEY AUTOINCREMENT, \"token\" BLOB NOT NULL, \"inserted_at\" INTEGER NOT NULL, \"issuer_key_version\" TEXT NOT NULL, \"reserved_at\" INTEGER);"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_search_tokens_inserted_at",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE INDEX idx_search_tokens_inserted_at ON search_tokens (inserted_at)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "idx_search_tokens_reserved_at",
|
||||||
|
"sql": [
|
||||||
|
{
|
||||||
|
"dialect": "sqlite",
|
||||||
|
"sql": "CREATE INDEX idx_search_tokens_reserved_at ON search_tokens (reserved_at)"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -61,6 +61,12 @@ import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/c
|
|||||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_list.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_list.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_selection.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_selection.dart';
|
||||||
import 'package:weblibre/features/onboarding/presentation/onboarding.dart';
|
import 'package:weblibre/features/onboarding/presentation/onboarding.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/models/proxy_profile_seed.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/screens/proxy_routing_settings.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/screens/singbox_proxy_logs.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/screens/singbox_proxy_profile_editor.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/screens/singbox_proxy_profiles.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/screens/subscription_import.dart';
|
||||||
import 'package:weblibre/features/settings/presentation/screens/addon_collection.dart';
|
import 'package:weblibre/features/settings/presentation/screens/addon_collection.dart';
|
||||||
import 'package:weblibre/features/settings/presentation/screens/advanced_settings.dart';
|
import 'package:weblibre/features/settings/presentation/screens/advanced_settings.dart';
|
||||||
import 'package:weblibre/features/settings/presentation/screens/bang_settings.dart';
|
import 'package:weblibre/features/settings/presentation/screens/bang_settings.dart';
|
||||||
@@ -75,6 +81,7 @@ import 'package:weblibre/features/settings/presentation/screens/fingerprint_sett
|
|||||||
import 'package:weblibre/features/settings/presentation/screens/general_settings.dart';
|
import 'package:weblibre/features/settings/presentation/screens/general_settings.dart';
|
||||||
import 'package:weblibre/features/settings/presentation/screens/locale_settings.dart';
|
import 'package:weblibre/features/settings/presentation/screens/locale_settings.dart';
|
||||||
import 'package:weblibre/features/settings/presentation/screens/privacy_security_settings.dart';
|
import 'package:weblibre/features/settings/presentation/screens/privacy_security_settings.dart';
|
||||||
|
import 'package:weblibre/features/settings/presentation/screens/proxy_settings.dart';
|
||||||
import 'package:weblibre/features/settings/presentation/screens/search_settings.dart';
|
import 'package:weblibre/features/settings/presentation/screens/search_settings.dart';
|
||||||
import 'package:weblibre/features/settings/presentation/screens/settings.dart';
|
import 'package:weblibre/features/settings/presentation/screens/settings.dart';
|
||||||
import 'package:weblibre/features/settings/presentation/screens/toolbar_layout_settings.dart';
|
import 'package:weblibre/features/settings/presentation/screens/toolbar_layout_settings.dart';
|
||||||
|
|||||||
@@ -1660,6 +1660,38 @@ RouteBase get $settingsRoute => GoRouteData.$route(
|
|||||||
name: 'ContextualToolbarSettingsRoute',
|
name: 'ContextualToolbarSettingsRoute',
|
||||||
factory: $ContextualToolbarSettingsRoute._fromState,
|
factory: $ContextualToolbarSettingsRoute._fromState,
|
||||||
),
|
),
|
||||||
|
GoRouteData.$route(
|
||||||
|
path: 'singbox_proxy_profiles',
|
||||||
|
name: 'SingboxProxyProfilesRoute',
|
||||||
|
factory: $SingboxProxyProfilesRoute._fromState,
|
||||||
|
routes: [
|
||||||
|
GoRouteData.$route(
|
||||||
|
path: 'editor',
|
||||||
|
name: 'SingboxProxyProfileEditorRoute',
|
||||||
|
factory: $SingboxProxyProfileEditorRoute._fromState,
|
||||||
|
),
|
||||||
|
GoRouteData.$route(
|
||||||
|
path: 'logs',
|
||||||
|
name: 'SingboxProxyLogsRoute',
|
||||||
|
factory: $SingboxProxyLogsRoute._fromState,
|
||||||
|
),
|
||||||
|
GoRouteData.$route(
|
||||||
|
path: 'subscription',
|
||||||
|
name: 'SubscriptionImportRoute',
|
||||||
|
factory: $SubscriptionImportRoute._fromState,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
GoRouteData.$route(
|
||||||
|
path: 'proxy_routing',
|
||||||
|
name: 'ProxyRoutingSettingsRoute',
|
||||||
|
factory: $ProxyRoutingSettingsRoute._fromState,
|
||||||
|
),
|
||||||
|
GoRouteData.$route(
|
||||||
|
path: 'proxy',
|
||||||
|
name: 'ProxySettingsRoute',
|
||||||
|
factory: $ProxySettingsRoute._fromState,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -2213,6 +2245,146 @@ mixin $ContextualToolbarSettingsRoute on GoRouteData {
|
|||||||
void replace(BuildContext context) => context.replace(location);
|
void replace(BuildContext context) => context.replace(location);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mixin $SingboxProxyProfilesRoute on GoRouteData {
|
||||||
|
static SingboxProxyProfilesRoute _fromState(GoRouterState state) =>
|
||||||
|
const SingboxProxyProfilesRoute();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get location =>
|
||||||
|
GoRouteData.$location('/settings/singbox_proxy_profiles');
|
||||||
|
|
||||||
|
@override
|
||||||
|
void go(BuildContext context) => context.go(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void pushReplacement(BuildContext context) =>
|
||||||
|
context.pushReplacement(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void replace(BuildContext context) => context.replace(location);
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin $SingboxProxyProfileEditorRoute on GoRouteData {
|
||||||
|
static SingboxProxyProfileEditorRoute _fromState(GoRouterState state) =>
|
||||||
|
SingboxProxyProfileEditorRoute(
|
||||||
|
profileId: state.uri.queryParameters['profile-id'],
|
||||||
|
$extra: state.extra as ProxyProfileSeed?,
|
||||||
|
);
|
||||||
|
|
||||||
|
SingboxProxyProfileEditorRoute get _self =>
|
||||||
|
this as SingboxProxyProfileEditorRoute;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get location => GoRouteData.$location(
|
||||||
|
'/settings/singbox_proxy_profiles/editor',
|
||||||
|
queryParams: {if (_self.profileId != null) 'profile-id': _self.profileId},
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void go(BuildContext context) => context.go(location, extra: _self.$extra);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T?> push<T>(BuildContext context) =>
|
||||||
|
context.push<T>(location, extra: _self.$extra);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void pushReplacement(BuildContext context) =>
|
||||||
|
context.pushReplacement(location, extra: _self.$extra);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void replace(BuildContext context) =>
|
||||||
|
context.replace(location, extra: _self.$extra);
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin $SingboxProxyLogsRoute on GoRouteData {
|
||||||
|
static SingboxProxyLogsRoute _fromState(GoRouterState state) =>
|
||||||
|
const SingboxProxyLogsRoute();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get location =>
|
||||||
|
GoRouteData.$location('/settings/singbox_proxy_profiles/logs');
|
||||||
|
|
||||||
|
@override
|
||||||
|
void go(BuildContext context) => context.go(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void pushReplacement(BuildContext context) =>
|
||||||
|
context.pushReplacement(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void replace(BuildContext context) => context.replace(location);
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin $SubscriptionImportRoute on GoRouteData {
|
||||||
|
static SubscriptionImportRoute _fromState(GoRouterState state) =>
|
||||||
|
const SubscriptionImportRoute();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get location =>
|
||||||
|
GoRouteData.$location('/settings/singbox_proxy_profiles/subscription');
|
||||||
|
|
||||||
|
@override
|
||||||
|
void go(BuildContext context) => context.go(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void pushReplacement(BuildContext context) =>
|
||||||
|
context.pushReplacement(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void replace(BuildContext context) => context.replace(location);
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin $ProxyRoutingSettingsRoute on GoRouteData {
|
||||||
|
static ProxyRoutingSettingsRoute _fromState(GoRouterState state) =>
|
||||||
|
const ProxyRoutingSettingsRoute();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get location => GoRouteData.$location('/settings/proxy_routing');
|
||||||
|
|
||||||
|
@override
|
||||||
|
void go(BuildContext context) => context.go(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void pushReplacement(BuildContext context) =>
|
||||||
|
context.pushReplacement(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void replace(BuildContext context) => context.replace(location);
|
||||||
|
}
|
||||||
|
|
||||||
|
mixin $ProxySettingsRoute on GoRouteData {
|
||||||
|
static ProxySettingsRoute _fromState(GoRouterState state) =>
|
||||||
|
const ProxySettingsRoute();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String get location => GoRouteData.$location('/settings/proxy');
|
||||||
|
|
||||||
|
@override
|
||||||
|
void go(BuildContext context) => context.go(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T?> push<T>(BuildContext context) => context.push<T>(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void pushReplacement(BuildContext context) =>
|
||||||
|
context.pushReplacement(location);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void replace(BuildContext context) => context.replace(location);
|
||||||
|
}
|
||||||
|
|
||||||
RouteBase get $torProxyRoute => GoRouteData.$route(
|
RouteBase get $torProxyRoute => GoRouteData.$route(
|
||||||
path: '/tor',
|
path: '/tor',
|
||||||
name: 'TorProxyRoute',
|
name: 'TorProxyRoute',
|
||||||
|
|||||||
@@ -113,6 +113,29 @@ part of 'routes.dart';
|
|||||||
name: 'ContextualToolbarSettingsRoute',
|
name: 'ContextualToolbarSettingsRoute',
|
||||||
path: 'contextual_toolbar',
|
path: 'contextual_toolbar',
|
||||||
),
|
),
|
||||||
|
TypedGoRoute<SingboxProxyProfilesRoute>(
|
||||||
|
name: 'SingboxProxyProfilesRoute',
|
||||||
|
path: 'singbox_proxy_profiles',
|
||||||
|
routes: [
|
||||||
|
TypedGoRoute<SingboxProxyProfileEditorRoute>(
|
||||||
|
name: 'SingboxProxyProfileEditorRoute',
|
||||||
|
path: 'editor',
|
||||||
|
),
|
||||||
|
TypedGoRoute<SingboxProxyLogsRoute>(
|
||||||
|
name: 'SingboxProxyLogsRoute',
|
||||||
|
path: 'logs',
|
||||||
|
),
|
||||||
|
TypedGoRoute<SubscriptionImportRoute>(
|
||||||
|
name: 'SubscriptionImportRoute',
|
||||||
|
path: 'subscription',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
TypedGoRoute<ProxyRoutingSettingsRoute>(
|
||||||
|
name: 'ProxyRoutingSettingsRoute',
|
||||||
|
path: 'proxy_routing',
|
||||||
|
),
|
||||||
|
TypedGoRoute<ProxySettingsRoute>(name: 'ProxySettingsRoute', path: 'proxy'),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class SettingsRoute extends GoRouteData with $SettingsRoute {
|
class SettingsRoute extends GoRouteData with $SettingsRoute {
|
||||||
@@ -315,3 +338,64 @@ class ContextualToolbarSettingsRoute extends GoRouteData
|
|||||||
return const ContextualToolbarSettingsScreen();
|
return const ContextualToolbarSettingsScreen();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class SingboxProxyProfilesRoute extends GoRouteData
|
||||||
|
with $SingboxProxyProfilesRoute {
|
||||||
|
const SingboxProxyProfilesRoute();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, GoRouterState state) {
|
||||||
|
return const SingboxProxyProfilesScreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SingboxProxyProfileEditorRoute extends GoRouteData
|
||||||
|
with $SingboxProxyProfileEditorRoute {
|
||||||
|
final String? profileId;
|
||||||
|
final ProxyProfileSeed? $extra;
|
||||||
|
|
||||||
|
const SingboxProxyProfileEditorRoute({this.profileId, this.$extra});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, GoRouterState state) {
|
||||||
|
return SingboxProxyProfileEditorScreen(profileId: profileId, seed: $extra);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SingboxProxyLogsRoute extends GoRouteData with $SingboxProxyLogsRoute {
|
||||||
|
const SingboxProxyLogsRoute();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, GoRouterState state) {
|
||||||
|
return const SingboxProxyLogsScreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class SubscriptionImportRoute extends GoRouteData
|
||||||
|
with $SubscriptionImportRoute {
|
||||||
|
const SubscriptionImportRoute();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, GoRouterState state) {
|
||||||
|
return const SubscriptionImportScreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProxyRoutingSettingsRoute extends GoRouteData
|
||||||
|
with $ProxyRoutingSettingsRoute {
|
||||||
|
const ProxyRoutingSettingsRoute();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, GoRouterState state) {
|
||||||
|
return const ProxyRoutingSettingsScreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProxySettingsRoute extends GoRouteData with $ProxySettingsRoute {
|
||||||
|
const ProxySettingsRoute();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, GoRouterState state) {
|
||||||
|
return const ProxySettingsScreen();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -54,7 +54,8 @@ class HandoffRedeemResult {
|
|||||||
class HandoffRedeemClient {
|
class HandoffRedeemClient {
|
||||||
final http.Client _client;
|
final http.Client _client;
|
||||||
|
|
||||||
HandoffRedeemClient({http.Client? client}) : _client = client ?? http.Client();
|
HandoffRedeemClient({http.Client? client})
|
||||||
|
: _client = client ?? http.Client();
|
||||||
|
|
||||||
void close() => _client.close();
|
void close() => _client.close();
|
||||||
|
|
||||||
|
|||||||
@@ -106,7 +106,10 @@ class AccountSettingsScreen extends HookConsumerWidget {
|
|||||||
final syncRepo = syncClient != null
|
final syncRepo = syncClient != null
|
||||||
? ref.read(accountSyncRepositoryProvider.notifier)
|
? ref.read(accountSyncRepositoryProvider.notifier)
|
||||||
: null;
|
: null;
|
||||||
final sourceDeviceId = ref.read(androidDeviceInfoProvider).value?.deviceName;
|
final sourceDeviceId = ref
|
||||||
|
.read(androidDeviceInfoProvider)
|
||||||
|
.value
|
||||||
|
?.deviceName;
|
||||||
final sourceAppVersion = _appVersion(ref);
|
final sourceAppVersion = _appVersion(ref);
|
||||||
|
|
||||||
return <SettingsSectionDefinition>[
|
return <SettingsSectionDefinition>[
|
||||||
@@ -121,7 +124,8 @@ class AccountSettingsScreen extends HookConsumerWidget {
|
|||||||
AccountAuthStatus.error => 'Sign-in failed',
|
AccountAuthStatus.error => 'Sign-in failed',
|
||||||
},
|
},
|
||||||
subtitle: switch (authState.status) {
|
subtitle: switch (authState.status) {
|
||||||
AccountAuthStatus.signedOut => 'Sync your settings across devices',
|
AccountAuthStatus.signedOut =>
|
||||||
|
'Sync your settings across devices',
|
||||||
AccountAuthStatus.signingIn => 'Complete sign-in in your browser',
|
AccountAuthStatus.signingIn => 'Complete sign-in in your browser',
|
||||||
AccountAuthStatus.signedIn =>
|
AccountAuthStatus.signedIn =>
|
||||||
authState.displayName ?? authState.email ?? 'Signed in',
|
authState.displayName ?? authState.email ?? 'Signed in',
|
||||||
|
|||||||
+49
-40
@@ -23,10 +23,12 @@ import 'dart:convert';
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:secure_archive/secure_archive.dart';
|
import 'package:secure_archive/secure_archive.dart';
|
||||||
|
import 'package:weblibre/core/logger.dart';
|
||||||
import 'package:weblibre/features/account/data/repositories/account_sync_repository.dart';
|
import 'package:weblibre/features/account/data/repositories/account_sync_repository.dart';
|
||||||
import 'package:weblibre/features/account/domain/services/sync_document_service.dart';
|
import 'package:weblibre/features/account/domain/services/sync_document_service.dart';
|
||||||
import 'package:weblibre/features/account/presentation/widgets/sync_document_dialogs.dart';
|
import 'package:weblibre/features/account/presentation/widgets/sync_document_dialogs.dart';
|
||||||
import 'package:weblibre/features/settings/presentation/widgets/settings_content_card.dart';
|
import 'package:weblibre/features/settings/presentation/widgets/settings_content_card.dart';
|
||||||
|
import 'package:weblibre/utils/ui_helper.dart';
|
||||||
|
|
||||||
class SyncDocumentListSection extends HookWidget {
|
class SyncDocumentListSection extends HookWidget {
|
||||||
const SyncDocumentListSection({
|
const SyncDocumentListSection({
|
||||||
@@ -59,12 +61,18 @@ class SyncDocumentListSection extends HookWidget {
|
|||||||
Future<void> refresh() async {
|
Future<void> refresh() async {
|
||||||
try {
|
try {
|
||||||
final docs = await syncRepo.listDocuments(kind: service.kind);
|
final docs = await syncRepo.listDocuments(kind: service.kind);
|
||||||
|
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
documents.value = docs;
|
documents.value = docs;
|
||||||
} catch (e) {
|
} catch (error, stackTrace) {
|
||||||
|
logger.e(
|
||||||
|
'Failed to list ${service.kind.name} sync documents',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showErrorMessage(context, 'Failed to load snapshots: $error');
|
||||||
SnackBar(content: Text('Failed to load snapshots: $e')),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -103,16 +111,17 @@ class SyncDocumentListSection extends HookWidget {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showInfoMessage(context, '${service.kind.displayName} stored');
|
||||||
SnackBar(content: Text('${service.kind.displayName} stored')),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (e) {
|
} catch (error, stackTrace) {
|
||||||
|
logger.e(
|
||||||
|
'Failed to store ${service.kind.name} sync document',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(
|
showErrorMessage(context, 'Failed to store: $error');
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text('Failed to store: $e')));
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
@@ -133,9 +142,7 @@ class SyncDocumentListSection extends HookWidget {
|
|||||||
final result = await syncRepo.fetchDocument(id: metadata.id);
|
final result = await syncRepo.fetchDocument(id: metadata.id);
|
||||||
if (result == null) {
|
if (result == null) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(
|
showErrorMessage(context, 'Snapshot not found');
|
||||||
context,
|
|
||||||
).showSnackBar(const SnackBar(content: Text('Snapshot not found')));
|
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -145,25 +152,23 @@ class SyncDocumentListSection extends HookWidget {
|
|||||||
await service.applyRestored(plaintext);
|
await service.applyRestored(plaintext);
|
||||||
|
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showInfoMessage(context, '${service.kind.displayName} restored');
|
||||||
SnackBar(content: Text('${service.kind.displayName} restored')),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (error, stackTrace) {
|
||||||
|
logger.e(
|
||||||
|
'Failed to restore ${service.kind.name} sync document ${metadata.id}',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
if (_isDecryptionFailure(e)) {
|
if (_isDecryptionFailure(error)) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
showErrorMessage(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text(
|
'Decryption failed — wrong password or data corrupted. '
|
||||||
'Decryption failed — wrong password or data corrupted. '
|
'Try resetting your sync key.',
|
||||||
'Try resetting your sync key.',
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
ScaffoldMessenger.of(
|
showErrorMessage(context, 'Failed to restore: $error');
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text('Failed to restore: $e')));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
@@ -186,11 +191,14 @@ class SyncDocumentListSection extends HookWidget {
|
|||||||
label: newLabel.isEmpty ? null : newLabel,
|
label: newLabel.isEmpty ? null : newLabel,
|
||||||
);
|
);
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (e) {
|
} catch (error, stackTrace) {
|
||||||
|
logger.e(
|
||||||
|
'Failed to update label for sync document ${metadata.id}',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(
|
showErrorMessage(context, 'Failed to update label: $error');
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text('Failed to update label: $e')));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -205,16 +213,17 @@ class SyncDocumentListSection extends HookWidget {
|
|||||||
try {
|
try {
|
||||||
await syncRepo.deleteDocument(id: metadata.id);
|
await syncRepo.deleteDocument(id: metadata.id);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(
|
showInfoMessage(context, 'Snapshot deleted');
|
||||||
context,
|
|
||||||
).showSnackBar(const SnackBar(content: Text('Snapshot deleted')));
|
|
||||||
}
|
}
|
||||||
await refresh();
|
await refresh();
|
||||||
} catch (e) {
|
} catch (error, stackTrace) {
|
||||||
|
logger.e(
|
||||||
|
'Failed to delete sync document ${metadata.id}',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(
|
showErrorMessage(context, 'Failed to delete: $error');
|
||||||
context,
|
|
||||||
).showSnackBar(SnackBar(content: Text('Failed to delete: $e')));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -163,9 +163,7 @@ class SyncSetupCard extends HookConsumerWidget {
|
|||||||
Align(
|
Align(
|
||||||
alignment: Alignment.centerRight,
|
alignment: Alignment.centerRight,
|
||||||
child: FilledButton(
|
child: FilledButton(
|
||||||
onPressed: (busy.value || !passwordsMatch)
|
onPressed: (busy.value || !passwordsMatch) ? null : setupSyncKey,
|
||||||
? null
|
|
||||||
: setupSyncKey,
|
|
||||||
child: busy.value
|
child: busy.value
|
||||||
? const SizedBox(
|
? const SizedBox(
|
||||||
height: 20,
|
height: 20,
|
||||||
@@ -236,9 +234,7 @@ Future<void> _uploadValidationProbe(
|
|||||||
String syncKey,
|
String syncKey,
|
||||||
) async {
|
) async {
|
||||||
final payload = utf8.encode('weblibre:sync-probe:v1');
|
final payload = utf8.encode('weblibre:sync-probe:v1');
|
||||||
final secureData = SecureData(
|
final secureData = SecureData(argon2Params: Argon2Params.memoryConstrained());
|
||||||
argon2Params: Argon2Params.memoryConstrained(),
|
|
||||||
);
|
|
||||||
final ciphertext = await secureData.encrypt(payload, syncKey);
|
final ciphertext = await secureData.encrypt(payload, syncKey);
|
||||||
await syncRepo.storeDocument(
|
await syncRepo.storeDocument(
|
||||||
kind: SyncDocumentKind.syncValidationProbe,
|
kind: SyncDocumentKind.syncValidationProbe,
|
||||||
|
|||||||
@@ -41,14 +41,13 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'
|
|||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/gecko_inference.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/utils/image_helper.dart';
|
import 'package:weblibre/features/geckoview/utils/image_helper.dart';
|
||||||
import 'package:weblibre/features/user/data/models/tor_settings.dart';
|
import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart';
|
||||||
import 'package:weblibre/features/user/domain/repositories/tor_settings.dart';
|
import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart';
|
||||||
|
|
||||||
part 'tab_state.g.dart';
|
part 'tab_state.g.dart';
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
class TabStates extends _$TabStates {
|
class TabStates extends _$TabStates {
|
||||||
|
|
||||||
Future<void> _onTabContentStateChange(TabContentState contentState) async {
|
Future<void> _onTabContentStateChange(TabContentState contentState) async {
|
||||||
final current = await patchedState(contentState.id);
|
final current = await patchedState(contentState.id);
|
||||||
|
|
||||||
@@ -377,25 +376,27 @@ Future<TabState> tabStateWithFallback(Ref ref, String tabId) async {
|
|||||||
@Riverpod()
|
@Riverpod()
|
||||||
Future<bool> isTabTunneled(Ref ref, String? tabId) async {
|
Future<bool> isTabTunneled(Ref ref, String? tabId) async {
|
||||||
final tabState = ref.watch(tabStateProvider(tabId));
|
final tabState = ref.watch(tabStateProvider(tabId));
|
||||||
final torSettings = ref.watch(torSettingsWithDefaultsProvider);
|
final proxyRoutingSettings = ref.watch(
|
||||||
|
proxyRoutingSettingsWithDefaultsProvider,
|
||||||
|
);
|
||||||
|
|
||||||
if (tabState != null) {
|
if (tabState != null) {
|
||||||
// Isolated tabs follow the same proxy rules as regular tabs
|
// Isolated tabs follow the same proxy rules as regular tabs
|
||||||
// (container-based routing via proxy aliasing)
|
// (container-based routing via proxy aliasing)
|
||||||
if (tabState.tabMode is PrivateTabMode) {
|
if (tabState.tabMode is PrivateTabMode) {
|
||||||
return torSettings.proxyPrivateTabsTor;
|
return proxyRoutingSettings.privateTabsProxyConnectionId != null;
|
||||||
} else {
|
} else {
|
||||||
switch (torSettings.proxyRegularTabsMode) {
|
switch (proxyRoutingSettings.regularTabsMode) {
|
||||||
case TorRegularTabProxyMode.container:
|
case ProxyRegularTabRoutingMode.container:
|
||||||
final containerData = await ref
|
final containerData = await ref
|
||||||
.read(tabDataRepositoryProvider.notifier)
|
.read(tabDataRepositoryProvider.notifier)
|
||||||
.getTabContainerData(tabState.id);
|
.getTabContainerData(tabState.id);
|
||||||
|
|
||||||
if (!ref.mounted) return false;
|
if (!ref.mounted) return false;
|
||||||
|
|
||||||
return containerData?.metadata.useProxy ?? false;
|
return containerData?.metadata.proxyConnectionId != null;
|
||||||
case TorRegularTabProxyMode.all:
|
case ProxyRegularTabRoutingMode.all:
|
||||||
return true;
|
return proxyRoutingSettings.regularTabsProxyConnectionId != null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ final class IsTabTunneledProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$isTabTunneledHash() => r'b11134b33ad0c98ffddd14afc2c790fb65735e93';
|
String _$isTabTunneledHash() => r'cb3a3afe5b94f7e9c77d6a32266afbb8a225c259';
|
||||||
|
|
||||||
final class IsTabTunneledFamily extends $Family
|
final class IsTabTunneledFamily extends $Family
|
||||||
with $FunctionalFamilyOverride<FutureOr<bool>, String?> {
|
with $FunctionalFamilyOverride<FutureOr<bool>, String?> {
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
|
|||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||||
import 'package:weblibre/features/tor/domain/repositories/tor_proxy.dart';
|
import 'package:weblibre/features/proxy/domain/repositories/container_proxy.dart';
|
||||||
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
import 'package:weblibre/features/user/data/models/general_settings.dart';
|
||||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||||
import 'package:weblibre/utils/debouncer.dart';
|
import 'package:weblibre/utils/debouncer.dart';
|
||||||
@@ -417,7 +417,7 @@ class TabRepository extends _$TabRepository {
|
|||||||
if (!ref.mounted) return false;
|
if (!ref.mounted) return false;
|
||||||
|
|
||||||
if (containerData != null) {
|
if (containerData != null) {
|
||||||
if (containerData.metadata.useProxy) {
|
if (containerData.metadata.proxyConnectionId != null) {
|
||||||
final proxyPluginHealthy = await GeckoContainerProxyService()
|
final proxyPluginHealthy = await GeckoContainerProxyService()
|
||||||
.healthcheck();
|
.healthcheck();
|
||||||
|
|
||||||
@@ -711,8 +711,8 @@ class TabRepository extends _$TabRepository {
|
|||||||
// Best-effort: remove proxy alias (no-op if never set)
|
// Best-effort: remove proxy alias (no-op if never set)
|
||||||
try {
|
try {
|
||||||
await ref
|
await ref
|
||||||
.read(torProxyRepositoryProvider.notifier)
|
.read(containerProxyRepositoryProvider.notifier)
|
||||||
.removeContainerProxy(contextId);
|
.clearContainerProxy(contextId);
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
logger.e(
|
logger.e(
|
||||||
'Failed to remove proxy for isolation context $contextId',
|
'Failed to remove proxy for isolation context $contextId',
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ final class TabRepositoryProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$tabRepositoryHash() => r'ea5caa3c9c4b19898db9de1f77d5476794b8b66f';
|
String _$tabRepositoryHash() => r'2cd5b6bf5bb9a62c05b038b28acef59cecbeae4e';
|
||||||
|
|
||||||
abstract class _$TabRepository extends $Notifier<void> {
|
abstract class _$TabRepository extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
|
|||||||
+178
-93
@@ -18,41 +18,66 @@
|
|||||||
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
import 'package:fast_equatable/fast_equatable.dart';
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
import 'package:nullability/nullability.dart';
|
|
||||||
import 'package:riverpod/riverpod.dart';
|
import 'package:riverpod/riverpod.dart';
|
||||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
import 'package:synchronized/synchronized.dart';
|
import 'package:synchronized/synchronized.dart';
|
||||||
import 'package:weblibre/core/logger.dart';
|
import 'package:weblibre/core/logger.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||||
import 'package:weblibre/features/tor/domain/repositories/tor_proxy.dart';
|
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/container_proxy.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
|
||||||
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
|
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
|
||||||
import 'package:weblibre/features/user/data/models/tor_settings.dart';
|
import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart';
|
||||||
import 'package:weblibre/features/user/domain/repositories/tor_settings.dart';
|
import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart';
|
||||||
|
|
||||||
part 'proxy_settings_replication.g.dart';
|
part 'proxy_settings_replication.g.dart';
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
class ProxySettingsReplication extends _$ProxySettingsReplication {
|
class ProxySettingsReplication extends _$ProxySettingsReplication {
|
||||||
var _proxiedIsolationContexts = <String>{};
|
var _isolatedProxyAssignments = <String, String>{};
|
||||||
|
var _appliedContainerProxies = <String, String?>{};
|
||||||
|
|
||||||
final _recomputeLock = Lock();
|
final _recomputeLock = Lock();
|
||||||
|
var _recomputeDirty = false;
|
||||||
|
|
||||||
|
Future<void> _ensureProxyConnectionAvailable(
|
||||||
|
ProxyConnectionId proxyConnectionId,
|
||||||
|
) async {
|
||||||
|
if (proxyConnectionId is! SingboxProxyConnectionId) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(singboxProxyRuntimeRepositoryProvider.notifier)
|
||||||
|
.ensureProxyConnectionAvailable(proxyConnectionId);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
logger.e(
|
||||||
|
'Failed to make proxy connection $proxyConnectionId available; '
|
||||||
|
'assigned traffic will remain blocked',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _queueIsolatedProxyAliasesRecompute() async {
|
Future<void> _queueIsolatedProxyAliasesRecompute() async {
|
||||||
if (_recomputeLock.inLock) {
|
_recomputeDirty = true;
|
||||||
return;
|
if (_recomputeLock.inLock) return;
|
||||||
}
|
|
||||||
|
|
||||||
await _recomputeLock.synchronized(() async {
|
await _recomputeLock.synchronized(() async {
|
||||||
try {
|
while (_recomputeDirty) {
|
||||||
await _recomputeIsolatedProxyAliases();
|
_recomputeDirty = false;
|
||||||
} catch (error, stackTrace) {
|
try {
|
||||||
logger.e(
|
await _recomputeIsolatedProxyAliases();
|
||||||
'Error recomputing isolated proxy aliases',
|
} catch (error, stackTrace) {
|
||||||
error: error,
|
logger.e(
|
||||||
stackTrace: stackTrace,
|
'Error recomputing isolated proxy aliases',
|
||||||
);
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -78,37 +103,56 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
|
|||||||
.read(containerRepositoryProvider.notifier)
|
.read(containerRepositoryProvider.notifier)
|
||||||
.getAllContainersWithCount();
|
.getAllContainersWithCount();
|
||||||
|
|
||||||
// Build set of container IDs that have useProxy enabled.
|
final containerProxyIds = <String, String>{
|
||||||
final proxiedContainerIds = <String>{
|
|
||||||
for (final c in containers)
|
for (final c in containers)
|
||||||
if (c.metadata.useProxy) c.id,
|
if (c.metadata.proxyConnectionId case final proxyId?)
|
||||||
|
c.id: proxyId.encode(),
|
||||||
};
|
};
|
||||||
|
|
||||||
// Compute which isolation contexts need proxy aliases.
|
final newAssignments = <String, String>{};
|
||||||
// A context needs an alias if ANY of its associated containers
|
for (final entry in contextContainerMap.entries) {
|
||||||
// has useProxy enabled.
|
final proxyIds =
|
||||||
final newProxied = <String>{
|
entry.value
|
||||||
for (final entry in contextContainerMap.entries)
|
.map((containerId) => containerProxyIds[containerId])
|
||||||
if (entry.value.any(proxiedContainerIds.contains)) entry.key,
|
.nonNulls
|
||||||
};
|
.toSet()
|
||||||
|
.toList()
|
||||||
|
..sort();
|
||||||
|
|
||||||
// Remove aliases that are no longer needed.
|
if (proxyIds.isEmpty) continue;
|
||||||
final toRemove = _proxiedIsolationContexts.difference(newProxied);
|
|
||||||
|
newAssignments[entry.key] = proxyIds.first;
|
||||||
|
|
||||||
|
if (proxyIds.length > 1) {
|
||||||
|
// Isolation contexts can hold multiple containers; if they disagree on
|
||||||
|
// a proxy connection the alias is forced to pick one. Surface this so
|
||||||
|
// the user can split the containers across isolation contexts.
|
||||||
|
logger.w(
|
||||||
|
'Isolation context ${entry.key} has containers with multiple '
|
||||||
|
'proxy connections (${proxyIds.join(', ')}); using ${proxyIds.first}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final previousContexts = _isolatedProxyAssignments.keys.toSet();
|
||||||
|
final nextContexts = newAssignments.keys.toSet();
|
||||||
|
|
||||||
|
final toRemove = previousContexts.difference(nextContexts);
|
||||||
for (final contextId in toRemove) {
|
for (final contextId in toRemove) {
|
||||||
await ref
|
await ref
|
||||||
.read(torProxyRepositoryProvider.notifier)
|
.read(containerProxyRepositoryProvider.notifier)
|
||||||
.removeContainerProxy(contextId);
|
.clearContainerProxy(contextId);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add aliases that are newly needed.
|
for (final MapEntry(:key, :value) in newAssignments.entries) {
|
||||||
final toAdd = newProxied.difference(_proxiedIsolationContexts);
|
if (_isolatedProxyAssignments[key] == value) continue;
|
||||||
for (final contextId in toAdd) {
|
|
||||||
await ref
|
await ref
|
||||||
.read(torProxyRepositoryProvider.notifier)
|
.read(containerProxyRepositoryProvider.notifier)
|
||||||
.addContainerProxy(contextId);
|
.setContainerProxy(key, value);
|
||||||
}
|
}
|
||||||
|
|
||||||
_proxiedIsolationContexts = newProxied;
|
_isolatedProxyAssignments = newAssignments;
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@@ -118,8 +162,8 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
|
|||||||
torProxyServiceProvider.select((data) => data.value),
|
torProxyServiceProvider.select((data) => data.value),
|
||||||
(previous, next) async {
|
(previous, next) async {
|
||||||
await ref
|
await ref
|
||||||
.read(torProxyRepositoryProvider.notifier)
|
.read(containerProxyRepositoryProvider.notifier)
|
||||||
.setProxyPort(next?.socksPort ?? -1);
|
.setTorProxyPort(next?.socksPort);
|
||||||
},
|
},
|
||||||
onError: (error, stackTrace) {
|
onError: (error, stackTrace) {
|
||||||
logger.e(
|
logger.e(
|
||||||
@@ -132,51 +176,18 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
|
|||||||
|
|
||||||
ref.listen(
|
ref.listen(
|
||||||
fireImmediately: true,
|
fireImmediately: true,
|
||||||
watchContainersWithCountProvider.select(
|
proxyRoutingSettingsWithDefaultsProvider.select(
|
||||||
(value) => EquatableValue(value.value),
|
(value) => value.privateTabsProxyConnectionId,
|
||||||
),
|
),
|
||||||
(previous, next) async {
|
(previous, next) async {
|
||||||
if (next.value != null) {
|
final containerProxy = ref.read(
|
||||||
for (final container in next.value!) {
|
containerProxyRepositoryProvider.notifier,
|
||||||
if (container.metadata.contextualIdentity.isNotEmpty) {
|
|
||||||
if (container.metadata.useProxy) {
|
|
||||||
await ref
|
|
||||||
.read(torProxyRepositoryProvider.notifier)
|
|
||||||
.addContainerProxy(container.metadata.contextualIdentity!);
|
|
||||||
} else {
|
|
||||||
await ref
|
|
||||||
.read(torProxyRepositoryProvider.notifier)
|
|
||||||
.removeContainerProxy(
|
|
||||||
container.metadata.contextualIdentity!,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (error, stackTrace) {
|
|
||||||
logger.e(
|
|
||||||
'Error listening to containersWithCountProvider',
|
|
||||||
error: error,
|
|
||||||
stackTrace: stackTrace,
|
|
||||||
);
|
);
|
||||||
},
|
if (next == null) {
|
||||||
);
|
await containerProxy.clearContainerProxy('private');
|
||||||
|
|
||||||
ref.listen(
|
|
||||||
fireImmediately: true,
|
|
||||||
torSettingsWithDefaultsProvider.select(
|
|
||||||
(value) => value.proxyPrivateTabsTor,
|
|
||||||
),
|
|
||||||
(previous, next) async {
|
|
||||||
if (next) {
|
|
||||||
await ref
|
|
||||||
.read(torProxyRepositoryProvider.notifier)
|
|
||||||
.addContainerProxy('private');
|
|
||||||
} else {
|
} else {
|
||||||
await ref
|
await containerProxy.setContainerProxy('private', next.encode());
|
||||||
.read(torProxyRepositoryProvider.notifier)
|
await _ensureProxyConnectionAvailable(next);
|
||||||
.removeContainerProxy('private');
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error, stackTrace) {
|
onError: (error, stackTrace) {
|
||||||
@@ -190,19 +201,27 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
|
|||||||
|
|
||||||
ref.listen(
|
ref.listen(
|
||||||
fireImmediately: true,
|
fireImmediately: true,
|
||||||
torSettingsWithDefaultsProvider.select(
|
proxyRoutingSettingsWithDefaultsProvider.select(
|
||||||
(value) => value.proxyRegularTabsMode,
|
(value) => (value.regularTabsMode, value.regularTabsProxyConnectionId),
|
||||||
),
|
),
|
||||||
(previous, next) async {
|
(previous, next) async {
|
||||||
switch (next) {
|
final (regularTabsMode, proxyConnectionId) = next;
|
||||||
case TorRegularTabProxyMode.container:
|
final containerProxy = ref.read(
|
||||||
await ref
|
containerProxyRepositoryProvider.notifier,
|
||||||
.read(torProxyRepositoryProvider.notifier)
|
);
|
||||||
.removeContainerProxy('general');
|
switch (regularTabsMode) {
|
||||||
case TorRegularTabProxyMode.all:
|
case ProxyRegularTabRoutingMode.container:
|
||||||
await ref
|
await containerProxy.clearContainerProxy('general');
|
||||||
.read(torProxyRepositoryProvider.notifier)
|
case ProxyRegularTabRoutingMode.all:
|
||||||
.addContainerProxy('general');
|
if (proxyConnectionId == null) {
|
||||||
|
await containerProxy.clearContainerProxy('general');
|
||||||
|
} else {
|
||||||
|
await containerProxy.setContainerProxy(
|
||||||
|
'general',
|
||||||
|
proxyConnectionId.encode(),
|
||||||
|
);
|
||||||
|
await _ensureProxyConnectionAvailable(proxyConnectionId);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onError: (error, stackTrace) {
|
onError: (error, stackTrace) {
|
||||||
@@ -217,7 +236,7 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
|
|||||||
ref.listen(watchAllAssignedSitesProvider, (previous, next) async {
|
ref.listen(watchAllAssignedSitesProvider, (previous, next) async {
|
||||||
if (next.hasValue) {
|
if (next.hasValue) {
|
||||||
await ref
|
await ref
|
||||||
.read(torProxyRepositoryProvider.notifier)
|
.read(containerProxyRepositoryProvider.notifier)
|
||||||
.setSiteAssignments(next.requireValue);
|
.setSiteAssignments(next.requireValue);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -242,14 +261,80 @@ class ProxySettingsReplication extends _$ProxySettingsReplication {
|
|||||||
watchContainersWithCountProvider.select(
|
watchContainersWithCountProvider.select(
|
||||||
(value) => EquatableValue(value.value),
|
(value) => EquatableValue(value.value),
|
||||||
),
|
),
|
||||||
(previous, next) => _queueIsolatedProxyAliasesRecompute(),
|
(previous, next) async {
|
||||||
|
await _applyContainerProxies(next.value);
|
||||||
|
await _queueIsolatedProxyAliasesRecompute();
|
||||||
|
},
|
||||||
onError: (error, stackTrace) {
|
onError: (error, stackTrace) {
|
||||||
logger.e(
|
logger.e(
|
||||||
'Error listening to container proxy changes for isolated aliases',
|
'Error listening to containersWithCountProvider',
|
||||||
error: error,
|
error: error,
|
||||||
stackTrace: stackTrace,
|
stackTrace: stackTrace,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Push per-container proxy assignments to Gecko, reconciling against the
|
||||||
|
/// last applied state so a transient mid-loop failure surfaces as a retry
|
||||||
|
/// on the next event rather than leaving Gecko half-updated forever.
|
||||||
|
Future<void> _applyContainerProxies(
|
||||||
|
List<ContainerDataWithCount>? containers,
|
||||||
|
) async {
|
||||||
|
if (containers == null) return;
|
||||||
|
|
||||||
|
final desired = <String, String?>{};
|
||||||
|
for (final container in containers) {
|
||||||
|
final contextId = container.metadata.contextualIdentity;
|
||||||
|
if (contextId == null || contextId.isEmpty) continue;
|
||||||
|
desired[contextId] = container.metadata.proxyConnectionId?.encode();
|
||||||
|
}
|
||||||
|
|
||||||
|
final repo = ref.read(containerProxyRepositoryProvider.notifier);
|
||||||
|
final nextApplied = Map<String, String?>.from(_appliedContainerProxies);
|
||||||
|
|
||||||
|
for (final contextId in _appliedContainerProxies.keys.toSet().difference(
|
||||||
|
desired.keys.toSet(),
|
||||||
|
)) {
|
||||||
|
try {
|
||||||
|
await repo.clearContainerProxy(contextId);
|
||||||
|
nextApplied.remove(contextId);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
logger.e(
|
||||||
|
'Failed to clear removed container proxy assignment for $contextId',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for (final MapEntry(:key, :value) in desired.entries) {
|
||||||
|
if (_appliedContainerProxies[key] == value &&
|
||||||
|
_appliedContainerProxies.containsKey(key)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (value != null) {
|
||||||
|
final proxyConnectionId = ProxyConnectionId.decode(value);
|
||||||
|
if (proxyConnectionId != null) {
|
||||||
|
await _ensureProxyConnectionAvailable(proxyConnectionId);
|
||||||
|
}
|
||||||
|
await repo.setContainerProxy(key, value);
|
||||||
|
} else {
|
||||||
|
await repo.clearContainerProxy(key);
|
||||||
|
}
|
||||||
|
nextApplied[key] = value;
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
logger.e(
|
||||||
|
'Failed to push container proxy assignment for $key',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
// Leave nextApplied[key] at its previous value so the next event
|
||||||
|
// sees it as out-of-sync and retries.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_appliedContainerProxies = nextApplied;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ final class ProxySettingsReplicationProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$proxySettingsReplicationHash() =>
|
String _$proxySettingsReplicationHash() =>
|
||||||
r'7f47a561441cc2d594e9380d62dcc31f4c1059d3';
|
r'3a7f15c1e09e304d355dada2de80112dec0adbf4';
|
||||||
|
|
||||||
abstract class _$ProxySettingsReplication extends $Notifier<void> {
|
abstract class _$ProxySettingsReplication extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
|
|||||||
+4
-7
@@ -877,13 +877,10 @@ Future<void> _adjustFontSize(
|
|||||||
|
|
||||||
if (settings.automaticFontSizeAdjustment) {
|
if (settings.automaticFontSizeAdjustment) {
|
||||||
if (context.mounted) {
|
if (context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ui_helper.showInfoMessage(
|
||||||
const SnackBar(
|
context,
|
||||||
content: Text(
|
'Disable automatic font size in settings to adjust manually',
|
||||||
'Disable automatic font size in settings to adjust manually',
|
duration: const Duration(seconds: 2),
|
||||||
),
|
|
||||||
duration: Duration(seconds: 2),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
|
|||||||
+52
@@ -58,6 +58,9 @@ import 'package:weblibre/features/geckoview/features/find_in_page/presentation/c
|
|||||||
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart';
|
import 'package:weblibre/features/geckoview/features/find_in_page/presentation/widgets/find_in_page.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
|
import 'package:weblibre/features/geckoview/features/readerview/presentation/controllers/readerable.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_autofocus.dart';
|
import 'package:weblibre/features/geckoview/features/search/domain/providers/search_autofocus.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/controllers/ensure_proxy_started.dart';
|
||||||
import 'package:weblibre/features/small_web/presentation/controllers/small_web_mode_controller.dart';
|
import 'package:weblibre/features/small_web/presentation/controllers/small_web_mode_controller.dart';
|
||||||
import 'package:weblibre/features/small_web/presentation/widgets/small_web_browser_overlay.dart';
|
import 'package:weblibre/features/small_web/presentation/widgets/small_web_browser_overlay.dart';
|
||||||
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
||||||
@@ -234,6 +237,55 @@ class BrowserScreen extends HookConsumerWidget {
|
|||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final eventService = ref.watch(eventServiceProvider);
|
final eventService = ref.watch(eventServiceProvider);
|
||||||
final viewportService = ref.watch(viewportServiceProvider);
|
final viewportService = ref.watch(viewportServiceProvider);
|
||||||
|
final activeProxyPromptKeys = useRef(<String>{});
|
||||||
|
|
||||||
|
useOnStreamChange(
|
||||||
|
eventService.proxyLoadErrorEvents,
|
||||||
|
onData: (event) async {
|
||||||
|
final selectedTabId = ref.read(selectedTabProvider);
|
||||||
|
final tabId = event.tabId ?? selectedTabId;
|
||||||
|
if (tabId == null || tabId != selectedTabId) return;
|
||||||
|
|
||||||
|
final promptKey = '$tabId:${event.url ?? event.errorType}';
|
||||||
|
if (!activeProxyPromptKeys.value.add(promptKey)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final contextId = event.contextId;
|
||||||
|
final contextContainer = contextId != null && contextId.isNotEmpty
|
||||||
|
? await ref
|
||||||
|
.read(containerRepositoryProvider.notifier)
|
||||||
|
.getContainerByContextualIdentity(contextId)
|
||||||
|
: null;
|
||||||
|
final container =
|
||||||
|
contextContainer ??
|
||||||
|
await ref
|
||||||
|
.read(tabDataRepositoryProvider.notifier)
|
||||||
|
.getTabContainerData(tabId);
|
||||||
|
|
||||||
|
if (!context.mounted || container == null) return;
|
||||||
|
|
||||||
|
final isProxyStarted = await ensureProxyStartedForContainer(
|
||||||
|
context,
|
||||||
|
ref,
|
||||||
|
container,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isProxyStarted &&
|
||||||
|
context.mounted &&
|
||||||
|
ref.read(selectedTabProvider) == tabId) {
|
||||||
|
await ref.read(tabSessionProvider(tabId: tabId).notifier).reload();
|
||||||
|
}
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
logger.e(
|
||||||
|
'Failed to handle proxy load error',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
activeProxyPromptKeys.value.remove(promptKey);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
final tabInFullScreen = ref.watch(
|
final tabInFullScreen = ref.watch(
|
||||||
selectedTabStateProvider.select((value) => value?.isFullScreen ?? false),
|
selectedTabStateProvider.select((value) => value?.isFullScreen ?? false),
|
||||||
|
|||||||
+177
-91
@@ -23,6 +23,7 @@ import 'dart:convert';
|
|||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:fading_scroll/fading_scroll.dart';
|
import 'package:fading_scroll/fading_scroll.dart';
|
||||||
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
import 'package:file_picker/file_picker.dart';
|
import 'package:file_picker/file_picker.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@@ -35,6 +36,7 @@ import 'package:nullability/nullability.dart';
|
|||||||
import 'package:share_plus/share_plus.dart';
|
import 'package:share_plus/share_plus.dart';
|
||||||
import 'package:skeletonizer/skeletonizer.dart';
|
import 'package:skeletonizer/skeletonizer.dart';
|
||||||
import 'package:weblibre/core/design/app_colors.dart';
|
import 'package:weblibre/core/design/app_colors.dart';
|
||||||
|
import 'package:weblibre/core/logger.dart';
|
||||||
import 'package:weblibre/core/providers/persisted_bool.dart';
|
import 'package:weblibre/core/providers/persisted_bool.dart';
|
||||||
import 'package:weblibre/core/routing/routes.dart';
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
|
import 'package:weblibre/features/geckoview/domain/controllers/bottom_sheet.dart';
|
||||||
@@ -67,10 +69,14 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart'
|
|||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart';
|
import 'package:weblibre/features/geckoview/features/top_sites/domain/repositories/top_site_repository.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/models/singbox_proxy_profile.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/providers/assigned_proxy_profiles.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
|
||||||
import 'package:weblibre/features/small_web/presentation/controllers/small_web_mode_controller.dart';
|
import 'package:weblibre/features/small_web/presentation/controllers/small_web_mode_controller.dart';
|
||||||
import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart';
|
import 'package:weblibre/features/sync/domain/entities/sync_repository_state.dart';
|
||||||
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
||||||
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
|
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
|
||||||
|
import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart';
|
||||||
import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart';
|
import 'package:weblibre/features/user/domain/presentation/dialogs/quit_browser_dialog.dart';
|
||||||
import 'package:weblibre/features/user/domain/providers.dart';
|
import 'package:weblibre/features/user/domain/providers.dart';
|
||||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||||
@@ -1930,102 +1936,162 @@ class _QuickLinksGrid extends ConsumerWidget {
|
|||||||
final isTorActive = ref.watch(
|
final isTorActive = ref.watch(
|
||||||
torProxyServiceProvider.select((value) => value.value?.isRunning == true),
|
torProxyServiceProvider.select((value) => value.value?.isRunning == true),
|
||||||
);
|
);
|
||||||
|
final isTorBusy = ref.watch(startProxyControllerProvider);
|
||||||
|
|
||||||
return Column(
|
final assignedProfiles = ref.watch(assignedSingboxProxyProfilesProvider);
|
||||||
children: [
|
final runtimeEndpointIds = ref
|
||||||
Row(
|
.watch(
|
||||||
children: [
|
singboxProxyRuntimeRepositoryProvider.select((value) {
|
||||||
Expanded(
|
final endpoints = value.value?.endpoints;
|
||||||
child: _buildGridItem(
|
if (endpoints == null) {
|
||||||
context,
|
return EquatableValue(const <String>{});
|
||||||
Icons.history,
|
}
|
||||||
'History',
|
|
||||||
() async {
|
return EquatableValue({
|
||||||
Navigator.pop(context);
|
for (final endpoint in endpoints) endpoint.profileId,
|
||||||
await const HistoryRoute().push(context);
|
});
|
||||||
},
|
}),
|
||||||
),
|
)
|
||||||
),
|
.value;
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
final torActiveColor = AppColors.of(context).torActiveGreen;
|
||||||
child: _buildGridItem(
|
|
||||||
context,
|
final items = <_QuickLinkItem>[
|
||||||
MdiIcons.bookmarkMultiple,
|
_QuickLinkItem(
|
||||||
'Bookmarks',
|
icon: Icons.history,
|
||||||
() async {
|
label: 'History',
|
||||||
Navigator.pop(context);
|
onTap: () async {
|
||||||
await BookmarkListRoute(
|
Navigator.pop(context);
|
||||||
entryGuid: BookmarkRoot.root.id,
|
await const HistoryRoute().push(context);
|
||||||
).push(context);
|
},
|
||||||
},
|
),
|
||||||
),
|
_QuickLinkItem(
|
||||||
),
|
icon: MdiIcons.bookmarkMultiple,
|
||||||
const SizedBox(width: 8),
|
label: 'Bookmarks',
|
||||||
Expanded(
|
onTap: () async {
|
||||||
child: _buildGridItem(
|
Navigator.pop(context);
|
||||||
context,
|
await BookmarkListRoute(
|
||||||
MdiIcons.fileDownload,
|
entryGuid: BookmarkRoot.root.id,
|
||||||
'Downloads',
|
).push(context);
|
||||||
() async {
|
},
|
||||||
Navigator.pop(context);
|
),
|
||||||
await const HistoryDownloadsRoute().push(context);
|
_QuickLinkItem(
|
||||||
},
|
icon: MdiIcons.fileDownload,
|
||||||
),
|
label: 'Downloads',
|
||||||
),
|
onTap: () async {
|
||||||
const SizedBox(width: 8),
|
Navigator.pop(context);
|
||||||
Expanded(
|
await const HistoryDownloadsRoute().push(context);
|
||||||
child: _buildGridItem(
|
},
|
||||||
context,
|
),
|
||||||
MdiIcons.exclamationThick,
|
_QuickLinkItem(
|
||||||
'Bangs',
|
icon: MdiIcons.exclamationThick,
|
||||||
() async {
|
label: 'Bangs',
|
||||||
Navigator.pop(context);
|
onTap: () async {
|
||||||
await const BangMenuRoute().push(context);
|
Navigator.pop(context);
|
||||||
},
|
await const BangMenuRoute().push(context);
|
||||||
),
|
},
|
||||||
),
|
),
|
||||||
],
|
_QuickLinkItem(
|
||||||
|
icon: Icons.rss_feed,
|
||||||
|
label: 'Feeds',
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await context.push(FeedListRoute().location);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_QuickLinkItem(
|
||||||
|
icon: Icons.explore,
|
||||||
|
label: 'Small Web',
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await ref.read(smallWebModeControllerProvider.notifier).enter();
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_QuickLinkItem(
|
||||||
|
icon: TorIcons.onionAlt,
|
||||||
|
label: 'Tor\u2122 Proxy',
|
||||||
|
badge: isTorActive,
|
||||||
|
badgeColor: torActiveColor,
|
||||||
|
onTap: () async {
|
||||||
|
if (isTorBusy) return;
|
||||||
|
if (isTorActive) {
|
||||||
|
await ref.read(torProxyServiceProvider.notifier).disconnect();
|
||||||
|
} else {
|
||||||
|
await ref.read(startProxyControllerProvider.notifier).startProxy();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPress: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await const TorProxyRoute().push(context);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
for (final profile in assignedProfiles)
|
||||||
|
_QuickLinkItem(
|
||||||
|
icon: MdiIcons.lanConnect,
|
||||||
|
label: profile.name,
|
||||||
|
badge: runtimeEndpointIds.contains(profile.proxyConnectionId),
|
||||||
|
badgeColor: torActiveColor,
|
||||||
|
onTap: () async {
|
||||||
|
final runtime = ref.read(
|
||||||
|
singboxProxyRuntimeRepositoryProvider.notifier,
|
||||||
|
);
|
||||||
|
final isRunning = runtimeEndpointIds.contains(
|
||||||
|
profile.proxyConnectionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (isRunning) {
|
||||||
|
await runtime.stopProfiles([profile.id]);
|
||||||
|
} else {
|
||||||
|
await runtime.startProfile(profile.id);
|
||||||
|
}
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
logger.e(
|
||||||
|
'Failed to toggle proxy profile ${profile.id} from menu',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
if (context.mounted) {
|
||||||
|
ui_helper.showErrorMessage(context, 'Proxy error: $error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onLongPress: () async {
|
||||||
|
Navigator.pop(context);
|
||||||
|
await SingboxProxyProfileEditorRoute(
|
||||||
|
profileId: profile.id,
|
||||||
|
).push(context);
|
||||||
|
},
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
];
|
||||||
Row(
|
|
||||||
|
const spacing = 8.0;
|
||||||
|
const itemsPerRow = 4;
|
||||||
|
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) {
|
||||||
|
final width =
|
||||||
|
(constraints.maxWidth - spacing * (itemsPerRow - 1)) / itemsPerRow;
|
||||||
|
return Wrap(
|
||||||
|
spacing: spacing,
|
||||||
|
runSpacing: spacing,
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
for (final item in items)
|
||||||
child: _buildGridItem(
|
SizedBox(
|
||||||
context,
|
width: width,
|
||||||
TorIcons.onionAlt,
|
child: _buildGridItem(
|
||||||
'Tor\u2122 Proxy',
|
context,
|
||||||
() async {
|
item.icon,
|
||||||
Navigator.pop(context);
|
item.label,
|
||||||
await const TorProxyRoute().push(context);
|
item.onTap,
|
||||||
},
|
badge: item.badge,
|
||||||
badge: isTorActive,
|
badgeColor: item.badgeColor,
|
||||||
badgeColor: AppColors.of(context).torActiveGreen,
|
onLongPress: item.onLongPress,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: _buildGridItem(context, Icons.rss_feed, 'Feeds', () async {
|
|
||||||
Navigator.pop(context);
|
|
||||||
await context.push(FeedListRoute().location);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 8),
|
|
||||||
Expanded(
|
|
||||||
child: _buildGridItem(
|
|
||||||
context,
|
|
||||||
Icons.explore,
|
|
||||||
'Small Web',
|
|
||||||
() async {
|
|
||||||
Navigator.pop(context);
|
|
||||||
await ref
|
|
||||||
.read(smallWebModeControllerProvider.notifier)
|
|
||||||
.enter();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
);
|
||||||
],
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2036,6 +2102,7 @@ class _QuickLinksGrid extends ConsumerWidget {
|
|||||||
VoidCallback onTap, {
|
VoidCallback onTap, {
|
||||||
bool badge = false,
|
bool badge = false,
|
||||||
Color? badgeColor,
|
Color? badgeColor,
|
||||||
|
VoidCallback? onLongPress,
|
||||||
}) {
|
}) {
|
||||||
final iconWidget = Icon(
|
final iconWidget = Icon(
|
||||||
icon,
|
icon,
|
||||||
@@ -2048,6 +2115,7 @@ class _QuickLinksGrid extends ConsumerWidget {
|
|||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
child: InkWell(
|
child: InkWell(
|
||||||
onTap: onTap,
|
onTap: onTap,
|
||||||
|
onLongPress: onLongPress,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||||
@@ -2075,6 +2143,24 @@ class _QuickLinksGrid extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class _QuickLinkItem {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final VoidCallback? onLongPress;
|
||||||
|
final bool badge;
|
||||||
|
final Color? badgeColor;
|
||||||
|
|
||||||
|
const _QuickLinkItem({
|
||||||
|
required this.icon,
|
||||||
|
required this.label,
|
||||||
|
required this.onTap,
|
||||||
|
this.onLongPress,
|
||||||
|
this.badge = false,
|
||||||
|
this.badgeColor,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Profile Card ───
|
// ─── Profile Card ───
|
||||||
|
|
||||||
class _ProfileCard extends HookConsumerWidget {
|
class _ProfileCard extends HookConsumerWidget {
|
||||||
|
|||||||
+7
-7
@@ -50,10 +50,10 @@ import 'package:weblibre/features/geckoview/features/history/domain/repositories
|
|||||||
import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart';
|
import 'package:weblibre/features/geckoview/features/preferences/data/repositories/preference_observer.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart';
|
import 'package:weblibre/features/geckoview/features/pwa/domain/providers.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/services/local_index_pruner.dart';
|
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/domain/services/local_index_pruner.dart';
|
||||||
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
|
import 'package:weblibre/features/intent_gatekeeper/domain/entities/intent_source_policy.dart';
|
||||||
import 'package:weblibre/features/intent_gatekeeper/domain/entities/pending_intent_decision.dart';
|
import 'package:weblibre/features/intent_gatekeeper/domain/entities/pending_intent_decision.dart';
|
||||||
import 'package:weblibre/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart';
|
import 'package:weblibre/features/intent_gatekeeper/domain/services/intent_gatekeeper.dart';
|
||||||
@@ -636,15 +636,15 @@ class _BrowserViewState extends ConsumerState<BrowserView>
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
//Ensure tor events don't get dropped
|
// Keep-alive subscription for Tor status. The body is intentionally empty:
|
||||||
|
// the listener exists so the provider stays active while the browser view
|
||||||
|
// is mounted and Tor state events are not dropped. singboxProxyLogs is
|
||||||
|
// kept alive from main.dart so startup logs are captured even before the
|
||||||
|
// browser view mounts.
|
||||||
ref.listenManual(
|
ref.listenManual(
|
||||||
fireImmediately: true,
|
fireImmediately: true,
|
||||||
torProxyServiceProvider,
|
torProxyServiceProvider,
|
||||||
(previous, next) {
|
(previous, next) {},
|
||||||
if (next.hasValue) {
|
|
||||||
debugPrint(next.requireValue.toString());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onError: (error, stackTrace) {
|
onError: (error, stackTrace) {
|
||||||
logger.e(
|
logger.e(
|
||||||
'Error listening to torProxyServiceProvider',
|
'Error listening to torProxyServiceProvider',
|
||||||
|
|||||||
+3
@@ -73,6 +73,9 @@ class ViewTabSheetWidget extends HookConsumerWidget {
|
|||||||
useEffect(
|
useEffect(
|
||||||
() {
|
() {
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
if (!draggableScrollableController.isAttached) return;
|
||||||
|
|
||||||
final diff =
|
final diff =
|
||||||
((MediaQuery.of(context).viewInsets.bottom / 2) /
|
((MediaQuery.of(context).viewInsets.bottom / 2) /
|
||||||
MediaQuery.of(context).size.height) -
|
MediaQuery.of(context).size.height) -
|
||||||
|
|||||||
+3
-22
@@ -51,9 +51,8 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selec
|
|||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab_search.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_chips.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/controllers/ensure_proxy_started.dart';
|
||||||
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
import 'package:weblibre/features/sync/domain/repositories/sync.dart';
|
||||||
import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart';
|
|
||||||
import 'package:weblibre/features/tor/presentation/widgets/tor_dialog.dart';
|
|
||||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||||
import 'package:weblibre/presentation/hooks/menu_controller.dart';
|
import 'package:weblibre/presentation/hooks/menu_controller.dart';
|
||||||
import 'package:weblibre/presentation/widgets/speech_to_text_button.dart';
|
import 'package:weblibre/presentation/widgets/speech_to_text_button.dart';
|
||||||
@@ -111,26 +110,8 @@ class _TabFilters extends ConsumerWidget {
|
|||||||
.read(selectedContainerProvider.notifier)
|
.read(selectedContainerProvider.notifier)
|
||||||
.setContainerId(container.id);
|
.setContainerId(container.id);
|
||||||
|
|
||||||
if (context.mounted &&
|
if (context.mounted && result == SetContainerResult.success) {
|
||||||
result == SetContainerResult.successHasProxy) {
|
await ensureProxyStartedForContainer(context, ref, container);
|
||||||
final shouldStartProxy = await ref
|
|
||||||
.read(startProxyControllerProvider.notifier)
|
|
||||||
.shouldPromptProxyStart();
|
|
||||||
|
|
||||||
if (!context.mounted || !shouldStartProxy) return;
|
|
||||||
|
|
||||||
final dialogResult = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) {
|
|
||||||
return const TorDialog();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (dialogResult == true) {
|
|
||||||
await ref
|
|
||||||
.read(startProxyControllerProvider.notifier)
|
|
||||||
.startProxy();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ref.read(selectedContainerProvider.notifier).clearContainer();
|
ref.read(selectedContainerProvider.notifier).clearContainer();
|
||||||
|
|||||||
+3
-3
@@ -73,9 +73,9 @@ List<CombinedHistoryItem> combinedHistorySuggestions(Ref ref) {
|
|||||||
final engineAsync = ref.watch(engineSuggestionsProvider);
|
final engineAsync = ref.watch(engineSuggestionsProvider);
|
||||||
final localAsync = ref.watch(historySearchRepositoryProvider);
|
final localAsync = ref.watch(historySearchRepositoryProvider);
|
||||||
|
|
||||||
final engineSuggestions =
|
final engineSuggestions = engineAsync.value ?? const <GeckoSuggestion>[];
|
||||||
engineAsync.value ?? const <GeckoSuggestion>[];
|
final localResults =
|
||||||
final localResults = localAsync.value?.results ?? const <HistoryQueryResult>[];
|
localAsync.value?.results ?? const <HistoryQueryResult>[];
|
||||||
|
|
||||||
// Index the local rows by canonical URL so engine items can pick up
|
// Index the local rows by canonical URL so engine items can pick up
|
||||||
// snippet/title-highlight without an N×M scan.
|
// snippet/title-highlight without an N×M scan.
|
||||||
|
|||||||
+5
-21
@@ -53,8 +53,8 @@ import 'package:weblibre/features/geckoview/features/search/presentation/widgets
|
|||||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_field.dart';
|
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_field.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_module_reorder_view.dart';
|
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_module_reorder_view.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/bookmark_search.dart';
|
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/bookmark_search.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart';
|
|
||||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/combined_history_suggestions.dart';
|
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/combined_history_suggestions.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/feed_search.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart';
|
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/history_suggestions.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/local_history_suggestions.dart';
|
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/local_history_suggestions.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_providers_section.dart';
|
import 'package:weblibre/features/geckoview/features/search/presentation/widgets/search_modules/search_providers_section.dart';
|
||||||
@@ -64,8 +64,7 @@ import 'package:weblibre/features/geckoview/features/tabs/data/entities/isolatio
|
|||||||
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/entities/tab_mode.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/providers/selected_container.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/compact_container_selector.dart';
|
||||||
import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart';
|
import 'package:weblibre/features/proxy/presentation/controllers/ensure_proxy_started.dart';
|
||||||
import 'package:weblibre/features/tor/presentation/widgets/tor_dialog.dart';
|
|
||||||
import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.dart';
|
import 'package:weblibre/features/search_credits/domain/repositories/web_search_settings.dart';
|
||||||
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
import 'package:weblibre/features/user/domain/repositories/general_settings.dart';
|
||||||
import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart';
|
import 'package:weblibre/features/web_search/domain/controllers/search_controller.dart';
|
||||||
@@ -496,26 +495,11 @@ class SearchScreen extends HookConsumerWidget {
|
|||||||
|
|
||||||
if (!context.mounted) return;
|
if (!context.mounted) return;
|
||||||
|
|
||||||
if (result == SetContainerResult.successHasProxy) {
|
if (result == SetContainerResult.success) {
|
||||||
final shouldStartProxy = await ref
|
await ensureProxyStartedForContainer(context, ref, container);
|
||||||
.read(startProxyControllerProvider.notifier)
|
|
||||||
.shouldPromptProxyStart();
|
|
||||||
|
|
||||||
if (context.mounted && shouldStartProxy) {
|
|
||||||
final dialogResult = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (_) => const TorDialog(),
|
|
||||||
);
|
|
||||||
|
|
||||||
if (dialogResult == true) {
|
|
||||||
await ref
|
|
||||||
.read(startProxyControllerProvider.notifier)
|
|
||||||
.startProxy();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (context.mounted && result != SetContainerResult.failed) {
|
if (context.mounted && result == SetContainerResult.success) {
|
||||||
const TabViewRoute().go(context);
|
const TabViewRoute().go(context);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+1
-3
@@ -75,9 +75,7 @@ class CombinedHistorySuggestions extends HookConsumerWidget {
|
|||||||
// which is the same constant the row builder scans for below.
|
// which is the same constant the row builder scans for below.
|
||||||
// Don't override the defaults here unless those scan constants
|
// Don't override the defaults here unless those scan constants
|
||||||
// are kept in sync.
|
// are kept in sync.
|
||||||
await ref
|
await ref.read(historySearchRepositoryProvider.notifier).addQuery(text);
|
||||||
.read(historySearchRepositoryProvider.notifier)
|
|
||||||
.addQuery(text);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+4
-2
@@ -97,8 +97,10 @@ class HistorySuggestions extends HookConsumerWidget {
|
|||||||
),
|
),
|
||||||
subtitle:
|
subtitle:
|
||||||
uri.mapNotNull(
|
uri.mapNotNull(
|
||||||
(uri) =>
|
(uri) => UriBreadcrumb(
|
||||||
UriBreadcrumb(uri: uri, showHttpScheme: false),
|
uri: uri,
|
||||||
|
showHttpScheme: false,
|
||||||
|
),
|
||||||
) ??
|
) ??
|
||||||
suggestion.description.mapNotNull(
|
suggestion.description.mapNotNull(
|
||||||
(description) => Text(
|
(description) => Text(
|
||||||
|
|||||||
+8
-5
@@ -83,12 +83,15 @@ class LocalHistorySuggestions extends HookConsumerWidget {
|
|||||||
final uri = Uri.tryParse(result.urlCanonical);
|
final uri = Uri.tryParse(result.urlCanonical);
|
||||||
|
|
||||||
final content =
|
final content =
|
||||||
(result.extractedContent?.contains(historyHighlightPrefix) ==
|
(result.extractedContent?.contains(
|
||||||
true)
|
historyHighlightPrefix,
|
||||||
? result.extractedContent
|
) ==
|
||||||
: result.fullContent;
|
true)
|
||||||
|
? result.extractedContent
|
||||||
|
: result.fullContent;
|
||||||
final titleHasMatch =
|
final titleHasMatch =
|
||||||
result.title?.contains(historyHighlightPrefix) ?? false;
|
result.title?.contains(historyHighlightPrefix) ??
|
||||||
|
false;
|
||||||
final bodyHasMatch =
|
final bodyHasMatch =
|
||||||
content?.contains(historyHighlightPrefix) ?? false;
|
content?.contains(historyHighlightPrefix) ?? false;
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ import 'package:flutter/widgets.dart';
|
|||||||
import 'package:json_annotation/json_annotation.dart';
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
import 'package:weblibre/data/database/converters/color.dart';
|
import 'package:weblibre/data/database/converters/color.dart';
|
||||||
import 'package:weblibre/data/database/converters/icon_data.dart';
|
import 'package:weblibre/data/database/converters/icon_data.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||||
|
|
||||||
part 'container_data.g.dart';
|
part 'container_data.g.dart';
|
||||||
|
|
||||||
@@ -31,10 +32,14 @@ part 'container_data.g.dart';
|
|||||||
class ContainerMetadata with FastEquatable {
|
class ContainerMetadata with FastEquatable {
|
||||||
@IconDataJsonConverter()
|
@IconDataJsonConverter()
|
||||||
final IconData? iconData;
|
final IconData? iconData;
|
||||||
|
|
||||||
final String? contextualIdentity;
|
final String? contextualIdentity;
|
||||||
|
|
||||||
@JsonKey(defaultValue: false)
|
@JsonKey(
|
||||||
final bool useProxy;
|
fromJson: _proxyConnectionIdFromJson,
|
||||||
|
toJson: _proxyConnectionIdToJson,
|
||||||
|
)
|
||||||
|
final ProxyConnectionId? proxyConnectionId;
|
||||||
|
|
||||||
@JsonKey(defaultValue: false)
|
@JsonKey(defaultValue: false)
|
||||||
final bool clearDataOnExit;
|
final bool clearDataOnExit;
|
||||||
@@ -44,7 +49,7 @@ class ContainerMetadata with FastEquatable {
|
|||||||
ContainerMetadata({
|
ContainerMetadata({
|
||||||
required this.iconData,
|
required this.iconData,
|
||||||
required this.contextualIdentity,
|
required this.contextualIdentity,
|
||||||
required this.useProxy,
|
required this.proxyConnectionId,
|
||||||
required this.clearDataOnExit,
|
required this.clearDataOnExit,
|
||||||
required this.assignedSites,
|
required this.assignedSites,
|
||||||
});
|
});
|
||||||
@@ -52,17 +57,19 @@ class ContainerMetadata with FastEquatable {
|
|||||||
ContainerMetadata.withDefaults({
|
ContainerMetadata.withDefaults({
|
||||||
IconData? iconData,
|
IconData? iconData,
|
||||||
String? contextualIdentity,
|
String? contextualIdentity,
|
||||||
bool? useProxy,
|
ProxyConnectionId? proxyConnectionId,
|
||||||
bool? clearDataOnExit,
|
bool? clearDataOnExit,
|
||||||
List<Uri>? assignedSites,
|
List<Uri>? assignedSites,
|
||||||
}) : this(
|
}) : this(
|
||||||
iconData: iconData,
|
iconData: iconData,
|
||||||
contextualIdentity: contextualIdentity,
|
contextualIdentity: contextualIdentity,
|
||||||
useProxy: useProxy ?? false,
|
proxyConnectionId: proxyConnectionId,
|
||||||
clearDataOnExit: clearDataOnExit ?? false,
|
clearDataOnExit: clearDataOnExit ?? false,
|
||||||
assignedSites: assignedSites,
|
assignedSites: assignedSites,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
bool get usesTorProxy => proxyConnectionId is TorProxyConnectionId;
|
||||||
|
|
||||||
factory ContainerMetadata.fromJson(Map<String, dynamic> json) =>
|
factory ContainerMetadata.fromJson(Map<String, dynamic> json) =>
|
||||||
_$ContainerMetadataFromJson(json);
|
_$ContainerMetadataFromJson(json);
|
||||||
|
|
||||||
@@ -72,7 +79,7 @@ class ContainerMetadata with FastEquatable {
|
|||||||
List<Object?> get hashParameters => [
|
List<Object?> get hashParameters => [
|
||||||
iconData,
|
iconData,
|
||||||
contextualIdentity,
|
contextualIdentity,
|
||||||
useProxy,
|
proxyConnectionId,
|
||||||
clearDataOnExit,
|
clearDataOnExit,
|
||||||
assignedSites,
|
assignedSites,
|
||||||
];
|
];
|
||||||
@@ -141,3 +148,8 @@ class ContainerDataWithCount extends ContainerData {
|
|||||||
@override
|
@override
|
||||||
List<Object?> get hashParameters => [...super.hashParameters, tabCount];
|
List<Object?> get hashParameters => [...super.hashParameters, tabCount];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ProxyConnectionId? _proxyConnectionIdFromJson(String? json) =>
|
||||||
|
ProxyConnectionId.decode(json);
|
||||||
|
|
||||||
|
String? _proxyConnectionIdToJson(ProxyConnectionId? object) => object?.encode();
|
||||||
|
|||||||
+12
-9
@@ -11,7 +11,7 @@ abstract class _$ContainerMetadataCWProxy {
|
|||||||
|
|
||||||
ContainerMetadata contextualIdentity(String? contextualIdentity);
|
ContainerMetadata contextualIdentity(String? contextualIdentity);
|
||||||
|
|
||||||
ContainerMetadata useProxy(bool useProxy);
|
ContainerMetadata proxyConnectionId(ProxyConnectionId? proxyConnectionId);
|
||||||
|
|
||||||
ContainerMetadata clearDataOnExit(bool clearDataOnExit);
|
ContainerMetadata clearDataOnExit(bool clearDataOnExit);
|
||||||
|
|
||||||
@@ -27,7 +27,7 @@ abstract class _$ContainerMetadataCWProxy {
|
|||||||
ContainerMetadata call({
|
ContainerMetadata call({
|
||||||
IconData? iconData,
|
IconData? iconData,
|
||||||
String? contextualIdentity,
|
String? contextualIdentity,
|
||||||
bool useProxy,
|
ProxyConnectionId? proxyConnectionId,
|
||||||
bool clearDataOnExit,
|
bool clearDataOnExit,
|
||||||
List<Uri>? assignedSites,
|
List<Uri>? assignedSites,
|
||||||
});
|
});
|
||||||
@@ -48,7 +48,8 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
|
|||||||
call(contextualIdentity: contextualIdentity);
|
call(contextualIdentity: contextualIdentity);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ContainerMetadata useProxy(bool useProxy) => call(useProxy: useProxy);
|
ContainerMetadata proxyConnectionId(ProxyConnectionId? proxyConnectionId) =>
|
||||||
|
call(proxyConnectionId: proxyConnectionId);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ContainerMetadata clearDataOnExit(bool clearDataOnExit) =>
|
ContainerMetadata clearDataOnExit(bool clearDataOnExit) =>
|
||||||
@@ -69,7 +70,7 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
|
|||||||
ContainerMetadata call({
|
ContainerMetadata call({
|
||||||
Object? iconData = const $CopyWithPlaceholder(),
|
Object? iconData = const $CopyWithPlaceholder(),
|
||||||
Object? contextualIdentity = const $CopyWithPlaceholder(),
|
Object? contextualIdentity = const $CopyWithPlaceholder(),
|
||||||
Object? useProxy = const $CopyWithPlaceholder(),
|
Object? proxyConnectionId = const $CopyWithPlaceholder(),
|
||||||
Object? clearDataOnExit = const $CopyWithPlaceholder(),
|
Object? clearDataOnExit = const $CopyWithPlaceholder(),
|
||||||
Object? assignedSites = const $CopyWithPlaceholder(),
|
Object? assignedSites = const $CopyWithPlaceholder(),
|
||||||
}) {
|
}) {
|
||||||
@@ -82,10 +83,10 @@ class _$ContainerMetadataCWProxyImpl implements _$ContainerMetadataCWProxy {
|
|||||||
? _value.contextualIdentity
|
? _value.contextualIdentity
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
: contextualIdentity as String?,
|
: contextualIdentity as String?,
|
||||||
useProxy: useProxy == const $CopyWithPlaceholder() || useProxy == null
|
proxyConnectionId: proxyConnectionId == const $CopyWithPlaceholder()
|
||||||
? _value.useProxy
|
? _value.proxyConnectionId
|
||||||
// ignore: cast_nullable_to_non_nullable
|
// ignore: cast_nullable_to_non_nullable
|
||||||
: useProxy as bool,
|
: proxyConnectionId as ProxyConnectionId?,
|
||||||
clearDataOnExit:
|
clearDataOnExit:
|
||||||
clearDataOnExit == const $CopyWithPlaceholder() ||
|
clearDataOnExit == const $CopyWithPlaceholder() ||
|
||||||
clearDataOnExit == null
|
clearDataOnExit == null
|
||||||
@@ -227,7 +228,9 @@ ContainerMetadata _$ContainerMetadataFromJson(Map<String, dynamic> json) =>
|
|||||||
const IconDataJsonConverter().fromJson,
|
const IconDataJsonConverter().fromJson,
|
||||||
),
|
),
|
||||||
contextualIdentity: json['contextualIdentity'] as String?,
|
contextualIdentity: json['contextualIdentity'] as String?,
|
||||||
useProxy: json['useProxy'] as bool? ?? false,
|
proxyConnectionId: _proxyConnectionIdFromJson(
|
||||||
|
json['proxyConnectionId'] as String?,
|
||||||
|
),
|
||||||
clearDataOnExit: json['clearDataOnExit'] as bool? ?? false,
|
clearDataOnExit: json['clearDataOnExit'] as bool? ?? false,
|
||||||
assignedSites: (json['assignedSites'] as List<dynamic>?)
|
assignedSites: (json['assignedSites'] as List<dynamic>?)
|
||||||
?.map((e) => Uri.parse(e as String))
|
?.map((e) => Uri.parse(e as String))
|
||||||
@@ -242,7 +245,7 @@ Map<String, dynamic> _$ContainerMetadataToJson(
|
|||||||
const IconDataJsonConverter().toJson,
|
const IconDataJsonConverter().toJson,
|
||||||
),
|
),
|
||||||
'contextualIdentity': instance.contextualIdentity,
|
'contextualIdentity': instance.contextualIdentity,
|
||||||
'useProxy': instance.useProxy,
|
'proxyConnectionId': _proxyConnectionIdToJson(instance.proxyConnectionId),
|
||||||
'clearDataOnExit': instance.clearDataOnExit,
|
'clearDataOnExit': instance.clearDataOnExit,
|
||||||
'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(),
|
'assignedSites': instance.assignedSites?.map((e) => e.toString()).toList(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ Stream<List<SiteAssignment>> watchAllAssignedSites(Ref ref) {
|
|||||||
///
|
///
|
||||||
/// Returns a map from isolation context ID to the set of container IDs it
|
/// Returns a map from isolation context ID to the set of container IDs it
|
||||||
/// appears in. An isolation context needs a proxy alias if ANY of its
|
/// appears in. An isolation context needs a proxy alias if ANY of its
|
||||||
/// associated containers has useProxy enabled.
|
/// associated containers has a proxy connection assigned.
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
Stream<Map<String, Set<String>>> watchIsolatedContextContainerMap(Ref ref) {
|
Stream<Map<String, Set<String>>> watchIsolatedContextContainerMap(Ref ref) {
|
||||||
final db = ref.watch(tabDatabaseProvider);
|
final db = ref.watch(tabDatabaseProvider);
|
||||||
|
|||||||
@@ -1082,7 +1082,7 @@ String _$watchAllAssignedSitesHash() =>
|
|||||||
///
|
///
|
||||||
/// Returns a map from isolation context ID to the set of container IDs it
|
/// Returns a map from isolation context ID to the set of container IDs it
|
||||||
/// appears in. An isolation context needs a proxy alias if ANY of its
|
/// appears in. An isolation context needs a proxy alias if ANY of its
|
||||||
/// associated containers has useProxy enabled.
|
/// associated containers has a proxy connection assigned.
|
||||||
|
|
||||||
@ProviderFor(watchIsolatedContextContainerMap)
|
@ProviderFor(watchIsolatedContextContainerMap)
|
||||||
final watchIsolatedContextContainerMapProvider =
|
final watchIsolatedContextContainerMapProvider =
|
||||||
@@ -1094,7 +1094,7 @@ final watchIsolatedContextContainerMapProvider =
|
|||||||
///
|
///
|
||||||
/// Returns a map from isolation context ID to the set of container IDs it
|
/// Returns a map from isolation context ID to the set of container IDs it
|
||||||
/// appears in. An isolation context needs a proxy alias if ANY of its
|
/// appears in. An isolation context needs a proxy alias if ANY of its
|
||||||
/// associated containers has useProxy enabled.
|
/// associated containers has a proxy connection assigned.
|
||||||
|
|
||||||
final class WatchIsolatedContextContainerMapProvider
|
final class WatchIsolatedContextContainerMapProvider
|
||||||
extends
|
extends
|
||||||
@@ -1112,7 +1112,7 @@ final class WatchIsolatedContextContainerMapProvider
|
|||||||
///
|
///
|
||||||
/// Returns a map from isolation context ID to the set of container IDs it
|
/// Returns a map from isolation context ID to the set of container IDs it
|
||||||
/// appears in. An isolation context needs a proxy alias if ANY of its
|
/// appears in. An isolation context needs a proxy alias if ANY of its
|
||||||
/// associated containers has useProxy enabled.
|
/// associated containers has a proxy connection assigned.
|
||||||
WatchIsolatedContextContainerMapProvider._()
|
WatchIsolatedContextContainerMapProvider._()
|
||||||
: super(
|
: super(
|
||||||
from: null,
|
from: null,
|
||||||
|
|||||||
+3
-3
@@ -37,7 +37,7 @@ import 'package:weblibre/features/user/data/providers.dart';
|
|||||||
|
|
||||||
part 'selected_container.g.dart';
|
part 'selected_container.g.dart';
|
||||||
|
|
||||||
enum SetContainerResult { failed, success, successHasProxy }
|
enum SetContainerResult { failed, success }
|
||||||
|
|
||||||
@Riverpod(keepAlive: true)
|
@Riverpod(keepAlive: true)
|
||||||
class SelectedContainer extends _$SelectedContainer {
|
class SelectedContainer extends _$SelectedContainer {
|
||||||
@@ -62,13 +62,13 @@ class SelectedContainer extends _$SelectedContainer {
|
|||||||
bool canApply() => shouldApply?.call() ?? true;
|
bool canApply() => shouldApply?.call() ?? true;
|
||||||
|
|
||||||
if (ref.mounted && container != null && canApply()) {
|
if (ref.mounted && container != null && canApply()) {
|
||||||
if (container.metadata.useProxy) {
|
if (container.metadata.proxyConnectionId != null) {
|
||||||
final proxyPluginHealthy = await GeckoContainerProxyService()
|
final proxyPluginHealthy = await GeckoContainerProxyService()
|
||||||
.healthcheck();
|
.healthcheck();
|
||||||
|
|
||||||
if (ref.mounted && proxyPluginHealthy && canApply()) {
|
if (ref.mounted && proxyPluginHealthy && canApply()) {
|
||||||
state = id;
|
state = id;
|
||||||
return SetContainerResult.successHasProxy;
|
return SetContainerResult.success;
|
||||||
}
|
}
|
||||||
} else if (canApply()) {
|
} else if (canApply()) {
|
||||||
state = id;
|
state = id;
|
||||||
|
|||||||
+1
-1
@@ -41,7 +41,7 @@ final class SelectedContainerProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$selectedContainerHash() => r'1f0828ce1d3f8b88fd2731a7aae2602a7323092c';
|
String _$selectedContainerHash() => r'3c9ab093bce7cd3b84e0318095b1ada25bdf8a65';
|
||||||
|
|
||||||
abstract class _$SelectedContainer extends $Notifier<String?> {
|
abstract class _$SelectedContainer extends $Notifier<String?> {
|
||||||
String? build();
|
String? build();
|
||||||
|
|||||||
+40
-9
@@ -28,6 +28,7 @@ import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assig
|
|||||||
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/providers.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/tab.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/utils/color_palette.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/utils/color_palette.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||||
|
|
||||||
part 'container.g.dart';
|
part 'container.g.dart';
|
||||||
|
|
||||||
@@ -78,6 +79,28 @@ class ContainerRepository extends _$ContainerRepository {
|
|||||||
.replaceContainer(container);
|
.replaceContainer(container);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> clearProxyConnectionAssignments(
|
||||||
|
ProxyConnectionId proxyConnectionId,
|
||||||
|
) async {
|
||||||
|
final containers = await getAllContainersWithCount();
|
||||||
|
final affectedContainers = containers.where(
|
||||||
|
(container) => container.metadata.proxyConnectionId == proxyConnectionId,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final container in affectedContainers) {
|
||||||
|
await replaceContainer(
|
||||||
|
ContainerData(
|
||||||
|
id: container.id,
|
||||||
|
name: container.name,
|
||||||
|
color: container.color,
|
||||||
|
orderKey: container.orderKey,
|
||||||
|
isPinned: container.isPinned,
|
||||||
|
metadata: container.metadata.copyWith.proxyConnectionId(null),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> assignContainerOrderKey(String id, {required String orderKey}) {
|
Future<void> assignContainerOrderKey(String id, {required String orderKey}) {
|
||||||
return ref
|
return ref
|
||||||
.read(tabDatabaseProvider)
|
.read(tabDatabaseProvider)
|
||||||
@@ -214,17 +237,25 @@ class ContainerRepository extends _$ContainerRepository {
|
|||||||
.take(targetIndex)
|
.take(targetIndex)
|
||||||
.where((container) => container.isPinned == movingContainer.isPinned)
|
.where((container) => container.isPinned == movingContainer.isPinned)
|
||||||
.length
|
.length
|
||||||
.clamp(0, scopedContainers.length - 1)
|
.clamp(0, scopedContainers.length - 1);
|
||||||
.toInt();
|
|
||||||
if (scopedTargetIndex == scopedOldIndex) return;
|
if (scopedTargetIndex == scopedOldIndex) return;
|
||||||
|
|
||||||
final orderKey = await _orderKeyForReorder(
|
// Generate a new order key and assign it in the same transaction so a
|
||||||
scopedContainers,
|
// crash between the two cannot leave the moving container with a stale
|
||||||
scopedOldIndex,
|
// key that no longer matches the surrounding rows.
|
||||||
scopedTargetIndex,
|
final db = ref.read(tabDatabaseProvider);
|
||||||
movingContainer.isPinned,
|
await db.transaction(() async {
|
||||||
);
|
final orderKey = await _orderKeyForReorder(
|
||||||
await assignContainerOrderKey(movingContainer.id, orderKey: orderKey);
|
scopedContainers,
|
||||||
|
scopedOldIndex,
|
||||||
|
scopedTargetIndex,
|
||||||
|
movingContainer.isPinned,
|
||||||
|
);
|
||||||
|
await db.containerDao.assignOrderKey(
|
||||||
|
movingContainer.id,
|
||||||
|
orderKey: orderKey,
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<String> _orderKeyForReorder(
|
Future<String> _orderKeyForReorder(
|
||||||
|
|||||||
+1
-1
@@ -42,7 +42,7 @@ final class ContainerRepositoryProvider
|
|||||||
}
|
}
|
||||||
|
|
||||||
String _$containerRepositoryHash() =>
|
String _$containerRepositoryHash() =>
|
||||||
r'a708f298d7bd50f1823029e00f8ed295b9f81f4e';
|
r'8db0f2a08ee5a67123f9c110ef7edf4c574992c4';
|
||||||
|
|
||||||
abstract class _$ContainerRepository extends $Notifier<void> {
|
abstract class _$ContainerRepository extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
|
|||||||
+1
-3
@@ -200,9 +200,7 @@ class HistorySearchRepository extends _$HistorySearchRepository {
|
|||||||
// last time the engine observed *anything* about this URL.
|
// last time the engine observed *anything* about this URL.
|
||||||
final updatedAt = DateTime.fromMillisecondsSinceEpoch(metadata.updatedAt);
|
final updatedAt = DateTime.fromMillisecondsSinceEpoch(metadata.updatedAt);
|
||||||
final ageDays = now.difference(updatedAt).inHours / 24.0;
|
final ageDays = now.difference(updatedAt).inHours / 24.0;
|
||||||
final decay = math.exp(
|
final decay = math.exp(-math.max(0.0, ageDays) / _recencyHalfLifeDays);
|
||||||
-math.max(0.0, ageDays) / _recencyHalfLifeDays,
|
|
||||||
);
|
|
||||||
score -= _recencyRankWeight * decay;
|
score -= _recencyRankWeight * decay;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+9
-9
@@ -55,14 +55,12 @@ class LocalIndexSettingsSync extends _$LocalIndexSettingsSync {
|
|||||||
/// last click. The lock is fine-grained per provider instance.
|
/// last click. The lock is fine-grained per provider instance.
|
||||||
final _writeLock = Lock();
|
final _writeLock = Lock();
|
||||||
|
|
||||||
Future<void> _push({
|
Future<void> _push({required bool enabled, required bool indexPrivate}) =>
|
||||||
required bool enabled,
|
_writeLock.synchronized(() async {
|
||||||
required bool indexPrivate,
|
final dao = ref.read(tabDatabaseProvider).historyDao;
|
||||||
}) => _writeLock.synchronized(() async {
|
await dao.upsertSetting(_kEnabledKey, enabled);
|
||||||
final dao = ref.read(tabDatabaseProvider).historyDao;
|
await dao.upsertSetting(_kIndexPrivateKey, indexPrivate);
|
||||||
await dao.upsertSetting(_kEnabledKey, enabled);
|
});
|
||||||
await dao.upsertSetting(_kIndexPrivateKey, indexPrivate);
|
|
||||||
});
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void build() {
|
void build() {
|
||||||
@@ -77,7 +75,9 @@ class LocalIndexSettingsSync extends _$LocalIndexSettingsSync {
|
|||||||
if (previous == next) return;
|
if (previous == next) return;
|
||||||
// Fire-and-forget: `_writeLock` ensures the second flip queues
|
// Fire-and-forget: `_writeLock` ensures the second flip queues
|
||||||
// behind the first instead of racing it.
|
// behind the first instead of racing it.
|
||||||
unawaited(_push(enabled: next.enabled, indexPrivate: next.indexPrivate));
|
unawaited(
|
||||||
|
_push(enabled: next.enabled, indexPrivate: next.indexPrivate),
|
||||||
|
);
|
||||||
},
|
},
|
||||||
fireImmediately: true,
|
fireImmediately: true,
|
||||||
);
|
);
|
||||||
|
|||||||
+156
-29
@@ -23,7 +23,6 @@ import 'package:flutter_hooks/flutter_hooks.dart';
|
|||||||
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
import 'package:flutter_material_design_icons/flutter_material_design_icons.dart';
|
||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:nullability/nullability.dart';
|
|
||||||
import 'package:weblibre/core/uuid.dart';
|
import 'package:weblibre/core/uuid.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/container.dart';
|
||||||
@@ -31,11 +30,12 @@ import 'package:weblibre/features/geckoview/features/tabs/presentation/controlle
|
|||||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/dialogs/delete_container_dialog.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/presentation/dialogs/delete_container_dialog.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/dialogs/discard_changes_dialog.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/presentation/dialogs/discard_changes_dialog.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_sites.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/presentation/screens/container_sites.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_icon_picker_sheet.dart';
|
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/color_picker_dialog.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_icon_picker_sheet.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart';
|
||||||
import 'package:weblibre/presentation/icons/tor_icons.dart';
|
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart';
|
||||||
|
|
||||||
enum _DialogMode { create, edit }
|
enum _DialogMode { create, edit }
|
||||||
|
|
||||||
@@ -77,12 +77,16 @@ class ContainerEditScreen extends HookConsumerWidget {
|
|||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
|
|
||||||
|
final proxyOptions = ref.watch(proxyConnectionOptionsProvider);
|
||||||
|
|
||||||
final selectedColor = useState(initialContainer.color);
|
final selectedColor = useState(initialContainer.color);
|
||||||
final selectedIcon = useState(initialContainer.metadata.iconData);
|
final selectedIcon = useState(initialContainer.metadata.iconData);
|
||||||
final contextualIdentity = useState(
|
final contextualIdentity = useState(
|
||||||
initialContainer.metadata.contextualIdentity,
|
initialContainer.metadata.contextualIdentity,
|
||||||
);
|
);
|
||||||
final useProxy = useState(initialContainer.metadata.useProxy);
|
final proxyConnectionId = useState<ProxyConnectionId?>(
|
||||||
|
initialContainer.metadata.proxyConnectionId,
|
||||||
|
);
|
||||||
final clearDataOnExit = useState(initialContainer.metadata.clearDataOnExit);
|
final clearDataOnExit = useState(initialContainer.metadata.clearDataOnExit);
|
||||||
final assignedSites = useState(initialContainer.metadata.assignedSites);
|
final assignedSites = useState(initialContainer.metadata.assignedSites);
|
||||||
|
|
||||||
@@ -99,7 +103,9 @@ class ContainerEditScreen extends HookConsumerWidget {
|
|||||||
metadata: initialContainer.metadata.copyWith(
|
metadata: initialContainer.metadata.copyWith(
|
||||||
contextualIdentity: contextualIdentity.value,
|
contextualIdentity: contextualIdentity.value,
|
||||||
iconData: selectedIcon.value,
|
iconData: selectedIcon.value,
|
||||||
useProxy: useProxy.value && contextualIdentity.value != null,
|
proxyConnectionId: contextualIdentity.value != null
|
||||||
|
? proxyConnectionId.value
|
||||||
|
: null,
|
||||||
clearDataOnExit:
|
clearDataOnExit:
|
||||||
clearDataOnExit.value && contextualIdentity.value != null,
|
clearDataOnExit.value && contextualIdentity.value != null,
|
||||||
assignedSites: assignedSites.value,
|
assignedSites: assignedSites.value,
|
||||||
@@ -217,6 +223,8 @@ class ContainerEditScreen extends HookConsumerWidget {
|
|||||||
selectedColor.value,
|
selectedColor.value,
|
||||||
);
|
);
|
||||||
final assignedSiteCount = assignedSites.value?.length ?? 0;
|
final assignedSiteCount = assignedSites.value?.length ?? 0;
|
||||||
|
final canPickProxy =
|
||||||
|
_mode == _DialogMode.create || contextualIdentity.value != null;
|
||||||
|
|
||||||
return PopScope(
|
return PopScope(
|
||||||
canPop: container == comparison,
|
canPop: container == comparison,
|
||||||
@@ -358,8 +366,8 @@ class ContainerEditScreen extends HookConsumerWidget {
|
|||||||
uuid.v4()
|
uuid.v4()
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
if (!value && useProxy.value) {
|
if (!value) {
|
||||||
useProxy.value = false;
|
proxyConnectionId.value = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!value && clearDataOnExit.value) {
|
if (!value && clearDataOnExit.value) {
|
||||||
@@ -369,29 +377,61 @@ class ContainerEditScreen extends HookConsumerWidget {
|
|||||||
: null,
|
: null,
|
||||||
),
|
),
|
||||||
const Divider(height: 1, indent: 56),
|
const Divider(height: 1, indent: 56),
|
||||||
SwitchListTile.adaptive(
|
ListTile(
|
||||||
value: useProxy.value,
|
leading: const Icon(Icons.route_outlined),
|
||||||
title: const Text('Use Tor™ Proxy'),
|
title: const Text('Proxy Connection'),
|
||||||
secondary: const Icon(TorIcons.onionAlt),
|
subtitle: Text(switch (proxyConnectionId.value) {
|
||||||
onChanged: switch (_mode) {
|
final id? => proxyConnectionTitle(proxyOptions, id),
|
||||||
_DialogMode.create => (value) {
|
null => 'None',
|
||||||
if (value && contextualIdentity.value == null) {
|
}),
|
||||||
contextualIdentity.value =
|
trailing: const Icon(Icons.chevron_right),
|
||||||
initialContainer
|
enabled: canPickProxy,
|
||||||
.metadata
|
onTap: canPickProxy
|
||||||
.contextualIdentity ??
|
? () async {
|
||||||
uuid.v4();
|
final createdTemporaryIdentity =
|
||||||
}
|
contextualIdentity.value == null;
|
||||||
|
|
||||||
useProxy.value = value;
|
if (createdTemporaryIdentity) {
|
||||||
},
|
contextualIdentity.value =
|
||||||
_DialogMode.edit =>
|
initialContainer
|
||||||
(contextualIdentity.value != null)
|
.metadata
|
||||||
? (value) {
|
.contextualIdentity ??
|
||||||
useProxy.value = value;
|
uuid.v4();
|
||||||
}
|
}
|
||||||
: null,
|
|
||||||
},
|
final outcome =
|
||||||
|
await showModalBottomSheet<
|
||||||
|
_ProxyPickerOutcome
|
||||||
|
>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (context) {
|
||||||
|
return _ProxyConnectionPickerSheet(
|
||||||
|
options: proxyOptions,
|
||||||
|
selectedProxyConnectionId:
|
||||||
|
proxyConnectionId.value,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
switch (outcome) {
|
||||||
|
case null:
|
||||||
|
// Dismissed without selecting — leave
|
||||||
|
// existing value untouched, but undo any
|
||||||
|
// temporary identity we created.
|
||||||
|
if (createdTemporaryIdentity) {
|
||||||
|
contextualIdentity.value = null;
|
||||||
|
}
|
||||||
|
case _ProxyPickerCleared():
|
||||||
|
proxyConnectionId.value = null;
|
||||||
|
if (createdTemporaryIdentity) {
|
||||||
|
contextualIdentity.value = null;
|
||||||
|
}
|
||||||
|
case _ProxyPickerSelected(:final id):
|
||||||
|
proxyConnectionId.value = id;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
const Divider(height: 1, indent: 56),
|
const Divider(height: 1, indent: 56),
|
||||||
SwitchListTile.adaptive(
|
SwitchListTile.adaptive(
|
||||||
@@ -524,3 +564,90 @@ class ContainerEditScreen extends HookConsumerWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Result of the proxy picker sheet. `null` (sheet dismissed) is distinct
|
||||||
|
/// from `_ProxyPickerCleared` (user explicitly picked None) so the caller can
|
||||||
|
/// avoid clobbering the previously-selected proxy on a stray swipe-down.
|
||||||
|
sealed class _ProxyPickerOutcome {
|
||||||
|
const _ProxyPickerOutcome();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProxyPickerCleared extends _ProxyPickerOutcome {
|
||||||
|
const _ProxyPickerCleared();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProxyPickerSelected extends _ProxyPickerOutcome {
|
||||||
|
final ProxyConnectionId id;
|
||||||
|
|
||||||
|
const _ProxyPickerSelected(this.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProxyConnectionPickerSheet extends StatelessWidget {
|
||||||
|
final List<ProxyConnectionOption> options;
|
||||||
|
final ProxyConnectionId? selectedProxyConnectionId;
|
||||||
|
|
||||||
|
const _ProxyConnectionPickerSheet({
|
||||||
|
required this.options,
|
||||||
|
required this.selectedProxyConnectionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final hasUnknownSelectedProxy =
|
||||||
|
selectedProxyConnectionId != null &&
|
||||||
|
!options.any((option) => option.id == selectedProxyConnectionId);
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: RadioGroup<ProxyConnectionId?>(
|
||||||
|
onChanged: (value) {
|
||||||
|
Navigator.pop(
|
||||||
|
context,
|
||||||
|
value == null
|
||||||
|
? const _ProxyPickerCleared()
|
||||||
|
: _ProxyPickerSelected(value),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: ListView(
|
||||||
|
shrinkWrap: true,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 4, 24, 12),
|
||||||
|
child: Text(
|
||||||
|
'Proxy Connection',
|
||||||
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const RadioListTile<ProxyConnectionId?>(
|
||||||
|
value: null,
|
||||||
|
title: Text('None'),
|
||||||
|
subtitle: Text('Use the normal browser connection'),
|
||||||
|
secondary: Icon(Icons.public),
|
||||||
|
),
|
||||||
|
if (hasUnknownSelectedProxy)
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
Icons.warning_amber_outlined,
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
title: const Text('Unknown proxy'),
|
||||||
|
subtitle: const Text('This proxy profile no longer exists'),
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.pop(context, const _ProxyPickerCleared()),
|
||||||
|
child: const Text('Clear'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final option in options)
|
||||||
|
RadioListTile<ProxyConnectionId?>(
|
||||||
|
value: option.id,
|
||||||
|
title: Text(option.title),
|
||||||
|
subtitle: Text(option.subtitle),
|
||||||
|
secondary: const Icon(Icons.route_outlined),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+11
-24
@@ -31,8 +31,8 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/co
|
|||||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart';
|
||||||
import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart';
|
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart';
|
||||||
import 'package:weblibre/features/tor/presentation/widgets/tor_dialog.dart';
|
import 'package:weblibre/features/proxy/presentation/controllers/ensure_proxy_started.dart';
|
||||||
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
||||||
|
|
||||||
class ContainerListScreen extends HookConsumerWidget {
|
class ContainerListScreen extends HookConsumerWidget {
|
||||||
@@ -49,25 +49,8 @@ class ContainerListScreen extends HookConsumerWidget {
|
|||||||
.read(selectedContainerProvider.notifier)
|
.read(selectedContainerProvider.notifier)
|
||||||
.setContainerId(container.id);
|
.setContainerId(container.id);
|
||||||
|
|
||||||
if (context.mounted && result == SetContainerResult.successHasProxy) {
|
if (context.mounted && result == SetContainerResult.success) {
|
||||||
final shouldStartProxy = await ref
|
await ensureProxyStartedForContainer(context, ref, container);
|
||||||
.read(startProxyControllerProvider.notifier)
|
|
||||||
.shouldPromptProxyStart();
|
|
||||||
|
|
||||||
if (!context.mounted || !shouldStartProxy) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final dialogResult = await showDialog<bool>(
|
|
||||||
context: context,
|
|
||||||
builder: (context) {
|
|
||||||
return const TorDialog();
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
if (dialogResult == true) {
|
|
||||||
await ref.read(startProxyControllerProvider.notifier).startProxy();
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -202,6 +185,7 @@ class _ContainerCard extends HookConsumerWidget {
|
|||||||
final containerColor = container.color;
|
final containerColor = container.color;
|
||||||
final tabCount = container.tabCount ?? 0;
|
final tabCount = container.tabCount ?? 0;
|
||||||
final palette = ContainerColors.palette(context, containerColor);
|
final palette = ContainerColors.palette(context, containerColor);
|
||||||
|
final proxyOptions = ref.watch(proxyConnectionOptionsProvider);
|
||||||
|
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
@@ -266,10 +250,13 @@ class _ContainerCard extends HookConsumerWidget {
|
|||||||
icon: Icons.cookie_outlined,
|
icon: Icons.cookie_outlined,
|
||||||
label: 'Isolated',
|
label: 'Isolated',
|
||||||
),
|
),
|
||||||
if (container.metadata.useProxy)
|
if (container.metadata.proxyConnectionId != null)
|
||||||
const _ContainerInfoChip(
|
_ContainerInfoChip(
|
||||||
icon: Icons.route_outlined,
|
icon: Icons.route_outlined,
|
||||||
label: 'Proxy',
|
label: proxyConnectionTitle(
|
||||||
|
proxyOptions,
|
||||||
|
container.metadata.proxyConnectionId!,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (container.metadata.clearDataOnExit)
|
if (container.metadata.clearDataOnExit)
|
||||||
const _ContainerInfoChip(
|
const _ContainerInfoChip(
|
||||||
|
|||||||
+11
-5
@@ -34,6 +34,7 @@ import 'package:weblibre/features/geckoview/features/tabs/domain/repositories/co
|
|||||||
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/presentation/widgets/container_title.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/utils/container_colors.dart';
|
||||||
import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart';
|
import 'package:weblibre/features/geckoview/features/tabs/utils/container_icons.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart';
|
||||||
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
import 'package:weblibre/presentation/widgets/failure_widget.dart';
|
||||||
|
|
||||||
class ContainerSelectionScreen extends HookConsumerWidget {
|
class ContainerSelectionScreen extends HookConsumerWidget {
|
||||||
@@ -221,7 +222,7 @@ class _UnassignedSelectionCard extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _SelectionContainerCard extends StatelessWidget {
|
class _SelectionContainerCard extends ConsumerWidget {
|
||||||
const _SelectionContainerCard({
|
const _SelectionContainerCard({
|
||||||
required this.container,
|
required this.container,
|
||||||
required this.isSelected,
|
required this.isSelected,
|
||||||
@@ -233,13 +234,15 @@ class _SelectionContainerCard extends StatelessWidget {
|
|||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final theme = Theme.of(context);
|
final theme = Theme.of(context);
|
||||||
final colorScheme = theme.colorScheme;
|
final colorScheme = theme.colorScheme;
|
||||||
final containerColor = container.color;
|
final containerColor = container.color;
|
||||||
final tabCount = container.tabCount ?? 0;
|
final tabCount = container.tabCount ?? 0;
|
||||||
final palette = ContainerColors.palette(context, containerColor);
|
final palette = ContainerColors.palette(context, containerColor);
|
||||||
|
|
||||||
|
final proxyOptions = ref.watch(proxyConnectionOptionsProvider);
|
||||||
|
|
||||||
return AnimatedContainer(
|
return AnimatedContainer(
|
||||||
duration: const Duration(milliseconds: 200),
|
duration: const Duration(milliseconds: 200),
|
||||||
curve: Curves.easeInOut,
|
curve: Curves.easeInOut,
|
||||||
@@ -306,10 +309,13 @@ class _SelectionContainerCard extends StatelessWidget {
|
|||||||
icon: Icons.cookie_outlined,
|
icon: Icons.cookie_outlined,
|
||||||
label: 'Isolated',
|
label: 'Isolated',
|
||||||
),
|
),
|
||||||
if (container.metadata.useProxy)
|
if (container.metadata.proxyConnectionId != null)
|
||||||
const _SelectionInfoChip(
|
_SelectionInfoChip(
|
||||||
icon: Icons.route_outlined,
|
icon: Icons.route_outlined,
|
||||||
label: 'Proxy',
|
label: proxyConnectionTitle(
|
||||||
|
proxyOptions,
|
||||||
|
container.metadata.proxyConnectionId!,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
if (container.metadata.clearDataOnExit)
|
if (container.metadata.clearDataOnExit)
|
||||||
const _SelectionInfoChip(
|
const _SelectionInfoChip(
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
enum SingboxFieldKind {
|
||||||
|
text,
|
||||||
|
secret,
|
||||||
|
integer,
|
||||||
|
integerList,
|
||||||
|
port,
|
||||||
|
boolean,
|
||||||
|
stringList,
|
||||||
|
}
|
||||||
|
|
||||||
|
class SingboxProxyFormField {
|
||||||
|
final String key;
|
||||||
|
final String label;
|
||||||
|
final String? helperText;
|
||||||
|
final String? defaultValue;
|
||||||
|
final bool required;
|
||||||
|
final SingboxFieldKind kind;
|
||||||
|
final int? exactListLength;
|
||||||
|
final int? minValue;
|
||||||
|
final int? maxValue;
|
||||||
|
|
||||||
|
const SingboxProxyFormField({
|
||||||
|
required this.key,
|
||||||
|
required this.label,
|
||||||
|
this.helperText,
|
||||||
|
this.defaultValue,
|
||||||
|
this.required = false,
|
||||||
|
this.kind = SingboxFieldKind.text,
|
||||||
|
this.exactListLength,
|
||||||
|
this.minValue,
|
||||||
|
this.maxValue,
|
||||||
|
});
|
||||||
|
|
||||||
|
bool get isSecret => kind == SingboxFieldKind.secret;
|
||||||
|
bool get isNumber =>
|
||||||
|
kind == SingboxFieldKind.integer || kind == SingboxFieldKind.port;
|
||||||
|
bool get isIntegerList => kind == SingboxFieldKind.integerList;
|
||||||
|
bool get isPort => kind == SingboxFieldKind.port;
|
||||||
|
bool get isBoolean => kind == SingboxFieldKind.boolean;
|
||||||
|
bool get isStringList => kind == SingboxFieldKind.stringList;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Shared parsing of boolean form values. Returns null when the text doesn't
|
||||||
|
/// look like a boolean (treated as "unset"), so callers can distinguish absent
|
||||||
|
/// from explicitly-false.
|
||||||
|
bool? parseFormBool(String value) {
|
||||||
|
return switch (value.trim().toLowerCase()) {
|
||||||
|
'true' || 'yes' || '1' || 'on' => true,
|
||||||
|
'false' || 'no' || '0' || 'off' => false,
|
||||||
|
_ => null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Splits a multi-value form input on newlines or commas, trims, drops blanks.
|
||||||
|
List<String> splitFormStringList(String value) {
|
||||||
|
return value
|
||||||
|
.split(RegExp(r'[\n,]'))
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.where((item) => item.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
@@ -0,0 +1,168 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/forms/singbox_form_field.dart';
|
||||||
|
|
||||||
|
class SingboxProxyFormSpec {
|
||||||
|
final SingboxProxyProfileType type;
|
||||||
|
final String outboundType;
|
||||||
|
final List<SingboxProxyFormField> fields;
|
||||||
|
|
||||||
|
const SingboxProxyFormSpec({
|
||||||
|
required this.type,
|
||||||
|
required this.outboundType,
|
||||||
|
required this.fields,
|
||||||
|
});
|
||||||
|
|
||||||
|
String? validate(Map<String, String> values) {
|
||||||
|
for (final field in fields) {
|
||||||
|
final value = values[field.key]?.trim() ?? '';
|
||||||
|
if (field.required && value.isEmpty) {
|
||||||
|
return '${field.label} is required.';
|
||||||
|
}
|
||||||
|
if (field.isNumber && value.isNotEmpty) {
|
||||||
|
final parsed = int.tryParse(value);
|
||||||
|
if (parsed == null || parsed < 1) {
|
||||||
|
return '${field.label} must be a positive number.';
|
||||||
|
}
|
||||||
|
if (field.isPort && parsed > 65535) {
|
||||||
|
return '${field.label} must be between 1 and 65535.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (field.isIntegerList && value.isNotEmpty) {
|
||||||
|
final items = splitFormStringList(value);
|
||||||
|
final exactListLength = field.exactListLength;
|
||||||
|
if (exactListLength != null && items.length != exactListLength) {
|
||||||
|
return '${field.label} must contain $exactListLength numbers.';
|
||||||
|
}
|
||||||
|
for (final item in items) {
|
||||||
|
final parsed = int.tryParse(item);
|
||||||
|
if (parsed == null) {
|
||||||
|
return '${field.label} must contain only numbers.';
|
||||||
|
}
|
||||||
|
final minValue = field.minValue;
|
||||||
|
if (minValue != null && parsed < minValue) {
|
||||||
|
return '${field.label} must contain numbers greater than or equal to $minValue.';
|
||||||
|
}
|
||||||
|
final maxValue = field.maxValue;
|
||||||
|
if (maxValue != null && parsed > maxValue) {
|
||||||
|
return '${field.label} must contain numbers less than or equal to $maxValue.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (field.isBoolean && value.isNotEmpty && parseFormBool(value) == null) {
|
||||||
|
return '${field.label} must be true or false.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
String toConfigJson(Map<String, String> values) {
|
||||||
|
final config = <String, dynamic>{'type': outboundType};
|
||||||
|
for (final field in fields.where((field) => !field.isSecret)) {
|
||||||
|
final value = values[field.key]?.trim() ?? '';
|
||||||
|
if (value.isEmpty) continue;
|
||||||
|
_setJsonValue(config, field.key, _fieldJsonValue(field, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
return const JsonEncoder.withIndent(' ').convert(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? toSecretJson(Map<String, String> values) {
|
||||||
|
final secrets = <String, dynamic>{};
|
||||||
|
for (final field in fields.where((field) => field.isSecret)) {
|
||||||
|
final value = values[field.key]?.trim() ?? '';
|
||||||
|
if (value.isEmpty) continue;
|
||||||
|
_setJsonValue(secrets, field.key, _fieldJsonValue(field, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secrets.isEmpty) return null;
|
||||||
|
return const JsonEncoder.withIndent(' ').convert(secrets);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> valuesFromJson({
|
||||||
|
required String configJson,
|
||||||
|
String? secretJson,
|
||||||
|
}) {
|
||||||
|
final config = _decodeJsonObject(configJson) ?? const <String, dynamic>{};
|
||||||
|
final secrets = secretJson == null
|
||||||
|
? const <String, dynamic>{}
|
||||||
|
: _decodeJsonObject(secretJson) ?? const <String, dynamic>{};
|
||||||
|
final merged = {...config, ...secrets};
|
||||||
|
|
||||||
|
return {
|
||||||
|
for (final field in fields)
|
||||||
|
field.key: _fieldTextValue(
|
||||||
|
_jsonValueAt(merged, field.key),
|
||||||
|
field.defaultValue ?? '',
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, dynamic>? _decodeJsonObject(String rawJson) {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(rawJson) as Object?;
|
||||||
|
if (decoded is Map<String, dynamic>) return decoded;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object _fieldJsonValue(SingboxProxyFormField field, String value) {
|
||||||
|
if (field.isNumber) return int.parse(value);
|
||||||
|
if (field.isIntegerList) {
|
||||||
|
return splitFormStringList(value).map(int.parse).toList();
|
||||||
|
}
|
||||||
|
if (field.isBoolean) return parseFormBool(value)!;
|
||||||
|
if (field.isStringList) return splitFormStringList(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _fieldTextValue(Object? value, String fallback) {
|
||||||
|
if (value == null) return fallback;
|
||||||
|
if (value is List) return value.map((item) => item.toString()).join('\n');
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _setJsonValue(Map<String, dynamic> target, String path, Object value) {
|
||||||
|
final segments = path.split('.');
|
||||||
|
var current = target;
|
||||||
|
for (final segment in segments.take(segments.length - 1)) {
|
||||||
|
current =
|
||||||
|
current.putIfAbsent(segment, () => <String, dynamic>{})
|
||||||
|
as Map<String, dynamic>;
|
||||||
|
}
|
||||||
|
current[segments.last] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
Object? _jsonValueAt(Map<String, dynamic> source, String path) {
|
||||||
|
Object? current = source;
|
||||||
|
for (final segment in path.split('.')) {
|
||||||
|
if (current is! Map<String, dynamic>) return null;
|
||||||
|
current = current[segment];
|
||||||
|
}
|
||||||
|
return current;
|
||||||
|
}
|
||||||
@@ -0,0 +1,415 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/forms/singbox_form_field.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/forms/singbox_form_spec.dart';
|
||||||
|
|
||||||
|
const _serverField = SingboxProxyFormField(
|
||||||
|
key: 'server',
|
||||||
|
label: 'Server Address',
|
||||||
|
required: true,
|
||||||
|
);
|
||||||
|
const _serverPortField = SingboxProxyFormField(
|
||||||
|
key: 'server_port',
|
||||||
|
label: 'Server Port',
|
||||||
|
required: true,
|
||||||
|
kind: SingboxFieldKind.port,
|
||||||
|
);
|
||||||
|
const _usernameField = SingboxProxyFormField(
|
||||||
|
key: 'username',
|
||||||
|
label: 'Username',
|
||||||
|
kind: SingboxFieldKind.secret,
|
||||||
|
);
|
||||||
|
const _passwordField = SingboxProxyFormField(
|
||||||
|
key: 'password',
|
||||||
|
label: 'Password',
|
||||||
|
required: true,
|
||||||
|
kind: SingboxFieldKind.secret,
|
||||||
|
);
|
||||||
|
const _optionalPasswordField = SingboxProxyFormField(
|
||||||
|
key: 'password',
|
||||||
|
label: 'Password',
|
||||||
|
kind: SingboxFieldKind.secret,
|
||||||
|
);
|
||||||
|
const _uuidField = SingboxProxyFormField(
|
||||||
|
key: 'uuid',
|
||||||
|
label: 'UUID',
|
||||||
|
required: true,
|
||||||
|
kind: SingboxFieldKind.secret,
|
||||||
|
);
|
||||||
|
const _tlsFields = [
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'tls.enabled',
|
||||||
|
label: 'TLS Enabled',
|
||||||
|
helperText: 'true or false.',
|
||||||
|
kind: SingboxFieldKind.boolean,
|
||||||
|
),
|
||||||
|
SingboxProxyFormField(key: 'tls.server_name', label: 'TLS Server Name'),
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'tls.insecure',
|
||||||
|
label: 'Allow Invalid TLS Certificates',
|
||||||
|
helperText: 'true or false.',
|
||||||
|
kind: SingboxFieldKind.boolean,
|
||||||
|
),
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'tls.alpn',
|
||||||
|
label: 'TLS ALPN',
|
||||||
|
helperText: 'Comma-separated or one value per line.',
|
||||||
|
kind: SingboxFieldKind.stringList,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const _transportFields = [
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'transport.type',
|
||||||
|
label: 'Transport Type',
|
||||||
|
helperText: 'For example ws, http, grpc, or quic.',
|
||||||
|
),
|
||||||
|
SingboxProxyFormField(key: 'transport.path', label: 'Transport Path'),
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'transport.service_name',
|
||||||
|
label: 'gRPC Service Name',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const _multiplexFields = [
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'multiplex.enabled',
|
||||||
|
label: 'Multiplex Enabled',
|
||||||
|
helperText: 'true or false.',
|
||||||
|
kind: SingboxFieldKind.boolean,
|
||||||
|
),
|
||||||
|
SingboxProxyFormField(key: 'multiplex.protocol', label: 'Multiplex Protocol'),
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'multiplex.max_connections',
|
||||||
|
label: 'Multiplex Max Connections',
|
||||||
|
kind: SingboxFieldKind.integer,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const _dialFields = [
|
||||||
|
SingboxProxyFormField(key: 'detour', label: 'Dial Detour'),
|
||||||
|
SingboxProxyFormField(key: 'bind_interface', label: 'Bind Interface'),
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'routing_mark',
|
||||||
|
label: 'Routing Mark',
|
||||||
|
kind: SingboxFieldKind.integer,
|
||||||
|
),
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'domain_strategy',
|
||||||
|
label: 'Domain Strategy',
|
||||||
|
helperText: 'For example prefer_ipv4 or prefer_ipv6.',
|
||||||
|
),
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'connect_timeout',
|
||||||
|
label: 'Connect Timeout',
|
||||||
|
helperText: 'For example 5s.',
|
||||||
|
),
|
||||||
|
];
|
||||||
|
const _v2rayAdvancedFields = [
|
||||||
|
..._tlsFields,
|
||||||
|
..._transportFields,
|
||||||
|
..._multiplexFields,
|
||||||
|
..._dialFields,
|
||||||
|
];
|
||||||
|
const _commonAdvancedFields = [..._multiplexFields, ..._dialFields];
|
||||||
|
|
||||||
|
const singboxProxyFormSpecs = <SingboxProxyProfileType, SingboxProxyFormSpec>{
|
||||||
|
SingboxProxyProfileType.socks: SingboxProxyFormSpec(
|
||||||
|
type: SingboxProxyProfileType.socks,
|
||||||
|
outboundType: 'socks',
|
||||||
|
fields: [
|
||||||
|
_serverField,
|
||||||
|
_serverPortField,
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'version',
|
||||||
|
label: 'SOCKS Version',
|
||||||
|
defaultValue: '5',
|
||||||
|
kind: SingboxFieldKind.integer,
|
||||||
|
),
|
||||||
|
_usernameField,
|
||||||
|
_optionalPasswordField,
|
||||||
|
..._commonAdvancedFields,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SingboxProxyProfileType.http: SingboxProxyFormSpec(
|
||||||
|
type: SingboxProxyProfileType.http,
|
||||||
|
outboundType: 'http',
|
||||||
|
fields: [
|
||||||
|
_serverField,
|
||||||
|
_serverPortField,
|
||||||
|
_usernameField,
|
||||||
|
_optionalPasswordField,
|
||||||
|
..._tlsFields,
|
||||||
|
..._commonAdvancedFields,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SingboxProxyProfileType.shadowsocks: SingboxProxyFormSpec(
|
||||||
|
type: SingboxProxyProfileType.shadowsocks,
|
||||||
|
outboundType: 'shadowsocks',
|
||||||
|
fields: [
|
||||||
|
_serverField,
|
||||||
|
_serverPortField,
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'method',
|
||||||
|
label: 'Method',
|
||||||
|
defaultValue: '2022-blake3-aes-128-gcm',
|
||||||
|
required: true,
|
||||||
|
),
|
||||||
|
_passwordField,
|
||||||
|
..._commonAdvancedFields,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SingboxProxyProfileType.vmess: SingboxProxyFormSpec(
|
||||||
|
type: SingboxProxyProfileType.vmess,
|
||||||
|
outboundType: 'vmess',
|
||||||
|
fields: [
|
||||||
|
_serverField,
|
||||||
|
_serverPortField,
|
||||||
|
_uuidField,
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'security',
|
||||||
|
label: 'Security',
|
||||||
|
defaultValue: 'auto',
|
||||||
|
),
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'alter_id',
|
||||||
|
label: 'Alter ID',
|
||||||
|
kind: SingboxFieldKind.integer,
|
||||||
|
),
|
||||||
|
..._v2rayAdvancedFields,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SingboxProxyProfileType.vless: SingboxProxyFormSpec(
|
||||||
|
type: SingboxProxyProfileType.vless,
|
||||||
|
outboundType: 'vless',
|
||||||
|
fields: [
|
||||||
|
_serverField,
|
||||||
|
_serverPortField,
|
||||||
|
_uuidField,
|
||||||
|
SingboxProxyFormField(key: 'flow', label: 'Flow'),
|
||||||
|
..._v2rayAdvancedFields,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SingboxProxyProfileType.trojan: SingboxProxyFormSpec(
|
||||||
|
type: SingboxProxyProfileType.trojan,
|
||||||
|
outboundType: 'trojan',
|
||||||
|
fields: [
|
||||||
|
_serverField,
|
||||||
|
_serverPortField,
|
||||||
|
_passwordField,
|
||||||
|
..._v2rayAdvancedFields,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SingboxProxyProfileType.naive: SingboxProxyFormSpec(
|
||||||
|
type: SingboxProxyProfileType.naive,
|
||||||
|
outboundType: 'naive',
|
||||||
|
fields: [
|
||||||
|
_serverField,
|
||||||
|
_serverPortField,
|
||||||
|
_usernameField,
|
||||||
|
_passwordField,
|
||||||
|
..._tlsFields,
|
||||||
|
..._commonAdvancedFields,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SingboxProxyProfileType.hysteria: SingboxProxyFormSpec(
|
||||||
|
type: SingboxProxyProfileType.hysteria,
|
||||||
|
outboundType: 'hysteria',
|
||||||
|
fields: [
|
||||||
|
_serverField,
|
||||||
|
_serverPortField,
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'auth_str',
|
||||||
|
label: 'Auth String',
|
||||||
|
kind: SingboxFieldKind.secret,
|
||||||
|
),
|
||||||
|
SingboxProxyFormField(key: 'up', label: 'Upload Bandwidth'),
|
||||||
|
SingboxProxyFormField(key: 'down', label: 'Download Bandwidth'),
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'obfs',
|
||||||
|
label: 'Obfuscation',
|
||||||
|
kind: SingboxFieldKind.secret,
|
||||||
|
),
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'recv_window_conn',
|
||||||
|
label: 'Receive Window Conn',
|
||||||
|
kind: SingboxFieldKind.integer,
|
||||||
|
),
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'recv_window',
|
||||||
|
label: 'Receive Window',
|
||||||
|
kind: SingboxFieldKind.integer,
|
||||||
|
),
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'disable_mtu_discovery',
|
||||||
|
label: 'Disable MTU Discovery',
|
||||||
|
helperText: 'true or false.',
|
||||||
|
kind: SingboxFieldKind.boolean,
|
||||||
|
),
|
||||||
|
..._tlsFields,
|
||||||
|
..._commonAdvancedFields,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SingboxProxyProfileType.hysteria2: SingboxProxyFormSpec(
|
||||||
|
type: SingboxProxyProfileType.hysteria2,
|
||||||
|
outboundType: 'hysteria2',
|
||||||
|
fields: [
|
||||||
|
_serverField,
|
||||||
|
_serverPortField,
|
||||||
|
_passwordField,
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'up_mbps',
|
||||||
|
label: 'Upload Mbps',
|
||||||
|
kind: SingboxFieldKind.integer,
|
||||||
|
),
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'down_mbps',
|
||||||
|
label: 'Download Mbps',
|
||||||
|
kind: SingboxFieldKind.integer,
|
||||||
|
),
|
||||||
|
SingboxProxyFormField(key: 'obfs.type', label: 'Obfuscation Type'),
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'obfs.password',
|
||||||
|
label: 'Obfuscation Password',
|
||||||
|
kind: SingboxFieldKind.secret,
|
||||||
|
),
|
||||||
|
..._tlsFields,
|
||||||
|
..._commonAdvancedFields,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SingboxProxyProfileType.tuic: SingboxProxyFormSpec(
|
||||||
|
type: SingboxProxyProfileType.tuic,
|
||||||
|
outboundType: 'tuic',
|
||||||
|
fields: [
|
||||||
|
_serverField,
|
||||||
|
_serverPortField,
|
||||||
|
_uuidField,
|
||||||
|
_passwordField,
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'congestion_control',
|
||||||
|
label: 'Congestion Control',
|
||||||
|
defaultValue: 'cubic',
|
||||||
|
),
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'udp_relay_mode',
|
||||||
|
label: 'UDP Relay Mode',
|
||||||
|
defaultValue: 'native',
|
||||||
|
),
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'zero_rtt_handshake',
|
||||||
|
label: 'Zero RTT Handshake',
|
||||||
|
helperText: 'true or false.',
|
||||||
|
kind: SingboxFieldKind.boolean,
|
||||||
|
),
|
||||||
|
..._tlsFields,
|
||||||
|
..._commonAdvancedFields,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SingboxProxyProfileType.ssh: SingboxProxyFormSpec(
|
||||||
|
type: SingboxProxyProfileType.ssh,
|
||||||
|
outboundType: 'ssh',
|
||||||
|
fields: [
|
||||||
|
_serverField,
|
||||||
|
_serverPortField,
|
||||||
|
SingboxProxyFormField(key: 'user', label: 'User', required: true),
|
||||||
|
_optionalPasswordField,
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'private_key',
|
||||||
|
label: 'Private Key',
|
||||||
|
kind: SingboxFieldKind.secret,
|
||||||
|
),
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'private_key_passphrase',
|
||||||
|
label: 'Private Key Passphrase',
|
||||||
|
kind: SingboxFieldKind.secret,
|
||||||
|
),
|
||||||
|
..._commonAdvancedFields,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SingboxProxyProfileType.wireguard: SingboxProxyFormSpec(
|
||||||
|
type: SingboxProxyProfileType.wireguard,
|
||||||
|
outboundType: 'wireguard',
|
||||||
|
fields: [
|
||||||
|
_serverField,
|
||||||
|
_serverPortField,
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'local_address',
|
||||||
|
label: 'Local Address',
|
||||||
|
helperText: 'One CIDR per line, for example 10.0.0.2/32.',
|
||||||
|
required: true,
|
||||||
|
kind: SingboxFieldKind.stringList,
|
||||||
|
),
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'peer_public_key',
|
||||||
|
label: 'Peer Public Key',
|
||||||
|
required: true,
|
||||||
|
),
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'private_key',
|
||||||
|
label: 'Private Key',
|
||||||
|
helperText: 'Stored in secure storage, not profile JSON.',
|
||||||
|
required: true,
|
||||||
|
kind: SingboxFieldKind.secret,
|
||||||
|
),
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'pre_shared_key',
|
||||||
|
label: 'Pre-shared Key',
|
||||||
|
helperText: 'Optional. Stored in secure storage.',
|
||||||
|
kind: SingboxFieldKind.secret,
|
||||||
|
),
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'mtu',
|
||||||
|
label: 'MTU',
|
||||||
|
helperText: 'Optional. sing-box uses 1408 by default.',
|
||||||
|
defaultValue: '1408',
|
||||||
|
kind: SingboxFieldKind.integer,
|
||||||
|
),
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'reserved',
|
||||||
|
label: 'Reserved Bytes',
|
||||||
|
helperText: 'Optional. Three comma-separated numbers, e.g. 0,0,0.',
|
||||||
|
kind: SingboxFieldKind.integerList,
|
||||||
|
exactListLength: 3,
|
||||||
|
minValue: 0,
|
||||||
|
maxValue: 255,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SingboxProxyProfileType.shadowTls: SingboxProxyFormSpec(
|
||||||
|
type: SingboxProxyProfileType.shadowTls,
|
||||||
|
outboundType: 'shadowtls',
|
||||||
|
fields: [
|
||||||
|
_serverField,
|
||||||
|
_serverPortField,
|
||||||
|
SingboxProxyFormField(
|
||||||
|
key: 'version',
|
||||||
|
label: 'Version',
|
||||||
|
defaultValue: '3',
|
||||||
|
kind: SingboxFieldKind.integer,
|
||||||
|
),
|
||||||
|
_passwordField,
|
||||||
|
..._tlsFields,
|
||||||
|
..._commonAdvancedFields,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SingboxProxyProfileType.anyTls: SingboxProxyFormSpec(
|
||||||
|
type: SingboxProxyProfileType.anyTls,
|
||||||
|
outboundType: 'anytls',
|
||||||
|
fields: [_serverField, _serverPortField, _passwordField, ..._dialFields],
|
||||||
|
),
|
||||||
|
};
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
|
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||||
|
import 'package:flutter_tor/flutter_tor.dart';
|
||||||
|
|
||||||
|
enum ProxyLogSource { singBox, tor }
|
||||||
|
|
||||||
|
class ProxyLogMessage with FastEquatable {
|
||||||
|
final ProxyLogSource source;
|
||||||
|
final String level;
|
||||||
|
final String message;
|
||||||
|
final int timestamp;
|
||||||
|
final String? profileId;
|
||||||
|
|
||||||
|
ProxyLogMessage({
|
||||||
|
required this.source,
|
||||||
|
required this.level,
|
||||||
|
required this.message,
|
||||||
|
required this.timestamp,
|
||||||
|
this.profileId,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ProxyLogMessage.fromSingbox(SingboxProxyLogMessage message) {
|
||||||
|
return ProxyLogMessage(
|
||||||
|
source: ProxyLogSource.singBox,
|
||||||
|
level: message.level,
|
||||||
|
message: message.message,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
profileId: message.profileId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory ProxyLogMessage.fromTor(TorLogMessage message) {
|
||||||
|
return ProxyLogMessage(
|
||||||
|
source: ProxyLogSource.tor,
|
||||||
|
level: _torSeverityToLevel(message.severity),
|
||||||
|
message: message.message,
|
||||||
|
timestamp: message.timestamp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get hashParameters => [
|
||||||
|
source,
|
||||||
|
level,
|
||||||
|
message,
|
||||||
|
timestamp,
|
||||||
|
profileId,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
String _torSeverityToLevel(String severity) {
|
||||||
|
return switch (severity.toUpperCase()) {
|
||||||
|
'ERR' => 'error',
|
||||||
|
'WARN' => 'warn',
|
||||||
|
'DEBUG' => 'debug',
|
||||||
|
'INFO' || 'NOTICE' => 'info',
|
||||||
|
_ => severity.toLowerCase(),
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
|
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||||
|
|
||||||
|
/// Pre-filled state handed to the create-mode editor when the user reached it
|
||||||
|
/// through a guided method (file import, clipboard, QR, etc.).
|
||||||
|
///
|
||||||
|
/// Carries structured [values] for [SingboxProxyFormSpec]-driven types.
|
||||||
|
/// [dnsOverrideJson] is applied to the editor's DNS override section when
|
||||||
|
/// present.
|
||||||
|
class ProxyProfileSeed with FastEquatable {
|
||||||
|
final SingboxProxyProfileType type;
|
||||||
|
final String? name;
|
||||||
|
final Map<String, String> values;
|
||||||
|
final String? dnsOverrideJson;
|
||||||
|
|
||||||
|
ProxyProfileSeed({
|
||||||
|
required this.type,
|
||||||
|
this.name,
|
||||||
|
this.values = const {},
|
||||||
|
this.dnsOverrideJson,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get hashParameters => [type, name, values, dnsOverrideJson];
|
||||||
|
}
|
||||||
@@ -0,0 +1,141 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
|
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||||
|
|
||||||
|
/// Custom share-link scheme for round-tripping a WebLibre proxy profile
|
||||||
|
/// (including any secret config) between WebLibre installs. NOT cross-app
|
||||||
|
/// compatible — for that use the standard ss://, vless://, etc. URIs imported
|
||||||
|
/// from the editor (and exposed by future per-protocol exporters).
|
||||||
|
const weblibreProxyShareScheme = 'weblibre-proxy';
|
||||||
|
|
||||||
|
class ProxyShareEnvelope with FastEquatable {
|
||||||
|
/// Schema version — bumped if the wrapped JSON shape ever changes.
|
||||||
|
/// v1: name + type + config + secret.
|
||||||
|
/// v2: also carries dnsOverrideJson so per-profile DNS settings survive
|
||||||
|
/// share/import. Older v1 payloads stay readable (treated as no
|
||||||
|
/// override).
|
||||||
|
static const int currentVersion = 2;
|
||||||
|
|
||||||
|
final String name;
|
||||||
|
final SingboxProxyProfileType type;
|
||||||
|
final String configJson;
|
||||||
|
final String? secretJson;
|
||||||
|
final String? dnsOverrideJson;
|
||||||
|
|
||||||
|
ProxyShareEnvelope({
|
||||||
|
required this.name,
|
||||||
|
required this.type,
|
||||||
|
required this.configJson,
|
||||||
|
this.secretJson,
|
||||||
|
this.dnsOverrideJson,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get hashParameters => [
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
configJson,
|
||||||
|
secretJson,
|
||||||
|
dnsOverrideJson,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Encodes the profile as `weblibre-proxy://<base64url(JSON)>`. The JSON body
|
||||||
|
/// is compact (no whitespace) to keep the URL short for QR rendering once
|
||||||
|
/// added.
|
||||||
|
String encodeProxyShareUri(ProxyShareEnvelope envelope) {
|
||||||
|
final body = <String, Object?>{
|
||||||
|
'v': ProxyShareEnvelope.currentVersion,
|
||||||
|
'name': envelope.name,
|
||||||
|
'type': envelope.type.name,
|
||||||
|
'config': jsonDecode(envelope.configJson),
|
||||||
|
if (envelope.secretJson != null && envelope.secretJson!.isNotEmpty)
|
||||||
|
'secret': jsonDecode(envelope.secretJson!),
|
||||||
|
if (envelope.dnsOverrideJson != null &&
|
||||||
|
envelope.dnsOverrideJson!.isNotEmpty)
|
||||||
|
'dnsOverride': jsonDecode(envelope.dnsOverrideJson!),
|
||||||
|
};
|
||||||
|
final encoded = base64UrlEncode(utf8.encode(jsonEncode(body)));
|
||||||
|
return '$weblibreProxyShareScheme://$encoded';
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a `weblibre-proxy://...` share URI. Throws [FormatException] on
|
||||||
|
/// malformed input or version mismatch.
|
||||||
|
const _shareUriPrefix = '$weblibreProxyShareScheme://';
|
||||||
|
|
||||||
|
ProxyShareEnvelope decodeProxyShareUri(String rawUri) {
|
||||||
|
final trimmed = rawUri.trim();
|
||||||
|
if (!trimmed.startsWith(_shareUriPrefix)) {
|
||||||
|
throw const FormatException(
|
||||||
|
'Not a WebLibre proxy share URI (expected scheme $weblibreProxyShareScheme).',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final payload = trimmed.substring(_shareUriPrefix.length);
|
||||||
|
final List<int> bytes;
|
||||||
|
try {
|
||||||
|
bytes = base64Url.decode(base64Url.normalize(payload));
|
||||||
|
} on FormatException {
|
||||||
|
throw const FormatException('Share URI payload is not valid base64url.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final dynamic decoded;
|
||||||
|
try {
|
||||||
|
decoded = jsonDecode(utf8.decode(bytes));
|
||||||
|
} on FormatException {
|
||||||
|
throw const FormatException('Share URI payload is not valid JSON.');
|
||||||
|
}
|
||||||
|
if (decoded is! Map<String, Object?>) {
|
||||||
|
throw const FormatException('Share URI payload must be a JSON object.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final version = decoded['v'];
|
||||||
|
if (version is! int ||
|
||||||
|
version < 1 ||
|
||||||
|
version > ProxyShareEnvelope.currentVersion) {
|
||||||
|
throw FormatException('Unsupported share URI version: $version.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final name = decoded['name'];
|
||||||
|
final typeName = decoded['type'];
|
||||||
|
final config = decoded['config'];
|
||||||
|
final secret = decoded['secret'];
|
||||||
|
final dnsOverride = decoded['dnsOverride'];
|
||||||
|
|
||||||
|
if (name is! String || typeName is! String || config == null) {
|
||||||
|
throw const FormatException('Share URI is missing required fields.');
|
||||||
|
}
|
||||||
|
final type = SingboxProxyProfileType.values
|
||||||
|
.where((value) => value.name == typeName)
|
||||||
|
.firstOrNull;
|
||||||
|
if (type == null) {
|
||||||
|
throw FormatException('Unknown profile type: $typeName.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProxyShareEnvelope(
|
||||||
|
name: name,
|
||||||
|
type: type,
|
||||||
|
configJson: jsonEncode(config),
|
||||||
|
secretJson: secret == null ? null : jsonEncode(secret),
|
||||||
|
dnsOverrideJson: dnsOverride == null ? null : jsonEncode(dnsOverride),
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||||
|
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
|
||||||
|
show ProxyProfile;
|
||||||
|
|
||||||
|
extension ProxyProfileExt on ProxyProfile {
|
||||||
|
ProxyConnectionId get proxyConnection => SingboxProxyConnectionId(id);
|
||||||
|
|
||||||
|
String get proxyConnectionId => proxyConnection.encode();
|
||||||
|
|
||||||
|
SingboxProxyProfile toRuntimeProfile({String? secretJson}) {
|
||||||
|
return SingboxProxyProfile(
|
||||||
|
id: proxyConnectionId,
|
||||||
|
name: name,
|
||||||
|
type: type,
|
||||||
|
configJson: configJson,
|
||||||
|
secretJson: secretJson,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/parsers/host_port.dart';
|
||||||
|
|
||||||
|
class WireguardConfigImport with FastEquatable {
|
||||||
|
/// Values that seed the shared [SingboxProxyFormSpec]-driven WireGuard form.
|
||||||
|
final Map<String, String> values;
|
||||||
|
|
||||||
|
/// Newline-separated DNS servers parsed from the WireGuard `[Interface] DNS`
|
||||||
|
/// line. Not round-tripped through sing-box outbound JSON because DNS lives
|
||||||
|
/// in the top-level `dns` block and is surfaced as a per-profile override.
|
||||||
|
final String dns;
|
||||||
|
|
||||||
|
WireguardConfigImport({required this.values, this.dns = ''});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get hashParameters => [values, dns];
|
||||||
|
|
||||||
|
factory WireguardConfigImport.fromConfigText(String configText) {
|
||||||
|
final sections = _parseWireguardConfig(configText);
|
||||||
|
final interface = sections['interface'];
|
||||||
|
final peer = sections['peer'];
|
||||||
|
if (interface == null || peer == null) {
|
||||||
|
throw const FormatException(
|
||||||
|
'WireGuard config must contain [Interface] and [Peer] sections.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final rawEndpoint = (peer['endpoint'] ?? '').trim();
|
||||||
|
final endpoint = rawEndpoint.isEmpty
|
||||||
|
? (host: '', port: '')
|
||||||
|
: parseHostPort(
|
||||||
|
rawEndpoint,
|
||||||
|
invalidMessage: 'WireGuard endpoint must be host:port.',
|
||||||
|
);
|
||||||
|
final mtu = interface['mtu']?.trim();
|
||||||
|
|
||||||
|
return WireguardConfigImport(
|
||||||
|
values: {
|
||||||
|
'server': endpoint.host,
|
||||||
|
'server_port': endpoint.port,
|
||||||
|
'local_address': _wireguardListValue(interface['address']),
|
||||||
|
'private_key': interface['privatekey']?.trim() ?? '',
|
||||||
|
'peer_public_key': peer['publickey']?.trim() ?? '',
|
||||||
|
'pre_shared_key': peer['presharedkey']?.trim() ?? '',
|
||||||
|
'mtu': mtu == null || mtu.isEmpty ? '1408' : mtu,
|
||||||
|
},
|
||||||
|
dns: _wireguardListValue(interface['dns']),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// First parsed DNS entry as a sing-box-compatible address, or null when
|
||||||
|
/// the imported config had no `DNS = …` line. Bare IPs become `udp://<ip>`
|
||||||
|
/// (sing-box's plain-UDP scheme); anything already containing `://` is kept
|
||||||
|
/// verbatim so users can paste `https://…/dns-query` etc.
|
||||||
|
String? get primaryDnsAddress {
|
||||||
|
final entries = _splitList(dns);
|
||||||
|
if (entries.isEmpty) return null;
|
||||||
|
final first = entries.first;
|
||||||
|
if (first.contains('://')) return first;
|
||||||
|
return 'udp://$first';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<String> _splitList(String value) {
|
||||||
|
return value
|
||||||
|
.replaceAll('[', '')
|
||||||
|
.replaceAll(']', '')
|
||||||
|
.split(RegExp(r'[\n,]'))
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.where((item) => item.isNotEmpty)
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, Map<String, String>> _parseWireguardConfig(String configText) {
|
||||||
|
final sections = <String, Map<String, String>>{};
|
||||||
|
String? currentSection;
|
||||||
|
|
||||||
|
for (final rawLine in const LineSplitter().convert(configText)) {
|
||||||
|
final line = _stripWireguardComment(rawLine).trim();
|
||||||
|
if (line.isEmpty) continue;
|
||||||
|
|
||||||
|
if (line.startsWith('[') && line.endsWith(']')) {
|
||||||
|
currentSection = line.substring(1, line.length - 1).trim().toLowerCase();
|
||||||
|
if (currentSection.isEmpty) {
|
||||||
|
throw const FormatException('WireGuard config contains empty section.');
|
||||||
|
}
|
||||||
|
sections.putIfAbsent(currentSection, () => <String, String>{});
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentSection == null) {
|
||||||
|
throw const FormatException(
|
||||||
|
'WireGuard config entries must be inside a section.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final separatorIndex = line.indexOf('=');
|
||||||
|
if (separatorIndex < 1) {
|
||||||
|
throw FormatException('Invalid WireGuard config line: $rawLine');
|
||||||
|
}
|
||||||
|
|
||||||
|
final key = line.substring(0, separatorIndex).trim().toLowerCase();
|
||||||
|
final value = line.substring(separatorIndex + 1).trim();
|
||||||
|
if (key.isEmpty) {
|
||||||
|
throw FormatException('Invalid WireGuard config line: $rawLine');
|
||||||
|
}
|
||||||
|
sections[currentSection]![key] = value;
|
||||||
|
}
|
||||||
|
|
||||||
|
return sections;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _stripWireguardComment(String line) {
|
||||||
|
final hashIndex = line.indexOf('#');
|
||||||
|
final semicolonIndex = line.indexOf(';');
|
||||||
|
final indexes = [
|
||||||
|
if (hashIndex >= 0) hashIndex,
|
||||||
|
if (semicolonIndex >= 0) semicolonIndex,
|
||||||
|
];
|
||||||
|
if (indexes.isEmpty) return line;
|
||||||
|
indexes.sort();
|
||||||
|
return line.substring(0, indexes.first);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _wireguardListValue(String? value) {
|
||||||
|
if (value == null) return '';
|
||||||
|
return value
|
||||||
|
.split(',')
|
||||||
|
.map((item) => item.trim())
|
||||||
|
.where((item) => item.isNotEmpty)
|
||||||
|
.join('\n');
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
/// Decodes URL-safe or standard base64 text, tolerating missing padding.
|
||||||
|
String decodeBase64Text(String value) {
|
||||||
|
final normalized = value.replaceAll('-', '+').replaceAll('_', '/');
|
||||||
|
final padded = normalized.padRight(
|
||||||
|
normalized.length + (4 - normalized.length % 4) % 4,
|
||||||
|
'=',
|
||||||
|
);
|
||||||
|
return utf8.decode(base64Decode(padded));
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
typedef HostPort = ({String host, String port});
|
||||||
|
|
||||||
|
/// Parses a `host:port` endpoint. Accepts bracketed IPv6 syntax
|
||||||
|
/// (`[2001:db8::1]:443`). Throws [FormatException] on invalid input.
|
||||||
|
HostPort parseHostPort(
|
||||||
|
String rawEndpoint, {
|
||||||
|
String invalidMessage = 'Endpoint must be host:port.',
|
||||||
|
}) {
|
||||||
|
final endpoint = rawEndpoint.trim();
|
||||||
|
if (endpoint.startsWith('[')) {
|
||||||
|
final closingIndex = endpoint.indexOf(']');
|
||||||
|
if (closingIndex < 0 || closingIndex == endpoint.length - 1) {
|
||||||
|
throw FormatException(invalidMessage);
|
||||||
|
}
|
||||||
|
final remainder = endpoint.substring(closingIndex + 1);
|
||||||
|
if (!remainder.startsWith(':')) {
|
||||||
|
throw FormatException(invalidMessage);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
host: endpoint.substring(1, closingIndex),
|
||||||
|
port: remainder.substring(1),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final separatorIndex = endpoint.lastIndexOf(':');
|
||||||
|
if (separatorIndex <= 0 || separatorIndex == endpoint.length - 1) {
|
||||||
|
throw FormatException(invalidMessage);
|
||||||
|
}
|
||||||
|
final host = endpoint.substring(0, separatorIndex);
|
||||||
|
if (host.contains(':')) {
|
||||||
|
throw const FormatException(
|
||||||
|
'IPv6 endpoints must use [address]:port syntax.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (host: host, port: endpoint.substring(separatorIndex + 1));
|
||||||
|
}
|
||||||
@@ -0,0 +1,320 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
|
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/parsers/base64_text.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/parsers/host_port.dart';
|
||||||
|
|
||||||
|
/// Form-spec-shaped result of parsing a single proxy URI (ss://, vless://, …).
|
||||||
|
class SingboxProxyUriImport with FastEquatable {
|
||||||
|
final SingboxProxyProfileType type;
|
||||||
|
final String? name;
|
||||||
|
final Map<String, String> values;
|
||||||
|
|
||||||
|
SingboxProxyUriImport({required this.type, required this.values, this.name});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get hashParameters => [type, name, values];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Dispatches on the URI scheme and delegates to the matching importer.
|
||||||
|
/// Throws [FormatException] for unknown or malformed schemes.
|
||||||
|
SingboxProxyUriImport importSingboxProxyUri(String rawUri) {
|
||||||
|
final trimmedUri = rawUri.trim();
|
||||||
|
final schemeEnd = trimmedUri.indexOf('://');
|
||||||
|
if (schemeEnd <= 0) {
|
||||||
|
throw const FormatException('Proxy URI must include a scheme.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final scheme = trimmedUri.substring(0, schemeEnd).toLowerCase();
|
||||||
|
return switch (scheme) {
|
||||||
|
'ss' => _importShadowsocksUri(trimmedUri),
|
||||||
|
'socks' || 'socks5' => _importSocksUri(trimmedUri),
|
||||||
|
'http' || 'https' => _importHttpUri(trimmedUri),
|
||||||
|
'trojan' => _importTrojanUri(trimmedUri),
|
||||||
|
'vless' => _importVlessUri(trimmedUri),
|
||||||
|
'vmess' => _importVmessUri(trimmedUri),
|
||||||
|
'hysteria2' || 'hy2' => _importHysteria2Uri(trimmedUri),
|
||||||
|
'tuic' => _importTuicUri(trimmedUri),
|
||||||
|
_ => throw FormatException('Unsupported proxy URI scheme: $scheme'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
SingboxProxyUriImport _importShadowsocksUri(String rawUri) {
|
||||||
|
final payload = rawUri.substring('ss://'.length);
|
||||||
|
final fragmentIndex = payload.indexOf('#');
|
||||||
|
final withoutFragment = fragmentIndex >= 0
|
||||||
|
? payload.substring(0, fragmentIndex)
|
||||||
|
: payload;
|
||||||
|
final name = fragmentIndex >= 0
|
||||||
|
? Uri.decodeComponent(payload.substring(fragmentIndex + 1))
|
||||||
|
: null;
|
||||||
|
final withoutQuery = withoutFragment.split('?').first;
|
||||||
|
final decodedPayload = withoutQuery.contains('@')
|
||||||
|
? withoutQuery
|
||||||
|
: decodeBase64Text(withoutQuery);
|
||||||
|
final atIndex = decodedPayload.lastIndexOf('@');
|
||||||
|
if (atIndex <= 0 || atIndex == decodedPayload.length - 1) {
|
||||||
|
throw const FormatException('Shadowsocks URI must include credentials.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final rawCredentials = decodedPayload.substring(0, atIndex);
|
||||||
|
final credentials = rawCredentials.contains(':')
|
||||||
|
? rawCredentials
|
||||||
|
: decodeBase64Text(rawCredentials);
|
||||||
|
final credentialSeparator = credentials.indexOf(':');
|
||||||
|
if (credentialSeparator <= 0) {
|
||||||
|
throw const FormatException(
|
||||||
|
'Shadowsocks URI credentials must be method:password.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final endpoint = parseHostPort(
|
||||||
|
decodedPayload.substring(atIndex + 1),
|
||||||
|
invalidMessage: 'Proxy URI endpoint must be host:port.',
|
||||||
|
);
|
||||||
|
|
||||||
|
return SingboxProxyUriImport(
|
||||||
|
type: SingboxProxyProfileType.shadowsocks,
|
||||||
|
name: _nonEmptyName(name),
|
||||||
|
values: {
|
||||||
|
'server': endpoint.host,
|
||||||
|
'server_port': endpoint.port,
|
||||||
|
'method': Uri.decodeComponent(
|
||||||
|
credentials.substring(0, credentialSeparator),
|
||||||
|
),
|
||||||
|
'password': Uri.decodeComponent(
|
||||||
|
credentials.substring(credentialSeparator + 1),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SingboxProxyUriImport _importTrojanUri(String rawUri) {
|
||||||
|
final uri = Uri.parse(rawUri);
|
||||||
|
final endpoint = _uriEndpoint(uri, 'Trojan');
|
||||||
|
|
||||||
|
return SingboxProxyUriImport(
|
||||||
|
type: SingboxProxyProfileType.trojan,
|
||||||
|
name: _uriName(uri),
|
||||||
|
values: {
|
||||||
|
'server': endpoint.host,
|
||||||
|
'server_port': endpoint.port,
|
||||||
|
'password': Uri.decodeComponent(uri.userInfo),
|
||||||
|
'tls.enabled': 'true',
|
||||||
|
..._tlsImportValues(uri),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SingboxProxyUriImport _importVlessUri(String rawUri) {
|
||||||
|
final uri = Uri.parse(rawUri);
|
||||||
|
final endpoint = _uriEndpoint(uri, 'VLESS');
|
||||||
|
|
||||||
|
return SingboxProxyUriImport(
|
||||||
|
type: SingboxProxyProfileType.vless,
|
||||||
|
name: _uriName(uri),
|
||||||
|
values: {
|
||||||
|
'server': endpoint.host,
|
||||||
|
'server_port': endpoint.port,
|
||||||
|
'uuid': Uri.decodeComponent(uri.userInfo),
|
||||||
|
'flow': uri.queryParameters['flow'] ?? '',
|
||||||
|
..._tlsImportValues(uri),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SingboxProxyUriImport _importSocksUri(String rawUri) {
|
||||||
|
final uri = Uri.parse(rawUri);
|
||||||
|
final endpoint = _uriHostPort(uri, 'SOCKS');
|
||||||
|
final userInfo = _splitUserInfo(uri.userInfo);
|
||||||
|
|
||||||
|
return SingboxProxyUriImport(
|
||||||
|
type: SingboxProxyProfileType.socks,
|
||||||
|
name: _uriName(uri),
|
||||||
|
values: {
|
||||||
|
'server': endpoint.host,
|
||||||
|
'server_port': endpoint.port,
|
||||||
|
'version': '5',
|
||||||
|
'username': userInfo.username,
|
||||||
|
'password': userInfo.password,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SingboxProxyUriImport _importHttpUri(String rawUri) {
|
||||||
|
final uri = Uri.parse(rawUri);
|
||||||
|
final endpoint = _uriHostPort(uri, 'HTTP');
|
||||||
|
final userInfo = _splitUserInfo(uri.userInfo);
|
||||||
|
|
||||||
|
return SingboxProxyUriImport(
|
||||||
|
type: SingboxProxyProfileType.http,
|
||||||
|
name: _uriName(uri),
|
||||||
|
values: {
|
||||||
|
'server': endpoint.host,
|
||||||
|
'server_port': endpoint.port,
|
||||||
|
'username': userInfo.username,
|
||||||
|
'password': userInfo.password,
|
||||||
|
if (uri.scheme.toLowerCase() == 'https') 'tls.enabled': 'true',
|
||||||
|
..._tlsImportValues(uri),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SingboxProxyUriImport _importHysteria2Uri(String rawUri) {
|
||||||
|
final uri = Uri.parse(rawUri);
|
||||||
|
final endpoint = _uriEndpoint(uri, 'Hysteria2');
|
||||||
|
final query = uri.queryParameters;
|
||||||
|
|
||||||
|
return SingboxProxyUriImport(
|
||||||
|
type: SingboxProxyProfileType.hysteria2,
|
||||||
|
name: _uriName(uri),
|
||||||
|
values: {
|
||||||
|
'server': endpoint.host,
|
||||||
|
'server_port': endpoint.port,
|
||||||
|
'password': Uri.decodeComponent(uri.userInfo),
|
||||||
|
'obfs.type': query['obfs'] ?? '',
|
||||||
|
'obfs.password': query['obfs-password'] ?? query['obfs_password'] ?? '',
|
||||||
|
'tls.enabled': 'true',
|
||||||
|
..._tlsImportValues(uri),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SingboxProxyUriImport _importTuicUri(String rawUri) {
|
||||||
|
final uri = Uri.parse(rawUri);
|
||||||
|
final endpoint = _uriEndpoint(uri, 'TUIC');
|
||||||
|
final userInfo = _splitUserInfo(uri.userInfo);
|
||||||
|
final query = uri.queryParameters;
|
||||||
|
|
||||||
|
return SingboxProxyUriImport(
|
||||||
|
type: SingboxProxyProfileType.tuic,
|
||||||
|
name: _uriName(uri),
|
||||||
|
values: {
|
||||||
|
'server': endpoint.host,
|
||||||
|
'server_port': endpoint.port,
|
||||||
|
'uuid': userInfo.username,
|
||||||
|
'password': userInfo.password,
|
||||||
|
'congestion_control':
|
||||||
|
query['congestion_control'] ?? query['congestion'] ?? 'cubic',
|
||||||
|
'udp_relay_mode': query['udp_relay_mode'] ?? 'native',
|
||||||
|
'tls.enabled': 'true',
|
||||||
|
..._tlsImportValues(uri),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
SingboxProxyUriImport _importVmessUri(String rawUri) {
|
||||||
|
final payload = rawUri.substring('vmess://'.length).split('#').first.trim();
|
||||||
|
final decoded = jsonDecode(decodeBase64Text(payload)) as Object?;
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
throw const FormatException('VMess URI payload must be a JSON object.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final server = _stringValue(decoded['add'], '');
|
||||||
|
final port = _stringValue(decoded['port'], '');
|
||||||
|
final uuid = _stringValue(decoded['id'], '');
|
||||||
|
if (server.isEmpty || port.isEmpty || uuid.isEmpty) {
|
||||||
|
throw const FormatException('VMess URI must include add, port, and id.');
|
||||||
|
}
|
||||||
|
|
||||||
|
return SingboxProxyUriImport(
|
||||||
|
type: SingboxProxyProfileType.vmess,
|
||||||
|
name: _nonEmptyName(_stringValue(decoded['ps'], '')),
|
||||||
|
values: {
|
||||||
|
'server': server,
|
||||||
|
'server_port': port,
|
||||||
|
'uuid': uuid,
|
||||||
|
'security': _stringValue(decoded['scy'], 'auto'),
|
||||||
|
'alter_id': _stringValue(decoded['aid'], ''),
|
||||||
|
'tls.enabled': _stringValue(decoded['tls'], '') == 'tls' ? 'true' : '',
|
||||||
|
'tls.server_name': _stringValue(decoded['sni'], ''),
|
||||||
|
'transport.type': _stringValue(decoded['net'], ''),
|
||||||
|
'transport.path': _stringValue(decoded['path'], ''),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
HostPort _uriEndpoint(Uri uri, String label) {
|
||||||
|
final endpoint = _uriHostPort(uri, label);
|
||||||
|
if (uri.userInfo.isEmpty) {
|
||||||
|
throw FormatException('$label URI must include credentials.');
|
||||||
|
}
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
HostPort _uriHostPort(Uri uri, String label) {
|
||||||
|
final hasExplicitOrDefaultPort = uri.hasPort || uri.port > 0;
|
||||||
|
if (uri.host.isEmpty || !hasExplicitOrDefaultPort) {
|
||||||
|
throw FormatException('$label URI must include host and port.');
|
||||||
|
}
|
||||||
|
return (host: uri.host, port: uri.port.toString());
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _uriName(Uri uri) {
|
||||||
|
if (!uri.hasFragment) return null;
|
||||||
|
return _nonEmptyName(Uri.decodeComponent(uri.fragment));
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _nonEmptyName(String? name) {
|
||||||
|
final trimmed = name?.trim();
|
||||||
|
if (trimmed == null || trimmed.isEmpty) return null;
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
({String username, String password}) _splitUserInfo(String userInfo) {
|
||||||
|
if (userInfo.isEmpty) return (username: '', password: '');
|
||||||
|
final separatorIndex = userInfo.indexOf(':');
|
||||||
|
if (separatorIndex < 0) {
|
||||||
|
return (username: Uri.decodeComponent(userInfo), password: '');
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
username: Uri.decodeComponent(userInfo.substring(0, separatorIndex)),
|
||||||
|
password: Uri.decodeComponent(userInfo.substring(separatorIndex + 1)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> _tlsImportValues(Uri uri) {
|
||||||
|
final query = uri.queryParameters;
|
||||||
|
final values = <String, String>{};
|
||||||
|
final security = query['security'] ?? query['tls'];
|
||||||
|
if (security == 'tls' || security == '1' || security == 'true') {
|
||||||
|
values['tls.enabled'] = 'true';
|
||||||
|
}
|
||||||
|
final serverName = query['sni'] ?? query['peer'] ?? query['server_name'];
|
||||||
|
if (serverName != null && serverName.isNotEmpty) {
|
||||||
|
values['tls.server_name'] = serverName;
|
||||||
|
}
|
||||||
|
final insecure =
|
||||||
|
query['allowInsecure'] ?? query['allow_insecure'] ?? query['insecure'];
|
||||||
|
if (insecure != null && insecure.isNotEmpty) {
|
||||||
|
values['tls.insecure'] = insecure;
|
||||||
|
}
|
||||||
|
final alpn = query['alpn'];
|
||||||
|
if (alpn != null && alpn.isNotEmpty) values['tls.alpn'] = alpn;
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
String _stringValue(Object? value, String fallback) {
|
||||||
|
if (value == null) return fallback;
|
||||||
|
return value.toString();
|
||||||
|
}
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
|
||||||
|
sealed class ProxyConnectionId {
|
||||||
|
const ProxyConnectionId();
|
||||||
|
|
||||||
|
String encode();
|
||||||
|
|
||||||
|
static ProxyConnectionId? decode(String? raw) {
|
||||||
|
if (raw == null) return null;
|
||||||
|
if (raw == TorProxyConnectionId.encoded) {
|
||||||
|
return const TorProxyConnectionId();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (raw.startsWith(SingboxProxyConnectionId._prefix)) {
|
||||||
|
final profileId = raw.substring(SingboxProxyConnectionId._prefix.length);
|
||||||
|
if (profileId.isEmpty) return null;
|
||||||
|
return SingboxProxyConnectionId(profileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final class TorProxyConnectionId extends ProxyConnectionId {
|
||||||
|
static const String encoded = 'tor';
|
||||||
|
|
||||||
|
const TorProxyConnectionId();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String encode() => encoded;
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) => other is TorProxyConnectionId;
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => encoded.hashCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
final class SingboxProxyConnectionId extends ProxyConnectionId {
|
||||||
|
static const String _prefix = 'singbox:';
|
||||||
|
|
||||||
|
final String profileId;
|
||||||
|
|
||||||
|
const SingboxProxyConnectionId(this.profileId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String encode() => '$_prefix$profileId';
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is SingboxProxyConnectionId && other.profileId == profileId;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode => Object.hash(_prefix, profileId);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||||
|
|
||||||
|
extension SingboxProxyProfileTypeExt on SingboxProxyProfileType {
|
||||||
|
/// Long human label for menus, dialogs, subtitles.
|
||||||
|
String get label => switch (this) {
|
||||||
|
SingboxProxyProfileType.socks => 'SOCKS',
|
||||||
|
SingboxProxyProfileType.http => 'HTTP',
|
||||||
|
SingboxProxyProfileType.shadowsocks => 'Shadowsocks',
|
||||||
|
SingboxProxyProfileType.vmess => 'VMess',
|
||||||
|
SingboxProxyProfileType.vless => 'VLESS',
|
||||||
|
SingboxProxyProfileType.trojan => 'Trojan',
|
||||||
|
SingboxProxyProfileType.naive => 'Naive',
|
||||||
|
SingboxProxyProfileType.hysteria => 'Hysteria',
|
||||||
|
SingboxProxyProfileType.hysteria2 => 'Hysteria2',
|
||||||
|
SingboxProxyProfileType.tuic => 'TUIC',
|
||||||
|
SingboxProxyProfileType.ssh => 'SSH',
|
||||||
|
SingboxProxyProfileType.wireguard => 'WireGuard',
|
||||||
|
SingboxProxyProfileType.shadowTls => 'ShadowTLS',
|
||||||
|
SingboxProxyProfileType.anyTls => 'AnyTLS',
|
||||||
|
SingboxProxyProfileType.customOutbound => 'Custom Outbound',
|
||||||
|
};
|
||||||
|
|
||||||
|
/// Short 2-5 char protocol abbreviation for the profile-list badge.
|
||||||
|
String get badge => switch (this) {
|
||||||
|
SingboxProxyProfileType.socks => 'SOCKS',
|
||||||
|
SingboxProxyProfileType.http => 'HTTP',
|
||||||
|
SingboxProxyProfileType.shadowsocks => 'SS',
|
||||||
|
SingboxProxyProfileType.vmess => 'VMESS',
|
||||||
|
SingboxProxyProfileType.vless => 'VLESS',
|
||||||
|
SingboxProxyProfileType.trojan => 'TRJ',
|
||||||
|
SingboxProxyProfileType.naive => 'NAIVE',
|
||||||
|
SingboxProxyProfileType.hysteria => 'HY1',
|
||||||
|
SingboxProxyProfileType.hysteria2 => 'HY2',
|
||||||
|
SingboxProxyProfileType.tuic => 'TUIC',
|
||||||
|
SingboxProxyProfileType.ssh => 'SSH',
|
||||||
|
SingboxProxyProfileType.wireguard => 'WG',
|
||||||
|
SingboxProxyProfileType.shadowTls => 'STLS',
|
||||||
|
SingboxProxyProfileType.anyTls => 'ATLS',
|
||||||
|
SingboxProxyProfileType.customOutbound => 'JSON',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/domain/providers.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/models/singbox_proxy_profile.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
|
||||||
|
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
|
||||||
|
show ProxyProfile;
|
||||||
|
import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart';
|
||||||
|
import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart';
|
||||||
|
|
||||||
|
part 'assigned_proxy_profiles.g.dart';
|
||||||
|
|
||||||
|
/// Returns sing-box proxy profiles that have at least one routing assignment:
|
||||||
|
/// global regular-tab routing, private-tab routing, or any container.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
List<ProxyProfile> assignedSingboxProxyProfiles(Ref ref) {
|
||||||
|
final profiles =
|
||||||
|
ref.watch(singboxProxyProfilesRepositoryProvider).value ?? const [];
|
||||||
|
if (profiles.isEmpty) return const [];
|
||||||
|
|
||||||
|
final routing = ref.watch(proxyRoutingSettingsWithDefaultsProvider);
|
||||||
|
final containers =
|
||||||
|
ref.watch(watchContainersWithCountProvider).value ?? const [];
|
||||||
|
|
||||||
|
final assignedConnectionIds = <String>{
|
||||||
|
if (routing.regularTabsMode == ProxyRegularTabRoutingMode.all &&
|
||||||
|
routing.regularTabsProxyConnectionId != null)
|
||||||
|
routing.regularTabsProxyConnectionId!.encode(),
|
||||||
|
if (routing.privateTabsProxyConnectionId != null)
|
||||||
|
routing.privateTabsProxyConnectionId!.encode(),
|
||||||
|
for (final container in containers)
|
||||||
|
if (container.metadata.proxyConnectionId != null)
|
||||||
|
container.metadata.proxyConnectionId!.encode(),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (assignedConnectionIds.isEmpty) return const [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
for (final profile in profiles)
|
||||||
|
if (assignedConnectionIds.contains(profile.proxyConnectionId)) profile,
|
||||||
|
];
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'assigned_proxy_profiles.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
/// Returns sing-box proxy profiles that have at least one routing assignment:
|
||||||
|
/// global regular-tab routing, private-tab routing, or any container.
|
||||||
|
|
||||||
|
@ProviderFor(assignedSingboxProxyProfiles)
|
||||||
|
final assignedSingboxProxyProfilesProvider =
|
||||||
|
AssignedSingboxProxyProfilesProvider._();
|
||||||
|
|
||||||
|
/// Returns sing-box proxy profiles that have at least one routing assignment:
|
||||||
|
/// global regular-tab routing, private-tab routing, or any container.
|
||||||
|
|
||||||
|
final class AssignedSingboxProxyProfilesProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<
|
||||||
|
List<ProxyProfile>,
|
||||||
|
List<ProxyProfile>,
|
||||||
|
List<ProxyProfile>
|
||||||
|
>
|
||||||
|
with $Provider<List<ProxyProfile>> {
|
||||||
|
/// Returns sing-box proxy profiles that have at least one routing assignment:
|
||||||
|
/// global regular-tab routing, private-tab routing, or any container.
|
||||||
|
AssignedSingboxProxyProfilesProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'assignedSingboxProxyProfilesProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$assignedSingboxProxyProfilesHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<List<ProxyProfile>> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $ProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<ProxyProfile> create(Ref ref) {
|
||||||
|
return assignedSingboxProxyProfiles(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(List<ProxyProfile> value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<List<ProxyProfile>>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$assignedSingboxProxyProfilesHash() =>
|
||||||
|
r'dbc5c429a3b5e4bff902023d363e568c2f955fc4';
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
|
import 'package:riverpod/riverpod.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:weblibre/core/logger.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/models/singbox_proxy_profile.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/extensions/singbox_proxy_profile_type_x.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
|
||||||
|
|
||||||
|
part 'proxy_connection_options.g.dart';
|
||||||
|
|
||||||
|
class ProxyConnectionOption with FastEquatable {
|
||||||
|
final ProxyConnectionId id;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
|
||||||
|
ProxyConnectionOption({
|
||||||
|
required this.id,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get hashParameters => [id, title, subtitle];
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
List<ProxyConnectionOption> proxyConnectionOptions(Ref ref) {
|
||||||
|
final profilesAsync = ref.watch(singboxProxyProfilesRepositoryProvider);
|
||||||
|
profilesAsync.whenOrNull(
|
||||||
|
error: (error, stackTrace) => logger.e(
|
||||||
|
'Failed to load sing-box proxy profiles for connection picker',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final singboxProfiles = profilesAsync.value ?? const [];
|
||||||
|
|
||||||
|
return [
|
||||||
|
ProxyConnectionOption(
|
||||||
|
id: const TorProxyConnectionId(),
|
||||||
|
title: 'Tor',
|
||||||
|
subtitle: 'Route through the Tor network',
|
||||||
|
),
|
||||||
|
for (final profile in singboxProfiles)
|
||||||
|
ProxyConnectionOption(
|
||||||
|
id: profile.proxyConnection,
|
||||||
|
title: profile.name,
|
||||||
|
subtitle: profile.type.label,
|
||||||
|
),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
String proxyConnectionTitle(
|
||||||
|
List<ProxyConnectionOption> options,
|
||||||
|
ProxyConnectionId proxyConnectionId,
|
||||||
|
) {
|
||||||
|
for (final option in options) {
|
||||||
|
if (option.id == proxyConnectionId) {
|
||||||
|
return option.title;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'Unknown proxy';
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'proxy_connection_options.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
|
||||||
|
@ProviderFor(proxyConnectionOptions)
|
||||||
|
final proxyConnectionOptionsProvider = ProxyConnectionOptionsProvider._();
|
||||||
|
|
||||||
|
final class ProxyConnectionOptionsProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<
|
||||||
|
List<ProxyConnectionOption>,
|
||||||
|
List<ProxyConnectionOption>,
|
||||||
|
List<ProxyConnectionOption>
|
||||||
|
>
|
||||||
|
with $Provider<List<ProxyConnectionOption>> {
|
||||||
|
ProxyConnectionOptionsProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'proxyConnectionOptionsProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$proxyConnectionOptionsHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<List<ProxyConnectionOption>> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $ProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<ProxyConnectionOption> create(Ref ref) {
|
||||||
|
return proxyConnectionOptions(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(List<ProxyConnectionOption> value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<List<ProxyConnectionOption>>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$proxyConnectionOptionsHash() =>
|
||||||
|
r'7832968f74acd98189110cf926208e694a20d55a';
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/data/models/site_assignment.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||||
|
|
||||||
|
part 'container_proxy.g.dart';
|
||||||
|
|
||||||
|
/// Slower-than-default healthcheck timeout for the Tor SOCKS port. Tor's
|
||||||
|
/// bootstrap can stall when the service is starting up before the proxy
|
||||||
|
/// service is fully ready; we accept a longer wait here than for other ops.
|
||||||
|
const _torHealthcheckTimeout = Duration(seconds: 30);
|
||||||
|
|
||||||
|
/// Default healthcheck timeout for ordinary proxy CRUD operations.
|
||||||
|
const _defaultHealthcheckTimeout = Duration(seconds: 10);
|
||||||
|
|
||||||
|
/// Outer cap on a single healthcheck wait. Exceeded → [TimeoutException].
|
||||||
|
const _maxHealthcheckTimeout = Duration(seconds: 15);
|
||||||
|
|
||||||
|
const _healthcheckInitialDelay = Duration(milliseconds: 50);
|
||||||
|
const _healthcheckMaxDelay = Duration(milliseconds: 500);
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class ContainerProxyRepository extends _$ContainerProxyRepository {
|
||||||
|
final _service = GeckoContainerProxyService();
|
||||||
|
final _serviceLock = Lock();
|
||||||
|
|
||||||
|
Future<void> setTorProxyPort(int? port) {
|
||||||
|
return _runLocked(
|
||||||
|
healthcheckTimeout: _torHealthcheckTimeout,
|
||||||
|
body: () {
|
||||||
|
if (port == null) {
|
||||||
|
return _service.removeProxy(const TorProxyConnectionId().encode());
|
||||||
|
}
|
||||||
|
|
||||||
|
return _service.upsertProxy(
|
||||||
|
GeckoProxySettings(
|
||||||
|
id: const TorProxyConnectionId().encode(),
|
||||||
|
title: 'Tor',
|
||||||
|
type: 'socks',
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: port,
|
||||||
|
proxyDNS: true,
|
||||||
|
doNotProxyLocal: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> upsertProxy(GeckoProxySettings proxy) {
|
||||||
|
return _runLocked(body: () => _service.upsertProxy(proxy));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeProxy(String proxyId) {
|
||||||
|
return _runLocked(body: () => _service.removeProxy(proxyId));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setContainerProxy(String contextId, String proxyId) {
|
||||||
|
return _runLocked(
|
||||||
|
body: () => _service.setContainerProxy(contextId, proxyId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> clearContainerProxy(String contextId) {
|
||||||
|
return _runLocked(body: () => _service.clearContainerProxy(contextId));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> removeContainerProxyRelation(String contextId, String proxyId) {
|
||||||
|
return _runLocked(
|
||||||
|
body: () => _service.removeContainerProxyRelation(contextId, proxyId),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> setSiteAssignments(List<SiteAssignment> assignements) {
|
||||||
|
return _runLocked(
|
||||||
|
body: () => _service.setSiteAssignments(
|
||||||
|
Map.fromEntries(
|
||||||
|
assignements.map(
|
||||||
|
(e) => MapEntry(
|
||||||
|
e.assignedSite.origin,
|
||||||
|
e.contextualIdentity ?? 'general',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Serialises [body] behind the service lock after the underlying Gecko
|
||||||
|
/// proxy service reports healthy. Replaces the per-method boilerplate that
|
||||||
|
/// each public mutator used to repeat.
|
||||||
|
Future<T> _runLocked<T>({
|
||||||
|
required Future<T> Function() body,
|
||||||
|
Duration healthcheckTimeout = _defaultHealthcheckTimeout,
|
||||||
|
}) {
|
||||||
|
return _serviceLock.synchronized(() async {
|
||||||
|
await _waitHealthcheck().timeout(healthcheckTimeout);
|
||||||
|
return body();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Polls the Gecko proxy service with exponential backoff (capped) until it
|
||||||
|
/// reports healthy. The previous implementation polled every 25ms, which
|
||||||
|
/// burns CPU during the cold-start window before the native plugin is ready.
|
||||||
|
Future<void> _waitHealthcheck({
|
||||||
|
Duration timeout = _maxHealthcheckTimeout,
|
||||||
|
}) async {
|
||||||
|
final startTime = DateTime.now();
|
||||||
|
var delay = _healthcheckInitialDelay;
|
||||||
|
|
||||||
|
while (!await _service.healthcheck()) {
|
||||||
|
if (DateTime.now().difference(startTime) > timeout) {
|
||||||
|
throw TimeoutException('Timed out waiting for proxy service');
|
||||||
|
}
|
||||||
|
|
||||||
|
await Future<void>.delayed(delay);
|
||||||
|
delay = delay * 2;
|
||||||
|
if (delay > _healthcheckMaxDelay) delay = _healthcheckMaxDelay;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void build() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'container_proxy.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
|
||||||
|
@ProviderFor(ContainerProxyRepository)
|
||||||
|
final containerProxyRepositoryProvider = ContainerProxyRepositoryProvider._();
|
||||||
|
|
||||||
|
final class ContainerProxyRepositoryProvider
|
||||||
|
extends $NotifierProvider<ContainerProxyRepository, void> {
|
||||||
|
ContainerProxyRepositoryProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'containerProxyRepositoryProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$containerProxyRepositoryHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
ContainerProxyRepository create() => ContainerProxyRepository();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(void value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<void>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$containerProxyRepositoryHash() =>
|
||||||
|
r'08cd408a3ae96c859ed5c9d56d61cf7e6514415f';
|
||||||
|
|
||||||
|
abstract class _$ContainerProxyRepository extends $Notifier<void> {
|
||||||
|
void build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref = this.ref as $Ref<void, void>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<void, void>,
|
||||||
|
void,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
part 'singbox_proxy_credentials.g.dart';
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class SingboxProxyCredentialsRepository
|
||||||
|
extends _$SingboxProxyCredentialsRepository {
|
||||||
|
static const _storage = FlutterSecureStorage();
|
||||||
|
|
||||||
|
String _secretKey(String profileId) => 'singbox_proxy.secret.$profileId';
|
||||||
|
|
||||||
|
Future<String?> readSecretJson(String profileId) {
|
||||||
|
return _storage.read(key: _secretKey(profileId));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> writeSecretJson(String profileId, String? secretJson) async {
|
||||||
|
if (secretJson == null || secretJson.trim().isEmpty) {
|
||||||
|
await deleteSecretJson(profileId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _storage.write(key: _secretKey(profileId), value: secretJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteSecretJson(String profileId) {
|
||||||
|
return _storage.delete(key: _secretKey(profileId));
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void build() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'singbox_proxy_credentials.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
|
||||||
|
@ProviderFor(SingboxProxyCredentialsRepository)
|
||||||
|
final singboxProxyCredentialsRepositoryProvider =
|
||||||
|
SingboxProxyCredentialsRepositoryProvider._();
|
||||||
|
|
||||||
|
final class SingboxProxyCredentialsRepositoryProvider
|
||||||
|
extends $NotifierProvider<SingboxProxyCredentialsRepository, void> {
|
||||||
|
SingboxProxyCredentialsRepositoryProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'singboxProxyCredentialsRepositoryProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() =>
|
||||||
|
_$singboxProxyCredentialsRepositoryHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
SingboxProxyCredentialsRepository create() =>
|
||||||
|
SingboxProxyCredentialsRepository();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(void value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<void>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$singboxProxyCredentialsRepositoryHash() =>
|
||||||
|
r'b4e11b001ccccfbf26417963adf6cb81e1b1e69f';
|
||||||
|
|
||||||
|
abstract class _$SingboxProxyCredentialsRepository extends $Notifier<void> {
|
||||||
|
void build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref = this.ref as $Ref<void, void>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<void, void>,
|
||||||
|
void,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:collection';
|
||||||
|
|
||||||
|
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||||
|
import 'package:flutter_tor/flutter_tor.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/models/proxy_log_message.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
|
||||||
|
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
|
||||||
|
|
||||||
|
part 'singbox_proxy_logs.g.dart';
|
||||||
|
|
||||||
|
/// Cap to keep memory bounded. ~2KB per line × 2000 = ~4MB worst-case, which
|
||||||
|
/// is well within budget for a debugging surface.
|
||||||
|
const int _ringBufferCapacity = 2000;
|
||||||
|
|
||||||
|
/// Snapshot of buffered log entries. Most-recent-last (chronological).
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class SingboxProxyLogs extends _$SingboxProxyLogs {
|
||||||
|
final _buffer = Queue<ProxyLogMessage>();
|
||||||
|
|
||||||
|
StreamSubscription<SingboxProxyLogMessage>? _singboxSubscription;
|
||||||
|
StreamSubscription<TorLogMessage>? _torSubscription;
|
||||||
|
|
||||||
|
void _append(ProxyLogMessage message) {
|
||||||
|
_buffer.add(message);
|
||||||
|
|
||||||
|
while (_buffer.length > _ringBufferCapacity) {
|
||||||
|
_buffer.removeFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
state = List.unmodifiable(_buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear() {
|
||||||
|
_buffer.clear();
|
||||||
|
state = const [];
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<ProxyLogMessage> build() {
|
||||||
|
final client = ref.watch(singboxProxyClientProvider);
|
||||||
|
final torLogs = torLogStream(ref);
|
||||||
|
|
||||||
|
_singboxSubscription = client.logStream.listen(
|
||||||
|
(message) => _append(ProxyLogMessage.fromSingbox(message)),
|
||||||
|
);
|
||||||
|
_torSubscription = torLogs.listen(
|
||||||
|
(message) => _append(ProxyLogMessage.fromTor(message)),
|
||||||
|
);
|
||||||
|
|
||||||
|
ref.onDispose(() {
|
||||||
|
unawaited(_singboxSubscription?.cancel());
|
||||||
|
unawaited(_torSubscription?.cancel());
|
||||||
|
});
|
||||||
|
|
||||||
|
return List.unmodifiable(_buffer);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'singbox_proxy_logs.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
/// Snapshot of buffered log entries. Most-recent-last (chronological).
|
||||||
|
|
||||||
|
@ProviderFor(SingboxProxyLogs)
|
||||||
|
final singboxProxyLogsProvider = SingboxProxyLogsProvider._();
|
||||||
|
|
||||||
|
/// Snapshot of buffered log entries. Most-recent-last (chronological).
|
||||||
|
final class SingboxProxyLogsProvider
|
||||||
|
extends $NotifierProvider<SingboxProxyLogs, List<ProxyLogMessage>> {
|
||||||
|
/// Snapshot of buffered log entries. Most-recent-last (chronological).
|
||||||
|
SingboxProxyLogsProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'singboxProxyLogsProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$singboxProxyLogsHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
SingboxProxyLogs create() => SingboxProxyLogs();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(List<ProxyLogMessage> value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<List<ProxyLogMessage>>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$singboxProxyLogsHash() => r'9fa30201ed4c128335142227226d937022f79d47';
|
||||||
|
|
||||||
|
/// Snapshot of buffered log entries. Most-recent-last (chronological).
|
||||||
|
|
||||||
|
abstract class _$SingboxProxyLogs extends $Notifier<List<ProxyLogMessage>> {
|
||||||
|
List<ProxyLogMessage> build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref = this.ref as $Ref<List<ProxyLogMessage>, List<ProxyLogMessage>>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<List<ProxyLogMessage>, List<ProxyLogMessage>>,
|
||||||
|
List<ProxyLogMessage>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||||
|
import 'package:riverpod/riverpod.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:weblibre/core/uuid.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_credentials.dart';
|
||||||
|
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
|
||||||
|
show ProxyProfile;
|
||||||
|
import 'package:weblibre/features/user/data/providers.dart';
|
||||||
|
|
||||||
|
part 'singbox_proxy_profiles.g.dart';
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class SingboxProxyProfilesRepository extends _$SingboxProxyProfilesRepository {
|
||||||
|
Future<List<ProxyProfile>> fetchProfiles() {
|
||||||
|
return ref.read(userDatabaseProvider).proxyProfileDao.fetchAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ProxyProfile?> findProfile(String id) {
|
||||||
|
return ref.read(userDatabaseProvider).proxyProfileDao.findById(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ProxyProfile> createProfile({
|
||||||
|
required String name,
|
||||||
|
required SingboxProxyProfileType type,
|
||||||
|
required String configJson,
|
||||||
|
String? secretJson,
|
||||||
|
String? dnsOverrideJson,
|
||||||
|
}) async {
|
||||||
|
final now = DateTime.now();
|
||||||
|
final profile = ProxyProfile(
|
||||||
|
id: uuid.v4(),
|
||||||
|
name: name,
|
||||||
|
type: type,
|
||||||
|
configJson: configJson,
|
||||||
|
dnsOverrideJson: dnsOverrideJson,
|
||||||
|
createdAt: now,
|
||||||
|
updatedAt: now,
|
||||||
|
);
|
||||||
|
|
||||||
|
await ref.read(userDatabaseProvider).proxyProfileDao.upsert(profile);
|
||||||
|
await ref
|
||||||
|
.read(singboxProxyCredentialsRepositoryProvider.notifier)
|
||||||
|
.writeSecretJson(profile.id, secretJson);
|
||||||
|
return profile;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Updates an existing profile, bumping `updatedAt` only when the persisted
|
||||||
|
/// row actually changed. Pass [secretJson] (possibly null) to replace the
|
||||||
|
/// stored secret; pass [updateSecret] = false to leave secrets untouched.
|
||||||
|
Future<void> updateProfile(ProxyProfile profile, {String? secretJson}) async {
|
||||||
|
final dao = ref.read(userDatabaseProvider).proxyProfileDao;
|
||||||
|
final existing = await dao.findById(profile.id);
|
||||||
|
|
||||||
|
final contentChanged =
|
||||||
|
existing == null ||
|
||||||
|
existing.name != profile.name ||
|
||||||
|
existing.type != profile.type ||
|
||||||
|
existing.configJson != profile.configJson ||
|
||||||
|
existing.dnsOverrideJson != profile.dnsOverrideJson;
|
||||||
|
|
||||||
|
if (contentChanged) {
|
||||||
|
await dao.upsert(
|
||||||
|
ProxyProfile(
|
||||||
|
id: profile.id,
|
||||||
|
name: profile.name,
|
||||||
|
type: profile.type,
|
||||||
|
configJson: profile.configJson,
|
||||||
|
dnsOverrideJson: profile.dnsOverrideJson,
|
||||||
|
createdAt: existing?.createdAt ?? profile.createdAt,
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (secretJson != null) {
|
||||||
|
await ref
|
||||||
|
.read(singboxProxyCredentialsRepositoryProvider.notifier)
|
||||||
|
.writeSecretJson(profile.id, secretJson);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteProfile(String profileId) async {
|
||||||
|
await ref.read(userDatabaseProvider).proxyProfileDao.deleteById(profileId);
|
||||||
|
await ref
|
||||||
|
.read(singboxProxyCredentialsRepositoryProvider.notifier)
|
||||||
|
.deleteSecretJson(profileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<List<ProxyProfile>> build() {
|
||||||
|
return ref.watch(userDatabaseProvider).proxyProfileDao.watch().watch();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'singbox_proxy_profiles.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
|
||||||
|
@ProviderFor(SingboxProxyProfilesRepository)
|
||||||
|
final singboxProxyProfilesRepositoryProvider =
|
||||||
|
SingboxProxyProfilesRepositoryProvider._();
|
||||||
|
|
||||||
|
final class SingboxProxyProfilesRepositoryProvider
|
||||||
|
extends
|
||||||
|
$StreamNotifierProvider<
|
||||||
|
SingboxProxyProfilesRepository,
|
||||||
|
List<ProxyProfile>
|
||||||
|
> {
|
||||||
|
SingboxProxyProfilesRepositoryProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'singboxProxyProfilesRepositoryProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$singboxProxyProfilesRepositoryHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
SingboxProxyProfilesRepository create() => SingboxProxyProfilesRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$singboxProxyProfilesRepositoryHash() =>
|
||||||
|
r'5250f2aafb7ec621b3b33a53ff5a00874e8a4b12';
|
||||||
|
|
||||||
|
abstract class _$SingboxProxyProfilesRepository
|
||||||
|
extends $StreamNotifier<List<ProxyProfile>> {
|
||||||
|
Stream<List<ProxyProfile>> build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref =
|
||||||
|
this.ref as $Ref<AsyncValue<List<ProxyProfile>>, List<ProxyProfile>>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<AsyncValue<List<ProxyProfile>>, List<ProxyProfile>>,
|
||||||
|
AsyncValue<List<ProxyProfile>>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,356 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/models/singbox_proxy_profile.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_credentials.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/services/dns_config_resolver.dart';
|
||||||
|
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
|
||||||
|
show ProxyProfile;
|
||||||
|
import 'package:weblibre/features/user/data/models/proxy_dns_override.dart';
|
||||||
|
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
|
||||||
|
|
||||||
|
part 'singbox_proxy_runtime.g.dart';
|
||||||
|
|
||||||
|
abstract interface class SingboxProxyClient {
|
||||||
|
Stream<SingboxProxyRuntimeState> get stateStream;
|
||||||
|
|
||||||
|
Stream<SingboxProxyLogMessage> get logStream;
|
||||||
|
|
||||||
|
Future<String?> validateProfile(SingboxProxyProfile profile);
|
||||||
|
|
||||||
|
Future<SingboxProxyConfigResult> buildConfig(
|
||||||
|
List<SingboxProxyProfile> profiles, {
|
||||||
|
SingboxProxyRuntimeOptions? options,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<SingboxProxyRuntimeState> start(
|
||||||
|
List<SingboxProxyProfile> profiles, {
|
||||||
|
SingboxProxyRuntimeOptions? options,
|
||||||
|
});
|
||||||
|
|
||||||
|
Future<void> stop(List<String> profileIds);
|
||||||
|
|
||||||
|
Future<void> stopAll();
|
||||||
|
|
||||||
|
Future<SingboxProxyRuntimeState> getState();
|
||||||
|
|
||||||
|
Future<void> dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
class FlutterSingboxProxyClient implements SingboxProxyClient {
|
||||||
|
final _plugin = FlutterSingboxProxy();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<SingboxProxyRuntimeState> get stateStream => _plugin.stateStream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Stream<SingboxProxyLogMessage> get logStream => _plugin.logStream;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<String?> validateProfile(SingboxProxyProfile profile) {
|
||||||
|
return _plugin.validateProfile(profile);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SingboxProxyConfigResult> buildConfig(
|
||||||
|
List<SingboxProxyProfile> profiles, {
|
||||||
|
SingboxProxyRuntimeOptions? options,
|
||||||
|
}) {
|
||||||
|
return _plugin.buildConfig(profiles, options: options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SingboxProxyRuntimeState> start(
|
||||||
|
List<SingboxProxyProfile> profiles, {
|
||||||
|
SingboxProxyRuntimeOptions? options,
|
||||||
|
}) {
|
||||||
|
return _plugin.start(profiles, options: options);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> stop(List<String> profileIds) => _plugin.stop(profileIds);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> stopAll() => _plugin.stopAll();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SingboxProxyRuntimeState> getState() => _plugin.getState();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> dispose() => _plugin.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
SingboxProxyClient singboxProxyClient(Ref ref) {
|
||||||
|
return FlutterSingboxProxyClient();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class SingboxProxyRuntimeRepository extends _$SingboxProxyRuntimeRepository {
|
||||||
|
final _lock = Lock();
|
||||||
|
|
||||||
|
SingboxProxyClient get _plugin => ref.read(singboxProxyClientProvider);
|
||||||
|
|
||||||
|
Future<SingboxProxyRuntimeState> _stateSnapshotUnlocked() async {
|
||||||
|
final currentState = state.asData?.value;
|
||||||
|
if (currentState != null) return currentState;
|
||||||
|
|
||||||
|
final nextState = await _plugin.getState();
|
||||||
|
state = AsyncData(nextState);
|
||||||
|
return nextState;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SingboxProxyRuntimeState> startProfile(
|
||||||
|
String profileId, {
|
||||||
|
SingboxProxyRuntimeOptions? options,
|
||||||
|
}) async {
|
||||||
|
return _lock.synchronized(() async {
|
||||||
|
final currentState = await _stateSnapshotUnlocked();
|
||||||
|
final activeProfileIds = _activeProfileIds(currentState);
|
||||||
|
|
||||||
|
return _startProfilesUnlocked(
|
||||||
|
{...activeProfileIds, profileId}.toList(),
|
||||||
|
options: options,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> ensureProxyConnectionAvailable(
|
||||||
|
SingboxProxyConnectionId connectionId,
|
||||||
|
) async {
|
||||||
|
await _lock.synchronized(() async {
|
||||||
|
final currentState = await _stateSnapshotUnlocked();
|
||||||
|
final isRunning = currentState.endpoints.any(
|
||||||
|
(endpoint) => endpoint.profileId == connectionId.encode(),
|
||||||
|
);
|
||||||
|
if (isRunning) return;
|
||||||
|
|
||||||
|
final activeProfileIds = _activeProfileIds(currentState);
|
||||||
|
await _startProfilesUnlocked(
|
||||||
|
{...activeProfileIds, connectionId.profileId}.toList(),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> _activeProfileIds(SingboxProxyRuntimeState runtimeState) {
|
||||||
|
return runtimeState.endpoints
|
||||||
|
.map((endpoint) => ProxyConnectionId.decode(endpoint.profileId))
|
||||||
|
.whereType<SingboxProxyConnectionId>()
|
||||||
|
.map((connectionId) => connectionId.profileId)
|
||||||
|
.toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SingboxProxyRuntimeState> startProfiles(
|
||||||
|
List<String> profileIds, {
|
||||||
|
SingboxProxyRuntimeOptions? options,
|
||||||
|
}) {
|
||||||
|
return _lock.synchronized(
|
||||||
|
() => _startProfilesUnlocked(profileIds, options: options),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SingboxProxyRuntimeState> _startProfilesUnlocked(
|
||||||
|
List<String> profileIds, {
|
||||||
|
SingboxProxyRuntimeOptions? options,
|
||||||
|
}) async {
|
||||||
|
state = const AsyncLoading<SingboxProxyRuntimeState>();
|
||||||
|
|
||||||
|
try {
|
||||||
|
final profiles = await _runtimeProfiles(profileIds);
|
||||||
|
final resolvedOptions = await _buildRuntimeOptions(
|
||||||
|
options ?? SingboxProxyRuntimeOptions(),
|
||||||
|
profileIds: profileIds.toSet(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final nextState = await _plugin.start(profiles, options: resolvedOptions);
|
||||||
|
|
||||||
|
state = AsyncData(nextState);
|
||||||
|
return nextState;
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
state = AsyncError(error, stackTrace);
|
||||||
|
rethrow;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SingboxProxyRuntimeOptions> _buildRuntimeOptions(
|
||||||
|
SingboxProxyRuntimeOptions base, {
|
||||||
|
required Set<String> profileIds,
|
||||||
|
}) async {
|
||||||
|
// Don't overwrite a caller-supplied dnsConfig (e.g. tests or one-off
|
||||||
|
// overrides).
|
||||||
|
final engineSettings = await ref
|
||||||
|
.read(engineSettingsRepositoryProvider.notifier)
|
||||||
|
.fetchSettings();
|
||||||
|
final dohUrl = engineSettings.dohProviderUrl;
|
||||||
|
|
||||||
|
if (base.dnsConfig != null) {
|
||||||
|
return SingboxProxyRuntimeOptions(
|
||||||
|
preferredBasePort: base.preferredBasePort,
|
||||||
|
blockUnmatchedTraffic: base.blockUnmatchedTraffic,
|
||||||
|
dnsConfig: base.dnsConfig,
|
||||||
|
bootstrapDohUrl: base.bootstrapDohUrl ?? dohUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final profiles = await ref
|
||||||
|
.read(singboxProxyProfilesRepositoryProvider.notifier)
|
||||||
|
.fetchProfiles();
|
||||||
|
|
||||||
|
final overrides = <String, ProxyDnsOverride?>{
|
||||||
|
for (final profile in profiles)
|
||||||
|
if (profileIds.contains(profile.id))
|
||||||
|
profile.id: _decodeOverride(profile.dnsOverrideJson),
|
||||||
|
};
|
||||||
|
|
||||||
|
final dnsConfig = buildDnsConfig(
|
||||||
|
overridesByProfileId: overrides,
|
||||||
|
runningProfileIds: profileIds,
|
||||||
|
browserDohUrl: dohUrl,
|
||||||
|
);
|
||||||
|
|
||||||
|
return SingboxProxyRuntimeOptions(
|
||||||
|
preferredBasePort: base.preferredBasePort,
|
||||||
|
blockUnmatchedTraffic: base.blockUnmatchedTraffic,
|
||||||
|
dnsConfig: dnsConfig,
|
||||||
|
bootstrapDohUrl: dohUrl,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProxyDnsOverride? _decodeOverride(String? json) {
|
||||||
|
if (json == null || json.trim().isEmpty) return null;
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(json);
|
||||||
|
if (decoded is! Map<String, dynamic>) return null;
|
||||||
|
return ProxyDnsOverride.fromJson(decoded);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stopProfiles(List<String> profileIds) {
|
||||||
|
return _lock.synchronized(() async {
|
||||||
|
await _stopProfilesUnlocked(profileIds);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> deleteProfile(String profileId) {
|
||||||
|
return _lock.synchronized(() async {
|
||||||
|
await _stopProfilesUnlocked([profileId]);
|
||||||
|
await ref
|
||||||
|
.read(singboxProxyProfilesRepositoryProvider.notifier)
|
||||||
|
.deleteProfile(profileId);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> stopAll() {
|
||||||
|
return _lock.synchronized(() async {
|
||||||
|
await _plugin.stopAll();
|
||||||
|
final nextState = await _plugin.getState();
|
||||||
|
state = AsyncData(nextState);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> validateProfile(ProxyProfile profile) async {
|
||||||
|
return _plugin.validateProfile(await _runtimeProfile(profile));
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _stopProfilesUnlocked(List<String> profileIds) async {
|
||||||
|
final proxyIds = profileIds
|
||||||
|
.map((profileId) => SingboxProxyConnectionId(profileId).encode())
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
await _plugin.stop(proxyIds);
|
||||||
|
final nextState = await _plugin.getState();
|
||||||
|
state = AsyncData(nextState);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String?> validateProfileDraft(
|
||||||
|
ProxyProfile profile, {
|
||||||
|
String? secretJson,
|
||||||
|
}) {
|
||||||
|
return _plugin.validateProfile(
|
||||||
|
profile.toRuntimeProfile(secretJson: secretJson),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SingboxProxyConfigResult> buildConfig(
|
||||||
|
List<String> profileIds, {
|
||||||
|
SingboxProxyRuntimeOptions? options,
|
||||||
|
}) async {
|
||||||
|
final resolvedOptions = await _buildRuntimeOptions(
|
||||||
|
options ?? SingboxProxyRuntimeOptions(),
|
||||||
|
profileIds: profileIds.toSet(),
|
||||||
|
);
|
||||||
|
return _plugin.buildConfig(
|
||||||
|
await _runtimeProfiles(profileIds),
|
||||||
|
options: resolvedOptions,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<List<SingboxProxyProfile>> _runtimeProfiles(
|
||||||
|
List<String> profileIds,
|
||||||
|
) async {
|
||||||
|
final profiles = await ref
|
||||||
|
.read(singboxProxyProfilesRepositoryProvider.notifier)
|
||||||
|
.fetchProfiles();
|
||||||
|
final profileMap = {for (final profile in profiles) profile.id: profile};
|
||||||
|
|
||||||
|
return Future.wait(
|
||||||
|
profileIds.map((profileId) async {
|
||||||
|
final profile = profileMap[profileId];
|
||||||
|
if (profile == null) {
|
||||||
|
throw StateError('Unknown sing-box proxy profile: $profileId');
|
||||||
|
}
|
||||||
|
|
||||||
|
return _runtimeProfile(profile);
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SingboxProxyProfile> _runtimeProfile(ProxyProfile profile) async {
|
||||||
|
final secretJson = await ref
|
||||||
|
.read(singboxProxyCredentialsRepositoryProvider.notifier)
|
||||||
|
.readSecretJson(profile.id);
|
||||||
|
|
||||||
|
return profile.toRuntimeProfile(secretJson: secretJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<SingboxProxyRuntimeState> build() async {
|
||||||
|
final plugin = ref.watch(singboxProxyClientProvider);
|
||||||
|
final stateSubscription = plugin.stateStream.listen((nextState) {
|
||||||
|
state = AsyncData(nextState);
|
||||||
|
});
|
||||||
|
|
||||||
|
ref.onDispose(() async {
|
||||||
|
await stateSubscription.cancel();
|
||||||
|
await plugin.dispose();
|
||||||
|
});
|
||||||
|
|
||||||
|
return plugin.getState();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'singbox_proxy_runtime.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
|
||||||
|
@ProviderFor(singboxProxyClient)
|
||||||
|
final singboxProxyClientProvider = SingboxProxyClientProvider._();
|
||||||
|
|
||||||
|
final class SingboxProxyClientProvider
|
||||||
|
extends
|
||||||
|
$FunctionalProvider<
|
||||||
|
SingboxProxyClient,
|
||||||
|
SingboxProxyClient,
|
||||||
|
SingboxProxyClient
|
||||||
|
>
|
||||||
|
with $Provider<SingboxProxyClient> {
|
||||||
|
SingboxProxyClientProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'singboxProxyClientProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$singboxProxyClientHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
$ProviderElement<SingboxProxyClient> $createElement(
|
||||||
|
$ProviderPointer pointer,
|
||||||
|
) => $ProviderElement(pointer);
|
||||||
|
|
||||||
|
@override
|
||||||
|
SingboxProxyClient create(Ref ref) {
|
||||||
|
return singboxProxyClient(ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(SingboxProxyClient value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<SingboxProxyClient>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$singboxProxyClientHash() =>
|
||||||
|
r'3e56b277667e92a2d4941fd084dddd0ff25afd52';
|
||||||
|
|
||||||
|
@ProviderFor(SingboxProxyRuntimeRepository)
|
||||||
|
final singboxProxyRuntimeRepositoryProvider =
|
||||||
|
SingboxProxyRuntimeRepositoryProvider._();
|
||||||
|
|
||||||
|
final class SingboxProxyRuntimeRepositoryProvider
|
||||||
|
extends
|
||||||
|
$AsyncNotifierProvider<
|
||||||
|
SingboxProxyRuntimeRepository,
|
||||||
|
SingboxProxyRuntimeState
|
||||||
|
> {
|
||||||
|
SingboxProxyRuntimeRepositoryProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'singboxProxyRuntimeRepositoryProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$singboxProxyRuntimeRepositoryHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
SingboxProxyRuntimeRepository create() => SingboxProxyRuntimeRepository();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$singboxProxyRuntimeRepositoryHash() =>
|
||||||
|
r'665908eaff34569f7a19dc5328a067711d1937f2';
|
||||||
|
|
||||||
|
abstract class _$SingboxProxyRuntimeRepository
|
||||||
|
extends $AsyncNotifier<SingboxProxyRuntimeState> {
|
||||||
|
FutureOr<SingboxProxyRuntimeState> build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref =
|
||||||
|
this.ref
|
||||||
|
as $Ref<
|
||||||
|
AsyncValue<SingboxProxyRuntimeState>,
|
||||||
|
SingboxProxyRuntimeState
|
||||||
|
>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<
|
||||||
|
AsyncValue<SingboxProxyRuntimeState>,
|
||||||
|
SingboxProxyRuntimeState
|
||||||
|
>,
|
||||||
|
AsyncValue<SingboxProxyRuntimeState>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,109 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:weblibre/core/logger.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
|
||||||
|
import 'package:weblibre/features/user/data/models/engine_settings.dart';
|
||||||
|
import 'package:weblibre/features/user/domain/repositories/engine_settings.dart';
|
||||||
|
|
||||||
|
part 'browser_dns_leak_guard.g.dart';
|
||||||
|
|
||||||
|
/// Watches the proxy runtime and **disables** GeckoView's TRR (sets
|
||||||
|
/// [DohSettingsMode.off]) while at least one profile is running.
|
||||||
|
///
|
||||||
|
/// Why "off" and not "max": TRR resolves URL hostnames over DoH directly via
|
||||||
|
/// the system network, *before* any SOCKS connection is established — so a
|
||||||
|
/// DoH lookup leaks the destination outside the proxy even when the data
|
||||||
|
/// itself goes through it. `max` (TRR-only) keeps that leak. `off` disables
|
||||||
|
/// TRR so GeckoView uses its native resolver, which — combined with
|
||||||
|
/// `proxyDNS: true` on our SOCKS proxy settings — sends hostnames through the
|
||||||
|
/// SOCKS inbound so sing-box can resolve them instead of GeckoView doing a
|
||||||
|
/// direct DoH lookup first.
|
||||||
|
///
|
||||||
|
/// The previous TRR mode is captured on engage and restored when all
|
||||||
|
/// profiles stop.
|
||||||
|
///
|
||||||
|
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
|
||||||
|
/// explicitly listened-to from main.dart so the side effect runs without any
|
||||||
|
/// widget needing to depend on it.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class BrowserDnsLeakGuard extends _$BrowserDnsLeakGuard {
|
||||||
|
DohSettingsMode? _savedMode;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> build() async {
|
||||||
|
final runtime = ref.watch(singboxProxyRuntimeRepositoryProvider);
|
||||||
|
|
||||||
|
// Skip while a start/stop is in flight. `startProfiles` resets the runtime
|
||||||
|
// state to `AsyncLoading` for the entire restart — `asData` is briefly
|
||||||
|
// null, which would otherwise look like "no profiles running" and trigger
|
||||||
|
// a premature DoH restore in the middle of e.g. starting a second profile
|
||||||
|
// while one is already active, opening a leak window during the transition.
|
||||||
|
if (runtime.isLoading) return;
|
||||||
|
|
||||||
|
final anyRunning = runtime.asData?.value.endpoints.isNotEmpty ?? false;
|
||||||
|
if (anyRunning) {
|
||||||
|
await _enforceOffMode();
|
||||||
|
} else if (_savedMode != null) {
|
||||||
|
await _restoreSavedMode();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _enforceOffMode() async {
|
||||||
|
final engine = ref.read(engineSettingsRepositoryProvider.notifier);
|
||||||
|
try {
|
||||||
|
final current = await engine.fetchSettings();
|
||||||
|
if (current.dohSettingsMode == DohSettingsMode.off) {
|
||||||
|
// Already off — don't capture it as the "saved" value, otherwise we
|
||||||
|
// would restore it back to off on disengage instead of the user's
|
||||||
|
// real previous choice.
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_savedMode = current.dohSettingsMode;
|
||||||
|
await engine.updateSettings(
|
||||||
|
(current) => current.copyWith.dohSettingsMode(DohSettingsMode.off),
|
||||||
|
);
|
||||||
|
} catch (error, stack) {
|
||||||
|
logger.e(
|
||||||
|
'browser DNS leak guard failed to disable TRR',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _restoreSavedMode() async {
|
||||||
|
final saved = _savedMode;
|
||||||
|
_savedMode = null;
|
||||||
|
if (saved == null) return;
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(engineSettingsRepositoryProvider.notifier)
|
||||||
|
.updateSettings((current) => current.copyWith.dohSettingsMode(saved));
|
||||||
|
} catch (error, stack) {
|
||||||
|
logger.e(
|
||||||
|
'browser DNS leak guard failed to restore DoH mode',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stack,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'browser_dns_leak_guard.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
/// Watches the proxy runtime and **disables** GeckoView's TRR (sets
|
||||||
|
/// [DohSettingsMode.off]) while at least one profile is running.
|
||||||
|
///
|
||||||
|
/// Why "off" and not "max": TRR resolves URL hostnames over DoH directly via
|
||||||
|
/// the system network, *before* any SOCKS connection is established — so a
|
||||||
|
/// DoH lookup leaks the destination outside the proxy even when the data
|
||||||
|
/// itself goes through it. `max` (TRR-only) keeps that leak. `off` disables
|
||||||
|
/// TRR so GeckoView uses its native resolver, which — combined with
|
||||||
|
/// `proxyDNS: true` on our SOCKS proxy settings — sends hostnames through the
|
||||||
|
/// SOCKS inbound so sing-box can resolve them instead of GeckoView doing a
|
||||||
|
/// direct DoH lookup first.
|
||||||
|
///
|
||||||
|
/// The previous TRR mode is captured on engage and restored when all
|
||||||
|
/// profiles stop.
|
||||||
|
///
|
||||||
|
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
|
||||||
|
/// explicitly listened-to from main.dart so the side effect runs without any
|
||||||
|
/// widget needing to depend on it.
|
||||||
|
|
||||||
|
@ProviderFor(BrowserDnsLeakGuard)
|
||||||
|
final browserDnsLeakGuardProvider = BrowserDnsLeakGuardProvider._();
|
||||||
|
|
||||||
|
/// Watches the proxy runtime and **disables** GeckoView's TRR (sets
|
||||||
|
/// [DohSettingsMode.off]) while at least one profile is running.
|
||||||
|
///
|
||||||
|
/// Why "off" and not "max": TRR resolves URL hostnames over DoH directly via
|
||||||
|
/// the system network, *before* any SOCKS connection is established — so a
|
||||||
|
/// DoH lookup leaks the destination outside the proxy even when the data
|
||||||
|
/// itself goes through it. `max` (TRR-only) keeps that leak. `off` disables
|
||||||
|
/// TRR so GeckoView uses its native resolver, which — combined with
|
||||||
|
/// `proxyDNS: true` on our SOCKS proxy settings — sends hostnames through the
|
||||||
|
/// SOCKS inbound so sing-box can resolve them instead of GeckoView doing a
|
||||||
|
/// direct DoH lookup first.
|
||||||
|
///
|
||||||
|
/// The previous TRR mode is captured on engage and restored when all
|
||||||
|
/// profiles stop.
|
||||||
|
///
|
||||||
|
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
|
||||||
|
/// explicitly listened-to from main.dart so the side effect runs without any
|
||||||
|
/// widget needing to depend on it.
|
||||||
|
final class BrowserDnsLeakGuardProvider
|
||||||
|
extends $AsyncNotifierProvider<BrowserDnsLeakGuard, void> {
|
||||||
|
/// Watches the proxy runtime and **disables** GeckoView's TRR (sets
|
||||||
|
/// [DohSettingsMode.off]) while at least one profile is running.
|
||||||
|
///
|
||||||
|
/// Why "off" and not "max": TRR resolves URL hostnames over DoH directly via
|
||||||
|
/// the system network, *before* any SOCKS connection is established — so a
|
||||||
|
/// DoH lookup leaks the destination outside the proxy even when the data
|
||||||
|
/// itself goes through it. `max` (TRR-only) keeps that leak. `off` disables
|
||||||
|
/// TRR so GeckoView uses its native resolver, which — combined with
|
||||||
|
/// `proxyDNS: true` on our SOCKS proxy settings — sends hostnames through the
|
||||||
|
/// SOCKS inbound so sing-box can resolve them instead of GeckoView doing a
|
||||||
|
/// direct DoH lookup first.
|
||||||
|
///
|
||||||
|
/// The previous TRR mode is captured on engage and restored when all
|
||||||
|
/// profiles stop.
|
||||||
|
///
|
||||||
|
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
|
||||||
|
/// explicitly listened-to from main.dart so the side effect runs without any
|
||||||
|
/// widget needing to depend on it.
|
||||||
|
BrowserDnsLeakGuardProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'browserDnsLeakGuardProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$browserDnsLeakGuardHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
BrowserDnsLeakGuard create() => BrowserDnsLeakGuard();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$browserDnsLeakGuardHash() =>
|
||||||
|
r'366aaa6ae8c163449dca50147be0451c766ba765';
|
||||||
|
|
||||||
|
/// Watches the proxy runtime and **disables** GeckoView's TRR (sets
|
||||||
|
/// [DohSettingsMode.off]) while at least one profile is running.
|
||||||
|
///
|
||||||
|
/// Why "off" and not "max": TRR resolves URL hostnames over DoH directly via
|
||||||
|
/// the system network, *before* any SOCKS connection is established — so a
|
||||||
|
/// DoH lookup leaks the destination outside the proxy even when the data
|
||||||
|
/// itself goes through it. `max` (TRR-only) keeps that leak. `off` disables
|
||||||
|
/// TRR so GeckoView uses its native resolver, which — combined with
|
||||||
|
/// `proxyDNS: true` on our SOCKS proxy settings — sends hostnames through the
|
||||||
|
/// SOCKS inbound so sing-box can resolve them instead of GeckoView doing a
|
||||||
|
/// direct DoH lookup first.
|
||||||
|
///
|
||||||
|
/// The previous TRR mode is captured on engage and restored when all
|
||||||
|
/// profiles stop.
|
||||||
|
///
|
||||||
|
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
|
||||||
|
/// explicitly listened-to from main.dart so the side effect runs without any
|
||||||
|
/// widget needing to depend on it.
|
||||||
|
|
||||||
|
abstract class _$BrowserDnsLeakGuard extends $AsyncNotifier<void> {
|
||||||
|
FutureOr<void> build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref = this.ref as $Ref<AsyncValue<void>, void>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<AsyncValue<void>, void>,
|
||||||
|
AsyncValue<void>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,135 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||||
|
import 'package:weblibre/features/user/data/models/proxy_dns_override.dart';
|
||||||
|
|
||||||
|
/// Builds a sing-box [SingboxProxyDnsConfig] from automatic browser-DNS
|
||||||
|
/// mirroring plus per-profile [overridesByProfileId].
|
||||||
|
///
|
||||||
|
/// Profiles without an override reuse the browser DoH URL, detoured through the
|
||||||
|
/// profile and scoped to that profile's SOCKS inbound. Profiles with an
|
||||||
|
/// override use their own resolver instead.
|
||||||
|
///
|
||||||
|
/// Returns null when there is nothing to configure, letting sing-box fall back
|
||||||
|
/// to its built-in resolver behaviour.
|
||||||
|
SingboxProxyDnsConfig? buildDnsConfig({
|
||||||
|
required Map<String, ProxyDnsOverride?> overridesByProfileId,
|
||||||
|
required Set<String> runningProfileIds,
|
||||||
|
required String? browserDohUrl,
|
||||||
|
}) {
|
||||||
|
final servers = <SingboxProxyDnsServerConfig>[];
|
||||||
|
final hasBrowserDoh = browserDohUrl != null && browserDohUrl.isNotEmpty;
|
||||||
|
|
||||||
|
if (hasBrowserDoh) {
|
||||||
|
servers.add(
|
||||||
|
SingboxProxyDnsServerConfig(tag: 'browser-doh', address: browserDohUrl),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Mirror the browser DoH URL through each running profile unless that
|
||||||
|
// profile has an explicit override. Scope by inbound so endpoint bootstrap
|
||||||
|
// lookups do not route through the not-yet-ready outbound and deadlock.
|
||||||
|
for (final profileId in runningProfileIds) {
|
||||||
|
if (overridesByProfileId[profileId] != null) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
final outboundTag = _outboundTagForProfile(profileId);
|
||||||
|
final inboundTag = _inboundTagForProfile(profileId);
|
||||||
|
|
||||||
|
servers.add(
|
||||||
|
SingboxProxyDnsServerConfig(
|
||||||
|
tag: 'browser-doh-${singboxSanitizeTag(profileId)}',
|
||||||
|
address: browserDohUrl,
|
||||||
|
detourTag: outboundTag,
|
||||||
|
matchInbounds: [inboundTag],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
overridesByProfileId.forEach((profileId, override) {
|
||||||
|
if (override == null || !runningProfileIds.contains(profileId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final outboundTag = _outboundTagForProfile(profileId);
|
||||||
|
final inboundTag = _inboundTagForProfile(profileId);
|
||||||
|
|
||||||
|
final address = override.remoteServerAddress;
|
||||||
|
if (address == null || address.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
servers.add(
|
||||||
|
SingboxProxyDnsServerConfig(
|
||||||
|
tag: 'override-${singboxSanitizeTag(profileId)}',
|
||||||
|
address: address,
|
||||||
|
detourTag: outboundTag,
|
||||||
|
matchInbounds: [inboundTag],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (servers.isEmpty) return null;
|
||||||
|
return SingboxProxyDnsConfig(
|
||||||
|
servers: servers,
|
||||||
|
finalServerTag: hasBrowserDoh ? 'browser-doh' : null,
|
||||||
|
domainStrategy: _domainStrategy(overridesByProfileId).singboxValue,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProxyDnsDomainStrategy _domainStrategy(
|
||||||
|
Map<String, ProxyDnsOverride?> overridesByProfileId,
|
||||||
|
) {
|
||||||
|
for (final override in overridesByProfileId.values) {
|
||||||
|
if (override != null) return override.domainStrategy;
|
||||||
|
}
|
||||||
|
return ProxyDnsDomainStrategy.preferIpv4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Must mirror Kotlin `SingboxTagFormat.outboundTag(profileId)`.
|
||||||
|
///
|
||||||
|
/// The Kotlin builder receives the *runtime* profile id (which Dart prefixes
|
||||||
|
/// with `singbox:` via [SingboxProxyConnectionId] in
|
||||||
|
/// `ProxyProfileX.toRuntimeProfile`), then sanitises it. We must therefore
|
||||||
|
/// apply the same prefix here so the detour tag we emit references the same
|
||||||
|
/// outbound the builder actually created.
|
||||||
|
///
|
||||||
|
/// The mirrored Kotlin test lives in `SingboxTagFormatTest.kt` — both must
|
||||||
|
/// update together if this format ever changes.
|
||||||
|
String _outboundTagForProfile(String profileId) {
|
||||||
|
return singboxOutboundTag(SingboxProxyConnectionId(profileId).encode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Must mirror Kotlin `SingboxTagFormat.inboundTag(profileId)`.
|
||||||
|
String _inboundTagForProfile(String profileId) {
|
||||||
|
return singboxInboundTag(SingboxProxyConnectionId(profileId).encode());
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Public so the format-contract test can assert it directly.
|
||||||
|
String singboxOutboundTag(String runtimeProfileId) =>
|
||||||
|
'out-${singboxSanitizeTag(runtimeProfileId)}';
|
||||||
|
|
||||||
|
String singboxInboundTag(String runtimeProfileId) =>
|
||||||
|
'in-${singboxSanitizeTag(runtimeProfileId)}';
|
||||||
|
|
||||||
|
String singboxSanitizeTag(String value) =>
|
||||||
|
value.replaceAll(RegExp('[^A-Za-z0-9_.-]'), '_');
|
||||||
@@ -0,0 +1,237 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:weblibre/core/logger.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/forms/singbox_form_specs.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/models/proxy_profile_seed.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/models/proxy_share.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/models/wireguard_config_import.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/parsers/singbox_proxy_uri.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
|
||||||
|
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
|
||||||
|
show ProxyProfile;
|
||||||
|
import 'package:weblibre/features/user/data/models/proxy_dns_override.dart';
|
||||||
|
|
||||||
|
part 'proxy_input_consumer.g.dart';
|
||||||
|
|
||||||
|
enum ProxyFileImportKind { wireguardConf, singboxOutboundJson }
|
||||||
|
|
||||||
|
sealed class ProxyInputOutcome {
|
||||||
|
const ProxyInputOutcome();
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProxyInputImported extends ProxyInputOutcome {
|
||||||
|
final ProxyProfile created;
|
||||||
|
|
||||||
|
const ProxyInputImported(this.created);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProxyInputSeed extends ProxyInputOutcome {
|
||||||
|
final ProxyProfileSeed seed;
|
||||||
|
|
||||||
|
const ProxyInputSeed(this.seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
class ProxyInputError extends ProxyInputOutcome {
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const ProxyInputError(this.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class ProxyInputConsumer extends _$ProxyInputConsumer {
|
||||||
|
Future<ProxyInputOutcome> consumeRawText(String raw) async {
|
||||||
|
final trimmed = raw.trim();
|
||||||
|
|
||||||
|
if (trimmed.startsWith('$weblibreProxyShareScheme://')) {
|
||||||
|
return _consumeShareUri(trimmed);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_looksLikeWireguardConf(trimmed)) {
|
||||||
|
return _seedOutcome(
|
||||||
|
() => _seedFromWireguardConf(trimmed, fileName: 'WireGuard'),
|
||||||
|
logMessage: 'Failed to parse WireGuard configuration',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_looksLikeJsonObject(trimmed)) {
|
||||||
|
return _seedOutcome(
|
||||||
|
() => _seedFromSingboxOutboundJson(trimmed, fileName: 'Outbound'),
|
||||||
|
logMessage: 'Failed to parse pasted sing-box outbound JSON',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _seedOutcome(() {
|
||||||
|
final imported = importSingboxProxyUri(trimmed);
|
||||||
|
return ProxyProfileSeed(
|
||||||
|
type: imported.type,
|
||||||
|
name: imported.name,
|
||||||
|
values: imported.values,
|
||||||
|
);
|
||||||
|
}, logMessage: 'Failed to import proxy URI');
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ProxyInputOutcome> consumeFile(
|
||||||
|
ProxyFileImportKind kind,
|
||||||
|
PlatformFile file,
|
||||||
|
) async {
|
||||||
|
final String text;
|
||||||
|
try {
|
||||||
|
text = await _readFileText(file);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
logger.e(
|
||||||
|
'Failed to read proxy import file ${file.name}',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
return ProxyInputError('Failed to read file: $error');
|
||||||
|
}
|
||||||
|
|
||||||
|
return _seedOutcome(
|
||||||
|
() => switch (kind) {
|
||||||
|
ProxyFileImportKind.wireguardConf => _seedFromWireguardConf(
|
||||||
|
text,
|
||||||
|
fileName: file.name,
|
||||||
|
),
|
||||||
|
ProxyFileImportKind.singboxOutboundJson => _seedFromSingboxOutboundJson(
|
||||||
|
text,
|
||||||
|
fileName: file.name,
|
||||||
|
),
|
||||||
|
},
|
||||||
|
logMessage: 'Invalid proxy import file ${file.name} ($kind)',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ProxyInputOutcome> _consumeShareUri(String text) async {
|
||||||
|
try {
|
||||||
|
final envelope = decodeProxyShareUri(text);
|
||||||
|
final created = await ref
|
||||||
|
.read(singboxProxyProfilesRepositoryProvider.notifier)
|
||||||
|
.createProfile(
|
||||||
|
name: envelope.name,
|
||||||
|
type: envelope.type,
|
||||||
|
configJson: envelope.configJson,
|
||||||
|
secretJson: envelope.secretJson,
|
||||||
|
dnsOverrideJson: envelope.dnsOverrideJson,
|
||||||
|
);
|
||||||
|
return ProxyInputImported(created);
|
||||||
|
} on FormatException catch (error, stackTrace) {
|
||||||
|
logger.e(
|
||||||
|
'Failed to decode WebLibre proxy share URI',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
return ProxyInputError(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ProxyInputOutcome _seedOutcome(
|
||||||
|
ProxyProfileSeed Function() createSeed, {
|
||||||
|
required String logMessage,
|
||||||
|
}) {
|
||||||
|
try {
|
||||||
|
return ProxyInputSeed(createSeed());
|
||||||
|
} on FormatException catch (error, stackTrace) {
|
||||||
|
logger.e(logMessage, error: error, stackTrace: stackTrace);
|
||||||
|
return ProxyInputError(error.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<String> _readFileText(PlatformFile file) async {
|
||||||
|
final bytes = file.bytes;
|
||||||
|
if (bytes != null) {
|
||||||
|
return utf8.decode(bytes, allowMalformed: true);
|
||||||
|
}
|
||||||
|
final path = file.path;
|
||||||
|
if (path == null) {
|
||||||
|
throw const FormatException('Unable to read file contents.');
|
||||||
|
}
|
||||||
|
return File(path).readAsString();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void build() {}
|
||||||
|
}
|
||||||
|
|
||||||
|
ProxyProfileSeed _seedFromWireguardConf(
|
||||||
|
String configText, {
|
||||||
|
required String fileName,
|
||||||
|
}) {
|
||||||
|
final imported = WireguardConfigImport.fromConfigText(configText);
|
||||||
|
final dnsAddress = imported.primaryDnsAddress;
|
||||||
|
final dnsOverrideJson = dnsAddress == null
|
||||||
|
? null
|
||||||
|
: jsonEncode(ProxyDnsOverride(remoteServerAddress: dnsAddress).toJson());
|
||||||
|
return ProxyProfileSeed(
|
||||||
|
type: SingboxProxyProfileType.wireguard,
|
||||||
|
name: _stripExtension(fileName),
|
||||||
|
values: imported.values,
|
||||||
|
dnsOverrideJson: dnsOverrideJson,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
ProxyProfileSeed _seedFromSingboxOutboundJson(
|
||||||
|
String text, {
|
||||||
|
required String fileName,
|
||||||
|
}) {
|
||||||
|
final decoded = jsonDecode(text);
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
throw const FormatException('Expected a sing-box outbound JSON object.');
|
||||||
|
}
|
||||||
|
final outboundType = decoded['type'];
|
||||||
|
if (outboundType is! String) {
|
||||||
|
throw const FormatException(
|
||||||
|
'Outbound JSON is missing a top-level "type" field.',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final spec = singboxProxyFormSpecs.values.firstWhere(
|
||||||
|
(entry) => entry.outboundType == outboundType,
|
||||||
|
orElse: () => throw FormatException(
|
||||||
|
'No structured form for outbound type "$outboundType". '
|
||||||
|
'Use Custom Outbound JSON instead.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final values = spec.valuesFromJson(configJson: jsonEncode(decoded));
|
||||||
|
return ProxyProfileSeed(
|
||||||
|
type: spec.type,
|
||||||
|
name: (decoded['tag'] as String?) ?? _stripExtension(fileName),
|
||||||
|
values: values,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String _stripExtension(String fileName) {
|
||||||
|
final dot = fileName.lastIndexOf('.');
|
||||||
|
if (dot <= 0) return fileName;
|
||||||
|
return fileName.substring(0, dot);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _looksLikeWireguardConf(String text) {
|
||||||
|
return text.contains('[Interface]') &&
|
||||||
|
(text.contains('PrivateKey') || text.contains('Address'));
|
||||||
|
}
|
||||||
|
|
||||||
|
bool _looksLikeJsonObject(String text) {
|
||||||
|
return text.startsWith('{') && text.endsWith('}');
|
||||||
|
}
|
||||||
+12
-12
@@ -1,6 +1,6 @@
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
part of 'tor_proxy.dart';
|
part of 'proxy_input_consumer.dart';
|
||||||
|
|
||||||
// **************************************************************************
|
// **************************************************************************
|
||||||
// RiverpodGenerator
|
// RiverpodGenerator
|
||||||
@@ -9,28 +9,28 @@ part of 'tor_proxy.dart';
|
|||||||
// GENERATED CODE - DO NOT MODIFY BY HAND
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
// ignore_for_file: type=lint, type=warning
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
|
||||||
@ProviderFor(TorProxyRepository)
|
@ProviderFor(ProxyInputConsumer)
|
||||||
final torProxyRepositoryProvider = TorProxyRepositoryProvider._();
|
final proxyInputConsumerProvider = ProxyInputConsumerProvider._();
|
||||||
|
|
||||||
final class TorProxyRepositoryProvider
|
final class ProxyInputConsumerProvider
|
||||||
extends $NotifierProvider<TorProxyRepository, void> {
|
extends $NotifierProvider<ProxyInputConsumer, void> {
|
||||||
TorProxyRepositoryProvider._()
|
ProxyInputConsumerProvider._()
|
||||||
: super(
|
: super(
|
||||||
from: null,
|
from: null,
|
||||||
argument: null,
|
argument: null,
|
||||||
retry: null,
|
retry: null,
|
||||||
name: r'torProxyRepositoryProvider',
|
name: r'proxyInputConsumerProvider',
|
||||||
isAutoDispose: false,
|
isAutoDispose: false,
|
||||||
dependencies: null,
|
dependencies: null,
|
||||||
$allTransitiveDependencies: null,
|
$allTransitiveDependencies: null,
|
||||||
);
|
);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
String debugGetCreateSourceHash() => _$torProxyRepositoryHash();
|
String debugGetCreateSourceHash() => _$proxyInputConsumerHash();
|
||||||
|
|
||||||
@$internal
|
@$internal
|
||||||
@override
|
@override
|
||||||
TorProxyRepository create() => TorProxyRepository();
|
ProxyInputConsumer create() => ProxyInputConsumer();
|
||||||
|
|
||||||
/// {@macro riverpod.override_with_value}
|
/// {@macro riverpod.override_with_value}
|
||||||
Override overrideWithValue(void value) {
|
Override overrideWithValue(void value) {
|
||||||
@@ -41,10 +41,10 @@ final class TorProxyRepositoryProvider
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
String _$torProxyRepositoryHash() =>
|
String _$proxyInputConsumerHash() =>
|
||||||
r'83c2976750f3f7907274b1ae4f926cd1de89be83';
|
r'fed529f253a9bdf72d0b1c23a298765b681d07b8';
|
||||||
|
|
||||||
abstract class _$TorProxyRepository extends $Notifier<void> {
|
abstract class _$ProxyInputConsumer extends $Notifier<void> {
|
||||||
void build();
|
void build();
|
||||||
@$mustCallSuper
|
@$mustCallSuper
|
||||||
@override
|
@override
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
import 'dart:io';
|
||||||
|
|
||||||
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:socks5_proxy/socks_client.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
|
||||||
|
import 'package:weblibre/features/tor/domain/extensions/tor_status_x.dart';
|
||||||
|
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
|
||||||
|
|
||||||
|
part 'proxy_latency_tester.g.dart';
|
||||||
|
|
||||||
|
/// Mullvad's connectivity check — returns JSON with the egress `ip` plus
|
||||||
|
/// geolocation. Single round trip gives both reachability and the IP we
|
||||||
|
/// surface in the chip. Mullvad has a no-logs policy, which fits a
|
||||||
|
/// privacy-focused browser better than funneling every probe through
|
||||||
|
/// Cloudflare.
|
||||||
|
const _probeUrl = 'https://am.i.mullvad.net/json';
|
||||||
|
|
||||||
|
const _testTimeout = Duration(seconds: 8);
|
||||||
|
|
||||||
|
class ProxyLatencyData with FastEquatable {
|
||||||
|
final Duration latency;
|
||||||
|
final int statusCode;
|
||||||
|
final String? egressIp;
|
||||||
|
|
||||||
|
ProxyLatencyData({
|
||||||
|
required this.latency,
|
||||||
|
required this.statusCode,
|
||||||
|
this.egressIp,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get hashParameters => [latency, statusCode, egressIp];
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-profile latency results, keyed by profile id. Holds the latest result
|
||||||
|
/// only; we don't keep history because the test is user-triggered and the user
|
||||||
|
/// is looking at the chip we render from it.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class ProxyLatencyResults extends _$ProxyLatencyResults {
|
||||||
|
@override
|
||||||
|
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>> build() => const {};
|
||||||
|
|
||||||
|
void _set(ProxyConnectionId id, AsyncValue<ProxyLatencyData> result) {
|
||||||
|
state = {...state, id: result};
|
||||||
|
}
|
||||||
|
|
||||||
|
void clear(ProxyConnectionId id) {
|
||||||
|
if (!state.containsKey(id)) return;
|
||||||
|
state = {
|
||||||
|
for (final entry in state.entries)
|
||||||
|
if (entry.key != id) entry.key: entry.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _run(
|
||||||
|
ProxyConnectionId id,
|
||||||
|
SingboxProxyRuntimeEndpoint endpoint,
|
||||||
|
) async {
|
||||||
|
_set(id, const AsyncLoading());
|
||||||
|
final result = await AsyncValue.guard(
|
||||||
|
() => measureViaSocks(endpoint: endpoint, url: Uri.parse(_probeUrl)),
|
||||||
|
);
|
||||||
|
_set(id, result);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a probe through the profile's local SOCKS endpoint and records the
|
||||||
|
/// result. Profile must already be running — the endpoint is read from the
|
||||||
|
/// live runtime state.
|
||||||
|
Future<void> test(String profileId) async {
|
||||||
|
final runtimeState = ref.read(singboxProxyRuntimeRepositoryProvider).value;
|
||||||
|
final endpoint = runtimeState?.endpoints.where((endpoint) {
|
||||||
|
final decoded = ProxyConnectionId.decode(endpoint.profileId);
|
||||||
|
return decoded is SingboxProxyConnectionId &&
|
||||||
|
decoded.profileId == profileId;
|
||||||
|
}).firstOrNull;
|
||||||
|
|
||||||
|
if (endpoint == null) {
|
||||||
|
_set(
|
||||||
|
SingboxProxyConnectionId(profileId),
|
||||||
|
AsyncError('Profile is not running', StackTrace.current),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _run(SingboxProxyConnectionId(profileId), endpoint);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Probes Tor's local SOCKS endpoint. Keyed by [TorProxyConnectionId] so the
|
||||||
|
/// chip and clear/retain logic share a code path with sing-box profiles.
|
||||||
|
Future<void> testTor() async {
|
||||||
|
final socksPort = ref.read(torProxyServiceProvider).value?.usableSocksPort;
|
||||||
|
|
||||||
|
if (socksPort == null) {
|
||||||
|
_set(
|
||||||
|
const TorProxyConnectionId(),
|
||||||
|
AsyncError('Tor is not ready', StackTrace.current),
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await _run(
|
||||||
|
const TorProxyConnectionId(),
|
||||||
|
SingboxProxyRuntimeEndpoint(
|
||||||
|
profileId: const TorProxyConnectionId().encode(),
|
||||||
|
host: '127.0.0.1',
|
||||||
|
port: socksPort,
|
||||||
|
username: '',
|
||||||
|
password: '',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Drop any cached results for profile ids that are no longer running.
|
||||||
|
void retainRunning(Set<ProxyConnectionId> runningIds) {
|
||||||
|
if (setEquals(runningIds, state.keys.toSet())) return;
|
||||||
|
|
||||||
|
state = {
|
||||||
|
for (final entry in state.entries)
|
||||||
|
if (runningIds.contains(entry.key)) entry.key: entry.value,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Runs a warmup probe (discarded) followed by [sampleCount] timed requests
|
||||||
|
/// through a single SOCKS5-bound [HttpClient] and reports the minimum RTT —
|
||||||
|
/// mirrors the speedtest-style "best RTT" reporting used by NekoBox/v2rayN/
|
||||||
|
/// clash for user-triggered URL tests. The cold path (TCP + SOCKS5 handshake +
|
||||||
|
/// upstream outbound warmup) skews the first sample, so we discard it. Reusing
|
||||||
|
/// the HttpClient lets later samples reuse the pooled SOCKS connection.
|
||||||
|
///
|
||||||
|
/// Throws on failure; the latest successful sample also yields the egress IP
|
||||||
|
/// parsed from the probe body.
|
||||||
|
Future<ProxyLatencyData> measureViaSocks({
|
||||||
|
required SingboxProxyRuntimeEndpoint endpoint,
|
||||||
|
required Uri url,
|
||||||
|
Duration timeout = _testTimeout,
|
||||||
|
int sampleCount = 3,
|
||||||
|
}) async {
|
||||||
|
final httpClient = HttpClient()..connectionTimeout = timeout;
|
||||||
|
SocksTCPClient.assignToHttpClient(httpClient, [
|
||||||
|
ProxySettings(
|
||||||
|
InternetAddress(endpoint.host),
|
||||||
|
endpoint.port,
|
||||||
|
username: endpoint.username,
|
||||||
|
password: endpoint.password,
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Warmup — result discarded for timing, but if it fails we surface the
|
||||||
|
// error rather than aggregating min of {failures}.
|
||||||
|
await _singleProbe(httpClient, url, timeout);
|
||||||
|
|
||||||
|
Duration? best;
|
||||||
|
var lastStatusCode = 0;
|
||||||
|
String? lastEgressIp;
|
||||||
|
Object? lastError;
|
||||||
|
StackTrace? lastStackTrace;
|
||||||
|
for (var i = 0; i < sampleCount; i++) {
|
||||||
|
try {
|
||||||
|
final probe = await _singleProbe(httpClient, url, timeout);
|
||||||
|
if (best == null || probe.latency < best) best = probe.latency;
|
||||||
|
lastStatusCode = probe.statusCode;
|
||||||
|
lastEgressIp = probe.egressIp ?? lastEgressIp;
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
lastError = error;
|
||||||
|
lastStackTrace = stackTrace;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (best == null) {
|
||||||
|
if (lastError != null) {
|
||||||
|
Error.throwWithStackTrace(lastError, lastStackTrace!);
|
||||||
|
}
|
||||||
|
throw const SocketException('No samples completed');
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProxyLatencyData(
|
||||||
|
latency: best,
|
||||||
|
statusCode: lastStatusCode,
|
||||||
|
egressIp: lastEgressIp,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
httpClient.close(force: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<ProxyLatencyData> _singleProbe(
|
||||||
|
HttpClient httpClient,
|
||||||
|
Uri url,
|
||||||
|
Duration timeout,
|
||||||
|
) async {
|
||||||
|
final stopwatch = Stopwatch()..start();
|
||||||
|
final request = await httpClient.getUrl(url).timeout(timeout);
|
||||||
|
final response = await request.close().timeout(timeout);
|
||||||
|
stopwatch.stop();
|
||||||
|
|
||||||
|
String? egressIp;
|
||||||
|
if (response.statusCode == 200) {
|
||||||
|
final body = await response.transform(utf8.decoder).join();
|
||||||
|
egressIp = _parseEgressIp(body);
|
||||||
|
} else {
|
||||||
|
await response.drain<void>();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ProxyLatencyData(
|
||||||
|
latency: stopwatch.elapsed,
|
||||||
|
statusCode: response.statusCode,
|
||||||
|
egressIp: egressIp,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _parseEgressIp(String body) {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(body);
|
||||||
|
if (decoded is Map<String, dynamic>) {
|
||||||
|
final ip = decoded['ip'];
|
||||||
|
if (ip is String && ip.isNotEmpty) return ip;
|
||||||
|
}
|
||||||
|
} on FormatException {
|
||||||
|
// Not JSON — fall through.
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'proxy_latency_tester.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
/// Per-profile latency results, keyed by profile id. Holds the latest result
|
||||||
|
/// only; we don't keep history because the test is user-triggered and the user
|
||||||
|
/// is looking at the chip we render from it.
|
||||||
|
|
||||||
|
@ProviderFor(ProxyLatencyResults)
|
||||||
|
final proxyLatencyResultsProvider = ProxyLatencyResultsProvider._();
|
||||||
|
|
||||||
|
/// Per-profile latency results, keyed by profile id. Holds the latest result
|
||||||
|
/// only; we don't keep history because the test is user-triggered and the user
|
||||||
|
/// is looking at the chip we render from it.
|
||||||
|
final class ProxyLatencyResultsProvider
|
||||||
|
extends
|
||||||
|
$NotifierProvider<
|
||||||
|
ProxyLatencyResults,
|
||||||
|
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>
|
||||||
|
> {
|
||||||
|
/// Per-profile latency results, keyed by profile id. Holds the latest result
|
||||||
|
/// only; we don't keep history because the test is user-triggered and the user
|
||||||
|
/// is looking at the chip we render from it.
|
||||||
|
ProxyLatencyResultsProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'proxyLatencyResultsProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$proxyLatencyResultsHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
ProxyLatencyResults create() => ProxyLatencyResults();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(
|
||||||
|
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>> value,
|
||||||
|
) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride:
|
||||||
|
$SyncValueProvider<
|
||||||
|
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>
|
||||||
|
>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$proxyLatencyResultsHash() =>
|
||||||
|
r'f86dba5c5f17d1f74fdf2b7ca6f5adaa4c42ff75';
|
||||||
|
|
||||||
|
/// Per-profile latency results, keyed by profile id. Holds the latest result
|
||||||
|
/// only; we don't keep history because the test is user-triggered and the user
|
||||||
|
/// is looking at the chip we render from it.
|
||||||
|
|
||||||
|
abstract class _$ProxyLatencyResults
|
||||||
|
extends $Notifier<Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>> {
|
||||||
|
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>> build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref =
|
||||||
|
this.ref
|
||||||
|
as $Ref<
|
||||||
|
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>,
|
||||||
|
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>
|
||||||
|
>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<
|
||||||
|
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>,
|
||||||
|
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>
|
||||||
|
>,
|
||||||
|
Map<ProxyConnectionId, AsyncValue<ProxyLatencyData>>,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter_mozilla_components/flutter_mozilla_components.dart';
|
||||||
|
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:synchronized/synchronized.dart';
|
||||||
|
import 'package:weblibre/core/logger.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/models/singbox_proxy_profile.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/container_proxy.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
|
||||||
|
|
||||||
|
part 'singbox_proxy_endpoint_sync.g.dart';
|
||||||
|
|
||||||
|
/// Mirrors the sing-box runtime's active SOCKS endpoints into Gecko's
|
||||||
|
/// container-proxy registry. Listens to [singboxProxyRuntimeRepositoryProvider]
|
||||||
|
/// and diffs the previously registered set against the current endpoints.
|
||||||
|
///
|
||||||
|
/// Extracted from the runtime repository so that:
|
||||||
|
/// - the runtime repo only owns process state (start/stop/validate), and
|
||||||
|
/// - sync is a single side-effect channel — every transition (start, stop,
|
||||||
|
/// stream-driven refresh, native crash) flows through the same listener,
|
||||||
|
/// eliminating the duplicate-sync-call hazard that the inline approach had.
|
||||||
|
///
|
||||||
|
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
|
||||||
|
/// explicitly listened-to from main.dart so the side effect runs without any
|
||||||
|
/// widget needing to depend on it.
|
||||||
|
@Riverpod(keepAlive: true)
|
||||||
|
class SingboxProxyEndpointSync extends _$SingboxProxyEndpointSync {
|
||||||
|
/// Proxy connection ids most recently registered with Gecko. Used to compute
|
||||||
|
/// the unregister set on the next sync.
|
||||||
|
var _registeredProxyIds = <String>{};
|
||||||
|
|
||||||
|
/// Serialises [_sync] runs so that a fast start→stop→start sequence can't
|
||||||
|
/// interleave upserts/removals.
|
||||||
|
final _syncLock = Lock();
|
||||||
|
|
||||||
|
Future<void> _sync(SingboxProxyRuntimeState runtimeState) async {
|
||||||
|
await _syncLock.synchronized(() async {
|
||||||
|
final nextProxyIds = runtimeState.endpoints
|
||||||
|
.map((endpoint) => endpoint.profileId)
|
||||||
|
.toSet();
|
||||||
|
|
||||||
|
final containerProxy = ref.read(
|
||||||
|
containerProxyRepositoryProvider.notifier,
|
||||||
|
);
|
||||||
|
|
||||||
|
for (final proxyId in _registeredProxyIds.difference(nextProxyIds)) {
|
||||||
|
await containerProxy.removeProxy(proxyId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (runtimeState.endpoints.isNotEmpty) {
|
||||||
|
final profiles = await ref
|
||||||
|
.read(singboxProxyProfilesRepositoryProvider.notifier)
|
||||||
|
.fetchProfiles();
|
||||||
|
final profileNames = {
|
||||||
|
for (final profile in profiles)
|
||||||
|
profile.proxyConnectionId: profile.name,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (final endpoint in runtimeState.endpoints) {
|
||||||
|
await containerProxy.upsertProxy(
|
||||||
|
GeckoProxySettings(
|
||||||
|
id: endpoint.profileId,
|
||||||
|
title: profileNames[endpoint.profileId] ?? endpoint.profileId,
|
||||||
|
type: 'socks',
|
||||||
|
host: endpoint.host,
|
||||||
|
port: endpoint.port,
|
||||||
|
username: endpoint.username,
|
||||||
|
password: endpoint.password,
|
||||||
|
proxyDNS: true,
|
||||||
|
doNotProxyLocal: true,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_registeredProxyIds = nextProxyIds;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void build() {
|
||||||
|
ref.listen<AsyncValue<SingboxProxyRuntimeState>>(
|
||||||
|
singboxProxyRuntimeRepositoryProvider,
|
||||||
|
fireImmediately: true,
|
||||||
|
(previous, next) {
|
||||||
|
final runtimeState = next.value;
|
||||||
|
if (runtimeState == null) return;
|
||||||
|
_sync(runtimeState).catchError((Object error, StackTrace stackTrace) {
|
||||||
|
logger.e(
|
||||||
|
'Failed to sync sing-box proxy endpoints to Gecko',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'singbox_proxy_endpoint_sync.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
/// Mirrors the sing-box runtime's active SOCKS endpoints into Gecko's
|
||||||
|
/// container-proxy registry. Listens to [singboxProxyRuntimeRepositoryProvider]
|
||||||
|
/// and diffs the previously registered set against the current endpoints.
|
||||||
|
///
|
||||||
|
/// Extracted from the runtime repository so that:
|
||||||
|
/// - the runtime repo only owns process state (start/stop/validate), and
|
||||||
|
/// - sync is a single side-effect channel — every transition (start, stop,
|
||||||
|
/// stream-driven refresh, native crash) flows through the same listener,
|
||||||
|
/// eliminating the duplicate-sync-call hazard that the inline approach had.
|
||||||
|
///
|
||||||
|
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
|
||||||
|
/// explicitly listened-to from main.dart so the side effect runs without any
|
||||||
|
/// widget needing to depend on it.
|
||||||
|
|
||||||
|
@ProviderFor(SingboxProxyEndpointSync)
|
||||||
|
final singboxProxyEndpointSyncProvider = SingboxProxyEndpointSyncProvider._();
|
||||||
|
|
||||||
|
/// Mirrors the sing-box runtime's active SOCKS endpoints into Gecko's
|
||||||
|
/// container-proxy registry. Listens to [singboxProxyRuntimeRepositoryProvider]
|
||||||
|
/// and diffs the previously registered set against the current endpoints.
|
||||||
|
///
|
||||||
|
/// Extracted from the runtime repository so that:
|
||||||
|
/// - the runtime repo only owns process state (start/stop/validate), and
|
||||||
|
/// - sync is a single side-effect channel — every transition (start, stop,
|
||||||
|
/// stream-driven refresh, native crash) flows through the same listener,
|
||||||
|
/// eliminating the duplicate-sync-call hazard that the inline approach had.
|
||||||
|
///
|
||||||
|
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
|
||||||
|
/// explicitly listened-to from main.dart so the side effect runs without any
|
||||||
|
/// widget needing to depend on it.
|
||||||
|
final class SingboxProxyEndpointSyncProvider
|
||||||
|
extends $NotifierProvider<SingboxProxyEndpointSync, void> {
|
||||||
|
/// Mirrors the sing-box runtime's active SOCKS endpoints into Gecko's
|
||||||
|
/// container-proxy registry. Listens to [singboxProxyRuntimeRepositoryProvider]
|
||||||
|
/// and diffs the previously registered set against the current endpoints.
|
||||||
|
///
|
||||||
|
/// Extracted from the runtime repository so that:
|
||||||
|
/// - the runtime repo only owns process state (start/stop/validate), and
|
||||||
|
/// - sync is a single side-effect channel — every transition (start, stop,
|
||||||
|
/// stream-driven refresh, native crash) flows through the same listener,
|
||||||
|
/// eliminating the duplicate-sync-call hazard that the inline approach had.
|
||||||
|
///
|
||||||
|
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
|
||||||
|
/// explicitly listened-to from main.dart so the side effect runs without any
|
||||||
|
/// widget needing to depend on it.
|
||||||
|
SingboxProxyEndpointSyncProvider._()
|
||||||
|
: super(
|
||||||
|
from: null,
|
||||||
|
argument: null,
|
||||||
|
retry: null,
|
||||||
|
name: r'singboxProxyEndpointSyncProvider',
|
||||||
|
isAutoDispose: false,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$singboxProxyEndpointSyncHash();
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
SingboxProxyEndpointSync create() => SingboxProxyEndpointSync();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(void value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<void>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$singboxProxyEndpointSyncHash() =>
|
||||||
|
r'2d6b0641db33638b0b339b510dd63cdddab7f7cf';
|
||||||
|
|
||||||
|
/// Mirrors the sing-box runtime's active SOCKS endpoints into Gecko's
|
||||||
|
/// container-proxy registry. Listens to [singboxProxyRuntimeRepositoryProvider]
|
||||||
|
/// and diffs the previously registered set against the current endpoints.
|
||||||
|
///
|
||||||
|
/// Extracted from the runtime repository so that:
|
||||||
|
/// - the runtime repo only owns process state (start/stop/validate), and
|
||||||
|
/// - sync is a single side-effect channel — every transition (start, stop,
|
||||||
|
/// stream-driven refresh, native crash) flows through the same listener,
|
||||||
|
/// eliminating the duplicate-sync-call hazard that the inline approach had.
|
||||||
|
///
|
||||||
|
/// This is a side-effect-only provider: it must be `keepAlive: true` and is
|
||||||
|
/// explicitly listened-to from main.dart so the side effect runs without any
|
||||||
|
/// widget needing to depend on it.
|
||||||
|
|
||||||
|
abstract class _$SingboxProxyEndpointSync extends $Notifier<void> {
|
||||||
|
void build();
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref = this.ref as $Ref<void, void>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<void, void>,
|
||||||
|
void,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(ref, build);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:weblibre/features/proxy/data/parsers/singbox_proxy_uri.dart';
|
||||||
|
|
||||||
|
/// One line in a subscription that we attempted to parse. Either a usable
|
||||||
|
/// [SingboxProxyUriImport] or the [FormatException]-style error explaining
|
||||||
|
/// why the line failed to parse, so the UI can show a per-line outcome
|
||||||
|
/// instead of silently dropping nodes.
|
||||||
|
sealed class SubscriptionImportEntry {
|
||||||
|
final String rawLine;
|
||||||
|
|
||||||
|
const SubscriptionImportEntry({required this.rawLine});
|
||||||
|
}
|
||||||
|
|
||||||
|
class SubscriptionEntrySuccess extends SubscriptionImportEntry {
|
||||||
|
final SingboxProxyUriImport imported;
|
||||||
|
|
||||||
|
const SubscriptionEntrySuccess({
|
||||||
|
required super.rawLine,
|
||||||
|
required this.imported,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class SubscriptionEntryFailure extends SubscriptionImportEntry {
|
||||||
|
final Object error;
|
||||||
|
|
||||||
|
const SubscriptionEntryFailure({required super.rawLine, required this.error});
|
||||||
|
}
|
||||||
|
|
||||||
|
class SubscriptionImportResult {
|
||||||
|
final List<SubscriptionImportEntry> entries;
|
||||||
|
|
||||||
|
const SubscriptionImportResult(this.entries);
|
||||||
|
|
||||||
|
Iterable<SubscriptionEntrySuccess> get successes =>
|
||||||
|
entries.whereType<SubscriptionEntrySuccess>();
|
||||||
|
|
||||||
|
Iterable<SubscriptionEntryFailure> get failures =>
|
||||||
|
entries.whereType<SubscriptionEntryFailure>();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Fetches a v2rayN-style subscription URL and parses its contents.
|
||||||
|
///
|
||||||
|
/// Most subscription servers serve a base64-encoded blob whose decoded body is
|
||||||
|
/// a newline-delimited list of `ss://`, `vless://`, etc. URIs. Some serve the
|
||||||
|
/// raw newline-delimited list. We try both: base64 first, then raw, and use
|
||||||
|
/// whichever produces parseable URIs.
|
||||||
|
Future<SubscriptionImportResult> fetchSubscription(
|
||||||
|
Uri url, {
|
||||||
|
http.Client? client,
|
||||||
|
}) async {
|
||||||
|
final ownsClient = client == null;
|
||||||
|
final actualClient = client ?? http.Client();
|
||||||
|
try {
|
||||||
|
final response = await actualClient
|
||||||
|
.get(url, headers: {'User-Agent': 'WebLibre/sing-box-subscriber'})
|
||||||
|
.timeout(const Duration(seconds: 30));
|
||||||
|
if (response.statusCode >= 400) {
|
||||||
|
throw http.ClientException(
|
||||||
|
'Subscription returned HTTP ${response.statusCode}.',
|
||||||
|
url,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return parseSubscriptionBody(response.body);
|
||||||
|
} finally {
|
||||||
|
if (ownsClient) actualClient.close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Decodes a subscription body into individual proxy entries. Public so the
|
||||||
|
/// UI can preview a pasted body without making a network call.
|
||||||
|
SubscriptionImportResult parseSubscriptionBody(String body) {
|
||||||
|
final lines = _tryBase64Decode(body) ?? body;
|
||||||
|
final entries = <SubscriptionImportEntry>[];
|
||||||
|
for (final raw in const LineSplitter().convert(lines)) {
|
||||||
|
final line = raw.trim();
|
||||||
|
if (line.isEmpty || line.startsWith('#')) continue;
|
||||||
|
try {
|
||||||
|
entries.add(
|
||||||
|
SubscriptionEntrySuccess(
|
||||||
|
rawLine: line,
|
||||||
|
imported: importSingboxProxyUri(line),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
} on FormatException catch (error) {
|
||||||
|
entries.add(SubscriptionEntryFailure(rawLine: line, error: error));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SubscriptionImportResult(entries);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _tryBase64Decode(String body) {
|
||||||
|
// Subscription bodies are base64 (sometimes URL-safe) without padding.
|
||||||
|
final stripped = body.replaceAll(RegExp(r'\s'), '');
|
||||||
|
if (stripped.isEmpty) return null;
|
||||||
|
// Only attempt if the body looks like base64 — bail out if it contains
|
||||||
|
// characters never present in base64 alphabets.
|
||||||
|
if (!RegExp(r'^[A-Za-z0-9+/_=-]+$').hasMatch(stripped)) return null;
|
||||||
|
try {
|
||||||
|
return utf8.decode(base64.decode(base64.normalize(stripped)));
|
||||||
|
} catch (_) {
|
||||||
|
try {
|
||||||
|
return utf8.decode(base64Url.decode(base64Url.normalize(stripped)));
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/core/logger.dart';
|
||||||
|
import 'package:weblibre/features/geckoview/features/tabs/data/models/container_data.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
|
||||||
|
import 'package:weblibre/features/tor/presentation/controllers/start_tor_proxy.dart';
|
||||||
|
import 'package:weblibre/features/tor/presentation/widgets/tor_dialog.dart';
|
||||||
|
import 'package:weblibre/utils/ui_helper.dart';
|
||||||
|
|
||||||
|
/// Single entry point that prompts the user to start whichever proxy backend
|
||||||
|
/// a container is configured to use. No-op when the container has no proxy
|
||||||
|
/// assigned or when the relevant backend is already running.
|
||||||
|
Future<bool> ensureProxyStartedForContainer(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
ContainerData container,
|
||||||
|
) async {
|
||||||
|
final proxyConnectionId = container.metadata.proxyConnectionId;
|
||||||
|
if (proxyConnectionId == null) return true;
|
||||||
|
|
||||||
|
if (proxyConnectionId is TorProxyConnectionId) {
|
||||||
|
return await _ensureTorStarted(context, ref);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (proxyConnectionId is SingboxProxyConnectionId) {
|
||||||
|
return await _maybeStartSingboxProxyForContainer(context, ref, container);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _ensureTorStarted(BuildContext context, WidgetRef ref) async {
|
||||||
|
final shouldPrompt = await ref
|
||||||
|
.read(startProxyControllerProvider.notifier)
|
||||||
|
.shouldPromptProxyStart();
|
||||||
|
if (!context.mounted) return false;
|
||||||
|
if (!shouldPrompt) return true;
|
||||||
|
|
||||||
|
final dialogResult = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => const TorDialog(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (dialogResult == true) {
|
||||||
|
await ref.read(startProxyControllerProvider.notifier).startProxy();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<bool> _maybeStartSingboxProxyForContainer(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
ContainerData container,
|
||||||
|
) async {
|
||||||
|
final proxyConnectionId = container.metadata.proxyConnectionId;
|
||||||
|
if (proxyConnectionId == null) return true;
|
||||||
|
|
||||||
|
if (proxyConnectionId is! SingboxProxyConnectionId) return true;
|
||||||
|
|
||||||
|
// Await any start/stop in flight so we don't prompt the user a second time
|
||||||
|
// while a start they already triggered is still resolving. If the runtime
|
||||||
|
// provider is already in AsyncError, keep treating that as "not running" so
|
||||||
|
// the user can retry from the container entry point.
|
||||||
|
final runtimeState = ref.read(singboxProxyRuntimeRepositoryProvider);
|
||||||
|
final resolvedRuntimeState = await switch (runtimeState) {
|
||||||
|
AsyncData(:final value) => Future.value(value),
|
||||||
|
AsyncLoading() => _resolveRuntimeStateForPrompt(ref),
|
||||||
|
AsyncError(:final error, :final stackTrace) => Future.value(
|
||||||
|
_runtimeStateRetryFallback(
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
message:
|
||||||
|
'Singbox proxy runtime is in error state; continuing so the user can retry startup',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
if (!context.mounted) return false;
|
||||||
|
|
||||||
|
final isRunning = resolvedRuntimeState.endpoints.any(
|
||||||
|
(endpoint) => endpoint.profileId == proxyConnectionId.encode(),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (isRunning) return true;
|
||||||
|
|
||||||
|
final proxyTitle = proxyConnectionTitle(
|
||||||
|
ref.read(proxyConnectionOptionsProvider),
|
||||||
|
proxyConnectionId,
|
||||||
|
);
|
||||||
|
final shouldStart = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
icon: const Icon(Icons.route_outlined),
|
||||||
|
title: const Text('Start Proxy Connection?'),
|
||||||
|
content: Text(
|
||||||
|
'This container uses $proxyTitle, but that connection is not running. Start it now?',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: const Text('Start'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (shouldStart != true) return false;
|
||||||
|
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(singboxProxyRuntimeRepositoryProvider.notifier)
|
||||||
|
.startProfile(proxyConnectionId.profileId);
|
||||||
|
return true;
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
logger.e(
|
||||||
|
'Failed to start singbox proxy profile ${proxyConnectionId.profileId}',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
if (context.mounted) {
|
||||||
|
showErrorMessage(context, 'Failed to start proxy: $error');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SingboxProxyRuntimeState> _resolveRuntimeStateForPrompt(
|
||||||
|
WidgetRef ref,
|
||||||
|
) async {
|
||||||
|
try {
|
||||||
|
return await ref.read(singboxProxyRuntimeRepositoryProvider.future);
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
return _runtimeStateRetryFallback(
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
message:
|
||||||
|
'Singbox proxy runtime is unresolved; continuing so the user can retry startup',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
SingboxProxyRuntimeState _runtimeStateRetryFallback({
|
||||||
|
required Object error,
|
||||||
|
required StackTrace stackTrace,
|
||||||
|
required String message,
|
||||||
|
}) {
|
||||||
|
logger.w(message, error: error, stackTrace: stackTrace);
|
||||||
|
return SingboxProxyRuntimeState(
|
||||||
|
status: SingboxProxyRuntimeStatus.error,
|
||||||
|
endpoints: const [],
|
||||||
|
message: error.toString(),
|
||||||
|
);
|
||||||
|
}
|
||||||
+391
@@ -0,0 +1,391 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'dart:async';
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:copy_with_extension/copy_with_extension.dart';
|
||||||
|
import 'package:fast_equatable/fast_equatable.dart';
|
||||||
|
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
import 'package:weblibre/core/uuid.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/forms/singbox_form_specs.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/models/proxy_profile_seed.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_credentials.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
|
||||||
|
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
|
||||||
|
show ProxyProfile;
|
||||||
|
|
||||||
|
part 'proxy_profile_draft_controller.g.dart';
|
||||||
|
|
||||||
|
const defaultCustomOutboundConfigJson = '''
|
||||||
|
{
|
||||||
|
"type": "socks",
|
||||||
|
"server": "127.0.0.1",
|
||||||
|
"server_port": 1080
|
||||||
|
}''';
|
||||||
|
|
||||||
|
sealed class SaveOutcome {
|
||||||
|
const SaveOutcome();
|
||||||
|
}
|
||||||
|
|
||||||
|
class SaveSucceeded extends SaveOutcome {
|
||||||
|
const SaveSucceeded();
|
||||||
|
}
|
||||||
|
|
||||||
|
class SaveFailed extends SaveOutcome {
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const SaveFailed(this.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
@CopyWith()
|
||||||
|
class ProxyProfileDraftState with FastEquatable {
|
||||||
|
final String? profileId;
|
||||||
|
final ProxyProfile? existingProfile;
|
||||||
|
final String? loadError;
|
||||||
|
final String name;
|
||||||
|
final SingboxProxyProfileType type;
|
||||||
|
final Map<String, String> values;
|
||||||
|
final String? dnsOverrideJson;
|
||||||
|
final String customConfigJson;
|
||||||
|
final String customSecretJson;
|
||||||
|
final bool isSaving;
|
||||||
|
final bool secretLoaded;
|
||||||
|
|
||||||
|
ProxyProfileDraftState({
|
||||||
|
required this.profileId,
|
||||||
|
required this.existingProfile,
|
||||||
|
required this.loadError,
|
||||||
|
required this.name,
|
||||||
|
required this.type,
|
||||||
|
required this.values,
|
||||||
|
required this.dnsOverrideJson,
|
||||||
|
required this.customConfigJson,
|
||||||
|
required this.customSecretJson,
|
||||||
|
required this.isSaving,
|
||||||
|
required this.secretLoaded,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory ProxyProfileDraftState.newProfile({ProxyProfileSeed? seed}) {
|
||||||
|
final type = seed?.type ?? SingboxProxyProfileType.customOutbound;
|
||||||
|
return ProxyProfileDraftState(
|
||||||
|
profileId: null,
|
||||||
|
existingProfile: null,
|
||||||
|
loadError: null,
|
||||||
|
name: seed?.name ?? '',
|
||||||
|
type: type,
|
||||||
|
values: _initialValuesForType(type, overlay: seed?.values),
|
||||||
|
dnsOverrideJson: seed?.dnsOverrideJson,
|
||||||
|
customConfigJson: defaultCustomOutboundConfigJson,
|
||||||
|
customSecretJson: '',
|
||||||
|
isSaving: false,
|
||||||
|
secretLoaded: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
factory ProxyProfileDraftState.loadingExisting(String profileId) {
|
||||||
|
return ProxyProfileDraftState(
|
||||||
|
profileId: profileId,
|
||||||
|
existingProfile: null,
|
||||||
|
loadError: null,
|
||||||
|
name: '',
|
||||||
|
type: SingboxProxyProfileType.customOutbound,
|
||||||
|
values: const {},
|
||||||
|
dnsOverrideJson: null,
|
||||||
|
customConfigJson: defaultCustomOutboundConfigJson,
|
||||||
|
customSecretJson: '',
|
||||||
|
isSaving: false,
|
||||||
|
secretLoaded: false,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isEditing => profileId != null;
|
||||||
|
|
||||||
|
bool get isLoading =>
|
||||||
|
isEditing && existingProfile == null && loadError == null;
|
||||||
|
|
||||||
|
@override
|
||||||
|
List<Object?> get hashParameters => [
|
||||||
|
profileId,
|
||||||
|
existingProfile,
|
||||||
|
loadError,
|
||||||
|
name,
|
||||||
|
type,
|
||||||
|
values,
|
||||||
|
dnsOverrideJson,
|
||||||
|
customConfigJson,
|
||||||
|
customSecretJson,
|
||||||
|
isSaving,
|
||||||
|
secretLoaded,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
@riverpod
|
||||||
|
class ProxyProfileDraft extends _$ProxyProfileDraft {
|
||||||
|
@override
|
||||||
|
ProxyProfileDraftState build({String? profileId, ProxyProfileSeed? seed}) {
|
||||||
|
if (profileId == null) {
|
||||||
|
return ProxyProfileDraftState.newProfile(seed: seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
unawaited(_loadExistingProfile(profileId));
|
||||||
|
return ProxyProfileDraftState.loadingExisting(profileId);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadExistingProfile(String profileId) async {
|
||||||
|
try {
|
||||||
|
final profile = await ref
|
||||||
|
.read(singboxProxyProfilesRepositoryProvider.notifier)
|
||||||
|
.findProfile(profileId);
|
||||||
|
if (!ref.mounted) return;
|
||||||
|
|
||||||
|
if (profile == null) {
|
||||||
|
state = state.copyWith(
|
||||||
|
loadError: 'Proxy profile not found.',
|
||||||
|
secretLoaded: true,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final secretJson = await ref
|
||||||
|
.read(singboxProxyCredentialsRepositoryProvider.notifier)
|
||||||
|
.readSecretJson(profile.id);
|
||||||
|
if (!ref.mounted) return;
|
||||||
|
|
||||||
|
final spec = singboxProxyFormSpecs[profile.type];
|
||||||
|
state = state.copyWith(
|
||||||
|
existingProfile: profile,
|
||||||
|
name: profile.name,
|
||||||
|
type: profile.type,
|
||||||
|
values: spec == null
|
||||||
|
? const {}
|
||||||
|
: spec.valuesFromJson(
|
||||||
|
configJson: profile.configJson,
|
||||||
|
secretJson: secretJson,
|
||||||
|
),
|
||||||
|
dnsOverrideJson: profile.dnsOverrideJson,
|
||||||
|
customConfigJson: profile.type == SingboxProxyProfileType.customOutbound
|
||||||
|
? profile.configJson
|
||||||
|
: defaultCustomOutboundConfigJson,
|
||||||
|
customSecretJson: profile.type == SingboxProxyProfileType.customOutbound
|
||||||
|
? secretJson ?? ''
|
||||||
|
: '',
|
||||||
|
secretLoaded: true,
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
if (!ref.mounted) return;
|
||||||
|
state = state.copyWith(
|
||||||
|
loadError: 'Failed to load proxy profile: $error',
|
||||||
|
secretLoaded: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void setName(String name) {
|
||||||
|
state = state.copyWith(name: name);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setType(SingboxProxyProfileType type) {
|
||||||
|
if (state.isEditing || state.type == type) return;
|
||||||
|
state = state.copyWith(
|
||||||
|
type: type,
|
||||||
|
values: _initialValuesForType(type),
|
||||||
|
customConfigJson: defaultCustomOutboundConfigJson,
|
||||||
|
customSecretJson: '',
|
||||||
|
secretLoaded: true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setFieldValue(String key, String value) {
|
||||||
|
state = state.copyWith(values: {...state.values, key: value});
|
||||||
|
}
|
||||||
|
|
||||||
|
void setDnsOverrideJson(String? json) {
|
||||||
|
state = state.copyWith.dnsOverrideJson(json);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setCustomConfigJson(String json) {
|
||||||
|
state = state.copyWith(customConfigJson: json);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setCustomSecretJson(String json) {
|
||||||
|
state = state.copyWith(customSecretJson: json);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<SaveOutcome> save() async {
|
||||||
|
if (state.isSaving) {
|
||||||
|
return const SaveFailed('Profile is already saving.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final draft = state;
|
||||||
|
final trimmedName = draft.name.trim();
|
||||||
|
if (trimmedName.isEmpty) {
|
||||||
|
return const SaveFailed('Profile name is required.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draft.isLoading) {
|
||||||
|
return const SaveFailed('Profile is still loading, please wait.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draft.loadError != null) {
|
||||||
|
return SaveFailed(draft.loadError!);
|
||||||
|
}
|
||||||
|
|
||||||
|
final encoded = _encodeDraft(draft);
|
||||||
|
switch (encoded) {
|
||||||
|
case _DraftEncodeFailure(:final message):
|
||||||
|
return SaveFailed(message);
|
||||||
|
case _DraftEncodeSuccess(:final configJson, :final secretJson):
|
||||||
|
state = state.copyWith(isSaving: true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
final existing = draft.existingProfile;
|
||||||
|
final profile = ProxyProfile(
|
||||||
|
id: existing?.id ?? uuid.v4(),
|
||||||
|
name: trimmedName,
|
||||||
|
type: draft.type,
|
||||||
|
configJson: configJson,
|
||||||
|
dnsOverrideJson: draft.dnsOverrideJson,
|
||||||
|
createdAt: existing?.createdAt ?? DateTime.now(),
|
||||||
|
updatedAt: DateTime.now(),
|
||||||
|
);
|
||||||
|
|
||||||
|
final validationMessage = await ref
|
||||||
|
.read(singboxProxyRuntimeRepositoryProvider.notifier)
|
||||||
|
.validateProfileDraft(profile, secretJson: secretJson);
|
||||||
|
if (validationMessage != null) {
|
||||||
|
return SaveFailed(validationMessage);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existing == null) {
|
||||||
|
await ref
|
||||||
|
.read(singboxProxyProfilesRepositoryProvider.notifier)
|
||||||
|
.createProfile(
|
||||||
|
name: trimmedName,
|
||||||
|
type: draft.type,
|
||||||
|
configJson: configJson,
|
||||||
|
secretJson: secretJson,
|
||||||
|
dnsOverrideJson: draft.dnsOverrideJson,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await ref
|
||||||
|
.read(singboxProxyProfilesRepositoryProvider.notifier)
|
||||||
|
.updateProfile(profile);
|
||||||
|
await ref
|
||||||
|
.read(singboxProxyCredentialsRepositoryProvider.notifier)
|
||||||
|
.writeSecretJson(profile.id, secretJson);
|
||||||
|
}
|
||||||
|
|
||||||
|
return const SaveSucceeded();
|
||||||
|
} catch (error) {
|
||||||
|
return SaveFailed('Failed to save proxy profile: $error');
|
||||||
|
} finally {
|
||||||
|
if (ref.mounted) {
|
||||||
|
state = state.copyWith(isSaving: false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sealed class _DraftEncodeResult {
|
||||||
|
const _DraftEncodeResult();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DraftEncodeSuccess extends _DraftEncodeResult {
|
||||||
|
final String configJson;
|
||||||
|
final String? secretJson;
|
||||||
|
|
||||||
|
const _DraftEncodeSuccess({
|
||||||
|
required this.configJson,
|
||||||
|
required this.secretJson,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
class _DraftEncodeFailure extends _DraftEncodeResult {
|
||||||
|
final String message;
|
||||||
|
|
||||||
|
const _DraftEncodeFailure(this.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates and encodes the draft into wire-format JSON in a single pass.
|
||||||
|
/// For custom-outbound profiles we only check JSON shape; for spec-driven
|
||||||
|
/// types the spec carries field-level validation.
|
||||||
|
_DraftEncodeResult _encodeDraft(ProxyProfileDraftState draft) {
|
||||||
|
if (draft.type == SingboxProxyProfileType.customOutbound) {
|
||||||
|
final normalizedConfigJson = _normalizeJsonObject(draft.customConfigJson);
|
||||||
|
if (normalizedConfigJson == null) {
|
||||||
|
return const _DraftEncodeFailure('Config must be a JSON object.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final rawSecret = draft.customSecretJson.trim();
|
||||||
|
if (rawSecret.isEmpty) {
|
||||||
|
return _DraftEncodeSuccess(
|
||||||
|
configJson: normalizedConfigJson,
|
||||||
|
secretJson: null,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
final normalizedSecretJson = _normalizeJsonObject(draft.customSecretJson);
|
||||||
|
if (normalizedSecretJson == null) {
|
||||||
|
return const _DraftEncodeFailure('Secrets must be a JSON object.');
|
||||||
|
}
|
||||||
|
return _DraftEncodeSuccess(
|
||||||
|
configJson: normalizedConfigJson,
|
||||||
|
secretJson: normalizedSecretJson,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final spec = singboxProxyFormSpecs[draft.type]!;
|
||||||
|
final validationMessage = spec.validate(draft.values);
|
||||||
|
if (validationMessage != null) {
|
||||||
|
return _DraftEncodeFailure(validationMessage);
|
||||||
|
}
|
||||||
|
return _DraftEncodeSuccess(
|
||||||
|
configJson: spec.toConfigJson(draft.values),
|
||||||
|
secretJson: spec.toSecretJson(draft.values),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
String? _normalizeJsonObject(String rawJson) {
|
||||||
|
try {
|
||||||
|
final decoded = jsonDecode(rawJson) as Object?;
|
||||||
|
if (decoded is! Map<String, dynamic>) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return const JsonEncoder.withIndent(' ').convert(decoded);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, String> _initialValuesForType(
|
||||||
|
SingboxProxyProfileType type, {
|
||||||
|
Map<String, String>? overlay,
|
||||||
|
}) {
|
||||||
|
final spec = singboxProxyFormSpecs[type];
|
||||||
|
if (spec == null) return overlay == null ? const {} : Map.of(overlay);
|
||||||
|
|
||||||
|
return {
|
||||||
|
for (final field in spec.fields)
|
||||||
|
if (field.defaultValue != null) field.key: field.defaultValue!,
|
||||||
|
if (overlay != null) ...overlay,
|
||||||
|
};
|
||||||
|
}
|
||||||
+296
@@ -0,0 +1,296 @@
|
|||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
|
||||||
|
part of 'proxy_profile_draft_controller.dart';
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// CopyWithGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
abstract class _$ProxyProfileDraftStateCWProxy {
|
||||||
|
ProxyProfileDraftState profileId(String? profileId);
|
||||||
|
|
||||||
|
ProxyProfileDraftState existingProfile(ProxyProfile? existingProfile);
|
||||||
|
|
||||||
|
ProxyProfileDraftState loadError(String? loadError);
|
||||||
|
|
||||||
|
ProxyProfileDraftState name(String name);
|
||||||
|
|
||||||
|
ProxyProfileDraftState type(SingboxProxyProfileType type);
|
||||||
|
|
||||||
|
ProxyProfileDraftState values(Map<String, String> values);
|
||||||
|
|
||||||
|
ProxyProfileDraftState dnsOverrideJson(String? dnsOverrideJson);
|
||||||
|
|
||||||
|
ProxyProfileDraftState customConfigJson(String customConfigJson);
|
||||||
|
|
||||||
|
ProxyProfileDraftState customSecretJson(String customSecretJson);
|
||||||
|
|
||||||
|
ProxyProfileDraftState isSaving(bool isSaving);
|
||||||
|
|
||||||
|
ProxyProfileDraftState secretLoaded(bool secretLoaded);
|
||||||
|
|
||||||
|
/// Creates a new instance with the provided field values.
|
||||||
|
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ProxyProfileDraftState(...).copyWith.fieldName(value)`.
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
/// ```dart
|
||||||
|
/// ProxyProfileDraftState(...).copyWith(id: 12, name: "My name")
|
||||||
|
/// ```
|
||||||
|
ProxyProfileDraftState call({
|
||||||
|
String? profileId,
|
||||||
|
ProxyProfile? existingProfile,
|
||||||
|
String? loadError,
|
||||||
|
String name,
|
||||||
|
SingboxProxyProfileType type,
|
||||||
|
Map<String, String> values,
|
||||||
|
String? dnsOverrideJson,
|
||||||
|
String customConfigJson,
|
||||||
|
String customSecretJson,
|
||||||
|
bool isSaving,
|
||||||
|
bool secretLoaded,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Callable proxy for `copyWith` functionality.
|
||||||
|
/// Use as `instanceOfProxyProfileDraftState.copyWith(...)` or call `instanceOfProxyProfileDraftState.copyWith.fieldName(value)` for a single field.
|
||||||
|
class _$ProxyProfileDraftStateCWProxyImpl
|
||||||
|
implements _$ProxyProfileDraftStateCWProxy {
|
||||||
|
const _$ProxyProfileDraftStateCWProxyImpl(this._value);
|
||||||
|
|
||||||
|
final ProxyProfileDraftState _value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
ProxyProfileDraftState profileId(String? profileId) =>
|
||||||
|
call(profileId: profileId);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ProxyProfileDraftState existingProfile(ProxyProfile? existingProfile) =>
|
||||||
|
call(existingProfile: existingProfile);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ProxyProfileDraftState loadError(String? loadError) =>
|
||||||
|
call(loadError: loadError);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ProxyProfileDraftState name(String name) => call(name: name);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ProxyProfileDraftState type(SingboxProxyProfileType type) => call(type: type);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ProxyProfileDraftState values(Map<String, String> values) =>
|
||||||
|
call(values: values);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ProxyProfileDraftState dnsOverrideJson(String? dnsOverrideJson) =>
|
||||||
|
call(dnsOverrideJson: dnsOverrideJson);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ProxyProfileDraftState customConfigJson(String customConfigJson) =>
|
||||||
|
call(customConfigJson: customConfigJson);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ProxyProfileDraftState customSecretJson(String customSecretJson) =>
|
||||||
|
call(customSecretJson: customSecretJson);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ProxyProfileDraftState isSaving(bool isSaving) => call(isSaving: isSaving);
|
||||||
|
|
||||||
|
@override
|
||||||
|
ProxyProfileDraftState secretLoaded(bool secretLoaded) =>
|
||||||
|
call(secretLoaded: secretLoaded);
|
||||||
|
|
||||||
|
@override
|
||||||
|
/// Creates a new instance with the provided field values.
|
||||||
|
/// Passing `null` to a nullable field nullifies it, while `null` for a non-nullable field is ignored. To update a single field use `ProxyProfileDraftState(...).copyWith.fieldName(value)`.
|
||||||
|
///
|
||||||
|
/// Example:
|
||||||
|
/// ```dart
|
||||||
|
/// ProxyProfileDraftState(...).copyWith(id: 12, name: "My name")
|
||||||
|
/// ```
|
||||||
|
ProxyProfileDraftState call({
|
||||||
|
Object? profileId = const $CopyWithPlaceholder(),
|
||||||
|
Object? existingProfile = const $CopyWithPlaceholder(),
|
||||||
|
Object? loadError = const $CopyWithPlaceholder(),
|
||||||
|
Object? name = const $CopyWithPlaceholder(),
|
||||||
|
Object? type = const $CopyWithPlaceholder(),
|
||||||
|
Object? values = const $CopyWithPlaceholder(),
|
||||||
|
Object? dnsOverrideJson = const $CopyWithPlaceholder(),
|
||||||
|
Object? customConfigJson = const $CopyWithPlaceholder(),
|
||||||
|
Object? customSecretJson = const $CopyWithPlaceholder(),
|
||||||
|
Object? isSaving = const $CopyWithPlaceholder(),
|
||||||
|
Object? secretLoaded = const $CopyWithPlaceholder(),
|
||||||
|
}) {
|
||||||
|
return ProxyProfileDraftState(
|
||||||
|
profileId: profileId == const $CopyWithPlaceholder()
|
||||||
|
? _value.profileId
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: profileId as String?,
|
||||||
|
existingProfile: existingProfile == const $CopyWithPlaceholder()
|
||||||
|
? _value.existingProfile
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: existingProfile as ProxyProfile?,
|
||||||
|
loadError: loadError == const $CopyWithPlaceholder()
|
||||||
|
? _value.loadError
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: loadError as String?,
|
||||||
|
name: name == const $CopyWithPlaceholder() || name == null
|
||||||
|
? _value.name
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: name as String,
|
||||||
|
type: type == const $CopyWithPlaceholder() || type == null
|
||||||
|
? _value.type
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: type as SingboxProxyProfileType,
|
||||||
|
values: values == const $CopyWithPlaceholder() || values == null
|
||||||
|
? _value.values
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: values as Map<String, String>,
|
||||||
|
dnsOverrideJson: dnsOverrideJson == const $CopyWithPlaceholder()
|
||||||
|
? _value.dnsOverrideJson
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: dnsOverrideJson as String?,
|
||||||
|
customConfigJson:
|
||||||
|
customConfigJson == const $CopyWithPlaceholder() ||
|
||||||
|
customConfigJson == null
|
||||||
|
? _value.customConfigJson
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: customConfigJson as String,
|
||||||
|
customSecretJson:
|
||||||
|
customSecretJson == const $CopyWithPlaceholder() ||
|
||||||
|
customSecretJson == null
|
||||||
|
? _value.customSecretJson
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: customSecretJson as String,
|
||||||
|
isSaving: isSaving == const $CopyWithPlaceholder() || isSaving == null
|
||||||
|
? _value.isSaving
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: isSaving as bool,
|
||||||
|
secretLoaded:
|
||||||
|
secretLoaded == const $CopyWithPlaceholder() || secretLoaded == null
|
||||||
|
? _value.secretLoaded
|
||||||
|
// ignore: cast_nullable_to_non_nullable
|
||||||
|
: secretLoaded as bool,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension $ProxyProfileDraftStateCopyWith on ProxyProfileDraftState {
|
||||||
|
/// Returns a callable class used to build a new instance with modified fields.
|
||||||
|
/// Example: `instanceOfProxyProfileDraftState.copyWith(...)` or `instanceOfProxyProfileDraftState.copyWith.fieldName(...)`.
|
||||||
|
// ignore: library_private_types_in_public_api
|
||||||
|
_$ProxyProfileDraftStateCWProxy get copyWith =>
|
||||||
|
_$ProxyProfileDraftStateCWProxyImpl(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
// **************************************************************************
|
||||||
|
// RiverpodGenerator
|
||||||
|
// **************************************************************************
|
||||||
|
|
||||||
|
// GENERATED CODE - DO NOT MODIFY BY HAND
|
||||||
|
// ignore_for_file: type=lint, type=warning
|
||||||
|
|
||||||
|
@ProviderFor(ProxyProfileDraft)
|
||||||
|
final proxyProfileDraftProvider = ProxyProfileDraftFamily._();
|
||||||
|
|
||||||
|
final class ProxyProfileDraftProvider
|
||||||
|
extends $NotifierProvider<ProxyProfileDraft, ProxyProfileDraftState> {
|
||||||
|
ProxyProfileDraftProvider._({
|
||||||
|
required ProxyProfileDraftFamily super.from,
|
||||||
|
required ({String? profileId, ProxyProfileSeed? seed}) super.argument,
|
||||||
|
}) : super(
|
||||||
|
retry: null,
|
||||||
|
name: r'proxyProfileDraftProvider',
|
||||||
|
isAutoDispose: true,
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String debugGetCreateSourceHash() => _$proxyProfileDraftHash();
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return r'proxyProfileDraftProvider'
|
||||||
|
''
|
||||||
|
'$argument';
|
||||||
|
}
|
||||||
|
|
||||||
|
@$internal
|
||||||
|
@override
|
||||||
|
ProxyProfileDraft create() => ProxyProfileDraft();
|
||||||
|
|
||||||
|
/// {@macro riverpod.override_with_value}
|
||||||
|
Override overrideWithValue(ProxyProfileDraftState value) {
|
||||||
|
return $ProviderOverride(
|
||||||
|
origin: this,
|
||||||
|
providerOverride: $SyncValueProvider<ProxyProfileDraftState>(value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
return other is ProxyProfileDraftProvider && other.argument == argument;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode {
|
||||||
|
return argument.hashCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _$proxyProfileDraftHash() => r'88dccb74624a8257f77646ab6755dce4e0de465a';
|
||||||
|
|
||||||
|
final class ProxyProfileDraftFamily extends $Family
|
||||||
|
with
|
||||||
|
$ClassFamilyOverride<
|
||||||
|
ProxyProfileDraft,
|
||||||
|
ProxyProfileDraftState,
|
||||||
|
ProxyProfileDraftState,
|
||||||
|
ProxyProfileDraftState,
|
||||||
|
({String? profileId, ProxyProfileSeed? seed})
|
||||||
|
> {
|
||||||
|
ProxyProfileDraftFamily._()
|
||||||
|
: super(
|
||||||
|
retry: null,
|
||||||
|
name: r'proxyProfileDraftProvider',
|
||||||
|
dependencies: null,
|
||||||
|
$allTransitiveDependencies: null,
|
||||||
|
isAutoDispose: true,
|
||||||
|
);
|
||||||
|
|
||||||
|
ProxyProfileDraftProvider call({String? profileId, ProxyProfileSeed? seed}) =>
|
||||||
|
ProxyProfileDraftProvider._(
|
||||||
|
argument: (profileId: profileId, seed: seed),
|
||||||
|
from: this,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() => r'proxyProfileDraftProvider';
|
||||||
|
}
|
||||||
|
|
||||||
|
abstract class _$ProxyProfileDraft extends $Notifier<ProxyProfileDraftState> {
|
||||||
|
late final _$args = ref.$arg as ({String? profileId, ProxyProfileSeed? seed});
|
||||||
|
String? get profileId => _$args.profileId;
|
||||||
|
ProxyProfileSeed? get seed => _$args.seed;
|
||||||
|
|
||||||
|
ProxyProfileDraftState build({String? profileId, ProxyProfileSeed? seed});
|
||||||
|
@$mustCallSuper
|
||||||
|
@override
|
||||||
|
void runBuild() {
|
||||||
|
final ref =
|
||||||
|
this.ref as $Ref<ProxyProfileDraftState, ProxyProfileDraftState>;
|
||||||
|
final element =
|
||||||
|
ref.element
|
||||||
|
as $ClassProviderElement<
|
||||||
|
AnyNotifier<ProxyProfileDraftState, ProxyProfileDraftState>,
|
||||||
|
ProxyProfileDraftState,
|
||||||
|
Object?,
|
||||||
|
Object?
|
||||||
|
>;
|
||||||
|
element.handleCreate(
|
||||||
|
ref,
|
||||||
|
() => build(profileId: _$args.profileId, seed: _$args.seed),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,214 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/providers/proxy_connection_options.dart';
|
||||||
|
import 'package:weblibre/features/settings/presentation/widgets/settings_detail.dart';
|
||||||
|
import 'package:weblibre/features/user/data/models/proxy_routing_settings.dart';
|
||||||
|
import 'package:weblibre/features/user/domain/repositories/proxy_routing_settings.dart';
|
||||||
|
|
||||||
|
const List<SettingsSectionDefinition> proxyRoutingSettingsSections = [
|
||||||
|
SettingsSectionDefinition(
|
||||||
|
title: 'Regular Tabs',
|
||||||
|
keywords: ['routing'],
|
||||||
|
entries: [
|
||||||
|
SettingsEntryDefinition(
|
||||||
|
title: 'Regular Tabs Routing Mode',
|
||||||
|
subtitle: 'Choose how regular tabs are routed through proxies',
|
||||||
|
keywords: ['container', 'global'],
|
||||||
|
child: _RegularTabsModeSection(),
|
||||||
|
),
|
||||||
|
SettingsEntryDefinition(
|
||||||
|
title: 'Proxy for global routing',
|
||||||
|
subtitle: 'Selected proxy when global routing is enabled',
|
||||||
|
keywords: ['proxy'],
|
||||||
|
child: _GlobalRoutingProxySection(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
SettingsSectionDefinition(
|
||||||
|
title: 'Private Tabs',
|
||||||
|
keywords: ['private', 'incognito'],
|
||||||
|
entries: [
|
||||||
|
SettingsEntryDefinition(
|
||||||
|
title: 'Proxy for private tabs',
|
||||||
|
subtitle: 'Selected proxy that carries private-tab traffic',
|
||||||
|
keywords: ['proxy'],
|
||||||
|
child: _PrivateTabsProxySection(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
|
class ProxyRoutingSettingsScreen extends StatelessWidget {
|
||||||
|
const ProxyRoutingSettingsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return const SettingsDetailScaffold(
|
||||||
|
title: 'Proxy Routing',
|
||||||
|
subtitle: 'Choose which proxy carries regular and private tab traffic.',
|
||||||
|
icon: Icons.route_outlined,
|
||||||
|
sections: proxyRoutingSettingsSections,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _RegularTabsModeSection extends ConsumerWidget {
|
||||||
|
const _RegularTabsModeSection();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final settings = ref.watch(proxyRoutingSettingsWithDefaultsProvider);
|
||||||
|
|
||||||
|
return RadioGroup<ProxyRegularTabRoutingMode>(
|
||||||
|
groupValue: settings.regularTabsMode,
|
||||||
|
onChanged: (value) async {
|
||||||
|
if (value != null) {
|
||||||
|
await ref
|
||||||
|
.read(proxyRoutingSettingsRepositoryProvider.notifier)
|
||||||
|
.updateSettings(
|
||||||
|
(current) => current.copyWith(regularTabsMode: value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
child: const Column(
|
||||||
|
children: [
|
||||||
|
RadioListTile<ProxyRegularTabRoutingMode>.adaptive(
|
||||||
|
value: ProxyRegularTabRoutingMode.container,
|
||||||
|
title: Text('Container-Based Routing'),
|
||||||
|
subtitle: Text(
|
||||||
|
'Only tabs in containers with a proxy assigned are routed.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
RadioListTile<ProxyRegularTabRoutingMode>.adaptive(
|
||||||
|
value: ProxyRegularTabRoutingMode.all,
|
||||||
|
title: Text('Global Routing'),
|
||||||
|
subtitle: Text(
|
||||||
|
'Route every regular tab through the selected proxy.',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GlobalRoutingProxySection extends ConsumerWidget {
|
||||||
|
const _GlobalRoutingProxySection();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final settings = ref.watch(proxyRoutingSettingsWithDefaultsProvider);
|
||||||
|
if (settings.regularTabsMode != ProxyRegularTabRoutingMode.all) {
|
||||||
|
return const ListTile(
|
||||||
|
leading: Icon(Icons.info_outline),
|
||||||
|
title: Text('Not used in container-based routing'),
|
||||||
|
subtitle: Text(
|
||||||
|
'Switch to global routing above to pick the proxy that carries every regular tab.',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final options = ref.watch(proxyConnectionOptionsProvider);
|
||||||
|
return _ProxyConnectionPicker(
|
||||||
|
options: options,
|
||||||
|
selectedId: settings.regularTabsProxyConnectionId,
|
||||||
|
onChanged: (id) => ref
|
||||||
|
.read(proxyRoutingSettingsRepositoryProvider.notifier)
|
||||||
|
.updateSettings(
|
||||||
|
(current) => current.copyWith(regularTabsProxyConnectionId: id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _PrivateTabsProxySection extends ConsumerWidget {
|
||||||
|
const _PrivateTabsProxySection();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final settings = ref.watch(proxyRoutingSettingsWithDefaultsProvider);
|
||||||
|
final options = ref.watch(proxyConnectionOptionsProvider);
|
||||||
|
return _ProxyConnectionPicker(
|
||||||
|
options: options,
|
||||||
|
selectedId: settings.privateTabsProxyConnectionId,
|
||||||
|
onChanged: (id) => ref
|
||||||
|
.read(proxyRoutingSettingsRepositoryProvider.notifier)
|
||||||
|
.updateSettings(
|
||||||
|
(current) => current.copyWith(privateTabsProxyConnectionId: id),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProxyConnectionPicker extends StatelessWidget {
|
||||||
|
final List<ProxyConnectionOption> options;
|
||||||
|
final ProxyConnectionId? selectedId;
|
||||||
|
final ValueChanged<ProxyConnectionId?> onChanged;
|
||||||
|
|
||||||
|
const _ProxyConnectionPicker({
|
||||||
|
required this.options,
|
||||||
|
required this.selectedId,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final hasUnknownSelection =
|
||||||
|
selectedId != null && !options.any((option) => option.id == selectedId);
|
||||||
|
|
||||||
|
return RadioGroup<ProxyConnectionId?>(
|
||||||
|
groupValue: selectedId,
|
||||||
|
onChanged: onChanged,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const RadioListTile<ProxyConnectionId?>.adaptive(
|
||||||
|
value: null,
|
||||||
|
title: Text('None'),
|
||||||
|
subtitle: Text('Use the normal browser connection'),
|
||||||
|
secondary: Icon(Icons.public),
|
||||||
|
),
|
||||||
|
if (hasUnknownSelection)
|
||||||
|
ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
Icons.warning_amber_outlined,
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
title: const Text('Unknown proxy'),
|
||||||
|
subtitle: const Text('The selected proxy no longer exists.'),
|
||||||
|
trailing: TextButton(
|
||||||
|
onPressed: () => onChanged(null),
|
||||||
|
child: const Text('Clear'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
for (final option in options)
|
||||||
|
RadioListTile<ProxyConnectionId?>.adaptive(
|
||||||
|
value: option.id,
|
||||||
|
title: Text(option.title),
|
||||||
|
subtitle: Text(option.subtitle),
|
||||||
|
secondary: const Icon(Icons.route_outlined),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,231 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:intl/intl.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/models/proxy_log_message.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_logs.dart';
|
||||||
|
import 'package:weblibre/utils/ui_helper.dart';
|
||||||
|
|
||||||
|
class SingboxProxyLogsScreen extends HookConsumerWidget {
|
||||||
|
const SingboxProxyLogsScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final logs = ref.watch(singboxProxyLogsProvider);
|
||||||
|
final filter = useState<String?>(null);
|
||||||
|
final autoScroll = useState(true);
|
||||||
|
final scrollController = useScrollController();
|
||||||
|
|
||||||
|
// Stick to the bottom when new lines arrive — unless the user scrolled up.
|
||||||
|
// We coalesce scroll-to-bottom across rapid bursts via a pending flag so a
|
||||||
|
// chatty proxy can't fight the user trying to scroll up.
|
||||||
|
final pendingAutoScroll = useRef(false);
|
||||||
|
useEffect(() {
|
||||||
|
if (!autoScroll.value) return null;
|
||||||
|
if (pendingAutoScroll.value) return null;
|
||||||
|
pendingAutoScroll.value = true;
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
pendingAutoScroll.value = false;
|
||||||
|
if (!autoScroll.value) return;
|
||||||
|
if (scrollController.hasClients) {
|
||||||
|
scrollController.jumpTo(scrollController.position.maxScrollExtent);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return null;
|
||||||
|
}, [logs.length, autoScroll.value]);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
void onScroll() {
|
||||||
|
if (!scrollController.hasClients) return;
|
||||||
|
final atBottom =
|
||||||
|
scrollController.position.pixels >=
|
||||||
|
scrollController.position.maxScrollExtent - 24;
|
||||||
|
if (autoScroll.value != atBottom) {
|
||||||
|
autoScroll.value = atBottom;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
scrollController.addListener(onScroll);
|
||||||
|
return () => scrollController.removeListener(onScroll);
|
||||||
|
}, [scrollController]);
|
||||||
|
|
||||||
|
final filtered = filter.value == null
|
||||||
|
? logs
|
||||||
|
: logs.where((m) => m.level.toLowerCase() == filter.value).toList();
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(
|
||||||
|
title: const Text('Proxy Logs'),
|
||||||
|
actions: [
|
||||||
|
PopupMenuButton<String?>(
|
||||||
|
tooltip: 'Filter by level',
|
||||||
|
icon: const Icon(Icons.filter_list),
|
||||||
|
onSelected: (value) => filter.value = value,
|
||||||
|
itemBuilder: (context) => const [
|
||||||
|
PopupMenuItem<String?>(child: Text('All levels')),
|
||||||
|
PopupMenuItem(value: 'error', child: Text('Error')),
|
||||||
|
PopupMenuItem(value: 'warn', child: Text('Warning')),
|
||||||
|
PopupMenuItem(value: 'info', child: Text('Info')),
|
||||||
|
PopupMenuItem(value: 'debug', child: Text('Debug')),
|
||||||
|
PopupMenuItem(value: 'trace', child: Text('Trace')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Copy all',
|
||||||
|
icon: const Icon(Icons.copy_all),
|
||||||
|
onPressed: filtered.isEmpty
|
||||||
|
? null
|
||||||
|
: () async {
|
||||||
|
await Clipboard.setData(
|
||||||
|
ClipboardData(text: _formatLogs(filtered)),
|
||||||
|
);
|
||||||
|
if (context.mounted) {
|
||||||
|
showInfoMessage(context, 'Copied to clipboard');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Share',
|
||||||
|
icon: const Icon(Icons.share),
|
||||||
|
onPressed: filtered.isEmpty
|
||||||
|
? null
|
||||||
|
: () => SharePlus.instance.share(
|
||||||
|
ShareParams(
|
||||||
|
text: _formatLogs(filtered),
|
||||||
|
subject: 'proxy logs',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Clear',
|
||||||
|
icon: const Icon(Icons.delete_outline),
|
||||||
|
onPressed: logs.isEmpty
|
||||||
|
? null
|
||||||
|
: () => ref.read(singboxProxyLogsProvider.notifier).clear(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: filtered.isEmpty
|
||||||
|
? _EmptyLogs(hasFilter: filter.value != null)
|
||||||
|
: ListView.builder(
|
||||||
|
controller: scrollController,
|
||||||
|
itemCount: filtered.length,
|
||||||
|
itemBuilder: (context, index) =>
|
||||||
|
_LogLine(message: filtered[index]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LogLine extends StatelessWidget {
|
||||||
|
final ProxyLogMessage message;
|
||||||
|
|
||||||
|
const _LogLine({required this.message});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final color = switch (message.level.toLowerCase()) {
|
||||||
|
'error' || 'fatal' => scheme.error,
|
||||||
|
'warn' || 'warning' => scheme.tertiary,
|
||||||
|
_ => scheme.onSurface,
|
||||||
|
};
|
||||||
|
final time = DateFormat(
|
||||||
|
'HH:mm:ss',
|
||||||
|
).format(DateTime.fromMillisecondsSinceEpoch(message.timestamp));
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
|
||||||
|
child: SelectableText.rich(
|
||||||
|
TextSpan(
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
fontFamily: 'monospace',
|
||||||
|
color: color,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: '$time ',
|
||||||
|
style: TextStyle(color: scheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: '[${_sourceLabel(message.source)}] ',
|
||||||
|
style: TextStyle(color: scheme.primary),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: '[${message.level}] ',
|
||||||
|
style: const TextStyle(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
if (message.profileId != null)
|
||||||
|
TextSpan(
|
||||||
|
text: '${message.profileId} ',
|
||||||
|
style: TextStyle(color: scheme.primary),
|
||||||
|
),
|
||||||
|
TextSpan(text: message.message),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptyLogs extends StatelessWidget {
|
||||||
|
final bool hasFilter;
|
||||||
|
|
||||||
|
const _EmptyLogs({required this.hasFilter});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(
|
||||||
|
hasFilter
|
||||||
|
? 'No log lines match the current filter.'
|
||||||
|
: 'No log lines yet. Start a proxy or Tor to see output here.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _formatLogs(List<ProxyLogMessage> messages) {
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
for (final m in messages) {
|
||||||
|
final time = DateTime.fromMillisecondsSinceEpoch(
|
||||||
|
m.timestamp,
|
||||||
|
).toIso8601String();
|
||||||
|
buffer.writeln(
|
||||||
|
'$time [${_sourceLabel(m.source)}] [${m.level}]${m.profileId == null ? '' : ' (${m.profileId})'} ${m.message}',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
String _sourceLabel(ProxyLogSource source) {
|
||||||
|
return switch (source) {
|
||||||
|
ProxyLogSource.singBox => 'sing-box',
|
||||||
|
ProxyLogSource.tor => 'tor',
|
||||||
|
};
|
||||||
|
}
|
||||||
+265
@@ -0,0 +1,265 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:fading_scroll/fading_scroll.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/forms/singbox_form_specs.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/models/proxy_profile_seed.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/extensions/singbox_proxy_profile_type_x.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/controllers/proxy_profile_draft_controller.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/custom_outbound_profile_form.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/profile_dns_override_section.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/profile_editor_section.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/structured_profile_form.dart';
|
||||||
|
import 'package:weblibre/presentation/widgets/button_spinner.dart';
|
||||||
|
import 'package:weblibre/utils/ui_helper.dart';
|
||||||
|
|
||||||
|
class SingboxProxyProfileEditorScreen extends ConsumerWidget {
|
||||||
|
final String? profileId;
|
||||||
|
final ProxyProfileSeed? seed;
|
||||||
|
|
||||||
|
const SingboxProxyProfileEditorScreen({super.key, this.profileId, this.seed});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final draftProvider = proxyProfileDraftProvider(
|
||||||
|
profileId: profileId,
|
||||||
|
seed: seed,
|
||||||
|
);
|
||||||
|
final draft = ref.watch(draftProvider);
|
||||||
|
|
||||||
|
if (draft.isLoading) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Edit Profile')),
|
||||||
|
body: const Center(child: CircularProgressIndicator()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (draft.loadError != null) {
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Edit Profile')),
|
||||||
|
body: Center(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Text(draft.loadError!),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return _Editor(draftProvider: draftProvider, draft: draft);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _Editor extends ConsumerWidget {
|
||||||
|
final ProxyProfileDraftProvider draftProvider;
|
||||||
|
final ProxyProfileDraftState draft;
|
||||||
|
|
||||||
|
const _Editor({required this.draftProvider, required this.draft});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
Future<void> handleSave() async {
|
||||||
|
final outcome = await ref.read(draftProvider.notifier).save();
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
switch (outcome) {
|
||||||
|
case SaveSucceeded():
|
||||||
|
Navigator.pop(context);
|
||||||
|
case SaveFailed(:final message):
|
||||||
|
showErrorMessage(context, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
bottomNavigationBar: SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
|
child: FilledButton.icon(
|
||||||
|
onPressed: draft.isSaving ? null : handleSave,
|
||||||
|
icon: draft.isSaving
|
||||||
|
? const ButtonSpinner()
|
||||||
|
: const Icon(Icons.check),
|
||||||
|
style: FilledButton.styleFrom(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(16),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
label: Text(draft.isEditing ? 'Save Changes' : 'Create Profile'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
bottom: false,
|
||||||
|
child: FadingScroll(
|
||||||
|
fadingSize: 25,
|
||||||
|
builder: (context, controller) {
|
||||||
|
return CustomScrollView(
|
||||||
|
controller: controller,
|
||||||
|
slivers: [
|
||||||
|
SliverAppBar.large(
|
||||||
|
centerTitle: false,
|
||||||
|
title: Text(draft.isEditing ? 'Edit Profile' : 'New Profile'),
|
||||||
|
),
|
||||||
|
SliverPadding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 24),
|
||||||
|
sliver: SliverList(
|
||||||
|
delegate: SliverChildListDelegate.fixed([
|
||||||
|
ProfileEditorSection(
|
||||||
|
title: 'General',
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: _GeneralSection(
|
||||||
|
draftProvider: draftProvider,
|
||||||
|
draft: draft,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_ProtocolForm(draftProvider: draftProvider, draft: draft),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ProfileEditorSection(
|
||||||
|
title: 'DNS Override',
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 16),
|
||||||
|
child: ProfileDnsOverrideSection(
|
||||||
|
draftProvider: draftProvider,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (!draft.isEditing)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
child: Text(
|
||||||
|
'Tip: use the add menu on the previous screen to '
|
||||||
|
'import from a file, paste a share link, or scan '
|
||||||
|
'a QR code.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall
|
||||||
|
?.copyWith(color: scheme.onSurfaceVariant),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _GeneralSection extends HookConsumerWidget {
|
||||||
|
final ProxyProfileDraftProvider draftProvider;
|
||||||
|
final ProxyProfileDraftState draft;
|
||||||
|
|
||||||
|
const _GeneralSection({required this.draftProvider, required this.draft});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final nameController = useTextEditingController(text: draft.name);
|
||||||
|
useEffect(() {
|
||||||
|
if (nameController.text != draft.name) {
|
||||||
|
nameController.text = draft.name;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [draft.name]);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: nameController,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Profile Name',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onChanged: ref.read(draftProvider.notifier).setName,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
if (draft.isEditing)
|
||||||
|
// Protocol is locked after creation: each type stores a different
|
||||||
|
// config/secret JSON shape, so switching mid-edit would silently
|
||||||
|
// rewrite the profile under a foreign schema. To change protocol,
|
||||||
|
// create a new profile.
|
||||||
|
InputDecorator(
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Protocol',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
helperText: 'Protocol is fixed once a profile is created.',
|
||||||
|
),
|
||||||
|
child: Text(draft.type.label),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
DropdownButtonFormField<SingboxProxyProfileType>(
|
||||||
|
key: ValueKey(draft.type),
|
||||||
|
initialValue: draft.type,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Protocol',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
items: [
|
||||||
|
for (final type in SingboxProxyProfileType.values)
|
||||||
|
DropdownMenuItem(value: type, child: Text(type.label)),
|
||||||
|
],
|
||||||
|
onChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
ref.read(draftProvider.notifier).setType(value);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProtocolForm extends StatelessWidget {
|
||||||
|
final ProxyProfileDraftProvider draftProvider;
|
||||||
|
final ProxyProfileDraftState draft;
|
||||||
|
|
||||||
|
const _ProtocolForm({required this.draftProvider, required this.draft});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final spec = singboxProxyFormSpecs[draft.type];
|
||||||
|
if (spec != null) {
|
||||||
|
return StructuredProfileForm(
|
||||||
|
key: ValueKey((draft.type, draft.profileId)),
|
||||||
|
spec: spec,
|
||||||
|
draftProvider: draftProvider,
|
||||||
|
draft: draft,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return CustomOutboundProfileForm(
|
||||||
|
key: ValueKey(('custom', draft.profileId)),
|
||||||
|
draftProvider: draftProvider,
|
||||||
|
draft: draft,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
import 'package:fading_scroll/fading_scroll.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/core/logger.dart';
|
||||||
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/services/proxy_latency_tester.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/widgets/add_proxy_method_sheet.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/profile_tile.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/status_header.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/tor_tile.dart';
|
||||||
|
import 'package:weblibre/features/tor/domain/extensions/tor_status_x.dart';
|
||||||
|
import 'package:weblibre/features/tor/domain/services/tor_proxy.dart';
|
||||||
|
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
|
||||||
|
show ProxyProfile;
|
||||||
|
import 'package:weblibre/utils/ui_helper.dart';
|
||||||
|
|
||||||
|
class SingboxProxyProfilesScreen extends HookConsumerWidget {
|
||||||
|
const SingboxProxyProfilesScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final profilesAsync = ref.watch(singboxProxyProfilesRepositoryProvider);
|
||||||
|
final runtimeState = ref.watch(singboxProxyRuntimeRepositoryProvider);
|
||||||
|
final torState = ref.watch(torProxyServiceProvider);
|
||||||
|
final deletingProfileIds = useState(<String>{});
|
||||||
|
|
||||||
|
final activeProfileIds = _activeProfileIds(runtimeState);
|
||||||
|
final runtimeBusy = runtimeState.isLoading;
|
||||||
|
final torIsRunning = torState.value?.isRunning ?? false;
|
||||||
|
final torIsBusy = torState.isBusy;
|
||||||
|
|
||||||
|
// Drop cached latency results for profiles that are no longer running so a
|
||||||
|
// stale "120 ms" chip can't outlive its connection.
|
||||||
|
ref.listen(singboxProxyRuntimeRepositoryProvider, (_, _) {
|
||||||
|
_pruneLatencyCache(ref);
|
||||||
|
});
|
||||||
|
ref.listen(torProxyServiceProvider, (_, _) {
|
||||||
|
_pruneLatencyCache(ref);
|
||||||
|
});
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
onPressed: () => unawaited(_showAddSheet(context)),
|
||||||
|
icon: const Icon(Icons.add),
|
||||||
|
label: const Text('Add Profile'),
|
||||||
|
),
|
||||||
|
body: SafeArea(
|
||||||
|
child: FadingScroll(
|
||||||
|
fadingSize: 25,
|
||||||
|
builder: (context, controller) {
|
||||||
|
return CustomScrollView(
|
||||||
|
controller: controller,
|
||||||
|
slivers: [
|
||||||
|
SliverAppBar.large(
|
||||||
|
centerTitle: false,
|
||||||
|
title: const Text('Proxy Connections'),
|
||||||
|
actions: [
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'View logs',
|
||||||
|
icon: const Icon(Icons.subject),
|
||||||
|
onPressed: () =>
|
||||||
|
const SingboxProxyLogsRoute().push(context),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
...profilesAsync.when(
|
||||||
|
data: (profiles) {
|
||||||
|
// Prune ids whose profile was deleted (or otherwise
|
||||||
|
// disappeared) so the set can't grow unbounded if a tile
|
||||||
|
// is unmounted while its delete is still in flight.
|
||||||
|
final liveProfileIds = {
|
||||||
|
for (final profile in profiles) profile.id,
|
||||||
|
};
|
||||||
|
final pruned = deletingProfileIds.value.intersection(
|
||||||
|
liveProfileIds,
|
||||||
|
);
|
||||||
|
if (pruned.length != deletingProfileIds.value.length) {
|
||||||
|
// Schedule for the next frame to avoid mutating state
|
||||||
|
// during build.
|
||||||
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
|
deletingProfileIds.value = pruned;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
_ProfileListBody(
|
||||||
|
profiles: profiles,
|
||||||
|
activeProfileIds: activeProfileIds,
|
||||||
|
deletingProfileIds: pruned,
|
||||||
|
runtimeBusy: runtimeBusy,
|
||||||
|
torIsRunning: torIsRunning,
|
||||||
|
torIsBusy: torIsBusy,
|
||||||
|
onDeletingChanged: (id, deleting) {
|
||||||
|
final next = {...deletingProfileIds.value};
|
||||||
|
if (deleting) {
|
||||||
|
next.add(id);
|
||||||
|
} else {
|
||||||
|
next.remove(id);
|
||||||
|
}
|
||||||
|
deletingProfileIds.value = next;
|
||||||
|
},
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
loading: () => const [
|
||||||
|
SliverFillRemaining(
|
||||||
|
child: Center(child: CircularProgressIndicator()),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
error: (error, stackTrace) {
|
||||||
|
logger.e(
|
||||||
|
'Failed to load singbox proxy profiles',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
return [
|
||||||
|
SliverFillRemaining(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(24),
|
||||||
|
child: Center(
|
||||||
|
child: Text(
|
||||||
|
'Failed to load proxy profiles:\n$error',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<ProxyConnectionId> _activeConnectionIds(
|
||||||
|
AsyncValue<SingboxProxyRuntimeState> runtimeState,
|
||||||
|
) {
|
||||||
|
return runtimeState.asData?.value.endpoints
|
||||||
|
.map((endpoint) => ProxyConnectionId.decode(endpoint.profileId))
|
||||||
|
.nonNulls
|
||||||
|
.toSet() ??
|
||||||
|
const <ProxyConnectionId>{};
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<String> _activeProfileIds(
|
||||||
|
AsyncValue<SingboxProxyRuntimeState> runtimeState,
|
||||||
|
) {
|
||||||
|
return _activeConnectionIds(
|
||||||
|
runtimeState,
|
||||||
|
).whereType<SingboxProxyConnectionId>().map((id) => id.profileId).toSet();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _pruneLatencyCache(WidgetRef ref) {
|
||||||
|
final runtimeState = ref.read(singboxProxyRuntimeRepositoryProvider);
|
||||||
|
final torRunning =
|
||||||
|
ref.read(torProxyServiceProvider).value?.isRunning ?? false;
|
||||||
|
ref.read(proxyLatencyResultsProvider.notifier).retainRunning({
|
||||||
|
..._activeConnectionIds(runtimeState),
|
||||||
|
if (torRunning) const TorProxyConnectionId(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _showAddSheet(BuildContext context) async {
|
||||||
|
final action = await showModalBottomSheet<AddProxyAction>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (_) => const AddProxyMethodSheet(),
|
||||||
|
);
|
||||||
|
if (action == null) return;
|
||||||
|
if (!context.mounted) return;
|
||||||
|
switch (action) {
|
||||||
|
case AddProxyManual():
|
||||||
|
await const SingboxProxyProfileEditorRoute().push(context);
|
||||||
|
case AddProxySubscription():
|
||||||
|
await const SubscriptionImportRoute().push(context);
|
||||||
|
case AddProxyWithSeed(:final seed):
|
||||||
|
await SingboxProxyProfileEditorRoute($extra: seed).push(context);
|
||||||
|
case AddProxyImported(:final message):
|
||||||
|
showInfoMessage(context, message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ProfileListBody extends ConsumerWidget {
|
||||||
|
final List<ProxyProfile> profiles;
|
||||||
|
final Set<String> activeProfileIds;
|
||||||
|
final Set<String> deletingProfileIds;
|
||||||
|
final bool runtimeBusy;
|
||||||
|
final bool torIsRunning;
|
||||||
|
final bool torIsBusy;
|
||||||
|
final void Function(String id, bool deleting) onDeletingChanged;
|
||||||
|
|
||||||
|
const _ProfileListBody({
|
||||||
|
required this.profiles,
|
||||||
|
required this.activeProfileIds,
|
||||||
|
required this.deletingProfileIds,
|
||||||
|
required this.runtimeBusy,
|
||||||
|
required this.torIsRunning,
|
||||||
|
required this.torIsBusy,
|
||||||
|
required this.onDeletingChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final sorted = [...profiles]
|
||||||
|
..sort((a, b) {
|
||||||
|
final aRunning = activeProfileIds.contains(a.id);
|
||||||
|
final bRunning = activeProfileIds.contains(b.id);
|
||||||
|
if (aRunning == bRunning) return 0;
|
||||||
|
return aRunning ? -1 : 1;
|
||||||
|
});
|
||||||
|
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final totalRunning = activeProfileIds.length + (torIsRunning ? 1 : 0);
|
||||||
|
final totalCount = profiles.length + 1;
|
||||||
|
|
||||||
|
Future<void> stopAll() async {
|
||||||
|
await ref.read(singboxProxyRuntimeRepositoryProvider.notifier).stopAll();
|
||||||
|
ref.read(proxyLatencyResultsProvider.notifier).retainRunning(const {});
|
||||||
|
if (torIsRunning) {
|
||||||
|
await ref.read(torProxyServiceProvider.notifier).disconnect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return SliverList.list(
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 12, 16, 0),
|
||||||
|
child: StatusHeader(
|
||||||
|
totalCount: totalCount,
|
||||||
|
runningCount: totalRunning,
|
||||||
|
isBusy: runtimeBusy || torIsBusy,
|
||||||
|
onStopAll: totalRunning == 0 ? null : stopAll,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 24, 16, 12),
|
||||||
|
child: Text(
|
||||||
|
'Profiles',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
color: scheme.primary,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 96),
|
||||||
|
child: Card.filled(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
color: scheme.surfaceContainer,
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: Column(
|
||||||
|
children: [
|
||||||
|
TorProfileTile(isRunning: torIsRunning, isBusy: torIsBusy),
|
||||||
|
for (final profile in sorted) ...[
|
||||||
|
const Divider(height: 1),
|
||||||
|
ProfileTile(
|
||||||
|
profile: profile,
|
||||||
|
isRunning: activeProfileIds.contains(profile.id),
|
||||||
|
isDeleting: deletingProfileIds.contains(profile.id),
|
||||||
|
runtimeBusy: runtimeBusy,
|
||||||
|
onDeletingChanged: onDeletingChanged,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/core/logger.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/forms/singbox_form_specs.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_profiles.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/services/subscription_importer.dart';
|
||||||
|
import 'package:weblibre/presentation/widgets/button_spinner.dart';
|
||||||
|
import 'package:weblibre/utils/ui_helper.dart';
|
||||||
|
|
||||||
|
class SubscriptionImportScreen extends HookConsumerWidget {
|
||||||
|
const SubscriptionImportScreen({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final urlController = useTextEditingController();
|
||||||
|
final hasUrl = useListenableSelector(
|
||||||
|
urlController,
|
||||||
|
() => urlController.text.trim().isNotEmpty,
|
||||||
|
);
|
||||||
|
final fetching = useState(false);
|
||||||
|
final result = useState<SubscriptionImportResult?>(null);
|
||||||
|
final selection = useState(<int>{});
|
||||||
|
final fetchError = useState<String?>(null);
|
||||||
|
final isImporting = useState(false);
|
||||||
|
|
||||||
|
Future<void> fetch() async {
|
||||||
|
final raw = urlController.text.trim();
|
||||||
|
if (raw.isEmpty) return;
|
||||||
|
final uri = Uri.tryParse(raw);
|
||||||
|
if (uri == null || !uri.hasScheme) {
|
||||||
|
fetchError.value = 'Enter a full https:// subscription URL.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetching.value = true;
|
||||||
|
fetchError.value = null;
|
||||||
|
try {
|
||||||
|
final outcome = await fetchSubscription(uri);
|
||||||
|
result.value = outcome;
|
||||||
|
selection.value = {
|
||||||
|
for (final (index, entry) in outcome.entries.indexed)
|
||||||
|
if (entry is SubscriptionEntrySuccess) index,
|
||||||
|
};
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
logger.e(
|
||||||
|
'Failed to fetch subscription from $uri',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
fetchError.value = error.toString();
|
||||||
|
result.value = null;
|
||||||
|
} finally {
|
||||||
|
if (context.mounted) fetching.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> importSelected() async {
|
||||||
|
final outcome = result.value;
|
||||||
|
if (outcome == null) return;
|
||||||
|
isImporting.value = true;
|
||||||
|
var imported = 0;
|
||||||
|
try {
|
||||||
|
final notifier = ref.read(
|
||||||
|
singboxProxyProfilesRepositoryProvider.notifier,
|
||||||
|
);
|
||||||
|
for (final (index, entry) in outcome.entries.indexed) {
|
||||||
|
if (!selection.value.contains(index)) continue;
|
||||||
|
if (entry is! SubscriptionEntrySuccess) continue;
|
||||||
|
|
||||||
|
final parsed = entry.imported;
|
||||||
|
final spec = singboxProxyFormSpecs[parsed.type];
|
||||||
|
if (spec == null) continue;
|
||||||
|
await notifier.createProfile(
|
||||||
|
name: parsed.name ?? 'Imported ${imported + 1}',
|
||||||
|
type: parsed.type,
|
||||||
|
configJson: spec.toConfigJson(parsed.values),
|
||||||
|
secretJson: spec.toSecretJson(parsed.values),
|
||||||
|
);
|
||||||
|
imported++;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (context.mounted) isImporting.value = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (context.mounted) {
|
||||||
|
showInfoMessage(context, 'Imported $imported profile(s)');
|
||||||
|
Navigator.of(context).pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
appBar: AppBar(title: const Text('Import Subscription')),
|
||||||
|
body: ListView(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
children: [
|
||||||
|
TextField(
|
||||||
|
controller: urlController,
|
||||||
|
keyboardType: TextInputType.url,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'Subscription URL',
|
||||||
|
hintText: 'https://example.com/sub',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
'Supports the v2rayN-style format: a base64-encoded list of '
|
||||||
|
'ss://, vless://, vmess://, trojan://, hysteria2://, tuic:// '
|
||||||
|
'and similar URIs. Routing rules from the subscription are '
|
||||||
|
'ignored — only proxy nodes are imported.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: !hasUrl || fetching.value ? null : fetch,
|
||||||
|
icon: fetching.value
|
||||||
|
? const ButtonSpinner()
|
||||||
|
: const Icon(Icons.cloud_download_outlined),
|
||||||
|
label: const Text('Fetch'),
|
||||||
|
),
|
||||||
|
if (fetchError.value != null) ...[
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
fetchError.value!,
|
||||||
|
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
if (result.value != null) ...[
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
_ResultsSection(
|
||||||
|
result: result.value!,
|
||||||
|
selectedIndices: selection.value,
|
||||||
|
isImporting: isImporting.value,
|
||||||
|
onSelectionChanged: (next) => selection.value = next,
|
||||||
|
onImport: importSelected,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _ResultsSection extends StatelessWidget {
|
||||||
|
final SubscriptionImportResult result;
|
||||||
|
final Set<int> selectedIndices;
|
||||||
|
final bool isImporting;
|
||||||
|
final ValueChanged<Set<int>> onSelectionChanged;
|
||||||
|
final Future<void> Function() onImport;
|
||||||
|
|
||||||
|
const _ResultsSection({
|
||||||
|
required this.result,
|
||||||
|
required this.selectedIndices,
|
||||||
|
required this.isImporting,
|
||||||
|
required this.onSelectionChanged,
|
||||||
|
required this.onImport,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final successCount = result.successes.length;
|
||||||
|
final failureCount = result.failures.length;
|
||||||
|
|
||||||
|
void selectAll() {
|
||||||
|
onSelectionChanged({
|
||||||
|
for (final (index, entry) in result.entries.indexed)
|
||||||
|
if (entry is SubscriptionEntrySuccess) index,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggle(int index, bool selected) {
|
||||||
|
final next = {...selectedIndices};
|
||||||
|
if (selected) {
|
||||||
|
next.add(index);
|
||||||
|
} else {
|
||||||
|
next.remove(index);
|
||||||
|
}
|
||||||
|
onSelectionChanged(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'$successCount usable node(s)'
|
||||||
|
'${failureCount > 0 ? ', $failureCount failed' : ''}',
|
||||||
|
style: Theme.of(context).textTheme.titleSmall,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: successCount == 0 ? null : selectAll,
|
||||||
|
child: const Text('Select all'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => onSelectionChanged(const {}),
|
||||||
|
child: const Text('Clear'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(),
|
||||||
|
for (final (index, entry) in result.entries.indexed)
|
||||||
|
_EntryTile(
|
||||||
|
entry: entry,
|
||||||
|
selected: selectedIndices.contains(index),
|
||||||
|
onChanged: entry is SubscriptionEntrySuccess
|
||||||
|
? (checked) => toggle(index, checked ?? false)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
FilledButton.icon(
|
||||||
|
onPressed: isImporting || selectedIndices.isEmpty ? null : onImport,
|
||||||
|
icon: isImporting
|
||||||
|
? const ButtonSpinner()
|
||||||
|
: const Icon(Icons.download_done),
|
||||||
|
label: Text('Import ${selectedIndices.length} profile(s)'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EntryTile extends StatelessWidget {
|
||||||
|
final SubscriptionImportEntry entry;
|
||||||
|
final bool selected;
|
||||||
|
final ValueChanged<bool?>? onChanged;
|
||||||
|
|
||||||
|
const _EntryTile({
|
||||||
|
required this.entry,
|
||||||
|
required this.selected,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return switch (entry) {
|
||||||
|
SubscriptionEntrySuccess(:final imported) => CheckboxListTile(
|
||||||
|
value: selected,
|
||||||
|
onChanged: onChanged,
|
||||||
|
title: Text(imported.name ?? entry.rawLine),
|
||||||
|
subtitle: Text(
|
||||||
|
imported.type.name,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
dense: true,
|
||||||
|
),
|
||||||
|
SubscriptionEntryFailure(:final error) => ListTile(
|
||||||
|
leading: Icon(
|
||||||
|
Icons.error_outline,
|
||||||
|
color: Theme.of(context).colorScheme.error,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
entry.rawLine,
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
error is FormatException ? error.message : error.toString(),
|
||||||
|
),
|
||||||
|
dense: true,
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,340 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:file_picker/file_picker.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/models/proxy_profile_seed.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/services/proxy_input_consumer.dart';
|
||||||
|
import 'package:weblibre/features/qr_scanner/presentation/dialogs/qr_scanner_dialog.dart';
|
||||||
|
import 'package:weblibre/utils/ui_helper.dart';
|
||||||
|
|
||||||
|
/// Outcome of the add-proxy bottom sheet. The sheet itself does not navigate
|
||||||
|
/// or surface success messages: it pops with one of these so the caller can
|
||||||
|
/// drive navigation from a stable, non-deactivated context.
|
||||||
|
sealed class AddProxyAction {
|
||||||
|
const AddProxyAction();
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddProxyManual extends AddProxyAction {
|
||||||
|
const AddProxyManual();
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddProxySubscription extends AddProxyAction {
|
||||||
|
const AddProxySubscription();
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddProxyWithSeed extends AddProxyAction {
|
||||||
|
final ProxyProfileSeed seed;
|
||||||
|
const AddProxyWithSeed(this.seed);
|
||||||
|
}
|
||||||
|
|
||||||
|
class AddProxyImported extends AddProxyAction {
|
||||||
|
final String message;
|
||||||
|
const AddProxyImported(this.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Guided bottom sheet shown when the user adds a new proxy profile. Each
|
||||||
|
/// method either pops with an [AddProxyAction] for the caller to apply or
|
||||||
|
/// stays open so the user can try another method on error.
|
||||||
|
class AddProxyMethodSheet extends ConsumerWidget {
|
||||||
|
const AddProxyMethodSheet({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
void popWith(AddProxyAction action) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
Navigator.of(context).pop(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> scanQr() async {
|
||||||
|
final result = await showDialog<Barcode>(
|
||||||
|
context: context,
|
||||||
|
builder: (_) => const QrScannerDialog(),
|
||||||
|
);
|
||||||
|
final code = result?.code?.trim();
|
||||||
|
if (code == null || code.isEmpty) return;
|
||||||
|
if (!context.mounted) return;
|
||||||
|
final action = await _consumeRawText(context, ref, code);
|
||||||
|
if (action == null) return;
|
||||||
|
popWith(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> pasteClipboard() async {
|
||||||
|
final data = await Clipboard.getData(Clipboard.kTextPlain);
|
||||||
|
final text = data?.text?.trim();
|
||||||
|
if (text == null || text.isEmpty) {
|
||||||
|
if (context.mounted) {
|
||||||
|
showInfoMessage(context, 'Clipboard is empty.');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!context.mounted) return;
|
||||||
|
final action = await _consumeRawText(context, ref, text);
|
||||||
|
if (action == null) return;
|
||||||
|
popWith(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> importFromFile() async {
|
||||||
|
final kind = await showModalBottomSheet<ProxyFileImportKind>(
|
||||||
|
context: context,
|
||||||
|
showDragHandle: true,
|
||||||
|
builder: (_) => const _FileKindPicker(),
|
||||||
|
);
|
||||||
|
if (kind == null) return;
|
||||||
|
if (!context.mounted) return;
|
||||||
|
final action = await _consumeFile(context, ref, kind);
|
||||||
|
if (action == null) return;
|
||||||
|
popWith(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(24, 8, 24, 24),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
'Add Connection',
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w600),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
'Choose how you want to add a proxy profile.',
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||||
|
color: scheme.onSurfaceVariant,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
GridView.count(
|
||||||
|
crossAxisCount: 2,
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
mainAxisSpacing: 12,
|
||||||
|
crossAxisSpacing: 12,
|
||||||
|
childAspectRatio: 1.25,
|
||||||
|
children: [
|
||||||
|
_MethodCard(
|
||||||
|
icon: Icons.content_paste,
|
||||||
|
title: 'Clipboard',
|
||||||
|
subtitle: 'Paste share link or URI',
|
||||||
|
onTap: pasteClipboard,
|
||||||
|
isPrimary: true,
|
||||||
|
),
|
||||||
|
_MethodCard(
|
||||||
|
icon: Icons.qr_code_scanner,
|
||||||
|
title: 'Scan QR',
|
||||||
|
subtitle: 'From another device',
|
||||||
|
onTap: scanQr,
|
||||||
|
),
|
||||||
|
_MethodCard(
|
||||||
|
icon: Icons.cloud_download_outlined,
|
||||||
|
title: 'Subscription',
|
||||||
|
subtitle: 'Fetch from URL',
|
||||||
|
onTap: () => popWith(const AddProxySubscription()),
|
||||||
|
),
|
||||||
|
_MethodCard(
|
||||||
|
icon: Icons.upload_file_outlined,
|
||||||
|
title: 'Import file',
|
||||||
|
subtitle: '.conf or sing-box JSON',
|
||||||
|
onTap: importFromFile,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Center(
|
||||||
|
child: TextButton.icon(
|
||||||
|
onPressed: () => popWith(const AddProxyManual()),
|
||||||
|
icon: const Icon(Icons.edit_note),
|
||||||
|
label: const Text('Enter manually'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MethodCard extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
final bool isPrimary;
|
||||||
|
|
||||||
|
const _MethodCard({
|
||||||
|
required this.icon,
|
||||||
|
required this.title,
|
||||||
|
required this.subtitle,
|
||||||
|
required this.onTap,
|
||||||
|
this.isPrimary = false,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final background = isPrimary
|
||||||
|
? scheme.primaryContainer
|
||||||
|
: scheme.surfaceContainerHigh;
|
||||||
|
final iconColor = isPrimary ? scheme.onPrimaryContainer : scheme.primary;
|
||||||
|
final titleColor = isPrimary ? scheme.onPrimaryContainer : scheme.onSurface;
|
||||||
|
final subtitleColor = isPrimary
|
||||||
|
? scheme.onPrimaryContainer.withValues(alpha: 0.75)
|
||||||
|
: scheme.onSurfaceVariant;
|
||||||
|
|
||||||
|
return Material(
|
||||||
|
color: background,
|
||||||
|
borderRadius: BorderRadius.circular(20),
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: InkWell(
|
||||||
|
onTap: onTap,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(icon, size: 28, color: iconColor),
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: titleColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
subtitle,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall?.copyWith(color: subtitleColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _FileKindPicker extends StatelessWidget {
|
||||||
|
const _FileKindPicker();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return SafeArea(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(8, 0, 8, 12),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
'Import from file',
|
||||||
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.vpn_lock),
|
||||||
|
title: const Text('WireGuard config'),
|
||||||
|
subtitle: const Text('.conf file with [Interface]/[Peer]'),
|
||||||
|
onTap: () =>
|
||||||
|
Navigator.of(context).pop(ProxyFileImportKind.wireguardConf),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.data_object),
|
||||||
|
title: const Text('Sing-box outbound JSON'),
|
||||||
|
subtitle: const Text(
|
||||||
|
'Shadowsocks, Trojan, VMess, VLESS, Hysteria, …',
|
||||||
|
),
|
||||||
|
onTap: () => Navigator.of(
|
||||||
|
context,
|
||||||
|
).pop(ProxyFileImportKind.singboxOutboundJson),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AddProxyAction?> _consumeFile(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
ProxyFileImportKind kind,
|
||||||
|
) async {
|
||||||
|
final result = await FilePicker.pickFiles(withData: true);
|
||||||
|
final picked = result?.files.singleOrNull;
|
||||||
|
if (picked == null) return null;
|
||||||
|
|
||||||
|
final outcome = await ref
|
||||||
|
.read(proxyInputConsumerProvider.notifier)
|
||||||
|
.consumeFile(kind, picked);
|
||||||
|
if (!context.mounted) return null;
|
||||||
|
return _actionFromOutcome(context, outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<AddProxyAction?> _consumeRawText(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
String rawText,
|
||||||
|
) async {
|
||||||
|
final outcome = await ref
|
||||||
|
.read(proxyInputConsumerProvider.notifier)
|
||||||
|
.consumeRawText(rawText);
|
||||||
|
if (!context.mounted) return null;
|
||||||
|
return _actionFromOutcome(context, outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
AddProxyAction? _actionFromOutcome(
|
||||||
|
BuildContext context,
|
||||||
|
ProxyInputOutcome outcome,
|
||||||
|
) {
|
||||||
|
switch (outcome) {
|
||||||
|
case ProxyInputImported(:final created):
|
||||||
|
return AddProxyImported('Imported profile "${created.name}"');
|
||||||
|
case ProxyInputSeed(:final seed):
|
||||||
|
return AddProxyWithSeed(seed);
|
||||||
|
case ProxyInputError(:final message):
|
||||||
|
showErrorMessage(context, message);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/controllers/proxy_profile_draft_controller.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/profile_editor_section.dart';
|
||||||
|
import 'package:weblibre/presentation/widgets/obscurable_text_field.dart';
|
||||||
|
|
||||||
|
class CustomOutboundProfileForm extends HookConsumerWidget {
|
||||||
|
final ProxyProfileDraftProvider draftProvider;
|
||||||
|
final ProxyProfileDraftState draft;
|
||||||
|
|
||||||
|
const CustomOutboundProfileForm({
|
||||||
|
super.key,
|
||||||
|
required this.draftProvider,
|
||||||
|
required this.draft,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final configController = useTextEditingController(
|
||||||
|
text: draft.customConfigJson,
|
||||||
|
);
|
||||||
|
final secretController = useTextEditingController(
|
||||||
|
text: draft.customSecretJson,
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
if (configController.text != draft.customConfigJson) {
|
||||||
|
configController.text = draft.customConfigJson;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [draft.customConfigJson]);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
if (secretController.text != draft.customSecretJson) {
|
||||||
|
secretController.text = draft.customSecretJson;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [draft.customSecretJson]);
|
||||||
|
|
||||||
|
final notifier = ref.read(draftProvider.notifier);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
ProfileEditorSection(
|
||||||
|
title: 'Outbound',
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: TextField(
|
||||||
|
controller: configController,
|
||||||
|
minLines: 10,
|
||||||
|
maxLines: 18,
|
||||||
|
keyboardType: TextInputType.multiline,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
labelText: 'Outbound JSON',
|
||||||
|
helperText: 'Public sing-box outbound object.',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onChanged: notifier.setCustomConfigJson,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
ProfileEditorSection(
|
||||||
|
title: 'Secrets',
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: ObscurableTextField(
|
||||||
|
controller: secretController,
|
||||||
|
enabled: draft.secretLoaded,
|
||||||
|
revealedMinLines: 4,
|
||||||
|
revealedMaxLines: 10,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
alignLabelWithHint: true,
|
||||||
|
labelText: 'Secret JSON',
|
||||||
|
helperText:
|
||||||
|
'Optional values merged into the outbound at runtime.',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onChanged: notifier.setCustomSecretJson,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+118
@@ -0,0 +1,118 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'dart:convert';
|
||||||
|
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/controllers/proxy_profile_draft_controller.dart';
|
||||||
|
import 'package:weblibre/features/user/data/models/proxy_dns_override.dart';
|
||||||
|
|
||||||
|
/// Per-profile DNS override editor. Keeps the UI surface minimal: a switch to
|
||||||
|
/// opt in, plus the most common shape (single resolver routed through *this*
|
||||||
|
/// profile).
|
||||||
|
class ProfileDnsOverrideSection extends HookConsumerWidget {
|
||||||
|
final ProxyProfileDraftProvider draftProvider;
|
||||||
|
|
||||||
|
const ProfileDnsOverrideSection({super.key, required this.draftProvider});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final overrideJson = ref.watch(
|
||||||
|
draftProvider.select((state) => state.dnsOverrideJson),
|
||||||
|
);
|
||||||
|
final initialOverride = useMemoized(() {
|
||||||
|
if (overrideJson == null || overrideJson.trim().isEmpty) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
return ProxyDnsOverride.fromJson(
|
||||||
|
jsonDecode(overrideJson) as Map<String, dynamic>,
|
||||||
|
);
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}, [overrideJson]);
|
||||||
|
|
||||||
|
final enabled = useState(initialOverride != null);
|
||||||
|
final addressController = useTextEditingController(
|
||||||
|
text: initialOverride?.remoteServerAddress ?? '',
|
||||||
|
);
|
||||||
|
|
||||||
|
// Reseed controls when the parent passes a new override (e.g. the
|
||||||
|
// WireGuard form populating DNS from an imported `[Interface] DNS = …`).
|
||||||
|
useEffect(() {
|
||||||
|
enabled.value = initialOverride != null;
|
||||||
|
final next = initialOverride?.remoteServerAddress ?? '';
|
||||||
|
if (addressController.text != next) {
|
||||||
|
addressController.text = next;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}, [initialOverride]);
|
||||||
|
|
||||||
|
void emitChange() {
|
||||||
|
if (!enabled.value) {
|
||||||
|
ref.read(draftProvider.notifier).setDnsOverrideJson(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
final override = ProxyDnsOverride(
|
||||||
|
remoteServerAddress: addressController.text.trim().isEmpty
|
||||||
|
? null
|
||||||
|
: addressController.text.trim(),
|
||||||
|
);
|
||||||
|
ref
|
||||||
|
.read(draftProvider.notifier)
|
||||||
|
.setDnsOverrideJson(jsonEncode(override.toJson()));
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
'Resolve names through a server reachable inside this profile '
|
||||||
|
'(e.g. an internal DoH server behind a corporate WireGuard). '
|
||||||
|
'Leave off to use automatic DNS handling.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
SwitchListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
value: enabled.value,
|
||||||
|
onChanged: (value) {
|
||||||
|
enabled.value = value;
|
||||||
|
emitChange();
|
||||||
|
},
|
||||||
|
title: const Text('Use a profile-specific resolver'),
|
||||||
|
),
|
||||||
|
if (enabled.value) ...[
|
||||||
|
const SizedBox(height: 8),
|
||||||
|
TextField(
|
||||||
|
controller: addressController,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
labelText: 'DNS server address',
|
||||||
|
hintText: 'https://10.0.0.1/dns-query',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onChanged: (_) => emitChange(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class ProfileEditorSection extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final Widget child;
|
||||||
|
|
||||||
|
const ProfileEditorSection({
|
||||||
|
super.key,
|
||||||
|
required this.title,
|
||||||
|
required this.child,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
title,
|
||||||
|
style: Theme.of(context).textTheme.titleSmall?.copyWith(
|
||||||
|
color: scheme.primary,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Card.filled(
|
||||||
|
margin: EdgeInsets.zero,
|
||||||
|
color: scheme.surfaceContainer,
|
||||||
|
clipBehavior: Clip.antiAlias,
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+297
@@ -0,0 +1,297 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/forms/singbox_form_field.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/forms/singbox_form_spec.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/controllers/proxy_profile_draft_controller.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/widgets/profile_editor/profile_editor_section.dart';
|
||||||
|
import 'package:weblibre/presentation/widgets/obscurable_text_field.dart';
|
||||||
|
|
||||||
|
class StructuredProfileForm extends HookConsumerWidget {
|
||||||
|
final SingboxProxyFormSpec spec;
|
||||||
|
final ProxyProfileDraftProvider draftProvider;
|
||||||
|
final ProxyProfileDraftState draft;
|
||||||
|
|
||||||
|
const StructuredProfileForm({
|
||||||
|
super.key,
|
||||||
|
required this.spec,
|
||||||
|
required this.draftProvider,
|
||||||
|
required this.draft,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final controllers = useMemoized(
|
||||||
|
() => {
|
||||||
|
for (final field in spec.fields) field.key: TextEditingController(),
|
||||||
|
},
|
||||||
|
[spec.type],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
return () {
|
||||||
|
for (final controller in controllers.values) {
|
||||||
|
controller.dispose();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [controllers]);
|
||||||
|
|
||||||
|
useEffect(() {
|
||||||
|
_syncControllers(controllers, draft.values);
|
||||||
|
return null;
|
||||||
|
}, [controllers, draft.values]);
|
||||||
|
|
||||||
|
final sections = _structuredFieldSections(spec.fields);
|
||||||
|
final notifier = ref.read(draftProvider.notifier);
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
for (final (sectionIndex, section) in sections.indexed) ...[
|
||||||
|
if (sectionIndex > 0) const SizedBox(height: 24),
|
||||||
|
ProfileEditorSection(
|
||||||
|
title: section.title,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: _SectionFields(
|
||||||
|
fields: section.fields,
|
||||||
|
controllers: controllers,
|
||||||
|
secretLoaded: draft.secretLoaded,
|
||||||
|
onChanged: notifier.setFieldValue,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
const SizedBox(height: 12),
|
||||||
|
Text(
|
||||||
|
'Advanced protocol options can still be entered with Custom Outbound JSON.',
|
||||||
|
style: Theme.of(context).textTheme.bodySmall,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _SectionFields extends StatelessWidget {
|
||||||
|
final List<SingboxProxyFormField> fields;
|
||||||
|
final Map<String, TextEditingController> controllers;
|
||||||
|
final bool secretLoaded;
|
||||||
|
final void Function(String key, String value) onChanged;
|
||||||
|
|
||||||
|
const _SectionFields({
|
||||||
|
required this.fields,
|
||||||
|
required this.controllers,
|
||||||
|
required this.secretLoaded,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
for (final (index, field) in fields.indexed) ...[
|
||||||
|
if (index > 0) const SizedBox(height: 16),
|
||||||
|
if (field.isBoolean)
|
||||||
|
_BooleanField(
|
||||||
|
field: field,
|
||||||
|
controller: controllers[field.key]!,
|
||||||
|
onChanged: (value) => onChanged(field.key, value),
|
||||||
|
)
|
||||||
|
else if (field.isSecret)
|
||||||
|
ObscurableTextField(
|
||||||
|
controller: controllers[field.key],
|
||||||
|
enabled: secretLoaded,
|
||||||
|
keyboardType: field.isNumber
|
||||||
|
? TextInputType.number
|
||||||
|
: TextInputType.text,
|
||||||
|
textInputAction: index == fields.length - 1
|
||||||
|
? TextInputAction.done
|
||||||
|
: TextInputAction.next,
|
||||||
|
revealedMinLines: field.key == 'private_key' ? 4 : null,
|
||||||
|
revealedMaxLines: field.key == 'private_key' ? 8 : 1,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: field.required ? '${field.label} *' : field.label,
|
||||||
|
helperText: field.helperText ?? 'Stored in secure storage.',
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onChanged: (value) => onChanged(field.key, value),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
TextField(
|
||||||
|
controller: controllers[field.key],
|
||||||
|
keyboardType: field.isNumber
|
||||||
|
? TextInputType.number
|
||||||
|
: field.isStringList
|
||||||
|
? TextInputType.multiline
|
||||||
|
: TextInputType.text,
|
||||||
|
textInputAction: field.isStringList
|
||||||
|
? TextInputAction.newline
|
||||||
|
: index == fields.length - 1
|
||||||
|
? TextInputAction.done
|
||||||
|
: TextInputAction.next,
|
||||||
|
minLines: field.isStringList ? 2 : 1,
|
||||||
|
maxLines: field.isStringList ? 4 : 1,
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: field.required ? '${field.label} *' : field.label,
|
||||||
|
helperText: field.helperText,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
onChanged: (value) => onChanged(field.key, value),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
({String title, List<SingboxProxyFormField> fields}) _section(
|
||||||
|
String title,
|
||||||
|
List<SingboxProxyFormField> fields,
|
||||||
|
) {
|
||||||
|
return (title: title, fields: fields);
|
||||||
|
}
|
||||||
|
|
||||||
|
List<({String title, List<SingboxProxyFormField> fields})>
|
||||||
|
_structuredFieldSections(List<SingboxProxyFormField> fields) {
|
||||||
|
final basic = <SingboxProxyFormField>[];
|
||||||
|
final tls = <SingboxProxyFormField>[];
|
||||||
|
final transport = <SingboxProxyFormField>[];
|
||||||
|
final multiplex = <SingboxProxyFormField>[];
|
||||||
|
final dial = <SingboxProxyFormField>[];
|
||||||
|
final secrets = <SingboxProxyFormField>[];
|
||||||
|
final protocol = <SingboxProxyFormField>[];
|
||||||
|
|
||||||
|
for (final field in fields) {
|
||||||
|
if (field.key.startsWith('tls.')) {
|
||||||
|
tls.add(field);
|
||||||
|
} else if (field.key.startsWith('transport.')) {
|
||||||
|
transport.add(field);
|
||||||
|
} else if (field.key.startsWith('multiplex.')) {
|
||||||
|
multiplex.add(field);
|
||||||
|
} else if (_dialFieldKeys.contains(field.key)) {
|
||||||
|
dial.add(field);
|
||||||
|
} else if (field.isSecret) {
|
||||||
|
secrets.add(field);
|
||||||
|
} else if (_basicFieldKeys.contains(field.key)) {
|
||||||
|
basic.add(field);
|
||||||
|
} else {
|
||||||
|
protocol.add(field);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
if (basic.isNotEmpty) _section('Connection', basic),
|
||||||
|
if (secrets.isNotEmpty) _section('Credentials', secrets),
|
||||||
|
if (protocol.isNotEmpty) _section('Protocol Options', protocol),
|
||||||
|
if (tls.isNotEmpty) _section('TLS', tls),
|
||||||
|
if (transport.isNotEmpty) _section('Transport', transport),
|
||||||
|
if (multiplex.isNotEmpty) _section('Multiplex', multiplex),
|
||||||
|
if (dial.isNotEmpty) _section('Dial', dial),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
const _basicFieldKeys = {
|
||||||
|
'server',
|
||||||
|
'server_port',
|
||||||
|
'version',
|
||||||
|
'local_address',
|
||||||
|
'peer_public_key',
|
||||||
|
};
|
||||||
|
const _dialFieldKeys = {
|
||||||
|
'detour',
|
||||||
|
'bind_interface',
|
||||||
|
'routing_mark',
|
||||||
|
'domain_strategy',
|
||||||
|
'connect_timeout',
|
||||||
|
};
|
||||||
|
|
||||||
|
void _syncControllers(
|
||||||
|
Map<String, TextEditingController> controllers,
|
||||||
|
Map<String, String> values,
|
||||||
|
) {
|
||||||
|
for (final entry in controllers.entries) {
|
||||||
|
final next = values[entry.key] ?? '';
|
||||||
|
if (entry.value.text != next) {
|
||||||
|
entry.value.text = next;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _BooleanField extends HookWidget {
|
||||||
|
final SingboxProxyFormField field;
|
||||||
|
final TextEditingController controller;
|
||||||
|
final ValueChanged<String> onChanged;
|
||||||
|
|
||||||
|
const _BooleanField({
|
||||||
|
required this.field,
|
||||||
|
required this.controller,
|
||||||
|
required this.onChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final value = useListenableSelector(
|
||||||
|
controller,
|
||||||
|
() => parseFormBool(controller.text),
|
||||||
|
);
|
||||||
|
|
||||||
|
return InputDecorator(
|
||||||
|
decoration: InputDecoration(
|
||||||
|
labelText: field.required ? '${field.label} *' : field.label,
|
||||||
|
helperText: field.helperText,
|
||||||
|
helperMaxLines: 3,
|
||||||
|
border: const OutlineInputBorder(),
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
value == null
|
||||||
|
? 'Unset (uses default)'
|
||||||
|
: (value ? 'Enabled' : 'Disabled'),
|
||||||
|
style: Theme.of(context).textTheme.bodyMedium,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (value != null)
|
||||||
|
IconButton(
|
||||||
|
tooltip: 'Clear',
|
||||||
|
icon: const Icon(Icons.clear, size: 18),
|
||||||
|
onPressed: () {
|
||||||
|
controller.text = '';
|
||||||
|
onChanged('');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
Switch(
|
||||||
|
value: value ?? false,
|
||||||
|
onChanged: (next) {
|
||||||
|
final text = next ? 'true' : 'false';
|
||||||
|
controller.text = text;
|
||||||
|
onChanged(text);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class IpChip extends StatelessWidget {
|
||||||
|
final String ip;
|
||||||
|
|
||||||
|
const IpChip({super.key, required this.ip});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
|
||||||
|
return Tooltip(
|
||||||
|
message: 'Egress IP $ip',
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: scheme.secondaryContainer,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
ip,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: scheme.onSecondaryContainer,
|
||||||
|
fontFeatures: const [FontFeature.tabularFigures()],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/services/proxy_latency_tester.dart';
|
||||||
|
|
||||||
|
class LatencyChip extends StatelessWidget {
|
||||||
|
final AsyncValue<ProxyLatencyData> result;
|
||||||
|
|
||||||
|
const LatencyChip({super.key, required this.result});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return switch (result) {
|
||||||
|
AsyncLoading() => const _LatencyStatusChip.loading(),
|
||||||
|
AsyncError(:final error) => _LatencyStatusChip.error(error),
|
||||||
|
AsyncData(:final value) => _LatencySuccessChip(value: value),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LatencyStatusChip extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String tooltip;
|
||||||
|
final bool isError;
|
||||||
|
|
||||||
|
const _LatencyStatusChip({
|
||||||
|
required this.label,
|
||||||
|
required this.tooltip,
|
||||||
|
required this.isError,
|
||||||
|
});
|
||||||
|
|
||||||
|
const _LatencyStatusChip.loading()
|
||||||
|
: this(
|
||||||
|
label: 'Testing...',
|
||||||
|
tooltip: 'Latency test running',
|
||||||
|
isError: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
_LatencyStatusChip.error(Object error)
|
||||||
|
: this(label: 'Failed', tooltip: error.toString(), isError: true);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
return _LatencyChipContainer(
|
||||||
|
label: label,
|
||||||
|
tooltip: tooltip,
|
||||||
|
backgroundColor: isError
|
||||||
|
? scheme.errorContainer
|
||||||
|
: scheme.surfaceContainerHighest,
|
||||||
|
foregroundColor: isError
|
||||||
|
? scheme.onErrorContainer
|
||||||
|
: scheme.onSurfaceVariant,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LatencySuccessChip extends StatelessWidget {
|
||||||
|
final ProxyLatencyData value;
|
||||||
|
|
||||||
|
const _LatencySuccessChip({required this.value});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final (background, foreground) = _latencyColors(scheme, value.latency);
|
||||||
|
|
||||||
|
return _LatencyChipContainer(
|
||||||
|
label: '${value.latency.inMilliseconds} ms',
|
||||||
|
tooltip: 'HTTP ${value.statusCode} in ${value.latency.inMilliseconds} ms',
|
||||||
|
backgroundColor: background,
|
||||||
|
foregroundColor: foreground,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static (Color, Color) _latencyColors(ColorScheme scheme, Duration latency) {
|
||||||
|
final ms = latency.inMilliseconds;
|
||||||
|
if (ms < 500) return (scheme.primaryContainer, scheme.onPrimaryContainer);
|
||||||
|
if (ms < 1500) {
|
||||||
|
return (scheme.tertiaryContainer, scheme.onTertiaryContainer);
|
||||||
|
}
|
||||||
|
return (scheme.errorContainer, scheme.onErrorContainer);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _LatencyChipContainer extends StatelessWidget {
|
||||||
|
final String label;
|
||||||
|
final String tooltip;
|
||||||
|
final Color backgroundColor;
|
||||||
|
final Color foregroundColor;
|
||||||
|
|
||||||
|
const _LatencyChipContainer({
|
||||||
|
required this.label,
|
||||||
|
required this.tooltip,
|
||||||
|
required this.backgroundColor,
|
||||||
|
required this.foregroundColor,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Tooltip(
|
||||||
|
message: tooltip,
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: backgroundColor,
|
||||||
|
borderRadius: BorderRadius.circular(10),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
label,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.labelSmall?.copyWith(color: foregroundColor),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class MenuRow extends StatelessWidget {
|
||||||
|
final IconData icon;
|
||||||
|
final String label;
|
||||||
|
|
||||||
|
const MenuRow({super.key, required this.icon, required this.label});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [Icon(icon, size: 20), const SizedBox(width: 12), Text(label)],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/services/proxy_latency_tester.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/ip_chip.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/latency_chip.dart';
|
||||||
|
|
||||||
|
class ProfileSubtitle extends StatelessWidget {
|
||||||
|
final String typeLabel;
|
||||||
|
final AsyncValue<ProxyLatencyData>? latency;
|
||||||
|
|
||||||
|
const ProfileSubtitle({
|
||||||
|
super.key,
|
||||||
|
required this.typeLabel,
|
||||||
|
required this.latency,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final latency = this.latency;
|
||||||
|
if (latency == null) {
|
||||||
|
return Text(typeLabel);
|
||||||
|
}
|
||||||
|
|
||||||
|
final egressIp = latency.value?.egressIp;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text(typeLabel),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 4,
|
||||||
|
crossAxisAlignment: WrapCrossAlignment.center,
|
||||||
|
children: [
|
||||||
|
LatencyChip(result: latency),
|
||||||
|
if (egressIp != null) IpChip(ip: egressIp),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,249 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:weblibre/core/logger.dart';
|
||||||
|
import 'package:weblibre/core/routing/routes.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/models/proxy_share.dart';
|
||||||
|
import 'package:weblibre/features/proxy/data/proxy_connection.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/extensions/singbox_proxy_profile_type_x.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_credentials.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/repositories/singbox_proxy_runtime.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/services/proxy_latency_tester.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/menu_row.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/profile_subtitle.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/protocol_badge.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/run_switch.dart';
|
||||||
|
import 'package:weblibre/features/proxy/presentation/widgets/profile_list/share_profile_dialog.dart';
|
||||||
|
import 'package:weblibre/features/user/data/database/definitions.drift.dart'
|
||||||
|
show ProxyProfile;
|
||||||
|
import 'package:weblibre/utils/ui_helper.dart';
|
||||||
|
|
||||||
|
enum ProfileAction { edit, testLatency, share, delete }
|
||||||
|
|
||||||
|
class ProfileTile extends ConsumerWidget {
|
||||||
|
final ProxyProfile profile;
|
||||||
|
final bool isRunning;
|
||||||
|
final bool isDeleting;
|
||||||
|
final bool runtimeBusy;
|
||||||
|
final void Function(String profileId, bool deleting) onDeletingChanged;
|
||||||
|
|
||||||
|
const ProfileTile({
|
||||||
|
super.key,
|
||||||
|
required this.profile,
|
||||||
|
required this.isRunning,
|
||||||
|
required this.isDeleting,
|
||||||
|
required this.runtimeBusy,
|
||||||
|
required this.onDeletingChanged,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isBusy = runtimeBusy || isDeleting;
|
||||||
|
final latencyResult = ref.watch(
|
||||||
|
proxyLatencyResultsProvider.select(
|
||||||
|
(map) => map[SingboxProxyConnectionId(profile.id)],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||||||
|
leading: isDeleting
|
||||||
|
? const SizedBox.square(
|
||||||
|
dimension: 36,
|
||||||
|
child: Padding(
|
||||||
|
padding: EdgeInsets.all(6),
|
||||||
|
child: CircularProgressIndicator(strokeWidth: 2),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: ProtocolBadge(type: profile.type, active: isRunning),
|
||||||
|
title: Text(
|
||||||
|
profile.name,
|
||||||
|
style: TextStyle(
|
||||||
|
fontWeight: isRunning ? FontWeight.w600 : FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
subtitle: ProfileSubtitle(
|
||||||
|
typeLabel: profile.type.label,
|
||||||
|
latency: latencyResult,
|
||||||
|
),
|
||||||
|
trailing: Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
PopupMenuButton<ProfileAction>(
|
||||||
|
enabled: !isBusy,
|
||||||
|
onSelected: (action) => _onAction(context, ref, action),
|
||||||
|
itemBuilder: (context) => [
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: ProfileAction.edit,
|
||||||
|
child: MenuRow(icon: Icons.edit_outlined, label: 'Edit'),
|
||||||
|
),
|
||||||
|
PopupMenuItem(
|
||||||
|
value: ProfileAction.testLatency,
|
||||||
|
enabled: isRunning,
|
||||||
|
child: MenuRow(
|
||||||
|
icon: latencyResult is AsyncLoading
|
||||||
|
? Icons.hourglass_bottom
|
||||||
|
: Icons.network_check,
|
||||||
|
label: 'Test connection',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: ProfileAction.share,
|
||||||
|
child: MenuRow(icon: Icons.share_outlined, label: 'Share'),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: ProfileAction.delete,
|
||||||
|
child: MenuRow(icon: Icons.delete_outline, label: 'Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
RunSwitch(
|
||||||
|
isRunning: isRunning,
|
||||||
|
disabled: isBusy,
|
||||||
|
onTap: () => _toggleRunState(context, ref),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
onTap: () =>
|
||||||
|
SingboxProxyProfileEditorRoute(profileId: profile.id).push(context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleRunState(BuildContext context, WidgetRef ref) async {
|
||||||
|
try {
|
||||||
|
final notifier = ref.read(singboxProxyRuntimeRepositoryProvider.notifier);
|
||||||
|
if (isRunning) {
|
||||||
|
await notifier.stopProfiles([profile.id]);
|
||||||
|
ref
|
||||||
|
.read(proxyLatencyResultsProvider.notifier)
|
||||||
|
.clear(SingboxProxyConnectionId(profile.id));
|
||||||
|
} else {
|
||||||
|
await notifier.startProfile(profile.id);
|
||||||
|
}
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
logger.e(
|
||||||
|
'Failed to toggle singbox proxy run state for ${profile.id}',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
if (context.mounted) {
|
||||||
|
showErrorMessage(
|
||||||
|
context,
|
||||||
|
isRunning
|
||||||
|
? 'Failed to stop proxy: $error'
|
||||||
|
: 'Failed to start proxy: $error',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _onAction(
|
||||||
|
BuildContext context,
|
||||||
|
WidgetRef ref,
|
||||||
|
ProfileAction action,
|
||||||
|
) async {
|
||||||
|
switch (action) {
|
||||||
|
case ProfileAction.edit:
|
||||||
|
await _handleEdit(context);
|
||||||
|
case ProfileAction.testLatency:
|
||||||
|
await _handleTestLatency(ref);
|
||||||
|
case ProfileAction.share:
|
||||||
|
await _handleShare(context, ref);
|
||||||
|
case ProfileAction.delete:
|
||||||
|
await _handleDelete(context, ref);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleEdit(BuildContext context) {
|
||||||
|
return SingboxProxyProfileEditorRoute(profileId: profile.id).push(context);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleTestLatency(WidgetRef ref) {
|
||||||
|
return ref.read(proxyLatencyResultsProvider.notifier).test(profile.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleShare(BuildContext context, WidgetRef ref) async {
|
||||||
|
final secret = await ref
|
||||||
|
.read(singboxProxyCredentialsRepositoryProvider.notifier)
|
||||||
|
.readSecretJson(profile.id);
|
||||||
|
final shareUri = encodeProxyShareUri(
|
||||||
|
ProxyShareEnvelope(
|
||||||
|
name: profile.name,
|
||||||
|
type: profile.type,
|
||||||
|
configJson: profile.configJson,
|
||||||
|
secretJson: secret,
|
||||||
|
dnsOverrideJson: profile.dnsOverrideJson,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
await showDialog<void>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) =>
|
||||||
|
ShareProfileDialog(profileName: profile.name, shareUri: shareUri),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _handleDelete(BuildContext context, WidgetRef ref) async {
|
||||||
|
final confirmed = await showDialog<bool>(
|
||||||
|
context: context,
|
||||||
|
builder: (context) => AlertDialog(
|
||||||
|
title: const Text('Delete Profile?'),
|
||||||
|
content: Text(
|
||||||
|
isRunning
|
||||||
|
? 'Stop ${profile.name}, then delete it and its stored secrets? Tabs and containers assigned to this profile will be blocked until you choose another proxy or clear the assignment.'
|
||||||
|
: 'Delete ${profile.name} and its stored secrets? Tabs and containers assigned to this profile will be blocked until you choose another proxy or clear the assignment.',
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context, false),
|
||||||
|
child: const Text('Cancel'),
|
||||||
|
),
|
||||||
|
FilledButton(
|
||||||
|
onPressed: () => Navigator.pop(context, true),
|
||||||
|
child: Text(isRunning ? 'Stop and Delete' : 'Delete'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (confirmed != true) return;
|
||||||
|
|
||||||
|
onDeletingChanged(profile.id, true);
|
||||||
|
try {
|
||||||
|
await ref
|
||||||
|
.read(singboxProxyRuntimeRepositoryProvider.notifier)
|
||||||
|
.deleteProfile(profile.id);
|
||||||
|
ref
|
||||||
|
.read(proxyLatencyResultsProvider.notifier)
|
||||||
|
.clear(SingboxProxyConnectionId(profile.id));
|
||||||
|
} catch (error, stackTrace) {
|
||||||
|
logger.e(
|
||||||
|
'Failed to delete singbox proxy profile ${profile.id}',
|
||||||
|
error: error,
|
||||||
|
stackTrace: stackTrace,
|
||||||
|
);
|
||||||
|
if (context.mounted) {
|
||||||
|
showErrorMessage(context, 'Failed to delete profile: $error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (context.mounted) onDeletingChanged(profile.id, false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+56
@@ -0,0 +1,56 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter_singbox_proxy/flutter_singbox_proxy.dart';
|
||||||
|
import 'package:weblibre/features/proxy/domain/extensions/singbox_proxy_profile_type_x.dart';
|
||||||
|
|
||||||
|
class ProtocolBadge extends StatelessWidget {
|
||||||
|
final SingboxProxyProfileType type;
|
||||||
|
final bool active;
|
||||||
|
|
||||||
|
const ProtocolBadge({super.key, required this.type, required this.active});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final background = active
|
||||||
|
? scheme.primary.withValues(alpha: 0.15)
|
||||||
|
: scheme.surfaceContainerHighest;
|
||||||
|
final foreground = active ? scheme.primary : scheme.onSurfaceVariant;
|
||||||
|
|
||||||
|
return Container(
|
||||||
|
width: 44,
|
||||||
|
height: 44,
|
||||||
|
alignment: Alignment.center,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: background,
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
),
|
||||||
|
child: Text(
|
||||||
|
type.badge,
|
||||||
|
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||||
|
color: foreground,
|
||||||
|
fontWeight: FontWeight.w700,
|
||||||
|
letterSpacing: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
class RunSwitch extends StatelessWidget {
|
||||||
|
final bool isRunning;
|
||||||
|
final bool disabled;
|
||||||
|
final VoidCallback onTap;
|
||||||
|
|
||||||
|
const RunSwitch({
|
||||||
|
super.key,
|
||||||
|
required this.isRunning,
|
||||||
|
required this.disabled,
|
||||||
|
required this.onTap,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final scheme = Theme.of(context).colorScheme;
|
||||||
|
final background = isRunning
|
||||||
|
? scheme.primary
|
||||||
|
: scheme.surfaceContainerHighest;
|
||||||
|
final foreground = isRunning ? scheme.onPrimary : scheme.onSurface;
|
||||||
|
|
||||||
|
return IconButton.filled(
|
||||||
|
tooltip: isRunning ? 'Stop' : 'Start',
|
||||||
|
onPressed: disabled ? null : onTap,
|
||||||
|
style: IconButton.styleFrom(
|
||||||
|
backgroundColor: background,
|
||||||
|
foregroundColor: foreground,
|
||||||
|
disabledBackgroundColor: scheme.surfaceContainerHighest.withValues(
|
||||||
|
alpha: 0.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
icon: Icon(isRunning ? Icons.stop_rounded : Icons.play_arrow_rounded),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
@@ -0,0 +1,114 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2024-2026 Fabian Freund.
|
||||||
|
*
|
||||||
|
* This file is part of WebLibre
|
||||||
|
* (see https://weblibre.eu).
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU Affero General Public License as
|
||||||
|
* published by the Free Software Foundation, either version 3 of the
|
||||||
|
* License, or (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU Affero General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
|
* along with this program. If not, see <http://www.gnu.org/licenses/>.
|
||||||
|
*/
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
|
import 'package:share_plus/share_plus.dart';
|
||||||
|
import 'package:weblibre/utils/ui_helper.dart';
|
||||||
|
|
||||||
|
class ShareProfileDialog extends StatelessWidget {
|
||||||
|
final String profileName;
|
||||||
|
final String shareUri;
|
||||||
|
|
||||||
|
const ShareProfileDialog({
|
||||||
|
super.key,
|
||||||
|
required this.profileName,
|
||||||
|
required this.shareUri,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return AlertDialog(
|
||||||
|
title: Text('Share "$profileName"'),
|
||||||
|
content: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
|
children: [
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.errorContainer,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.warning_amber_outlined,
|
||||||
|
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
'This link contains the full profile, including any '
|
||||||
|
'stored credentials. Share carefully.',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Theme.of(context).colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.all(8),
|
||||||
|
constraints: const BoxConstraints(maxHeight: 160),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Theme.of(context).colorScheme.surfaceContainerHigh,
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: SingleChildScrollView(
|
||||||
|
child: SelectableText(
|
||||||
|
shareUri,
|
||||||
|
style: Theme.of(
|
||||||
|
context,
|
||||||
|
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
TextButton.icon(
|
||||||
|
icon: const Icon(Icons.copy),
|
||||||
|
label: const Text('Copy'),
|
||||||
|
onPressed: () async {
|
||||||
|
await Clipboard.setData(ClipboardData(text: shareUri));
|
||||||
|
if (context.mounted) {
|
||||||
|
showInfoMessage(context, 'Copied to clipboard');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
TextButton.icon(
|
||||||
|
icon: const Icon(Icons.share),
|
||||||
|
label: const Text('Share'),
|
||||||
|
onPressed: () async {
|
||||||
|
await SharePlus.instance.share(
|
||||||
|
ShareParams(text: shareUri, subject: profileName),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => Navigator.pop(context),
|
||||||
|
child: const Text('Close'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user