706 lines
19 KiB
Svelte
706 lines
19 KiB
Svelte
<script>
|
|
import { onMount } from 'svelte';
|
|
import { exec } from 'kernelsu';
|
|
import locate from './locate.json';
|
|
import './app.css';
|
|
|
|
const DEFAULT_CONFIG = {
|
|
moduledir: '/data/adb/modules',
|
|
tempdir: '',
|
|
mountsource: 'KSU',
|
|
logfile: '/data/adb/magic_mount/mm.log',
|
|
verbose: false,
|
|
umount: false,
|
|
partitions: []
|
|
};
|
|
|
|
const CONFIG_PATH = '/data/adb/magic_mount/mm.conf';
|
|
|
|
// i18n
|
|
let lang = 'en';
|
|
$: L = locate[lang];
|
|
|
|
// Autoload lang
|
|
const availableLanguages = Object.keys(locate).map(code => ({
|
|
code,
|
|
name: locate[code]?.lang?.display || code.toUpperCase()
|
|
}));
|
|
|
|
let langDropdownOpen = false;
|
|
|
|
function toggleLangDropdown() {
|
|
langDropdownOpen = !langDropdownOpen;
|
|
}
|
|
|
|
function selectLanguage(newLang) {
|
|
lang = newLang;
|
|
langDropdownOpen = false;
|
|
if (typeof window !== 'undefined') {
|
|
window.localStorage.setItem('mm-lang', newLang);
|
|
}
|
|
}
|
|
|
|
function handleClickOutside(event) {
|
|
const dropdown = event.target.closest('.lang-dropdown');
|
|
if (!dropdown && langDropdownOpen) {
|
|
langDropdownOpen = false;
|
|
}
|
|
}
|
|
|
|
// tabs
|
|
let activeTab = 'config';
|
|
let isSwitching = false;
|
|
|
|
function switchTab(tab) {
|
|
if (tab === activeTab || isSwitching) return;
|
|
|
|
isSwitching = true;
|
|
|
|
setTimeout(() => {
|
|
activeTab = tab;
|
|
isSwitching = false;
|
|
}, 300);
|
|
}
|
|
|
|
|
|
// config state
|
|
let config = { ...DEFAULT_CONFIG };
|
|
let configLoading = false;
|
|
let configSaving = false;
|
|
let configMessage = null;
|
|
|
|
// log state
|
|
let logSelection = 'current';
|
|
let logContent = '';
|
|
let logLoading = false;
|
|
let logError = null;
|
|
|
|
// partitions input
|
|
let partitionInput = '';
|
|
|
|
// modules state
|
|
let modules = [];
|
|
let modulesLoading = false;
|
|
let modulesError = null;
|
|
|
|
// helpers
|
|
function isTrueValue(v) {
|
|
const s = v.trim().toLowerCase();
|
|
return s === '1' || s === 'true' || s === 'yes' || s === 'on';
|
|
}
|
|
|
|
function parseKvConfig(text) {
|
|
try {
|
|
const result = { ...DEFAULT_CONFIG };
|
|
const lines = text.split('\n');
|
|
|
|
for (let line of lines) {
|
|
line = line.trim();
|
|
if (!line || line.startsWith('#')) continue;
|
|
|
|
const eqIndex = line.indexOf('=');
|
|
if (eqIndex < 0) continue;
|
|
|
|
let key = line.slice(0, eqIndex).trim();
|
|
let value = line.slice(eqIndex + 1).trim();
|
|
|
|
if (!key || !value) continue;
|
|
|
|
switch (key) {
|
|
case 'module_dir':
|
|
result.moduledir = value;
|
|
break;
|
|
case 'temp_dir':
|
|
result.tempdir = value;
|
|
break;
|
|
case 'mount_source':
|
|
result.mountsource = value;
|
|
break;
|
|
case 'log_file':
|
|
result.logfile = value;
|
|
break;
|
|
case 'debug':
|
|
result.verbose = isTrueValue(value);
|
|
break;
|
|
case 'umount':
|
|
result.umount = isTrueValue(value);
|
|
break;
|
|
case 'partitions':
|
|
result.partitions = value
|
|
.split(',')
|
|
.map(s => s.trim())
|
|
.filter(s => s.length > 0);
|
|
break;
|
|
}
|
|
}
|
|
|
|
return result;
|
|
} catch (e) {
|
|
console.error('Failed to parse config:', e);
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function serializeKvConfig(cfg) {
|
|
const lines = ['# Magic Mount Configuration File', '# Generated by Web UI', ''];
|
|
|
|
const moduleDir = cfg.moduledir || DEFAULT_CONFIG.moduledir;
|
|
const mountSource = cfg.mountsource || DEFAULT_CONFIG.mountsource;
|
|
const logFile = cfg.logfile || DEFAULT_CONFIG.logfile;
|
|
|
|
lines.push(`module_dir=${moduleDir}`);
|
|
|
|
if (cfg.tempdir) {
|
|
lines.push(`temp_dir=${cfg.tempdir}`);
|
|
}
|
|
|
|
lines.push(`mount_source=${mountSource}`);
|
|
|
|
if (logFile) {
|
|
lines.push(`log_file=${logFile}`);
|
|
}
|
|
|
|
lines.push(`debug=${cfg.verbose ? 'true' : 'false'}`);
|
|
|
|
lines.push(`umount=${cfg.umount ? 'true' : 'false'}`);
|
|
|
|
if (cfg.partitions.length > 0) {
|
|
lines.push(`partitions=${cfg.partitions.join(',')}`);
|
|
}
|
|
|
|
return lines.join('\n');
|
|
}
|
|
|
|
// load config
|
|
async function loadConfig() {
|
|
configLoading = true;
|
|
configMessage = null;
|
|
|
|
try {
|
|
const { errno, stdout, stderr } = await exec(
|
|
`[ -f "${CONFIG_PATH}" ] && cat "${CONFIG_PATH}" || echo ""`
|
|
);
|
|
|
|
if (errno !== 0) {
|
|
console.error(stderr);
|
|
config = { ...DEFAULT_CONFIG };
|
|
partitionInput = '';
|
|
configMessage = L.config.loadError;
|
|
return;
|
|
}
|
|
|
|
if (!stdout.trim()) {
|
|
config = { ...DEFAULT_CONFIG };
|
|
partitionInput = '';
|
|
configMessage = L.config.loadDefault;
|
|
return;
|
|
}
|
|
|
|
const parsed = parseKvConfig(stdout);
|
|
if (parsed) {
|
|
config = parsed;
|
|
partitionInput = parsed.partitions.join(', ');
|
|
configMessage = L.config.loadSuccess;
|
|
} else {
|
|
config = { ...DEFAULT_CONFIG };
|
|
partitionInput = '';
|
|
configMessage = L.config.loadError;
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
config = { ...DEFAULT_CONFIG };
|
|
partitionInput = '';
|
|
configMessage = L.config.loadError;
|
|
} finally {
|
|
configLoading = false;
|
|
}
|
|
}
|
|
|
|
// save config
|
|
async function saveConfig() {
|
|
configSaving = true;
|
|
configMessage = null;
|
|
|
|
try {
|
|
const partitions = partitionInput
|
|
.split(',')
|
|
.map(s => s.trim())
|
|
.filter(s => s);
|
|
|
|
const configToSave = { ...config, partitions };
|
|
|
|
const content = serializeKvConfig(configToSave);
|
|
const escapedContent = content.replace(/'/g, "'\\''");
|
|
|
|
const shell = `
|
|
mkdir -p "$(dirname "${CONFIG_PATH}")" && \
|
|
printf '%s\n' '${escapedContent}' > "${CONFIG_PATH}"
|
|
`;
|
|
|
|
const { errno, stderr } = await exec(shell);
|
|
|
|
if (errno !== 0) {
|
|
console.error(stderr);
|
|
configMessage = L.config.saveFailed;
|
|
} else {
|
|
config = configToSave;
|
|
configMessage = L.config.saveSuccess;
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
configMessage = L.config.saveError;
|
|
} finally {
|
|
configSaving = false;
|
|
}
|
|
}
|
|
|
|
// load log
|
|
async function loadLog() {
|
|
logLoading = true;
|
|
logError = null;
|
|
logContent = '';
|
|
try {
|
|
const basePath = config.logfile || DEFAULT_CONFIG.logfile;
|
|
const fullPath = logSelection === 'current' ? basePath : `${basePath}.old`;
|
|
|
|
const { errno, stdout, stderr } = await exec(
|
|
`[ -f "${fullPath}" ] && cat "${fullPath}" || echo "Log file not found"`
|
|
);
|
|
|
|
if (errno !== 0) {
|
|
console.error(stderr);
|
|
logError = L.logs.readFailed + ` (errno=${errno})`;
|
|
} else {
|
|
logContent = stdout || '';
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
logError = L.logs.readException;
|
|
} finally {
|
|
logLoading = false;
|
|
}
|
|
}
|
|
|
|
// load modules
|
|
async function loadModules() {
|
|
modulesLoading = true;
|
|
modulesError = null;
|
|
modules = [];
|
|
try {
|
|
const moduleDir = config.moduledir || DEFAULT_CONFIG.moduledir;
|
|
|
|
const shell = `
|
|
MOD_DIR="${moduleDir}"
|
|
[ -d "$MOD_DIR" ] || exit 0
|
|
for m in "$MOD_DIR"/*; do
|
|
[ -d "$m" ] || continue
|
|
[ -d "$m/system" ] || continue
|
|
name="$(basename "$m")"
|
|
disabled=0
|
|
skip=0
|
|
if [ -e "$m/disable" ] || [ -e "$m/remove" ]; then
|
|
disabled=1
|
|
fi
|
|
if [ -e "$m/skip_mount" ]; then
|
|
skip=1
|
|
fi
|
|
printf '%s|%s|%s\n' "$name" "$disabled" "$skip"
|
|
done
|
|
`;
|
|
|
|
const { errno, stdout, stderr } = await exec(shell);
|
|
|
|
if (errno !== 0) {
|
|
console.error(stderr);
|
|
modulesError = L.modules.loadError;
|
|
return;
|
|
}
|
|
|
|
const lines = stdout
|
|
.split('\n')
|
|
.map(l => l.trim())
|
|
.filter(l => l.length > 0);
|
|
|
|
modules = lines.map(line => {
|
|
const [name, disabledStr, skipStr] = line.split('|');
|
|
return {
|
|
name: name || '(unknown)',
|
|
disabledByFlag: disabledStr === '1',
|
|
skipMount: skipStr === '1',
|
|
toggling: false
|
|
};
|
|
});
|
|
} catch (e) {
|
|
console.error(e);
|
|
modulesError = L.modules.loadError;
|
|
} finally {
|
|
modulesLoading = false;
|
|
}
|
|
}
|
|
|
|
async function toggleSkipMount(event, mod) {
|
|
if (mod.disabledByFlag) {
|
|
event.target.checked = !event.target.checked;
|
|
return
|
|
};
|
|
|
|
const moduleDir = config.moduledir || DEFAULT_CONFIG.moduledir;
|
|
const modulePath = `${moduleDir}/${mod.name}`;
|
|
|
|
modules = modules.map(m =>
|
|
m.name === mod.name ? { ...m, toggling: true, error: undefined } : m
|
|
);
|
|
|
|
try {
|
|
const shell = mod.skipMount
|
|
? `rm -f "${modulePath}/skip_mount"`
|
|
: `touch "${modulePath}/skip_mount"`;
|
|
|
|
const { errno, stderr } = await exec(shell);
|
|
|
|
if (errno !== 0) {
|
|
console.error(stderr);
|
|
event.target.checked = !event.target.checked;
|
|
modules = modules.map(m =>
|
|
m.name === mod.name ? { ...m, toggling: false, error: L.modules.toggleError } : m
|
|
);
|
|
} else {
|
|
modules = modules.map(m =>
|
|
m.name === mod.name
|
|
? { ...m, skipMount: !m.skipMount, toggling: false, error: undefined }
|
|
: m
|
|
);
|
|
}
|
|
} catch (e) {
|
|
console.error(e);
|
|
event.target.checked = !event.target.checked;
|
|
modules = modules.map(m =>
|
|
m.name === mod.name ? { ...m, toggling: false, error: L.modules.toggleError } : m
|
|
);
|
|
}
|
|
}
|
|
|
|
onMount(() => {
|
|
if (typeof window !== 'undefined') {
|
|
const saved = window.localStorage.getItem('mm-lang');
|
|
if (saved && locate[saved]) {
|
|
lang = saved;
|
|
}
|
|
|
|
// 添加全局点击监听
|
|
window.addEventListener('click', handleClickOutside);
|
|
|
|
return () => {
|
|
window.removeEventListener('click', handleClickOutside);
|
|
};
|
|
}
|
|
loadConfig();
|
|
});
|
|
// reactive statements for tab changes
|
|
let configLoaded = false;
|
|
$: if (activeTab === 'config' && !configLoaded ) {
|
|
loadConfig().then(() => {
|
|
configLoaded = true;
|
|
});
|
|
}
|
|
|
|
$: if (activeTab === 'log') {
|
|
loadLog();
|
|
}
|
|
|
|
$: if (activeTab === 'module') {
|
|
loadModules();
|
|
}
|
|
|
|
onMount(() => {
|
|
if (typeof window !== 'undefined') {
|
|
const saved = window.localStorage.getItem('mm-lang');
|
|
if (saved && locate[saved]) {
|
|
lang = saved;
|
|
}
|
|
|
|
window.addEventListener('click', handleClickOutside);
|
|
|
|
return () => {
|
|
window.removeEventListener('click', handleClickOutside);
|
|
};
|
|
}
|
|
});
|
|
|
|
$: effectiveModuleDir = config.moduledir || DEFAULT_CONFIG.moduledir;
|
|
</script>
|
|
|
|
<div class="app-root">
|
|
<div class="top-bar">
|
|
<div class="title">Magic Mount</div>
|
|
|
|
<div class="lang-dropdown">
|
|
<button
|
|
type="button"
|
|
class="lang-select"
|
|
on:click|stopPropagation={toggleLangDropdown}
|
|
>
|
|
{availableLanguages.find(l => l.code === lang)?.name || lang}
|
|
<span class="arrow {langDropdownOpen ? 'open' : ''}">▼</span>
|
|
</button>
|
|
|
|
{#if langDropdownOpen}
|
|
<div class="lang-menu">
|
|
{#each availableLanguages as { code, name }}
|
|
<button
|
|
type="button"
|
|
class="lang-option {code === lang ? 'active' : ''}"
|
|
on:click={() => selectLanguage(code)}
|
|
>
|
|
{name}
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
</div>
|
|
|
|
<div class="app-main">
|
|
{#if isSwitching}
|
|
<div class="tab-overlay">
|
|
<div class="tab-spinner"></div>
|
|
</div>
|
|
{/if}
|
|
|
|
{#key activeTab}
|
|
{#if activeTab === 'config'}
|
|
<div
|
|
class="card"
|
|
in:fade={{ duration: 180 }}
|
|
out:fade={{ duration: 180 }}
|
|
>
|
|
<h2>{L.config.title}</h2>
|
|
|
|
{#if configLoading}
|
|
<p class="hint">Loading...</p>
|
|
{/if}
|
|
{#if configMessage}
|
|
<p class="hint">{configMessage}</p>
|
|
{/if}
|
|
|
|
<div class="field">
|
|
<label for="verbose-setting">{L.config.verboseLabel}</label>
|
|
<div class="loglevel-switch" id="verbose-setting" role="group">
|
|
<button
|
|
type="button"
|
|
class="lv-btn {!config.verbose ? 'active' : ''}"
|
|
on:click|preventDefault={() => (config.verbose = false)}
|
|
>
|
|
{L.config.verboseOff}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="lv-btn {config.verbose ? 'active' : ''}"
|
|
on:click|preventDefault={() => (config.verbose = true)}
|
|
>
|
|
{L.config.verboseOn}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="umount-setting">{L.config.umountLabel}</label>
|
|
<div class="loglevel-switch" id="umount-setting" role="group">
|
|
<button
|
|
type="button"
|
|
class="lv-btn {!config.umount ? 'active' : ''}"
|
|
on:click|preventDefault={() => (config.umount = false)}
|
|
>
|
|
{L.config.umountOff}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="lv-btn {config.umount ? 'active' : ''}"
|
|
on:click|preventDefault={() => (config.umount = true)}
|
|
>
|
|
{L.config.umountOn}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="moduledir-input">{L.config.moduleDir}</label>
|
|
<input
|
|
id="moduledir-input"
|
|
type="text"
|
|
bind:value={config.moduledir}
|
|
placeholder={DEFAULT_CONFIG.moduledir}
|
|
/>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="tempdir-input">{L.config.tempDir}</label>
|
|
<input
|
|
id="tempdir-input"
|
|
type="text"
|
|
bind:value={config.tempdir}
|
|
placeholder="(auto-select if empty)"
|
|
/>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="mountsource-input">{L.config.mountSource}</label>
|
|
<input
|
|
id="mountsource-input"
|
|
type="text"
|
|
bind:value={config.mountsource}
|
|
placeholder={DEFAULT_CONFIG.mountsource}
|
|
/>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="logfile-input">{L.config.logFile}</label>
|
|
<input
|
|
id="logfile-input"
|
|
type="text"
|
|
bind:value={config.logfile}
|
|
placeholder={DEFAULT_CONFIG.logfile}
|
|
/>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label for="partitions-input">{L.config.extPartitions}</label>
|
|
<input
|
|
id="partitions-input"
|
|
type="text"
|
|
bind:value={partitionInput}
|
|
placeholder="eg. mi_ext,my_stock"
|
|
/>
|
|
</div>
|
|
|
|
<div class="actions">
|
|
<button type="button" on:click|preventDefault={loadConfig} disabled={configLoading || configSaving}>
|
|
{L.config.reload}
|
|
</button>
|
|
<button type="button" class="primary" on:click|preventDefault={saveConfig} disabled={configSaving || configLoading}>
|
|
{configSaving ? 'Saving...' : L.config.save}
|
|
</button>
|
|
</div>
|
|
|
|
<p class="path">{L.config.pathLabel}: {CONFIG_PATH}</p>
|
|
</div>
|
|
|
|
{:else if activeTab === 'module'}
|
|
<div
|
|
class="card"
|
|
in:fade={{ duration: 180 }}
|
|
out:fade={{ duration: 180 }}
|
|
>
|
|
<h2>{L.modules.title}</h2>
|
|
|
|
<p class="path">{L.modules.basePath}: {effectiveModuleDir}</p>
|
|
|
|
{#if modulesError}
|
|
<p class="error">{modulesError}</p>
|
|
{/if}
|
|
{#if modulesLoading && modules.length === 0}
|
|
<p class="hint">Loading...</p>
|
|
{/if}
|
|
|
|
{#if !modulesLoading && modules.length === 0 && !modulesError}
|
|
<p class="hint">{L.modules.empty}</p>
|
|
{/if}
|
|
|
|
{#if !modulesLoading && modules.length > 0}
|
|
<div class="module-list">
|
|
{#each modules as m (m.name)}
|
|
<div class="module-row">
|
|
<div class="module-info">
|
|
<span class="module-name">{m.name}</span>
|
|
<label class="switch {m.disabledByFlag ? 'disabled' : ''}">
|
|
<input
|
|
type="checkbox"
|
|
checked={m.disabledByFlag ? false : !m.skipMount}
|
|
disabled={m.disabledByFlag || m.toggling}
|
|
on:change={(e) => {
|
|
if (!m.disabledByFlag) toggleSkipMount(e,m);
|
|
}}
|
|
/>
|
|
<span class="slider"></span>
|
|
</label>
|
|
</div>
|
|
|
|
|
|
{#if m.error}
|
|
<div class="error small">{m.error}</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
</div>
|
|
{/if}
|
|
|
|
<div class="actions" style="margin-top: 20px">
|
|
<button type="button" on:click|preventDefault={loadModules} disabled={modulesLoading}>
|
|
{modulesLoading ? 'Loading...' : L.modules.reload}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{:else}
|
|
<div
|
|
class="card"
|
|
in:fade={{ duration: 180 }}
|
|
out:fade={{ duration: 180 }}
|
|
>
|
|
<h2>{L.logs.title}</h2>
|
|
|
|
<div class="field">
|
|
<label for="log-select">{L.logs.select}</label>
|
|
<div class="log-select-row">
|
|
<select id="log-select" bind:value={logSelection}>
|
|
<option value="current">{L.logs.current}</option>
|
|
<option value="old">{L.logs.old}</option>
|
|
</select>
|
|
|
|
<button type="button" class="refresh-btn" on:click|preventDefault={loadLog} disabled={logLoading}>
|
|
{logLoading ? 'Loading...' : L.logs.refresh}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{#if logError}
|
|
<p class="error">{logError}</p>
|
|
{/if}
|
|
|
|
<pre class="log-view">{logLoading && !logContent ? 'Loading...' : logContent || L.logs.empty}</pre>
|
|
</div>
|
|
{/if}
|
|
{/key}
|
|
</div>
|
|
|
|
<div class="bottom-bar">
|
|
<button
|
|
type="button"
|
|
class="tab-btn {activeTab === 'config' ? 'active' : ''}"
|
|
on:click|preventDefault={() => switchTab('config')}
|
|
>
|
|
{L.tabs.config}
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
class="tab-btn {activeTab === 'module' ? 'active' : ''}"
|
|
on:click|preventDefault={() => switchTab('module')}
|
|
>
|
|
{L.tabs.modules}
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
class="tab-btn {activeTab === 'log' ? 'active' : ''}"
|
|
on:click|preventDefault={() => switchTab('log')}
|
|
>
|
|
{L.tabs.logs}
|
|
</button>
|
|
</div>
|
|
|
|
</div>
|
|
|
|
<style>
|
|
/* Component-specific styles only if needed */
|
|
</style>
|