mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-09-13 08:00:39 +00:00
feat(magisk): add module WebUI configuration (#2563)
* feat(magisk): add module WebUI configuration Reuse the existing config generator in KernelSU-compatible module managers. Validate and atomically persist TOML through a module helper before restarting EasyTier, and package the generated assets in CI. Closes #1915 * fix(magisk): preserve existing WebUI configuration Merge form-managed fields into the original TOML so advanced module settings remain intact. Resolve the running core by its exact executable path before restart.
This commit is contained in:
@@ -61,7 +61,6 @@ jobs:
|
||||
with:
|
||||
filters: |
|
||||
relevant: ${{ env.RELEVANT_PATHS }}
|
||||
|
||||
build_web:
|
||||
runs-on: ubuntu-latest
|
||||
needs: pre_job
|
||||
@@ -342,13 +341,22 @@ jobs:
|
||||
name: easytier-linux-aarch64
|
||||
path: ./downloaded-binaries/ # 独立目录避免冲突
|
||||
|
||||
# 将二进制文件复制到 Magisk 模块目录
|
||||
- name: Prepare binaries
|
||||
- name: Download Magisk WebUI
|
||||
uses: actions/download-artifact@v4
|
||||
with:
|
||||
name: easytier-web-dashboard
|
||||
path: ./downloaded-webui/
|
||||
|
||||
# 将二进制文件和 WebUI 复制到 Magisk 模块目录
|
||||
- name: Prepare Magisk module
|
||||
run: |
|
||||
mkdir -p ./easytier-contrib/easytier-magisk/
|
||||
cp ./downloaded-binaries/easytier-core ./easytier-contrib/easytier-magisk/
|
||||
cp ./downloaded-binaries/easytier-cli ./easytier-contrib/easytier-magisk/
|
||||
cp ./downloaded-binaries/easytier-web ./easytier-contrib/easytier-magisk/
|
||||
module_dir=./easytier-contrib/easytier-magisk
|
||||
mkdir -p "$module_dir/webroot"
|
||||
cp ./downloaded-binaries/easytier-core "$module_dir/"
|
||||
cp ./downloaded-binaries/easytier-cli "$module_dir/"
|
||||
cp ./downloaded-binaries/easytier-web "$module_dir/"
|
||||
cp -R ./downloaded-webui/config-generator/. "$module_dir/webroot/"
|
||||
test -f "$module_dir/webroot/index.html"
|
||||
|
||||
# 上传生成的模块
|
||||
- name: Upload Magisk Module
|
||||
|
||||
@@ -1,6 +1,23 @@
|
||||
# easytier_magisk版模块
|
||||
magisk安装后重启
|
||||
# EasyTier Magisk 模块
|
||||
|
||||
目录位置:/data/adb/modules/easytier_magisk
|
||||
配置文件位置://data/adb/modules/easytier_magisk/config/config.toml
|
||||
修改config.conf即可,修改后配置文件后去magisk app重新开关模块即可生效
|
||||
安装模块并重启后,EasyTier 会读取
|
||||
`/data/adb/modules/easytier_magisk/config/config.toml` 启动。
|
||||
|
||||
## WebUI
|
||||
|
||||
KernelSU 及支持模块 WebUI 的分支管理器可直接从模块详情页打开配置
|
||||
界面。Magisk 用户可安装兼容 KernelSU 模块 WebUI 的独立管理器后打开。
|
||||
|
||||
WebUI 使用 EasyTier 自身的配置模型加载和校验配置。点击“保存并重启”后,
|
||||
模块会先验证配置,原子替换 `config.toml`,再重启正在运行的 EasyTier
|
||||
进程。校验失败时原配置保持不变。
|
||||
|
||||
如果 `config/command_args` 存在,模块处于启动参数模式并会忽略
|
||||
`config.toml`。WebUI 会显示提示并禁止保存;删除 `command_args` 后即可
|
||||
使用 WebUI 管理配置。
|
||||
|
||||
## 手动配置
|
||||
|
||||
也可以直接编辑
|
||||
`/data/adb/modules/easytier_magisk/config/config.toml`。保存后在模块管理器中
|
||||
禁用 EasyTier,等待 10 秒后重新启用即可让配置生效。
|
||||
|
||||
@@ -1,25 +1,32 @@
|
||||
#!/bin/sh
|
||||
WEBUI_SOURCE="../../easytier-web/config-generator/dist"
|
||||
|
||||
version=$(cat module.prop | grep 'version=' | awk -F '=' '{print $2}' | sed 's/ (.*//')
|
||||
if [ -f "${WEBUI_SOURCE}/index.html" ]; then
|
||||
rm -rf ./webroot
|
||||
mkdir -p ./webroot
|
||||
cp -R "${WEBUI_SOURCE}/." ./webroot/
|
||||
elif [ ! -f "./webroot/index.html" ]; then
|
||||
echo "Error: WebUI 构建产物不存在,请先运行 pnpm --dir ../../easytier-web/config-generator build."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
version='v'$(grep '^version =' ../../easytier/Cargo.toml | cut -d '"' -f 2)
|
||||
version=$(grep '^version =' ../../easytier/Cargo.toml | cut -d '"' -f 2)
|
||||
|
||||
if [ -z "$version" ]; then
|
||||
echo "Error: 版本号不存在."
|
||||
exit 1
|
||||
fi
|
||||
version="v${version}"
|
||||
|
||||
filename="easytier_magisk_${version}.zip"
|
||||
echo $version
|
||||
echo "${version}"
|
||||
|
||||
|
||||
if [ -f "./easytier-core" ] && [ -f "./easytier-cli" ] && [ -f "./easytier-web" ]; then
|
||||
zip -r -o -X "$filename" ./ -x '.git/*' -x '.github/*' -x 'folder/*' -x 'build.sh' -x 'magisk_update.json'
|
||||
else
|
||||
wget -O "easytier_last.zip" https://github.com/EasyTier/EasyTier/releases/download/"$version"/easytier-linux-aarch64-"$version".zip
|
||||
if [ ! -f "./easytier-core" ] || [ ! -f "./easytier-cli" ] || [ ! -f "./easytier-web" ]; then
|
||||
wget -O "easytier_last.zip" "https://github.com/EasyTier/EasyTier/releases/download/${version}/easytier-linux-aarch64-${version}.zip"
|
||||
unzip -o easytier_last.zip -d ./
|
||||
mv ./easytier-linux-aarch64/* ./
|
||||
rm -rf ./easytier_last.zip
|
||||
rm -rf ./easytier-linux-aarch64
|
||||
zip -r -o -X "$filename" ./ -x '.git/*' -x '.github/*' -x 'folder/*' -x 'build.sh' -x 'magisk_update.json'
|
||||
fi
|
||||
fi
|
||||
|
||||
zip -r -o -X "${filename}" ./ -x '.git/*' -x '.github/*' -x 'folder/*' -x 'build.sh' -x 'magisk_update.json'
|
||||
@@ -16,7 +16,7 @@ rpc_portal = "0.0.0.0:15888"
|
||||
network_name = "default"
|
||||
network_secret = ""
|
||||
|
||||
[[peer]]
|
||||
#[[peer]]
|
||||
#uri = "协议://中转ip:端口"
|
||||
|
||||
[flags]
|
||||
@@ -29,7 +29,7 @@ latency_first = false
|
||||
enable_exit_node = false
|
||||
no_tun = false
|
||||
use_smoltcp = false
|
||||
foreign_network_whitelist = "*"
|
||||
relay_network_whitelist = "*"
|
||||
disable_p2p = false
|
||||
relay_all_peer_rpc = false
|
||||
disable_udp_hole_punching = false
|
||||
|
||||
@@ -11,6 +11,7 @@ ui_print "EasyTier 安装位置:/data/adb/modules/easytier_magisk"
|
||||
ui_print "配置文件位置:/data/adb/modules/easytier_magisk/config/config.toml"
|
||||
ui_print "如需使用启动参数模式,请将 /data/adb/modules/easytier_magisk/config/command_args_sample 重命名为 command_args,并修改其中的内容"
|
||||
ui_print "config 目录中存在 command_args 文件时,模块会自动忽略 config.toml 文件"
|
||||
ui_print "支持模块 WebUI 的管理器可直接打开配置界面;Magisk 用户可配合 KsuWebUI 使用"
|
||||
ui_print "----------------------------------"
|
||||
ui_print "注意!启动参数文件中不能存在 \" 和 ',配置文件则没有这个限制"
|
||||
ui_print "----------------------------------"
|
||||
|
||||
Executable
+77
@@ -0,0 +1,77 @@
|
||||
#!/system/bin/sh
|
||||
|
||||
MODDIR=$(CDPATH= cd "${0%/*}" 2>/dev/null && pwd -P) || {
|
||||
echo "Failed to resolve module directory" >&2
|
||||
exit 1
|
||||
}
|
||||
CONFIG_FILE="${MODDIR}/config/config.toml"
|
||||
COMMAND_ARGS="${MODDIR}/config/command_args"
|
||||
EASYTIER="${MODDIR}/easytier-core"
|
||||
|
||||
fail() {
|
||||
echo "$1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
running_pids() {
|
||||
for process_dir in /proc/[0-9]*; do
|
||||
[ "$(readlink "${process_dir}/exe" 2>/dev/null)" = "${EASYTIER}" ] || continue
|
||||
printf '%s\n' "${process_dir#/proc/}"
|
||||
done
|
||||
}
|
||||
|
||||
is_running() {
|
||||
[ -n "$(running_pids)" ]
|
||||
}
|
||||
|
||||
case "$1" in
|
||||
status)
|
||||
if is_running; then
|
||||
echo "running"
|
||||
else
|
||||
echo "stopped"
|
||||
fi
|
||||
;;
|
||||
config-mode)
|
||||
if [ -f "${COMMAND_ARGS}" ]; then
|
||||
echo "command-args"
|
||||
else
|
||||
echo "toml"
|
||||
fi
|
||||
;;
|
||||
read-config)
|
||||
[ -f "${CONFIG_FILE}" ] || fail "Configuration file not found: ${CONFIG_FILE}"
|
||||
cat "${CONFIG_FILE}"
|
||||
;;
|
||||
save-and-restart)
|
||||
[ -f "${COMMAND_ARGS}" ] && fail "command_args is active; config.toml would be ignored"
|
||||
[ -n "$2" ] || fail "Missing base64-encoded configuration"
|
||||
[ -x "${EASYTIER}" ] || fail "easytier-core is not executable"
|
||||
|
||||
TEMP_FILE="${CONFIG_FILE}.webui.$$"
|
||||
trap 'rm -f "${TEMP_FILE}"' EXIT HUP INT TERM
|
||||
umask 077
|
||||
|
||||
printf '%s' "$2" | base64 -d > "${TEMP_FILE}" \
|
||||
|| fail "Failed to decode configuration"
|
||||
|
||||
VALIDATION_OUTPUT=$("${EASYTIER}" --check-config -c "${TEMP_FILE}" 2>&1)
|
||||
if [ $? -ne 0 ]; then
|
||||
fail "${VALIDATION_OUTPUT:-Configuration validation failed}"
|
||||
fi
|
||||
|
||||
chmod 0644 "${TEMP_FILE}"
|
||||
mv -f "${TEMP_FILE}" "${CONFIG_FILE}" \
|
||||
|| fail "Failed to replace configuration"
|
||||
trap - EXIT HUP INT TERM
|
||||
|
||||
RUNNING_PIDS=$(running_pids)
|
||||
for pid in ${RUNNING_PIDS}; do
|
||||
kill "${pid}" || fail "Failed to restart EasyTier"
|
||||
done
|
||||
echo "saved"
|
||||
;;
|
||||
*)
|
||||
fail "Usage: $0 {status|config-mode|read-config|save-and-restart BASE64}"
|
||||
;;
|
||||
esac
|
||||
@@ -84,6 +84,120 @@ pub trait NetworkConfigExt {
|
||||
fn new_from_config(config: impl ConfigLoader) -> Result<NetworkConfig, anyhow::Error>;
|
||||
}
|
||||
|
||||
#[cfg(all(
|
||||
feature = "browser-config",
|
||||
any(test, all(target_arch = "wasm32", target_os = "unknown"))
|
||||
))]
|
||||
const FORM_MANAGED_TOML_FIELDS: &[&str] = &[
|
||||
"hostname",
|
||||
"instance_id",
|
||||
"ipv4",
|
||||
"ipv6_public_addr_provider",
|
||||
"ipv6_public_addr_auto",
|
||||
"ipv6_public_addr_prefix",
|
||||
"dhcp",
|
||||
"network_identity",
|
||||
"listeners",
|
||||
"mapped_listeners",
|
||||
"exit_nodes",
|
||||
"peer",
|
||||
"proxy_network",
|
||||
"vpn_portal_config",
|
||||
"routes",
|
||||
"socks5_proxy",
|
||||
"port_forward",
|
||||
"secure_mode",
|
||||
"acl",
|
||||
"credential_file",
|
||||
"managed_credentials",
|
||||
];
|
||||
|
||||
#[cfg(all(
|
||||
feature = "browser-config",
|
||||
any(test, all(target_arch = "wasm32", target_os = "unknown"))
|
||||
))]
|
||||
const FORM_MANAGED_FLAG_FIELDS: &[&str] = &[
|
||||
"latency_first",
|
||||
"dev_name",
|
||||
"use_smoltcp",
|
||||
"enable_ipv6",
|
||||
"enable_kcp_proxy",
|
||||
"disable_kcp_input",
|
||||
"enable_quic_proxy",
|
||||
"disable_quic_input",
|
||||
"disable_p2p",
|
||||
"p2p_only",
|
||||
"lazy_p2p",
|
||||
"bind_device",
|
||||
"socket_mark",
|
||||
"no_tun",
|
||||
"enable_exit_node",
|
||||
"relay_all_peer_rpc",
|
||||
"need_p2p",
|
||||
"multi_thread",
|
||||
"proxy_forward_by_system",
|
||||
"enable_encryption",
|
||||
"relay_network_whitelist",
|
||||
"disable_tcp_hole_punching",
|
||||
"disable_udp_hole_punching",
|
||||
"disable_upnp",
|
||||
"disable_relay_data",
|
||||
"prefer_peer_relay",
|
||||
"enable_udp_broadcast_relay",
|
||||
"disable_sym_hole_punching",
|
||||
"accept_dns",
|
||||
"mtu",
|
||||
"instance_recv_bps_limit",
|
||||
"private_mode",
|
||||
"encryption_algorithm",
|
||||
"data_compress_algo",
|
||||
];
|
||||
|
||||
#[cfg(all(
|
||||
feature = "browser-config",
|
||||
any(test, all(target_arch = "wasm32", target_os = "unknown"))
|
||||
))]
|
||||
pub(crate) fn merge_network_config_toml(
|
||||
original_toml: &str,
|
||||
config: &NetworkConfig,
|
||||
) -> Result<String, anyhow::Error> {
|
||||
let generated_toml = config.gen_config()?.dump();
|
||||
let mut original = toml::from_str::<toml::Table>(original_toml)
|
||||
.context("failed to parse the original TOML document")?;
|
||||
let mut generated = toml::from_str::<toml::Table>(&generated_toml)
|
||||
.context("failed to parse the generated TOML document")?;
|
||||
|
||||
for (key, value) in original.iter() {
|
||||
if key != "flags" && !FORM_MANAGED_TOML_FIELDS.contains(&key.as_str()) {
|
||||
generated.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
if !original.contains_key("instance_name") {
|
||||
generated.remove("instance_name");
|
||||
}
|
||||
|
||||
let mut merged_flags: toml::Table = original
|
||||
.remove("flags")
|
||||
.and_then(|value| value.try_into().ok())
|
||||
.unwrap_or_default();
|
||||
let generated_flags: toml::Table = generated
|
||||
.remove("flags")
|
||||
.and_then(|value| value.try_into().ok())
|
||||
.unwrap_or_default();
|
||||
for key in FORM_MANAGED_FLAG_FIELDS {
|
||||
if let Some(value) = generated_flags.get(*key) {
|
||||
merged_flags.insert((*key).to_owned(), value.clone());
|
||||
} else {
|
||||
merged_flags.remove(*key);
|
||||
}
|
||||
}
|
||||
if !merged_flags.is_empty() {
|
||||
generated.insert("flags".to_owned(), toml::Value::Table(merged_flags));
|
||||
}
|
||||
|
||||
toml::to_string_pretty(&generated).context("failed to serialize the merged TOML document")
|
||||
}
|
||||
|
||||
fn parse_peer(peer: &manage::NetworkPeerConfig) -> Result<Option<PeerConfig>, anyhow::Error> {
|
||||
let uri = peer.uri.trim();
|
||||
if uri.is_empty() {
|
||||
@@ -825,4 +939,49 @@ mod tests {
|
||||
|
||||
assert!(config.get_vpn_portal_config().is_none());
|
||||
}
|
||||
|
||||
#[cfg(feature = "browser-config")]
|
||||
#[test]
|
||||
fn browser_merge_preserves_fields_outside_the_shared_form() {
|
||||
let original = r#"
|
||||
instance_name = "module-instance"
|
||||
rpc_portal = "0.0.0.0:15888"
|
||||
tcp_whitelist = ["22"]
|
||||
stun_servers = ["custom.example.com:3478"]
|
||||
|
||||
[network_identity]
|
||||
network_name = "old-network"
|
||||
network_secret = "secret"
|
||||
|
||||
[flags]
|
||||
default_protocol = "udp"
|
||||
disable_p2p = true
|
||||
"#;
|
||||
let parsed = TomlConfigLoader::new_from_str(original).unwrap();
|
||||
let mut network_config = NetworkConfig::new_from_config(&parsed).unwrap();
|
||||
network_config.network_name = Some("edited-network".to_owned());
|
||||
network_config.disable_p2p = Some(false);
|
||||
|
||||
let merged = merge_network_config_toml(original, &network_config).unwrap();
|
||||
let merged: toml::Table = toml::from_str(&merged).unwrap();
|
||||
|
||||
assert_eq!(merged["instance_name"].as_str(), Some("module-instance"));
|
||||
assert_eq!(merged["rpc_portal"].as_str(), Some("0.0.0.0:15888"));
|
||||
assert_eq!(merged["tcp_whitelist"][0].as_str(), Some("22"));
|
||||
assert_eq!(
|
||||
merged["stun_servers"][0].as_str(),
|
||||
Some("custom.example.com:3478")
|
||||
);
|
||||
assert_eq!(
|
||||
merged["network_identity"]["network_name"].as_str(),
|
||||
Some("edited-network")
|
||||
);
|
||||
assert_eq!(merged["flags"]["default_protocol"].as_str(), Some("udp"));
|
||||
assert!(
|
||||
!merged["flags"]
|
||||
.as_table()
|
||||
.unwrap()
|
||||
.contains_key("disable_p2p")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
use wasm_bindgen::prelude::*;
|
||||
|
||||
use super::{
|
||||
api_input::{NetworkConfig, NetworkConfigExt},
|
||||
api_input::{NetworkConfig, NetworkConfigExt, merge_network_config_toml},
|
||||
toml::{ConfigLoader, TomlConfig},
|
||||
};
|
||||
|
||||
@@ -18,6 +18,12 @@ pub fn generate_config(config_json: &str) -> Result<String, JsValue> {
|
||||
.map_err(js_error)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn merge_config(original_toml: &str, config_json: &str) -> Result<String, JsValue> {
|
||||
let config: NetworkConfig = serde_json::from_str(config_json).map_err(js_error)?;
|
||||
merge_network_config_toml(original_toml, &config).map_err(js_error)
|
||||
}
|
||||
|
||||
#[wasm_bindgen]
|
||||
pub fn parse_config(toml_config: &str) -> Result<String, JsValue> {
|
||||
let config = TomlConfig::new_from_str(toml_config)
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
"dependencies": {
|
||||
"@primeuix/themes": "^1.2.3",
|
||||
"easytier-frontend-lib": "workspace:*",
|
||||
"kernelsu": "3.0.2",
|
||||
"primevue": "^4.3.9",
|
||||
"vue": "^3.5.12"
|
||||
},
|
||||
|
||||
@@ -1,20 +1,59 @@
|
||||
<script setup lang="ts">
|
||||
import { exec, moduleInfo } from 'kernelsu'
|
||||
import { Config, I18nUtils, NetworkTypes } from 'easytier-frontend-lib'
|
||||
import { Button, SelectButton, Textarea } from 'primevue'
|
||||
import { computed, onMounted, ref, watch } from 'vue'
|
||||
import initConfigWasm, {
|
||||
generate_config as generateTomlConfig,
|
||||
merge_config as mergeTomlConfig,
|
||||
parse_config as parseTomlConfig,
|
||||
} from './generated/config-wasm/easytier_config'
|
||||
|
||||
let configWasmReady: ReturnType<typeof initConfigWasm> | undefined
|
||||
const ensureConfigWasm = () => configWasmReady ??= initConfigWasm()
|
||||
type ModuleStatus = 'running' | 'stopped' | 'restarting'
|
||||
|
||||
interface KernelSuWindow extends Window {
|
||||
ksu?: unknown
|
||||
}
|
||||
|
||||
interface ModuleInfo {
|
||||
moduleDir?: unknown
|
||||
}
|
||||
|
||||
let configWasmReady: ReturnType<typeof initConfigWasm> | undefined
|
||||
|
||||
function ensureConfigWasm(): ReturnType<typeof initConfigWasm> {
|
||||
return configWasmReady ??= initConfigWasm()
|
||||
}
|
||||
|
||||
const isModuleWebUi = typeof (window as KernelSuWindow).ksu !== 'undefined'
|
||||
const moduleDirectory = ref('')
|
||||
const moduleStatus = ref<ModuleStatus>()
|
||||
const commandArgsMode = ref(false)
|
||||
const moduleReady = ref(!isModuleWebUi)
|
||||
const moduleMessage = ref('')
|
||||
const isSaving = ref(false)
|
||||
const networkConfig = ref<NetworkTypes.NetworkConfig>(NetworkTypes.DEFAULT_NETWORK_CONFIG())
|
||||
const tomlConfig = ref('')
|
||||
const originalTomlConfig = ref('')
|
||||
const errorMessage = ref('')
|
||||
const configCopied = ref(false)
|
||||
const t = (key: string) => I18nUtils.i18n.global.t(key)
|
||||
function t(key: string, params: Record<string, unknown> = {}): string {
|
||||
return I18nUtils.i18n.global.t(key, params)
|
||||
}
|
||||
|
||||
function errorDetail(error: unknown): string {
|
||||
return error instanceof Error ? error.message : String(error)
|
||||
}
|
||||
const copyButtonLabel = computed(() => t(configCopied.value ? 'config_copied' : 'copy_config'))
|
||||
const actionLabel = computed(() => t(isModuleWebUi ? 'magisk_save_restart' : 'generate_config'))
|
||||
const configActionDisabled = computed(() =>
|
||||
isModuleWebUi && (!moduleReady.value || commandArgsMode.value || isSaving.value),
|
||||
)
|
||||
const moduleStatusLabel = computed(() => {
|
||||
if (!moduleStatus.value)
|
||||
return t('magisk_status_loading')
|
||||
return t(`magisk_status_${moduleStatus.value}`)
|
||||
})
|
||||
type Language = 'cn' | 'en'
|
||||
const languageOptions: { label: string, value: Language }[] = [
|
||||
{ label: '中文', value: 'cn' },
|
||||
@@ -26,46 +65,158 @@ watch(tomlConfig, () => {
|
||||
configCopied.value = false
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
const savedLanguage = localStorage.getItem('lang')
|
||||
const language = savedLanguage === 'cn' || savedLanguage === 'en'
|
||||
? savedLanguage
|
||||
: navigator.language.toLowerCase().startsWith('zh') ? 'cn' : 'en'
|
||||
await I18nUtils.loadLanguageAsync(language)
|
||||
})
|
||||
function moduleDirFromBridge(): string {
|
||||
const info = JSON.parse(moduleInfo()) as ModuleInfo
|
||||
const moduleDir = info.moduleDir
|
||||
if (typeof moduleDir !== 'string' || !/^\/data\/adb\/modules\/[\w.-]+$/.test(moduleDir))
|
||||
throw new Error(t('magisk_invalid_module_directory'))
|
||||
return moduleDir
|
||||
}
|
||||
|
||||
const setLanguage = (language: Language) => {
|
||||
if (language !== currentLanguage.value) {
|
||||
void I18nUtils.loadLanguageAsync(language)
|
||||
async function runModuleCommand(command: string): Promise<string> {
|
||||
const { errno, stdout, stderr } = await exec(`${moduleDirectory.value}/webui.sh ${command}`, {
|
||||
cwd: moduleDirectory.value,
|
||||
})
|
||||
if (errno !== 0)
|
||||
throw new Error(stderr.trim() || stdout.trim() || `webui.sh exited with ${errno}`)
|
||||
return stdout
|
||||
}
|
||||
|
||||
function encodeBase64(value: string): string {
|
||||
const bytes = new TextEncoder().encode(value)
|
||||
const chunks: string[] = []
|
||||
const chunkSize = 0x8000
|
||||
for (let offset = 0; offset < bytes.length; offset += chunkSize)
|
||||
chunks.push(String.fromCharCode(...bytes.subarray(offset, offset + chunkSize)))
|
||||
return btoa(chunks.join(''))
|
||||
}
|
||||
|
||||
async function refreshModuleState(waitForRunning = false): Promise<'running' | 'stopped'> {
|
||||
const attempts = waitForRunning ? 18 : 1
|
||||
for (let attempt = 0; attempt < attempts; attempt++) {
|
||||
const status = (await runModuleCommand('status')).trim()
|
||||
if (status === 'running') {
|
||||
moduleStatus.value = 'running'
|
||||
return 'running'
|
||||
}
|
||||
moduleStatus.value = 'stopped'
|
||||
if (attempt + 1 < attempts)
|
||||
await new Promise(resolve => setTimeout(resolve, 1000))
|
||||
}
|
||||
return 'stopped'
|
||||
}
|
||||
|
||||
async function loadModuleConfig(): Promise<void> {
|
||||
moduleDirectory.value = moduleDirFromBridge()
|
||||
await ensureConfigWasm()
|
||||
|
||||
const [rawConfig, mode] = await Promise.all([
|
||||
runModuleCommand('read-config'),
|
||||
runModuleCommand('config-mode'),
|
||||
])
|
||||
await refreshModuleState()
|
||||
networkConfig.value = NetworkTypes.normalizeNetworkConfig(
|
||||
JSON.parse(parseTomlConfig(rawConfig)) as NetworkTypes.NetworkConfig,
|
||||
)
|
||||
originalTomlConfig.value = rawConfig
|
||||
tomlConfig.value = rawConfig
|
||||
commandArgsMode.value = mode.trim() === 'command-args'
|
||||
moduleReady.value = true
|
||||
}
|
||||
|
||||
function preferredLanguage(): Language {
|
||||
const savedLanguage = localStorage.getItem('lang')
|
||||
if (savedLanguage === 'cn' || savedLanguage === 'en')
|
||||
return savedLanguage
|
||||
return navigator.language.toLowerCase().startsWith('zh') ? 'cn' : 'en'
|
||||
}
|
||||
|
||||
async function initialize(): Promise<void> {
|
||||
await I18nUtils.loadLanguageAsync(preferredLanguage())
|
||||
|
||||
if (!isModuleWebUi)
|
||||
return
|
||||
|
||||
try {
|
||||
await loadModuleConfig()
|
||||
}
|
||||
catch (error) {
|
||||
errorMessage.value = t('magisk_load_failed', { error: errorDetail(error) })
|
||||
}
|
||||
}
|
||||
|
||||
const generateConfig = async (config: NetworkTypes.NetworkConfig) => {
|
||||
onMounted(initialize)
|
||||
|
||||
function setLanguage(language: Language): void {
|
||||
if (language !== currentLanguage.value)
|
||||
void I18nUtils.loadLanguageAsync(language)
|
||||
}
|
||||
|
||||
async function generateConfig(config: NetworkTypes.NetworkConfig): Promise<void> {
|
||||
try {
|
||||
errorMessage.value = ''
|
||||
tomlConfig.value = ''
|
||||
moduleMessage.value = ''
|
||||
configCopied.value = false
|
||||
isSaving.value = isModuleWebUi
|
||||
const configJson = JSON.stringify(NetworkTypes.toBackendNetworkConfig(config))
|
||||
await ensureConfigWasm()
|
||||
tomlConfig.value = generateTomlConfig(configJson)
|
||||
} catch (error) {
|
||||
errorMessage.value = t('config_generation_failed') + ': ' + (error instanceof Error ? error.message : String(error))
|
||||
const generatedConfig = isModuleWebUi
|
||||
? mergeTomlConfig(originalTomlConfig.value, configJson)
|
||||
: generateTomlConfig(configJson)
|
||||
tomlConfig.value = generatedConfig
|
||||
|
||||
if (isModuleWebUi) {
|
||||
moduleStatus.value = 'restarting'
|
||||
await runModuleCommand(`save-and-restart ${encodeBase64(generatedConfig)}`)
|
||||
originalTomlConfig.value = generatedConfig
|
||||
const status = await refreshModuleState(true)
|
||||
moduleMessage.value = t(
|
||||
status === 'running'
|
||||
? 'magisk_config_saved'
|
||||
: 'magisk_config_saved_restart_pending',
|
||||
)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
const detail = errorDetail(error)
|
||||
errorMessage.value = isModuleWebUi
|
||||
? t('magisk_save_failed', { error: detail })
|
||||
: `${t('config_generation_failed')}: ${detail}`
|
||||
}
|
||||
finally {
|
||||
isSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const copyConfig = async () => {
|
||||
async function copyConfig(): Promise<void> {
|
||||
try {
|
||||
errorMessage.value = ''
|
||||
configCopied.value = false
|
||||
await navigator.clipboard.writeText(tomlConfig.value)
|
||||
configCopied.value = true
|
||||
} catch (error) {
|
||||
errorMessage.value = t('config_copy_failed') + ': ' + (error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
catch (error) {
|
||||
errorMessage.value = `${t('config_copy_failed')}: ${errorDetail(error)}`
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="config-generator">
|
||||
<section v-if="isModuleWebUi" class="module-status">
|
||||
<div>
|
||||
<h1>{{ t('magisk_module_configuration') }}</h1>
|
||||
<p :class="['status', moduleStatus]">
|
||||
{{ moduleStatusLabel }}
|
||||
</p>
|
||||
</div>
|
||||
<p v-if="commandArgsMode" class="module-warning">
|
||||
{{ t('magisk_command_args_warning') }}
|
||||
</p>
|
||||
<p v-if="moduleMessage" class="module-success">
|
||||
{{ moduleMessage }}
|
||||
</p>
|
||||
</section>
|
||||
<section class="config-panel">
|
||||
<SelectButton
|
||||
class="language-switch"
|
||||
@@ -78,15 +229,21 @@ const copyConfig = async () => {
|
||||
:aria-label="t('exchange_language')"
|
||||
@update:model-value="setLanguage"
|
||||
/>
|
||||
<Config :cur-network="networkConfig" :action-label="t('generate_config')" @run-network="generateConfig" />
|
||||
<Config
|
||||
v-model:cur-network="networkConfig"
|
||||
:action-label="actionLabel"
|
||||
:config-invalid="configActionDisabled"
|
||||
@run-network="generateConfig"
|
||||
/>
|
||||
</section>
|
||||
<section class="output-panel">
|
||||
<section :class="['output-panel', { 'module-output-panel': isModuleWebUi }]">
|
||||
<pre v-if="errorMessage" class="error-message">{{ errorMessage }}</pre>
|
||||
<Textarea
|
||||
v-model="tomlConfig"
|
||||
spellcheck="false"
|
||||
class="toml-config"
|
||||
:placeholder="t('config_generator_placeholder')"
|
||||
:readonly="isModuleWebUi"
|
||||
/>
|
||||
<Button :label="copyButtonLabel" icon="pi pi-copy" :disabled="!tomlConfig" @click="copyConfig" />
|
||||
</section>
|
||||
@@ -101,6 +258,64 @@ const copyConfig = async () => {
|
||||
padding: 1.25rem;
|
||||
}
|
||||
|
||||
.module-status {
|
||||
grid-column: 1 / -1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
padding: 0.875rem 1rem;
|
||||
border: 1px solid var(--p-content-border-color);
|
||||
border-radius: var(--p-border-radius-md);
|
||||
background: var(--p-content-background);
|
||||
}
|
||||
|
||||
.module-status > div {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.module-status h1 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin: 0;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.status.running {
|
||||
color: var(--p-green-500);
|
||||
}
|
||||
|
||||
.status.stopped {
|
||||
color: var(--p-red-500);
|
||||
}
|
||||
|
||||
.status.restarting {
|
||||
color: var(--p-orange-500);
|
||||
}
|
||||
|
||||
.module-warning,
|
||||
.module-success {
|
||||
flex-basis: 100%;
|
||||
margin: 0;
|
||||
padding: 0.75rem;
|
||||
border-radius: var(--p-border-radius-sm);
|
||||
}
|
||||
|
||||
.module-warning {
|
||||
color: var(--p-orange-700);
|
||||
background: var(--p-orange-50);
|
||||
}
|
||||
|
||||
.module-success {
|
||||
color: var(--p-green-700);
|
||||
background: var(--p-green-50);
|
||||
}
|
||||
|
||||
.config-panel {
|
||||
position: relative;
|
||||
}
|
||||
@@ -125,6 +340,11 @@ const copyConfig = async () => {
|
||||
height: calc(100dvh - 2.5rem);
|
||||
}
|
||||
|
||||
.module-output-panel {
|
||||
height: calc(100vh - 8rem);
|
||||
height: calc(100dvh - 8rem);
|
||||
}
|
||||
|
||||
.toml-config {
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
|
||||
@@ -61,6 +61,18 @@ copy_config: 复制配置
|
||||
config_copied: 已复制
|
||||
config_generation_failed: 生成失败
|
||||
config_copy_failed: 复制失败
|
||||
magisk_module_configuration: EasyTier 模块配置
|
||||
magisk_status_loading: 正在读取状态
|
||||
magisk_status_running: 运行中
|
||||
magisk_status_stopped: 已停止
|
||||
magisk_status_restarting: 正在重启
|
||||
magisk_save_restart: 保存并重启
|
||||
magisk_command_args_warning: 当前启用了启动参数模式,模块会忽略 config.toml。请先删除 config/command_args,才能在此保存配置。
|
||||
magisk_invalid_module_directory: KernelSU 返回了无效的模块目录
|
||||
magisk_load_failed: '加载模块配置失败:{error}'
|
||||
magisk_save_failed: '保存模块配置失败:{error}'
|
||||
magisk_config_saved: 配置已验证并保存,EasyTier 已重新启动
|
||||
magisk_config_saved_restart_pending: 配置已验证并保存,EasyTier 守护进程仍在启动
|
||||
retain_network_instance: 保留网络实例
|
||||
collect_network_infos: 收集网络信息
|
||||
settings: 设置
|
||||
|
||||
@@ -61,6 +61,18 @@ copy_config: Copy Config
|
||||
config_copied: Copied
|
||||
config_generation_failed: Generation failed
|
||||
config_copy_failed: Copy failed
|
||||
magisk_module_configuration: EasyTier Module Configuration
|
||||
magisk_status_loading: Loading status
|
||||
magisk_status_running: Running
|
||||
magisk_status_stopped: Stopped
|
||||
magisk_status_restarting: Restarting
|
||||
magisk_save_restart: Save and Restart
|
||||
magisk_command_args_warning: Startup argument mode is active, so the module ignores config.toml. Remove config/command_args before saving configuration here.
|
||||
magisk_invalid_module_directory: KernelSU returned an invalid module directory
|
||||
magisk_load_failed: 'Failed to load module configuration: {error}'
|
||||
magisk_save_failed: 'Failed to save module configuration: {error}'
|
||||
magisk_config_saved: Configuration validated and saved; EasyTier restarted
|
||||
magisk_config_saved_restart_pending: Configuration validated and saved; the EasyTier supervisor is still starting
|
||||
retain_network_instance: Retain Network Instance
|
||||
collect_network_infos: Collect Network Infos
|
||||
settings: Settings
|
||||
|
||||
Generated
+8
@@ -156,6 +156,9 @@ importers:
|
||||
easytier-frontend-lib:
|
||||
specifier: workspace:*
|
||||
version: link:../frontend-lib
|
||||
kernelsu:
|
||||
specifier: 3.0.2
|
||||
version: 3.0.2
|
||||
primevue:
|
||||
specifier: ^4.3.9
|
||||
version: 4.3.9(vue@3.5.21(typescript@5.6.3))
|
||||
@@ -3205,6 +3208,9 @@ packages:
|
||||
jsonfile@6.2.0:
|
||||
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
||||
|
||||
kernelsu@3.0.2:
|
||||
resolution: {integrity: sha512-3MTGgIbl3TWCzqnJP6Fun9tHv6VULR3O188U3raeGRSMzK+G3pldL9ySwaZWhsHCtI2BTvlQuu/3p10s4a5c3w==}
|
||||
|
||||
keyv@4.5.4:
|
||||
resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==}
|
||||
|
||||
@@ -7551,6 +7557,8 @@ snapshots:
|
||||
optionalDependencies:
|
||||
graceful-fs: 4.2.11
|
||||
|
||||
kernelsu@3.0.2: {}
|
||||
|
||||
keyv@4.5.4:
|
||||
dependencies:
|
||||
json-buffer: 3.0.1
|
||||
|
||||
Reference in New Issue
Block a user