mirror of
https://github.com/EasyTier/EasyTier.git
synced 2026-10-08 10:56:13 -08: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:
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"
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user