diff --git a/module/common/get_extra.sh b/module/common/get_extra.sh
index 6d628f2..492041f 100644
--- a/module/common/get_extra.sh
+++ b/module/common/get_extra.sh
@@ -1,4 +1,7 @@
#!/bin/sh
+
+# This file is the backend of JavaScript
+
MODPATH=${0%/*}
ORG_PATH="$PATH"
SKIPLIST="$MODPATH/tmp/skiplist"
@@ -17,13 +20,11 @@ aapt() { "$MODPATH/aapt" "$@"; }
# wget = low pref, no ssl.
# curl, has ssl on android, we use it if found
download() {
- PATH=/data/adb/ap/bin:/data/adb/ksu/bin:/data/adb/magisk:/data/data/com.termux/files/usr/bin:$PATH
if command -v curl >/dev/null 2>&1; then
timeout 10 curl -Ls "$1"
else
timeout 10 busybox wget --no-check-certificate -qO- "$1"
fi
- PATH="$ORG_PATH"
}
get_xposed() {
@@ -39,6 +40,19 @@ get_xposed() {
cat "$XPOSED"
}
+get_applist() {
+ pm list packages -3 | awk -F: '{print $2}'
+ [ -s "/data/adb/tricky_store/system_app" ] && SYSTEM_APP=$(cat "/data/adb/tricky_store/system_app" | tr '\n' '|' | sed 's/|*$//') || SYSTEM_APP=""
+ [ -z "$SYSTEM_APP" ] || pm list packages -s | awk -F: '{print $2}' | grep -Ex "$SYSTEM_APP" || true
+}
+
+get_appname() {
+ base_apk=$(pm path $package_name | head -n1 | awk -F: '{print $2}')
+ app_name=$(aapt dump badging $base_apk 2>/dev/null | grep "application-label:" | sed "s/application-label://; s/'//g")
+ [ -z "$app_name" ] && app_name="$package_name"
+ echo "$app_name"
+}
+
check_update() {
[ -f "$MODDIR/disable" ] && rm -f "$MODDIR/disable"
LOCAL_VERSION=$(grep '^versionCode=' "$MODPATH/update/module.prop" | awk -F= '{print $2}')
@@ -68,7 +82,6 @@ get_update() {
}
install_update() {
- PATH=/data/adb/ap/bin:/data/adb/ksu/bin:/data/adb/magisk:$PATH
if command -v magisk >/dev/null 2>&1; then
magisk --install-module "$MODPATH/tmp/module.zip" || exit 1
elif command -v apd >/dev/null 2>&1; then
@@ -133,6 +146,15 @@ case "$1" in
get_xposed
exit
;;
+--applist)
+ get_applist
+ exit
+ ;;
+--appname)
+ package_name="$2"
+ get_appname
+ exit
+ ;;
--check-update)
REMOTE_VERSION="$2"
check_update
diff --git a/module/webui/scripts/applist.js b/module/webui/scripts/applist.js
index 75148ea..b5af484 100644
--- a/module/webui/scripts/applist.js
+++ b/module/webui/scripts/applist.js
@@ -1,41 +1,42 @@
-import { exec, toast } from './assets/kernelsu.js';
-import { basePath, hideFloatingBtn, appsWithExclamation, appsWithQuestion } from './main.js';
+import { exec, spawn, toast } from './assets/kernelsu.js';
+import { basePath, loadingIndicator, hideFloatingBtn, appsWithExclamation, appsWithQuestion, applyRippleEffect } from './main.js';
const appTemplate = document.getElementById('app-template').content;
const modeOverlay = document.querySelector('.mode-overlay');
export const appListContainer = document.getElementById('apps-list');
export const updateCard = document.getElementById('update-card');
+let targetList = [];
+
// Fetch and render applist
export async function fetchAppList() {
- try {
- // fetch target list
- let targetList = [];
- await exec('cat /data/adb/tricky_store/target.txt')
- .then(({ errno, stdout }) => {
- if (errno === 0) {
- targetList = processTargetList(stdout);
- } else {
- toast("Failed to read target.txt!");
- }
- });
+ // fetch target list
+ await exec('cat /data/adb/tricky_store/target.txt')
+ .then(({ errno, stdout }) => {
+ if (errno === 0) {
+ targetList = processTargetList(stdout);
+ } else {
+ toast("Failed to read target.txt!");
+ }
+ });
- // fetch applist
- const response = await fetch('applist.json');
- const appList = await response.json();
- const appNameMap = appList.reduce((map, app) => {
- map[app.package_name] = app.app_name;
- return map;
- }, {});
+ // Fetch cached applist
+ const response = await fetch('applist.json');
+ const appList = await response.json();
+ const appNameMap = appList.reduce((map, app) => {
+ map[app.package_name] = app.app_name;
+ return map;
+ }, {});
- // Get installed packages first
- let appEntries = [], installedPackages = [];
- const { stdout } = await exec(`
- pm list packages -3 | awk -F: '{print $2}'
- [ -s "/data/adb/tricky_store/system_app" ] && SYSTEM_APP=$(cat "/data/adb/tricky_store/system_app" | tr '\n' '|' | sed 's/|*$//') || SYSTEM_APP=""
- [ -z "$SYSTEM_APP" ] || pm list packages -s | awk -F: '{print $2}' | grep -Ex "$SYSTEM_APP" || true
- `);
- installedPackages = stdout.split("\n").map(line => line.trim()).filter(Boolean);
+ // Get installed packages
+ let appEntries = [], installedPackages = [];
+ const output = spawn('sh', [`${basePath}/common/get_extra.sh`, '--applist']);
+ output.stdout.on('data', (data) => {
+ if (data.trim() === "") return;
+ installedPackages.push(data);
+ });
+ output.on('exit', async () => {
+ // Create appEntries array contain { appName, packageName }
appEntries = await Promise.all(installedPackages.map(async (packageName) => {
if (appNameMap[packageName]) {
return {
@@ -43,76 +44,86 @@ export async function fetchAppList() {
packageName
};
}
- const { stdout: appName } = await exec(`
- base_apk=$(pm path ${packageName} | head -n1 | awk -F: '{print $2}')
- aapt dump badging $base_apk 2>/dev/null | grep "application-label:" | sed "s/application-label://; s/'//g"
- `, { env: { PATH: `$PATH:${basePath}/common:/data/data/com.termux/files/usr/bin` } });
- return {
- appName: appName || packageName,
- packageName
- };
- }));
-
- // Sort
- const sortedApps = appEntries.sort((a, b) => {
- const aChecked = targetList.includes(a.packageName);
- const bChecked = targetList.includes(b.packageName);
- if (aChecked !== bChecked) {
- return aChecked ? -1 : 1;
- }
- return (a.appName || "").localeCompare(b.appName || "");
- });
-
- // Render
- appListContainer.innerHTML = "";
- sortedApps.forEach(({ appName, packageName }) => {
- const appElement = document.importNode(appTemplate, true);
- const contentElement = appElement.querySelector(".content");
- contentElement.setAttribute("data-package", packageName);
-
- // Set unique names for radio button groups
- const radioButtons = appElement.querySelectorAll('input[type="radio"]');
- radioButtons.forEach((radio) => {
- radio.name = `mode-radio-${packageName}`;
+ return new Promise((resolve) => {
+ const output = spawn('sh', [`${basePath}/common/get_extra.sh`, '--appname', packageName],
+ { env: { PATH: `$PATH:${basePath}/common:/data/data/com.termux/files/usr/bin` } });
+ output.stdout.on('data', (data) => {
+ resolve({
+ appName: data,
+ packageName
+ });
+ });
});
-
- // Preselect the radio button based on the package name
- const generateRadio = appElement.querySelector('#generate-mode');
- const hackRadio = appElement.querySelector('#hack-mode');
- const normalRadio = appElement.querySelector('#normal-mode');
-
- if (appsWithExclamation.includes(packageName)) {
- generateRadio.checked = true;
- } else if (appsWithQuestion.includes(packageName)) {
- hackRadio.checked = true;
- } else {
- normalRadio.checked = true;
- }
-
- const nameElement = appElement.querySelector(".name");
- nameElement.innerHTML = `
-
-
${appName}
-
${packageName}
-
- `;
- const checkbox = appElement.querySelector(".checkbox");
- checkbox.checked = targetList.includes(packageName);
- appListContainer.appendChild(appElement);
+ }));
+ renderAppList(appEntries);
+ });
+}
+
+/**
+ * Render processed app list to the UI
+ * @param {Array} data - Array of objects containing appName and packageName
+ * @returns {void}
+ */
+function renderAppList(data) {
+ // Sort
+ const sortedApps = data.sort((a, b) => {
+ const aChecked = targetList.includes(a.packageName);
+ const bChecked = targetList.includes(b.packageName);
+ if (aChecked !== bChecked) {
+ return aChecked ? -1 : 1;
+ }
+ return (a.appName || "").localeCompare(b.appName || "");
+ });
+
+ // Render
+ appListContainer.innerHTML = "";
+ sortedApps.forEach(({ appName, packageName }) => {
+ const appElement = document.importNode(appTemplate, true);
+ const contentElement = appElement.querySelector(".content");
+ contentElement.setAttribute("data-package", packageName);
+
+ // Set unique names for radio button groups
+ const radioButtons = appElement.querySelectorAll('input[type="radio"]');
+ radioButtons.forEach((radio) => {
+ radio.name = `mode-radio-${packageName}`;
});
- } catch (error) {
- toast("Failed to fetch app list!");
- console.error("Failed to fetch or render app list with names:", error);
- }
+
+ // Preselect the radio button based on the package name
+ const generateRadio = appElement.querySelector('#generate-mode');
+ const hackRadio = appElement.querySelector('#hack-mode');
+ const normalRadio = appElement.querySelector('#normal-mode');
+
+ if (appsWithExclamation.includes(packageName)) {
+ generateRadio.checked = true;
+ } else if (appsWithQuestion.includes(packageName)) {
+ hackRadio.checked = true;
+ } else {
+ normalRadio.checked = true;
+ }
+
+ const nameElement = appElement.querySelector(".name");
+ nameElement.innerHTML = `
+
+
${appName}
+
${packageName}
+
+ `;
+ const checkbox = appElement.querySelector(".checkbox");
+ checkbox.checked = targetList.includes(packageName);
+ appListContainer.appendChild(appElement);
+ });
+
+ loadingIndicator.style.display = "none";
hideFloatingBtn(false);
+ document.querySelector('.uninstall-container').classList.remove('hidden-uninstall');
toggleableCheckbox();
if (appListContainer.firstChild !== updateCard) {
appListContainer.insertBefore(updateCard, appListContainer.firstChild);
}
- const checkboxes = appListContainer.querySelectorAll(".checkbox");
setupRadioButtonListeners();
setupModeMenu();
updateCheckboxColor();
+ applyRippleEffect();
}
// Function to save app with ! and ? then process target list
diff --git a/module/webui/scripts/assets/kernelsu.js b/module/webui/scripts/assets/kernelsu.js
index ed09323..1413862 100644
--- a/module/webui/scripts/assets/kernelsu.js
+++ b/module/webui/scripts/assets/kernelsu.js
@@ -41,6 +41,27 @@ export function exec(command, options = {}) {
});
}
+/**
+ * Standard I/O stream for a child process.
+ * @class
+ */
+class Stdio {
+ constructor() {
+ this.listeners = {};
+ }
+ on(event, listener) {
+ if (!this.listeners[event]) {
+ this.listeners[event] = [];
+ }
+ this.listeners[event].push(listener);
+ }
+ emit(event, ...args) {
+ if (this.listeners[event]) {
+ this.listeners[event].forEach(listener => listener(...args));
+ }
+ }
+}
+
/**
* Spawn shell process with ksu.spawn
* @param {string} command - The command to execute
@@ -71,20 +92,6 @@ export function spawn(command, args = [], options = {}) {
}
}
};
- function Stdio() {
- this.listeners = {};
- }
- Stdio.prototype.on = function(event, listener) {
- if (!this.listeners[event]) {
- this.listeners[event] = [];
- }
- this.listeners[event].push(listener);
- };
- Stdio.prototype.emit = function(event, ...args) {
- if (this.listeners[event]) {
- this.listeners[event].forEach(listener => listener(...args));
- }
- };
const callbackName = getUniqueCallbackName("spawn");
window[callbackName] = child;
child.on("exit", () => delete window[callbackName]);
diff --git a/module/webui/scripts/boot_hash.js b/module/webui/scripts/boot_hash.js
index b362f52..99b7512 100644
--- a/module/webui/scripts/boot_hash.js
+++ b/module/webui/scripts/boot_hash.js
@@ -22,14 +22,12 @@ document.getElementById("boot-hash").addEventListener("click", async () => {
}, 10);
// read current boot hash
- exec("cat /data/adb/boot_hash")
+ exec(`sed '/^#/d; /^$/d' /data/adb/boot_hash`)
.then(({ errno, stdout }) => {
if (errno !== 0) {
inputBox.value = "";
} else {
- const validHash = stdout
- .split("\n")
- .filter(line => !line.startsWith("#") && line.trim())[0];
+ const validHash = stdout.trim();
inputBox.value = validHash || "";
}
});
@@ -48,7 +46,7 @@ const closeBootHashMenu = () => {
saveButton.addEventListener("click", async () => {
const inputValue = inputBox.value.trim();
exec(`
- resetprop -n ro.boot.vbmeta.digest ${inputValue}
+ resetprop -n ro.boot.vbmeta.digest "${inputValue}"
[ -z "${inputValue}" ] && rm -f /data/adb/boot_hash || {
echo "${inputValue}" > /data/adb/boot_hash
chmod 644 /data/adb/boot_hash
diff --git a/module/webui/scripts/main.js b/module/webui/scripts/main.js
index 3e3ee2e..5571bf4 100644
--- a/module/webui/scripts/main.js
+++ b/module/webui/scripts/main.js
@@ -11,7 +11,7 @@ const title = document.querySelector('.header');
export const noConnection = document.querySelector('.no-connection');
// Loading, Save and Prompt Elements
-const loadingIndicator = document.querySelector('.loading');
+export const loadingIndicator = document.querySelector('.loading');
const prompt = document.getElementById('prompt');
const floatingCard = document.querySelector('.floating-card');
const floatingBtn = document.querySelector('.floating-btn');
@@ -50,16 +50,12 @@ export async function refreshAppList() {
appListContainer.innerHTML = '';
loadingIndicator.style.display = 'flex';
document.querySelector('.uninstall-container').classList.add('hidden-uninstall');
- await new Promise(resolve => setTimeout(resolve, 500));
window.scrollTo(0, 0);
if (noConnection.style.display === "flex") {
updateCheck();
exec(`rm -f "${basePath}/common/tmp/exclude-list"`);
}
- await fetchAppList();
- applyRippleEffect();
- loadingIndicator.style.display = 'none';
- document.querySelector('.uninstall-container').classList.remove('hidden-uninstall');
+ fetchAppList();
isRefreshing = false;
}
@@ -201,7 +197,10 @@ function checkMMRL() {
// Funtion to adapt floating button hide in MMRL
export function hideFloatingBtn(hide = true) {
- if (!hide) floatingCard.style.transform = 'translateY(0)';
+ if (!hide) {
+ floatingCard.style.transform = 'translateY(0)';
+ floatingBtn.style.display = 'block';
+ }
else floatingCard.style.transform = 'translateY(calc(var(--window-inset-bottom, 0px) + 120px))';
}
@@ -299,15 +298,10 @@ document.addEventListener('DOMContentLoaded', async () => {
setupMenuToggle();
setupLanguageMenu();
setupSystemAppMenu();
- await fetchAppList();
- applyRippleEffect();
+ fetchAppList();
checkTrickyStoreVersion();
checkMagisk();
updateCheck();
securityPatch();
- loadingIndicator.style.display = "none";
- floatingBtn.style.display = 'block';
- hideFloatingBtn(false);
document.getElementById("refresh").addEventListener("click", refreshAppList);
- document.querySelector('.uninstall-container').classList.remove('hidden-uninstall');
});
diff --git a/module/webui/scripts/security_patch.js b/module/webui/scripts/security_patch.js
index b212be4..6f0e611 100644
--- a/module/webui/scripts/security_patch.js
+++ b/module/webui/scripts/security_patch.js
@@ -1,4 +1,4 @@
-import { exec } from './assets/kernelsu.js';
+import { exec, spawn } from './assets/kernelsu.js';
import { basePath, showPrompt } from './main.js';
const overlay = document.getElementById('security-patch-overlay');
@@ -218,24 +218,29 @@ export function securityPatch() {
// Auto config button
autoButton.addEventListener('click', () => {
- exec(`sh ${basePath}/common/get_extra.sh --security-patch`)
- .then(({ errno, stdout }) => {
- if (errno !== 0 || stdout.trim() === "not set") {
- showPrompt('security_patch.auto_failed', false);
- } else {
- exec(`touch /data/adb/tricky_store/security_patch_auto_config`)
- // Reset inputs
- allPatchInput.value = '';
- systemPatchInput.value = '';
- bootPatchInput.value = '';
- vendorPatchInput.value = '';
+ const output = spawn('sh', [`${basePath}/common/get_extra.sh`, '--security-patch']);
+ output.stdout.on('data', (data) => {
+ if (data.includes("not set")) {
+ showPrompt('security_patch.auto_failed', false);
+ }
+ });
+ output.on('exit', (code) => {
+ if (code === 0) {
+ exec(`touch /data/adb/tricky_store/security_patch_auto_config`)
+ // Reset inputs
+ allPatchInput.value = '';
+ systemPatchInput.value = '';
+ bootPatchInput.value = '';
+ vendorPatchInput.value = '';
- checkAdvanced(false);
- showPrompt('security_patch.auto_success');
- }
- hideSecurityPatchDialog();
- loadCurrentConfig();
- });
+ checkAdvanced(false);
+ showPrompt('security_patch.auto_success');
+ } else {
+ showPrompt('security_patch.auto_failed', false);
+ }
+ hideSecurityPatchDialog();
+ loadCurrentConfig();
+ });
});
// Save button
@@ -308,21 +313,18 @@ export function securityPatch() {
// Get button
getButton.addEventListener('click', async () => {
showPrompt('security_patch.fetching');
- setTimeout(() => {
- exec(`sh ${basePath}/common/get_extra.sh --get-security-patch`)
- .then(({ errno, stdout }) => {
- if (errno !== 0) {
- showPrompt('security_patch.get_failed', false);
- } else {
- showPrompt('security_patch.fetched', true, 1000);
- checkAdvanced(true);
+ const output = spawn('sh', [`${basePath}/common/get_extra.sh`, '--get-security-patch']);
+ output.stdout.on('data', (data) => {
+ showPrompt('security_patch.fetched', true, 1000);
+ checkAdvanced(true);
- allPatchInput.value = stdout.replace(/-/g, '');
- systemPatchInput.value = 'prop';
- bootPatchInput.value = stdout;
- vendorPatchInput.value = stdout;
- }
- })
- }, 200);
+ allPatchInput.value = data.replace(/-/g, '');
+ systemPatchInput.value = 'prop';
+ bootPatchInput.value = data;
+ vendorPatchInput.value = data;
+ });
+ output.on('exit', (code) => {
+ if (code !== 0) showPrompt('security_patch.get_failed', false);
+ });
});
}
diff --git a/module/webui/scripts/update.js b/module/webui/scripts/update.js
index 652e112..9333b84 100644
--- a/module/webui/scripts/update.js
+++ b/module/webui/scripts/update.js
@@ -1,4 +1,4 @@
-import { exec } from './assets/kernelsu.js';
+import { exec, spawn } from './assets/kernelsu.js';
import { basePath, showPrompt, noConnection, linkRedirect } from './main.js';
import { updateCard } from './applist.js';
@@ -52,12 +52,14 @@ export async function updateCheck() {
zipURL = data.zipUrl;
changelogURL = data.changelog;
- const { stdout } = await exec(`sh ${basePath}/common/get_extra.sh --check-update ${remoteVersionCode}`);
- if (stdout.includes("update")) {
- showPrompt("prompt.new_update", true, 1500);
- updateCard.style.display = "flex";
- setupUpdateMenu();
- }
+ const output = spawn('sh', [`${basePath}/common/get_extra.sh`, '--check-update', `${remoteVersionCode}`]);
+ output.stdout.on('data', (data) => {
+ if (data.includes("update")) {
+ showPrompt("prompt.new_update", true, 1500);
+ updateCard.style.display = "flex";
+ setupUpdateMenu();
+ }
+ });
} catch (error) {
console.error("Error fetching JSON or executing command:", error);
showPrompt("prompt.no_internet", false);
@@ -111,43 +113,41 @@ function setupUpdateMenu() {
// Update card
updateCard.addEventListener('click', async () => {
- try {
- const { stdout } = await exec(`
- [ -f ${basePath}/common/tmp/module.zip ] || echo "noModule"
- [ -f ${basePath}/common/tmp/changelog.md ] || echo "noChangelog"
- [ ! -f /data/adb/modules/TA_utl/update ] || echo "updated"
- `);
- if (stdout.trim().includes("updated")) {
- installButton.style.display = "none";
- rebootButton.style.display = "flex";
- openUpdateMenu();
- } else if (stdout.trim().includes("noChangelog")) {
- showPrompt("prompt.downloading");
- await downloadFile(changelogURL, "changelog.md");
- renderChangelog();
- openUpdateMenu();
- setTimeout(() => {
- updateCard.click();
- }, 200);
- } else if (stdout.trim().includes("noModule")) {
- if (downloading) return;
- downloading = true;
- const { errno } = await exec(`sh ${basePath}/common/get_extra.sh --get-update ${zipURL}`);
- if (errno === 0) {
+ const { stdout } = await exec(`
+ [ -f ${basePath}/common/tmp/module.zip ] || echo "noModule"
+ [ -f ${basePath}/common/tmp/changelog.md ] || echo "noChangelog"
+ [ ! -f /data/adb/modules/TA_utl/update ] || echo "updated"
+ `);
+ if (stdout.trim().includes("updated")) {
+ installButton.style.display = "none";
+ rebootButton.style.display = "flex";
+ openUpdateMenu();
+ } else if (stdout.trim().includes("noChangelog")) {
+ showPrompt("prompt.downloading");
+ await downloadFile(changelogURL, "changelog.md");
+ renderChangelog();
+ openUpdateMenu();
+ setTimeout(() => {
+ updateCard.click();
+ }, 200);
+ } else if (stdout.trim().includes("noModule")) {
+ if (downloading) return;
+ downloading = true;
+ const download = spawn('sh', [`${basePath}/common/get_extra.sh`, '--get-update', `${zipURL}`],
+ { env: { PATH: "$PATH:/data/adb/ap/bin:/data/adb/ksu/bin:/data/adb/magisk:/data/data/com.termux/files/usr/bin" } });
+ download.on('exit', (code) => {
+ downloading = false;
+ if (code === 0) {
showPrompt("prompt.downloaded");
installButton.style.display = "flex";
} else {
showPrompt("prompt.download_fail", false);
}
- downloading = false;
- } else {
- installButton.style.display = "flex";
- renderChangelog();
- openUpdateMenu();
- }
- } catch (error) {
- showPrompt("prompt.download_fail", false);
- console.error('Error download module update:', error);
+ });
+ } else {
+ installButton.style.display = "flex";
+ renderChangelog();
+ openUpdateMenu();
}
});
@@ -160,16 +160,20 @@ function setupUpdateMenu() {
// Install button
installButton.addEventListener('click', async () => {
showPrompt("prompt.installing");
- await new Promise(resolve => setTimeout(resolve, 300));
- const { errno, stderr } = await exec(`sh ${basePath}/common/get_extra.sh --install-update`);
- if (errno === 0) {
- showPrompt("prompt.installed");
- installButton.style.display = "none";
- rebootButton.style.display = "flex";
- } else {
- showPrompt("prompt.install_fail", false);
- console.error('Fail to execute installation script:', stderr);
- }
+ const output = spawn('sh', [`${basePath}/common/get_extra.sh`, '--install-update'],
+ { env: { PATH: "$PATH:/data/adb/ap/bin:/data/adb/ksu/bin:/data/adb/magisk" } });
+ output.stderr.on('data', (data) => {
+ console.error('Error during installation:', data);
+ })
+ output.on('exit', (code) => {
+ if (code === 0) {
+ showPrompt("prompt.installed");
+ installButton.style.display = "none";
+ rebootButton.style.display = "flex";
+ } else {
+ showPrompt("prompt.install_fail", false);
+ }
+ });
});
// Reboot button
diff --git a/module/webui/styles/applist.css b/module/webui/styles/applist.css
index 9ee2e70..4986a33 100644
--- a/module/webui/styles/applist.css
+++ b/module/webui/styles/applist.css
@@ -58,12 +58,16 @@
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.2);
}
+.update-content h1,
.update-content h3 {
- font-size: 24px;
margin: 10px 0;
margin-top: 0;
}
+.update-content h3 {
+ font-size: 24px;
+}
+
.changelog {
max-height: 65vh;
overflow-y: auto;