0.1.0
This commit is contained in:
BIN
electron/assets/images/logo.png
Normal file
BIN
electron/assets/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
File diff suppressed because one or more lines are too long
BIN
electron/build/icon.png
Normal file
BIN
electron/build/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 109 KiB |
161
electron/loading.html
Normal file
161
electron/loading.html
Normal file
@@ -0,0 +1,161 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
|
||||
<meta name="color-scheme" content="light dark">
|
||||
<title>MeshChatX</title>
|
||||
<script src="./assets/js/tailwindcss/tailwind-v3.4.3-forms-v0.5.7.js"></script>
|
||||
</head>
|
||||
<body class="min-h-screen bg-slate-100 text-gray-900 antialiased dark:bg-zinc-950 dark:text-zinc-50 transition-colors">
|
||||
|
||||
<div class="absolute inset-0 -z-10 overflow-hidden">
|
||||
<div class="absolute -left-32 -top-40 h-80 w-80 rounded-full bg-gradient-to-br from-blue-500/30 via-indigo-500/20 to-purple-500/30 blur-3xl dark:from-blue-600/25 dark:via-indigo-600/25 dark:to-purple-600/25"></div>
|
||||
<div class="absolute -right-24 top-20 h-64 w-64 rounded-full bg-gradient-to-br from-emerald-400/30 via-cyan-500/20 to-blue-500/30 blur-3xl dark:from-emerald-500/25 dark:via-cyan-500/25 dark:to-blue-500/25"></div>
|
||||
</div>
|
||||
|
||||
<main class="relative flex min-h-screen items-center justify-center px-6 py-10">
|
||||
<div class="w-full max-w-xl">
|
||||
<div class="rounded-3xl border border-slate-200/80 bg-white/80 shadow-2xl backdrop-blur-xl ring-1 ring-white/60 dark:border-zinc-800/70 dark:bg-zinc-900/70 dark:ring-zinc-800/70 transition-colors">
|
||||
<div class="p-8 space-y-6">
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="flex h-16 w-16 items-center justify-center rounded-2xl bg-gradient-to-br from-blue-500 via-indigo-500 to-purple-500 shadow-lg ring-4 ring-white/60 dark:ring-zinc-800/70">
|
||||
<img class="h-10 w-10 object-contain" src="./assets/images/logo.png" alt="MeshChatX logo">
|
||||
</div>
|
||||
<div class="space-y-1">
|
||||
<p class="text-xs uppercase tracking-[0.2em] text-blue-600 dark:text-blue-300">MeshChatX</p>
|
||||
<div class="text-2xl font-semibold tracking-tight text-gray-900 dark:text-white">MeshChatX</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-300">Custom fork by Sudo-Ivan</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center justify-between rounded-2xl border border-dashed border-slate-200/90 bg-slate-50/70 px-4 py-3 text-sm text-gray-700 dark:border-zinc-800/80 dark:bg-zinc-900/70 dark:text-gray-200 transition-colors">
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="h-2 w-2 rounded-full bg-blue-500 animate-pulse"></span>
|
||||
<span>Preparing your node</span>
|
||||
</div>
|
||||
<div class="inline-flex items-center gap-2 rounded-full bg-blue-100/80 px-3 py-1 text-xs font-semibold text-blue-700 shadow-sm dark:bg-blue-900/50 dark:text-blue-200">
|
||||
<span class="h-2 w-2 rounded-full bg-blue-500"></span>
|
||||
<span id="status-text">Starting services</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-4">
|
||||
<div class="relative inline-flex h-14 w-14 items-center justify-center">
|
||||
<span class="absolute inset-0 rounded-full border-4 border-blue-500/25 dark:border-blue-500/20"></span>
|
||||
<span class="absolute inset-0 animate-spin rounded-full border-4 border-transparent border-t-blue-500 dark:border-t-blue-400"></span>
|
||||
<span class="absolute inset-2 rounded-full bg-blue-500/10 dark:bg-blue-500/15"></span>
|
||||
</div>
|
||||
<div class="flex-1 space-y-1">
|
||||
<div class="text-base font-medium text-gray-900 dark:text-white">Loading services</div>
|
||||
<div class="text-sm text-gray-600 dark:text-gray-400">Waiting for the MeshChatX API to come online.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-2 gap-4 text-sm">
|
||||
<div class="rounded-2xl border border-slate-200/90 bg-white/70 p-4 dark:border-zinc-800/80 dark:bg-zinc-900/70 transition-colors">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Version</div>
|
||||
<div class="mt-1 text-lg font-semibold text-gray-900 dark:text-white" id="app-version">v0.0.0</div>
|
||||
</div>
|
||||
<div class="rounded-2xl border border-slate-200/90 bg-white/70 p-4 text-right dark:border-zinc-800/80 dark:bg-zinc-900/70 transition-colors">
|
||||
<div class="text-xs uppercase tracking-wide text-gray-500 dark:text-gray-400">Status</div>
|
||||
<div class="mt-1 text-lg font-semibold text-emerald-600 dark:text-emerald-300" id="status-badge">Booting</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<script>
|
||||
const statusText = document.getElementById("status-text");
|
||||
const statusBadge = document.getElementById("status-badge");
|
||||
|
||||
applyTheme(detectPreferredTheme());
|
||||
showAppVersion();
|
||||
check();
|
||||
listenForSystemThemeChanges();
|
||||
|
||||
async function showAppVersion() {
|
||||
const appVersion = await window.electron.appVersion();
|
||||
document.getElementById("app-version").innerText = "v" + appVersion;
|
||||
}
|
||||
|
||||
function detectPreferredTheme() {
|
||||
try {
|
||||
const storedTheme = localStorage.getItem("meshchat.theme") || localStorage.getItem("meshchatx.theme");
|
||||
if (storedTheme === "dark" || storedTheme === "light") {
|
||||
return storedTheme;
|
||||
}
|
||||
} catch (e) {}
|
||||
return window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
const isDark = theme === "dark";
|
||||
document.documentElement.classList.toggle("dark", isDark);
|
||||
document.body.dataset.theme = isDark ? "dark" : "light";
|
||||
}
|
||||
|
||||
function listenForSystemThemeChanges() {
|
||||
if (!window.matchMedia) {
|
||||
return;
|
||||
}
|
||||
const media = window.matchMedia("(prefers-color-scheme: dark)");
|
||||
media.addEventListener("change", (event) => {
|
||||
applyTheme(event.matches ? "dark" : "light");
|
||||
});
|
||||
}
|
||||
|
||||
let detectedProtocol = "http";
|
||||
|
||||
async function check() {
|
||||
const protocols = ["https", "http"];
|
||||
for (const protocol of protocols) {
|
||||
try {
|
||||
const result = await fetch(`${protocol}://localhost:9337/api/v1/status`, {
|
||||
cache: "no-store",
|
||||
});
|
||||
const status = result.status;
|
||||
const data = await result.json();
|
||||
if (status === 200 && data.status === "ok") {
|
||||
detectedProtocol = protocol;
|
||||
statusText.innerText = "Launching UI";
|
||||
statusBadge.innerText = "Ready";
|
||||
syncThemeFromConfig();
|
||||
setTimeout(onReady, 200);
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
setTimeout(check, 300);
|
||||
}
|
||||
|
||||
function onReady() {
|
||||
const timestamp = (new Date()).getTime();
|
||||
window.location.href = `${detectedProtocol}://localhost:9337/?nocache=${timestamp}`;
|
||||
}
|
||||
|
||||
async function syncThemeFromConfig() {
|
||||
try {
|
||||
const response = await fetch(`${detectedProtocol}://localhost:9337/api/v1/config`, { cache: "no-store" });
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
const config = await response.json();
|
||||
if (config && (config.theme === "dark" || config.theme === "light")) {
|
||||
applyTheme(config.theme);
|
||||
try {
|
||||
localStorage.setItem("meshchat.theme", config.theme);
|
||||
} catch (e) {}
|
||||
}
|
||||
} catch (e) {}
|
||||
}
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
346
electron/main.js
Normal file
346
electron/main.js
Normal file
@@ -0,0 +1,346 @@
|
||||
const { app, BrowserWindow, dialog, ipcMain, shell, systemPreferences } = require("electron");
|
||||
const electronPrompt = require("electron-prompt");
|
||||
const { spawn } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const path = require("node:path");
|
||||
|
||||
// remember main window
|
||||
var mainWindow = null;
|
||||
|
||||
// remember child process for exe so we can kill it when app exits
|
||||
var exeChildProcess = null;
|
||||
|
||||
// allow fetching app version via ipc
|
||||
ipcMain.handle("app-version", () => {
|
||||
return app.getVersion();
|
||||
});
|
||||
|
||||
// add support for showing an alert window via ipc
|
||||
ipcMain.handle("alert", async (event, message) => {
|
||||
return await dialog.showMessageBox(mainWindow, {
|
||||
message: message,
|
||||
});
|
||||
});
|
||||
|
||||
// add support for showing a confirm window via ipc
|
||||
ipcMain.handle("confirm", async (event, message) => {
|
||||
// show confirm dialog
|
||||
const result = await dialog.showMessageBox(mainWindow, {
|
||||
type: "question",
|
||||
title: "Confirm",
|
||||
message: message,
|
||||
cancelId: 0, // esc key should press cancel button
|
||||
defaultId: 1, // enter key should press ok button
|
||||
buttons: [
|
||||
"Cancel", // 0
|
||||
"OK", // 1
|
||||
],
|
||||
});
|
||||
|
||||
// check if user clicked OK
|
||||
return result.response === 1;
|
||||
});
|
||||
|
||||
// add support for showing a prompt window via ipc
|
||||
ipcMain.handle("prompt", async (event, message) => {
|
||||
return await electronPrompt({
|
||||
title: message,
|
||||
label: "",
|
||||
value: "",
|
||||
type: "input",
|
||||
inputAttrs: {
|
||||
type: "text",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
// allow relaunching app via ipc
|
||||
ipcMain.handle("relaunch", () => {
|
||||
app.relaunch();
|
||||
app.exit();
|
||||
});
|
||||
|
||||
// allow showing a file path in os file manager
|
||||
ipcMain.handle("showPathInFolder", (event, path) => {
|
||||
shell.showItemInFolder(path);
|
||||
});
|
||||
|
||||
function log(message) {
|
||||
// log to stdout of this process
|
||||
console.log(message);
|
||||
|
||||
// make sure main window exists
|
||||
if (!mainWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
// make sure window is not destroyed
|
||||
if (mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
// log to web console
|
||||
mainWindow.webContents.send("log", message);
|
||||
}
|
||||
|
||||
function getDefaultStorageDir() {
|
||||
// if we are running a windows portable exe, we want to use .reticulum-meshchat in the portable exe dir
|
||||
// e.g if we launch "E:\Some\Path\MeshChat.exe" we want to use "E:\Some\Path\.reticulum-meshchat"
|
||||
const portableExecutableDir = process.env.PORTABLE_EXECUTABLE_DIR;
|
||||
if (process.platform === "win32" && portableExecutableDir != null) {
|
||||
return path.join(portableExecutableDir, ".reticulum-meshchat");
|
||||
}
|
||||
|
||||
// otherwise, we will fall back to putting the storage dir in the users home directory
|
||||
// e.g: ~/.reticulum-meshchat
|
||||
return path.join(app.getPath("home"), ".reticulum-meshchat");
|
||||
}
|
||||
|
||||
function getDefaultReticulumConfigDir() {
|
||||
// if we are running a windows portable exe, we want to use .reticulum in the portable exe dir
|
||||
// e.g if we launch "E:\Some\Path\MeshChat.exe" we want to use "E:\Some\Path\.reticulum"
|
||||
const portableExecutableDir = process.env.PORTABLE_EXECUTABLE_DIR;
|
||||
if (process.platform === "win32" && portableExecutableDir != null) {
|
||||
return path.join(portableExecutableDir, ".reticulum");
|
||||
}
|
||||
|
||||
// otherwise, we will fall back to using the .reticulum folder in the users home directory
|
||||
// e.g: ~/.reticulum
|
||||
return path.join(app.getPath("home"), ".reticulum");
|
||||
}
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
// get arguments passed to application, and remove the provided application path
|
||||
const ignoredArguments = ["--no-sandbox", "--ozone-platform-hint=auto"];
|
||||
const userProvidedArguments = process.argv.slice(1).filter((arg) => !ignoredArguments.includes(arg));
|
||||
const shouldLaunchHeadless = userProvidedArguments.includes("--headless");
|
||||
|
||||
if (!shouldLaunchHeadless) {
|
||||
// create browser window
|
||||
mainWindow = new BrowserWindow({
|
||||
width: 1500,
|
||||
height: 800,
|
||||
webPreferences: {
|
||||
// used to inject logging over ipc
|
||||
preload: path.join(__dirname, "preload.js"),
|
||||
// Security: disable node integration in renderer
|
||||
nodeIntegration: false,
|
||||
// Security: enable context isolation (default in Electron 12+)
|
||||
contextIsolation: true,
|
||||
// Security: enable sandbox for additional protection
|
||||
sandbox: true,
|
||||
// Security: disable remote module (deprecated but explicit)
|
||||
enableRemoteModule: false,
|
||||
},
|
||||
});
|
||||
|
||||
// open external links in default web browser instead of electron
|
||||
mainWindow.webContents.setWindowOpenHandler(({ url }) => {
|
||||
var shouldShowInNewElectronWindow = false;
|
||||
|
||||
// we want to open call.html in a new electron window
|
||||
// but all other target="_blank" links should open in the system web browser
|
||||
// we don't want /rnode-flasher/index.html to open in electron, otherwise user can't select usb devices...
|
||||
if (
|
||||
(url.startsWith("http://localhost") || url.startsWith("https://localhost")) &&
|
||||
url.includes("/call.html")
|
||||
) {
|
||||
shouldShowInNewElectronWindow = true;
|
||||
}
|
||||
|
||||
// we want to open blob urls in a new electron window
|
||||
else if (url.startsWith("blob:")) {
|
||||
shouldShowInNewElectronWindow = true;
|
||||
}
|
||||
|
||||
// open in new electron window
|
||||
if (shouldShowInNewElectronWindow) {
|
||||
return {
|
||||
action: "allow",
|
||||
};
|
||||
}
|
||||
|
||||
// fallback to opening any other url in external browser
|
||||
shell.openExternal(url);
|
||||
return {
|
||||
action: "deny",
|
||||
};
|
||||
});
|
||||
|
||||
// navigate to loading page
|
||||
await mainWindow.loadFile(path.join(__dirname, "loading.html"));
|
||||
|
||||
// ask mac users for microphone access for audio calls to work
|
||||
if (process.platform === "darwin") {
|
||||
await systemPreferences.askForMediaAccess("microphone");
|
||||
}
|
||||
}
|
||||
|
||||
// find path to python/cxfreeze reticulum meshchat executable
|
||||
// Note: setup.py creates ReticulumMeshChatX (with X), not ReticulumMeshChat
|
||||
const exeName = process.platform === "win32" ? "ReticulumMeshChatX.exe" : "ReticulumMeshChatX";
|
||||
|
||||
// get app path (handles both development and packaged app)
|
||||
const appPath = app.getAppPath();
|
||||
// get resources path (where extraFiles are placed)
|
||||
const resourcesPath = process.resourcesPath || path.join(appPath, "..", "..");
|
||||
var exe = null;
|
||||
|
||||
// when packaged, extraFiles are placed at resources/app/electron/build/exe
|
||||
// when packaged with asar, unpacked files are in app.asar.unpacked/ directory
|
||||
// app.getAppPath() returns the path to app.asar, so unpacked is at the same level
|
||||
const possiblePaths = [
|
||||
// packaged app - extraFiles location (resources/app/electron/build/exe)
|
||||
path.join(resourcesPath, "app", "electron", "build", "exe", exeName),
|
||||
// packaged app with asar (unpacked files from asarUnpack)
|
||||
path.join(appPath, "..", "app.asar.unpacked", "build", "exe", exeName),
|
||||
// packaged app without asar (relative to app path)
|
||||
path.join(appPath, "build", "exe", exeName),
|
||||
// development mode (relative to electron directory)
|
||||
path.join(__dirname, "build", "exe", exeName),
|
||||
// development mode (relative to project root)
|
||||
path.join(__dirname, "..", "build", "exe", exeName),
|
||||
];
|
||||
|
||||
// find the first path that exists
|
||||
for (const possibleExe of possiblePaths) {
|
||||
if (fs.existsSync(possibleExe)) {
|
||||
exe = possibleExe;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// verify executable exists
|
||||
if (!exe || !fs.existsSync(exe)) {
|
||||
const errorMsg = `Could not find executable: ${exeName}\nChecked paths:\n${possiblePaths.join("\n")}\n\nApp path: ${appPath}\nResources path: ${resourcesPath}`;
|
||||
log(errorMsg);
|
||||
if (mainWindow) {
|
||||
await dialog.showMessageBox(mainWindow, {
|
||||
message: errorMsg,
|
||||
});
|
||||
}
|
||||
app.quit();
|
||||
return;
|
||||
}
|
||||
|
||||
log(`Found executable at: ${exe}`);
|
||||
|
||||
try {
|
||||
// arguments we always want to pass in
|
||||
const requiredArguments = [
|
||||
"--headless", // reticulum meshchat usually launches default web browser, we don't want this when using electron
|
||||
"--port",
|
||||
"9337", // FIXME: let system pick a random unused port?
|
||||
// '--test-exception-message', 'Test Exception Message', // uncomment to test the crash dialog
|
||||
];
|
||||
|
||||
// if user didn't provide reticulum config dir, we should provide it
|
||||
if (!userProvidedArguments.includes("--reticulum-config-dir")) {
|
||||
requiredArguments.push("--reticulum-config-dir", getDefaultReticulumConfigDir());
|
||||
}
|
||||
|
||||
// if user didn't provide storage dir, we should provide it
|
||||
if (!userProvidedArguments.includes("--storage-dir")) {
|
||||
requiredArguments.push("--storage-dir", getDefaultStorageDir());
|
||||
}
|
||||
|
||||
// spawn executable
|
||||
exeChildProcess = await spawn(exe, [
|
||||
...requiredArguments, // always provide required arguments
|
||||
...userProvidedArguments, // also include any user provided arguments
|
||||
]);
|
||||
|
||||
// log stdout
|
||||
var stdoutLines = [];
|
||||
exeChildProcess.stdout.setEncoding("utf8");
|
||||
exeChildProcess.stdout.on("data", function (data) {
|
||||
// log
|
||||
log(data.toString());
|
||||
|
||||
// keep track of last 10 stdout lines
|
||||
stdoutLines.push(data.toString());
|
||||
if (stdoutLines.length > 10) {
|
||||
stdoutLines.shift();
|
||||
}
|
||||
});
|
||||
|
||||
// log stderr
|
||||
var stderrLines = [];
|
||||
exeChildProcess.stderr.setEncoding("utf8");
|
||||
exeChildProcess.stderr.on("data", function (data) {
|
||||
// log
|
||||
log(data.toString());
|
||||
|
||||
// keep track of last 10 stderr lines
|
||||
stderrLines.push(data.toString());
|
||||
if (stderrLines.length > 10) {
|
||||
stderrLines.shift();
|
||||
}
|
||||
});
|
||||
|
||||
// log errors
|
||||
exeChildProcess.on("error", function (error) {
|
||||
log(error);
|
||||
});
|
||||
|
||||
// quit electron app if exe dies
|
||||
exeChildProcess.on("exit", async function (code) {
|
||||
// if no exit code provided, we wanted exit to happen, so do nothing
|
||||
if (code == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
// tell user that Visual C++ redistributable needs to be installed on Windows
|
||||
if (code === 3221225781 && process.platform === "win32") {
|
||||
await dialog.showMessageBox(mainWindow, {
|
||||
message: "Microsoft Visual C++ redistributable must be installed to run this application.",
|
||||
});
|
||||
app.quit();
|
||||
return;
|
||||
}
|
||||
|
||||
// show crash log
|
||||
const stdout = stdoutLines.join("");
|
||||
const stderr = stderrLines.join("");
|
||||
await dialog.showMessageBox(mainWindow, {
|
||||
message: [
|
||||
"MeshChat Crashed!",
|
||||
"",
|
||||
`Exit Code: ${code}`,
|
||||
"",
|
||||
`----- stdout -----`,
|
||||
"",
|
||||
stdout,
|
||||
`----- stderr -----`,
|
||||
"",
|
||||
stderr,
|
||||
].join("\n"),
|
||||
});
|
||||
|
||||
// quit after dismissing error dialog
|
||||
app.quit();
|
||||
});
|
||||
} catch (e) {
|
||||
log(e);
|
||||
}
|
||||
});
|
||||
|
||||
function quit() {
|
||||
// kill python process
|
||||
if (exeChildProcess) {
|
||||
exeChildProcess.kill("SIGKILL");
|
||||
}
|
||||
|
||||
// quit electron app
|
||||
app.quit();
|
||||
}
|
||||
|
||||
// quit electron if all windows are closed
|
||||
app.on("window-all-closed", () => {
|
||||
quit();
|
||||
});
|
||||
|
||||
// make sure child process is killed if app is quiting
|
||||
app.on("quit", () => {
|
||||
quit();
|
||||
});
|
||||
36
electron/preload.js
Normal file
36
electron/preload.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const { ipcRenderer, contextBridge } = require("electron");
|
||||
|
||||
// forward logs received from exe to web console
|
||||
ipcRenderer.on("log", (event, message) => console.log(message));
|
||||
|
||||
contextBridge.exposeInMainWorld("electron", {
|
||||
// allow fetching app version in electron browser window
|
||||
appVersion: async function () {
|
||||
return await ipcRenderer.invoke("app-version");
|
||||
},
|
||||
|
||||
// show an alert dialog in electron browser window, this fixes a bug where alert breaks input fields on windows
|
||||
alert: async function (message) {
|
||||
return await ipcRenderer.invoke("alert", message);
|
||||
},
|
||||
|
||||
// show a confirm dialog in electron browser window, this fixes a bug where confirm breaks input fields on windows
|
||||
confirm: async function (message) {
|
||||
return await ipcRenderer.invoke("confirm", message);
|
||||
},
|
||||
|
||||
// add support for using "prompt" in electron browser window
|
||||
prompt: async function (message) {
|
||||
return await ipcRenderer.invoke("prompt", message);
|
||||
},
|
||||
|
||||
// allow relaunching app in electron browser window
|
||||
relaunch: async function () {
|
||||
return await ipcRenderer.invoke("relaunch");
|
||||
},
|
||||
|
||||
// allow showing a file path in os file manager
|
||||
showPathInFolder: async function (path) {
|
||||
return await ipcRenderer.invoke("showPathInFolder", path);
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user