mirror of
https://gitlab.com/vyla-entertainment/go.git
synced 2026-11-14 15:37:23 -05:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c869101923 | |||
| feccab9aa1 | |||
| 82c91334f6 | |||
| a0de7b4bca | |||
| 119b0ad28f | |||
| 6d38d9c335 |
@@ -1,3 +1,6 @@
|
|||||||
|
|
||||||
|
## Development & Build
|
||||||
|
|
||||||
Install dependencies:
|
Install dependencies:
|
||||||
```bash
|
```bash
|
||||||
npm install
|
npm install
|
||||||
@@ -13,11 +16,18 @@ Compile and assemble the release APK:
|
|||||||
npm run build:apk
|
npm run build:apk
|
||||||
```
|
```
|
||||||
|
|
||||||
Release to GitLab:
|
## Release
|
||||||
```bash
|
|
||||||
|
Set credentials once per session, then run release:
|
||||||
|
```powershell
|
||||||
|
$env:ANDROID_HOME = "$env:LOCALAPPDATA\Android\Sdk"
|
||||||
|
$env:NDK_HOME = "$env:LOCALAPPDATA\Android\Sdk\ndk\29.0.13846066"
|
||||||
|
$env:GITLAB_TOKEN = "your-token-here"
|
||||||
npm run release
|
npm run release
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This builds the payload, compiles the APK for arm64 and x86_64, commits and tags the version, uploads all artifacts, and creates the GitLab release automatically.
|
||||||
|
|
||||||
## Logs
|
## Logs
|
||||||
|
|
||||||
Stream live device logs:
|
Stream live device logs:
|
||||||
@@ -35,7 +45,7 @@ Dump logs to file:
|
|||||||
& "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe" logcat -d -s VylaGo > vyla-go.log
|
& "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe" logcat -d -s VylaGo > vyla-go.log
|
||||||
```
|
```
|
||||||
|
|
||||||
Show all crash/fatal logs:
|
Show all crash and fatal logs:
|
||||||
```powershell
|
```powershell
|
||||||
& "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe" logcat -d | Select-String "VylaGo|AndroidRuntime|FATAL"
|
& "$env:LOCALAPPDATA\Android\Sdk\platform-tools\adb.exe" logcat -d | Select-String "VylaGo|AndroidRuntime|FATAL"
|
||||||
```
|
```
|
||||||
|
|||||||
20
README.md
20
README.md
@@ -3,31 +3,17 @@
|
|||||||
Vyla Go is the native Android mobile launcher for the Vyla media ecosystem.
|
Vyla Go is the native Android mobile launcher for the Vyla media ecosystem.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Native Android APK Architecture**: Built on Tauri v2 with native hardware acceleration.
|
- **Native Android APK Architecture**: Built on Tauri v2 with native hardware acceleration.
|
||||||
- **Automated Resource Management**: Downloads, unpacks, and updates core services directly from official releases.
|
- **Automated Resource Management**: Downloads, unpacks, and updates core services directly from official releases.
|
||||||
- **Hardware Shader Interface**: Real-time WebGL shader lighting and glassmorphism elements.
|
- **Hardware Shader Interface**: Real-time WebGL shader lighting and glassmorphism elements.
|
||||||
|
|
||||||
## Prerequisites
|
## Prerequisites
|
||||||
|
|
||||||
- Node.js 20+
|
- Node.js 20+
|
||||||
- Rust & Cargo
|
- Rust & Cargo
|
||||||
- Android Studio with Android SDK & NDK 29+
|
- Android Studio with Android SDK & NDK 29+
|
||||||
- Toolchain targets:
|
- Toolchain targets:
|
||||||
```bash
|
|
||||||
rustup target add aarch64-linux-android
|
|
||||||
```
|
|
||||||
|
|
||||||
## Development & Build
|
|
||||||
Install dependencies:
|
|
||||||
```bash
|
```bash
|
||||||
npm install
|
rustup target add aarch64-linux-android x86_64-linux-android
|
||||||
```
|
|
||||||
|
|
||||||
Compile and assemble the release APK:
|
|
||||||
```bash
|
|
||||||
npm run build:apk
|
|
||||||
```
|
|
||||||
|
|
||||||
Release packages to GitLab:
|
|
||||||
```bash
|
|
||||||
npm run release
|
|
||||||
```
|
```
|
||||||
@@ -20,33 +20,91 @@ function cleanBuildDir() {
|
|||||||
fs.mkdirSync(BUILD_DIR, { recursive: true });
|
fs.mkdirSync(BUILD_DIR, { recursive: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeSensitiveFiles(dir) {
|
||||||
|
if (!fs.existsSync(dir)) return;
|
||||||
|
|
||||||
|
const gitDir = path.join(dir, '.git');
|
||||||
|
if (fs.existsSync(gitDir)) {
|
||||||
|
fs.rmSync(gitDir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = path.join(dir, entry.name);
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
if (entry.name === '.git') {
|
||||||
|
fs.rmSync(fullPath, { recursive: true, force: true });
|
||||||
|
} else {
|
||||||
|
removeSensitiveFiles(fullPath);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (
|
||||||
|
entry.name.startsWith('.env') ||
|
||||||
|
entry.name.endsWith('.key') ||
|
||||||
|
entry.name.endsWith('.pem') ||
|
||||||
|
entry.name === 'credentials.json' ||
|
||||||
|
entry.name === '.gitlab-ci.yml'
|
||||||
|
) {
|
||||||
|
fs.rmSync(fullPath, { force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function clonePrivateRepo(repoConfig, targetDir) {
|
function clonePrivateRepo(repoConfig, targetDir) {
|
||||||
if (fs.existsSync(targetDir)) {
|
if (fs.existsSync(targetDir)) {
|
||||||
fs.rmSync(targetDir, { recursive: true, force: true });
|
fs.rmSync(targetDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
const token = process.env.GITLAB_TOKEN || process.env.VYLA_GITLAB_TOKEN;
|
const token = process.env.GITLAB_TOKEN || process.env.VYLA_GITLAB_TOKEN;
|
||||||
const auth = token ? `oauth2:${token}@` : '';
|
const repoUrl = `https://gitlab.com/${GITLAB_GROUP}/${repoConfig.name}.git`;
|
||||||
const repoUrl = `https://${auth}gitlab.com/${GITLAB_GROUP}/${repoConfig.name}.git`;
|
|
||||||
execSync(`git clone --depth 1 --branch ${repoConfig.branch} ${repoUrl} ${targetDir}`, { stdio: 'inherit' });
|
try {
|
||||||
|
if (token) {
|
||||||
|
const authHeader = Buffer.from(`oauth2:${token.trim()}`).toString('base64');
|
||||||
|
execSync(
|
||||||
|
`git -c "http.extraHeader=Authorization: Basic ${authHeader}" clone --depth 1 --branch ${repoConfig.branch} ${repoUrl} "${targetDir}"`,
|
||||||
|
{ stdio: 'inherit' }
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
execSync(
|
||||||
|
`git clone --depth 1 --branch ${repoConfig.branch} ${repoUrl} "${targetDir}"`,
|
||||||
|
{ stdio: 'inherit' }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
const gitFolder = path.join(targetDir, '.git');
|
||||||
|
if (fs.existsSync(gitFolder)) {
|
||||||
|
fs.rmSync(gitFolder, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function build() {
|
async function build() {
|
||||||
cleanBuildDir();
|
cleanBuildDir();
|
||||||
|
|
||||||
for (const [key, config] of Object.entries(REPO_CONFIG)) {
|
for (const [key, config] of Object.entries(REPO_CONFIG)) {
|
||||||
const targetDir = path.join(__dirname, key);
|
const targetDir = path.join(__dirname, key);
|
||||||
|
console.log(`Cloning ${config.name}...`);
|
||||||
clonePrivateRepo(config, targetDir);
|
clonePrivateRepo(config, targetDir);
|
||||||
|
|
||||||
|
console.log(`Installing production dependencies for ${config.name}...`);
|
||||||
execSync('npm install --omit=dev', { cwd: targetDir, stdio: 'inherit' });
|
execSync('npm install --omit=dev', { cwd: targetDir, stdio: 'inherit' });
|
||||||
|
|
||||||
|
removeSensitiveFiles(targetDir);
|
||||||
}
|
}
|
||||||
|
|
||||||
const zip = new AdmZip();
|
const zip = new AdmZip();
|
||||||
|
|
||||||
const configDir = path.join(__dirname, 'config');
|
const configDir = path.join(__dirname, 'config');
|
||||||
if (fs.existsSync(configDir)) {
|
if (fs.existsSync(configDir)) {
|
||||||
|
removeSensitiveFiles(configDir);
|
||||||
zip.addLocalFolder(configDir, 'config');
|
zip.addLocalFolder(configDir, 'config');
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const repo of REPOS) {
|
for (const repo of REPOS) {
|
||||||
const srcRepo = path.join(__dirname, repo);
|
const srcRepo = path.join(__dirname, repo);
|
||||||
|
removeSensitiveFiles(srcRepo);
|
||||||
zip.addLocalFolder(srcRepo, repo);
|
zip.addLocalFolder(srcRepo, repo);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,6 +112,20 @@ async function build() {
|
|||||||
if (fs.existsSync(zipPath)) fs.rmSync(zipPath, { force: true });
|
if (fs.existsSync(zipPath)) fs.rmSync(zipPath, { force: true });
|
||||||
zip.writeZip(zipPath);
|
zip.writeZip(zipPath);
|
||||||
|
|
||||||
|
const verificationZip = new AdmZip(zipPath);
|
||||||
|
const leakedEntries = verificationZip.getEntries().filter(e =>
|
||||||
|
e.entryName.includes('.git/') ||
|
||||||
|
e.entryName.includes('.git\\') ||
|
||||||
|
e.entryName.startsWith('.env') ||
|
||||||
|
e.entryName.includes('/.env') ||
|
||||||
|
e.entryName.endsWith('config/encrypted-credentials.json')
|
||||||
|
);
|
||||||
|
|
||||||
|
if (leakedEntries.length > 0) {
|
||||||
|
fs.rmSync(zipPath, { force: true });
|
||||||
|
throw new Error(`SECURITY ABORT: Archive contains forbidden files:\n${leakedEntries.map(e => e.entryName).join('\n')}`);
|
||||||
|
}
|
||||||
|
|
||||||
const hash = crypto.createHash('sha256');
|
const hash = crypto.createHash('sha256');
|
||||||
hash.update(fs.readFileSync(zipPath));
|
hash.update(fs.readFileSync(zipPath));
|
||||||
const checksum = hash.digest('hex');
|
const checksum = hash.digest('hex');
|
||||||
@@ -68,6 +140,8 @@ async function build() {
|
|||||||
for (const repo of REPOS) {
|
for (const repo of REPOS) {
|
||||||
fs.rmSync(path.join(__dirname, repo), { recursive: true, force: true });
|
fs.rmSync(path.join(__dirname, repo), { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
console.log('Build complete and verified clean.');
|
||||||
}
|
}
|
||||||
|
|
||||||
build().catch(err => {
|
build().catch(err => {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "vyla-go",
|
"name": "vyla-go",
|
||||||
"version": "1.0.2",
|
"version": "1.0.6",
|
||||||
"description": "Vyla Go Mobile Launcher",
|
"description": "Vyla Go Mobile Launcher",
|
||||||
"main": "main.js",
|
"main": "main.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
|
|||||||
37
release.js
37
release.js
@@ -32,11 +32,21 @@ function incrementVersion() {
|
|||||||
function gitCommitAndTag(version) {
|
function gitCommitAndTag(version) {
|
||||||
try {
|
try {
|
||||||
execSync('git add package.json src-tauri/tauri.conf.json', { stdio: 'inherit' });
|
execSync('git add package.json src-tauri/tauri.conf.json', { stdio: 'inherit' });
|
||||||
execSync(`git commit -m "chore: release ${version}"`, { stdio: 'inherit' });
|
try {
|
||||||
execSync(`git tag -a ${version} -m "Release ${version}"`, { stdio: 'inherit' });
|
execSync(`git commit -m "chore: release ${version}"`, { stdio: 'inherit' });
|
||||||
|
} catch (_) {
|
||||||
|
console.log('Working tree clean, skipping commit.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
execSync(`git tag -a ${version} -m "Release ${version}"`, { stdio: 'inherit' });
|
||||||
|
} catch (_) {
|
||||||
|
console.log(`Tag ${version} already exists locally.`);
|
||||||
|
}
|
||||||
|
|
||||||
execSync('git push origin HEAD --tags', { stdio: 'inherit' });
|
execSync('git push origin HEAD --tags', { stdio: 'inherit' });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.warn('Git operations failed (non-fatal):', e.message);
|
console.warn('Git push failed (will fallback to GitLab API ref):', e.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,6 +104,11 @@ async function createRelease(version, assets) {
|
|||||||
const payload = assets.payload;
|
const payload = assets.payload;
|
||||||
const manifest = assets.manifest;
|
const manifest = assets.manifest;
|
||||||
|
|
||||||
|
let branch = 'main';
|
||||||
|
try {
|
||||||
|
branch = execSync('git rev-parse --abbrev-ref HEAD').toString().trim() || 'main';
|
||||||
|
} catch (_) { }
|
||||||
|
|
||||||
const description = [
|
const description = [
|
||||||
`## Automated Release ${version}`,
|
`## Automated Release ${version}`,
|
||||||
'',
|
'',
|
||||||
@@ -110,6 +125,7 @@ async function createRelease(version, assets) {
|
|||||||
const body = JSON.stringify({
|
const body = JSON.stringify({
|
||||||
name: `Vyla Go Release ${version}`,
|
name: `Vyla Go Release ${version}`,
|
||||||
tag_name: tag,
|
tag_name: tag,
|
||||||
|
ref: branch,
|
||||||
description,
|
description,
|
||||||
assets: {
|
assets: {
|
||||||
links: [
|
links: [
|
||||||
@@ -137,11 +153,22 @@ async function main() {
|
|||||||
const version = `v${rawVersion}`;
|
const version = `v${rawVersion}`;
|
||||||
console.log(`Releasing ${version}...`);
|
console.log(`Releasing ${version}...`);
|
||||||
|
|
||||||
|
const androidHome = process.env.ANDROID_HOME || `${process.env.LOCALAPPDATA}\\Android\\Sdk`;
|
||||||
|
const ndkHome = process.env.NDK_HOME || `${androidHome}\\ndk\\29.0.13846066`;
|
||||||
|
|
||||||
|
const buildEnv = {
|
||||||
|
...process.env,
|
||||||
|
ANDROID_HOME: androidHome,
|
||||||
|
NDK_HOME: ndkHome,
|
||||||
|
GITLAB_TOKEN: TOKEN,
|
||||||
|
VYLA_GITLAB_TOKEN: TOKEN,
|
||||||
|
};
|
||||||
|
|
||||||
console.log('Building payload...');
|
console.log('Building payload...');
|
||||||
execSync('npm run build:payload', { stdio: 'inherit' });
|
execSync('npm run build:payload', { stdio: 'inherit', env: buildEnv });
|
||||||
|
|
||||||
console.log('Building APK...');
|
console.log('Building APK...');
|
||||||
execSync('npm run build:apk', { stdio: 'inherit' });
|
execSync('npm run build:apk', { stdio: 'inherit', env: buildEnv });
|
||||||
|
|
||||||
const apkSearchDirs = [
|
const apkSearchDirs = [
|
||||||
path.join(__dirname, 'src-tauri', 'gen', 'android', 'app', 'build', 'outputs', 'apk', 'universal', 'release'),
|
path.join(__dirname, 'src-tauri', 'gen', 'android', 'app', 'build', 'outputs', 'apk', 'universal', 'release'),
|
||||||
|
|||||||
@@ -1,9 +1,36 @@
|
|||||||
package com.vyla.go
|
package com.vyla.go
|
||||||
|
|
||||||
|
import android.graphics.Bitmap
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
|
import android.view.View
|
||||||
|
import android.view.ViewGroup
|
||||||
|
import android.webkit.WebChromeClient
|
||||||
|
import android.webkit.WebView
|
||||||
|
|
||||||
class MainActivity : TauriActivity() {
|
class MainActivity : TauriActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
|
val root = findViewById<ViewGroup>(android.R.id.content)
|
||||||
|
root.viewTreeObserver.addOnGlobalLayoutListener {
|
||||||
|
findAndHookWebView(root)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun findAndHookWebView(view: View) {
|
||||||
|
if (view is WebView) {
|
||||||
|
view.webChromeClient = object : WebChromeClient() {
|
||||||
|
override fun getDefaultVideoPoster(): Bitmap {
|
||||||
|
return Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (view is ViewGroup) {
|
||||||
|
for (i in 0 until view.childCount) {
|
||||||
|
findAndHookWebView(view.getChildAt(i))
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -21,7 +21,6 @@ open class BuildTask : DefaultTask() {
|
|||||||
runTauriCli(executable)
|
runTauriCli(executable)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
|
if (Os.isFamily(Os.FAMILY_WINDOWS)) {
|
||||||
// Try different Windows-specific extensions
|
|
||||||
val fallbacks = listOf(
|
val fallbacks = listOf(
|
||||||
"$executable.exe",
|
"$executable.exe",
|
||||||
"$executable.cmd",
|
"$executable.cmd",
|
||||||
|
|||||||
@@ -323,6 +323,45 @@ async fn download_and_extract_payload(app: tauri::AppHandle, window: WebviewWind
|
|||||||
Ok(base_dir)
|
Ok(base_dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ANDROID_VIDEO_FIX_SCRIPT: &str = r#"
|
||||||
|
(function() {
|
||||||
|
function suppressNativeOverlay() {
|
||||||
|
const styleId = '__vyla_native_video_fix';
|
||||||
|
if (!document.getElementById(styleId)) {
|
||||||
|
const s = document.createElement('style');
|
||||||
|
s.id = styleId;
|
||||||
|
s.textContent = `
|
||||||
|
video::-webkit-media-controls,
|
||||||
|
video::-webkit-media-controls-enclosure,
|
||||||
|
video::-webkit-media-controls-panel,
|
||||||
|
video::-webkit-media-controls-overlay-play-button,
|
||||||
|
video::-webkit-media-controls-start-playback-button {
|
||||||
|
display: none !important;
|
||||||
|
-webkit-appearance: none !important;
|
||||||
|
opacity: 0 !important;
|
||||||
|
pointer-events: none !important;
|
||||||
|
}
|
||||||
|
`;
|
||||||
|
(document.head || document.documentElement).appendChild(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.querySelectorAll('video').forEach(v => {
|
||||||
|
v.setAttribute('playsinline', '');
|
||||||
|
v.setAttribute('webkit-playsinline', '');
|
||||||
|
v.removeAttribute('poster');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
suppressNativeOverlay();
|
||||||
|
new MutationObserver(suppressNativeOverlay).observe(document.documentElement, {
|
||||||
|
childList: true,
|
||||||
|
subtree: true,
|
||||||
|
attributes: true,
|
||||||
|
attributeFilter: ['poster']
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
"#;
|
||||||
|
|
||||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||||
pub fn run() {
|
pub fn run() {
|
||||||
tauri::Builder::default()
|
tauri::Builder::default()
|
||||||
@@ -389,6 +428,10 @@ pub fn run() {
|
|||||||
.setup(|app| {
|
.setup(|app| {
|
||||||
let handle = app.handle().clone();
|
let handle = app.handle().clone();
|
||||||
|
|
||||||
|
if let Some(window) = handle.get_webview_window("main") {
|
||||||
|
let _ = window.eval(ANDROID_VIDEO_FIX_SCRIPT);
|
||||||
|
}
|
||||||
|
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
std::thread::sleep(std::time::Duration::from_millis(400));
|
std::thread::sleep(std::time::Duration::from_millis(400));
|
||||||
|
|
||||||
@@ -398,7 +441,10 @@ pub fn run() {
|
|||||||
match download_and_extract_payload(handle.clone(), window.clone()).await {
|
match download_and_extract_payload(handle.clone(), window.clone()).await {
|
||||||
Ok(_) => {
|
Ok(_) => {
|
||||||
std::thread::sleep(std::time::Duration::from_millis(400));
|
std::thread::sleep(std::time::Duration::from_millis(400));
|
||||||
let _ = window.eval("window.location.replace('http://vyla.localhost/frontend/index.html');");
|
let _ = window.eval(&format!(
|
||||||
|
"{}; window.location.replace('http://vyla.localhost/frontend/index.html');",
|
||||||
|
ANDROID_VIDEO_FIX_SCRIPT
|
||||||
|
));
|
||||||
}
|
}
|
||||||
Err(err) => {
|
Err(err) => {
|
||||||
update_ui(
|
update_ui(
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"$schema": "https://schema.tauri.app/config/2",
|
"$schema": "https://schema.tauri.app/config/2",
|
||||||
"productName": "Vyla Go",
|
"productName": "Vyla Go",
|
||||||
"version": "1.0.2",
|
"version": "1.0.6",
|
||||||
"identifier": "com.vyla.go",
|
"identifier": "com.vyla.go",
|
||||||
"build": {
|
"build": {
|
||||||
"beforeDevCommand": "",
|
"beforeDevCommand": "",
|
||||||
|
|||||||
Reference in New Issue
Block a user