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:
KKRainbow
2026-09-10 20:13:21 +08:00
committed by GitHub
parent 3d0c9c3ca5
commit 974c3270e8
13 changed files with 576 additions and 48 deletions
+15 -7
View File
@@ -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
+22 -5
View File
@@ -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 秒后重新启用即可让配置生效。
+17 -10
View File
@@ -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 "----------------------------------"
+77
View File
@@ -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
+159
View File
@@ -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")
);
}
}
+7 -1
View File
@@ -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"
},
+243 -23
View File
@@ -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
+8
View File
@@ -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