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 authored and GitHub committed 2026-09-10 20:13:21 +08:00
1 parent 3d0c9c3ca5
commit 974c3270e8
13 files changed
+576 -48

No files matched your search

@@ -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