mirror of
https://github.com/HMCL-dev/HMCL.git
synced 2025-09-19 00:36:10 -04:00
Upgrader
This commit is contained in:
parent
8c61bc1a5e
commit
41337f66a8
@ -0,0 +1,224 @@
|
|||||||
|
/*
|
||||||
|
* Hello Minecraft! Launcher.
|
||||||
|
* Copyright (C) 2017 huangyuhui <huanghongxun2008@126.com>
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see {http://www.gnu.org/licenses/}.
|
||||||
|
*/
|
||||||
|
package org.jackhuang.hmcl.upgrade
|
||||||
|
|
||||||
|
import java.security.PrivilegedActionException
|
||||||
|
import java.io.IOException
|
||||||
|
import com.google.gson.JsonSyntaxException
|
||||||
|
import javafx.scene.control.Alert
|
||||||
|
import java.io.File
|
||||||
|
import java.net.URLClassLoader
|
||||||
|
import java.security.PrivilegedExceptionAction
|
||||||
|
import java.security.AccessController
|
||||||
|
import java.util.Arrays
|
||||||
|
import java.util.ArrayList
|
||||||
|
import java.util.jar.JarFile
|
||||||
|
import org.jackhuang.hmcl.Main
|
||||||
|
import java.util.HashMap
|
||||||
|
import org.jackhuang.hmcl.task.*
|
||||||
|
import java.util.logging.Level
|
||||||
|
import java.util.zip.GZIPInputStream
|
||||||
|
import java.util.jar.Pack200
|
||||||
|
import java.util.jar.JarOutputStream
|
||||||
|
import org.jackhuang.hmcl.util.*
|
||||||
|
import java.net.URISyntaxException
|
||||||
|
import org.jackhuang.hmcl.util.OS
|
||||||
|
import org.jackhuang.hmcl.i18n
|
||||||
|
import org.jackhuang.hmcl.ui.alert
|
||||||
|
import org.jackhuang.hmcl.util.VersionNumber
|
||||||
|
import java.net.URI
|
||||||
|
|
||||||
|
class AppDataUpgrader : IUpgrader {
|
||||||
|
|
||||||
|
@Throws(IOException::class, PrivilegedActionException::class)
|
||||||
|
private fun launchNewerVersion(args: Array<String>, jar: File): Boolean {
|
||||||
|
JarFile(jar).use { jarFile ->
|
||||||
|
val mainClass = jarFile.manifest.mainAttributes.getValue("Main-Class")
|
||||||
|
if (mainClass != null) {
|
||||||
|
val al = ArrayList(Arrays.asList(*args))
|
||||||
|
al.add("--noupdate")
|
||||||
|
AccessController.doPrivileged(PrivilegedExceptionAction<Void> {
|
||||||
|
URLClassLoader(arrayOf(jar.toURI().toURL()),
|
||||||
|
URLClassLoader.getSystemClassLoader().parent).loadClass(mainClass)
|
||||||
|
.getMethod("main", Array<String>::class.java).invoke(null, *arrayOf<Any>(al.toTypedArray()))
|
||||||
|
null
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun parseArguments(nowVersion: VersionNumber, args: Array<String>) {
|
||||||
|
val f = AppDataUpgraderPackGzTask.HMCL_VER_FILE
|
||||||
|
if (!args.contains("--noupdate"))
|
||||||
|
try {
|
||||||
|
if (f.exists()) {
|
||||||
|
val m = GSON.fromJson(f.readText(), Map::class.java)
|
||||||
|
val s = m["ver"] as? String?
|
||||||
|
if (s != null && VersionNumber.asVersion(s.toString()) > nowVersion) {
|
||||||
|
val j = m["loc"] as? String?
|
||||||
|
if (j != null) {
|
||||||
|
val jar = File(j)
|
||||||
|
if (jar.exists() && launchNewerVersion(args, jar))
|
||||||
|
System.exit(0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (ex: JsonSyntaxException) {
|
||||||
|
f.delete()
|
||||||
|
} catch (t: IOException) {
|
||||||
|
LOG.log(Level.SEVERE, "Failed to execute newer version application", t)
|
||||||
|
} catch (t: PrivilegedActionException) {
|
||||||
|
LOG.log(Level.SEVERE, "Failed to execute newer version application", t)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun download(checker: UpdateChecker, versionNumber: VersionNumber) {
|
||||||
|
val version = versionNumber as IntVersionNumber
|
||||||
|
checker.requestDownloadLink().then {
|
||||||
|
val map: Map<String, String>? = it["update_checker.request_download_link"]
|
||||||
|
if (alert(Alert.AlertType.CONFIRMATION, "Alert", i18n("update.newest_version") + version[0] + "." + version[1] + "." + version[2] + "\n"
|
||||||
|
+ i18n("update.should_open_link")))
|
||||||
|
if (map != null && map.containsKey("jar") && map["jar"]!!.isNotBlank())
|
||||||
|
try {
|
||||||
|
var hash: String? = null
|
||||||
|
if (map.containsKey("jarsha1"))
|
||||||
|
hash = map.get("jarsha1")
|
||||||
|
if (AppDataUpgraderJarTask(map["jar"]!!, version.toString(), hash!!).test()) {
|
||||||
|
ProcessBuilder(JavaVersion.fromCurrentEnvironment().binary.absolutePath, "-jar", AppDataUpgraderJarTask.getSelf(version.toString()).absolutePath).directory(File("").absoluteFile).start()
|
||||||
|
System.exit(0)
|
||||||
|
}
|
||||||
|
} catch (ex: IOException) {
|
||||||
|
LOG.log(Level.SEVERE, "Failed to create upgrader", ex)
|
||||||
|
}
|
||||||
|
else if (map != null && map.containsKey("pack") && map["pack"]!!.isNotBlank())
|
||||||
|
try {
|
||||||
|
var hash: String? = null
|
||||||
|
if (map.containsKey("packsha1"))
|
||||||
|
hash = map["packsha1"]
|
||||||
|
if (AppDataUpgraderPackGzTask(map["pack"]!!, version.toString(), hash!!).test()) {
|
||||||
|
ProcessBuilder(JavaVersion.fromCurrentEnvironment().binary.absolutePath, "-jar", AppDataUpgraderPackGzTask.getSelf(version.toString()).absolutePath).directory(File("").absoluteFile).start()
|
||||||
|
System.exit(0)
|
||||||
|
}
|
||||||
|
} catch (ex: IOException) {
|
||||||
|
LOG.log(Level.SEVERE, "Failed to create upgrader", ex)
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
var url = URL_PUBLISH
|
||||||
|
if (map != null)
|
||||||
|
if (map.containsKey(OS.CURRENT_OS.checkedName))
|
||||||
|
url = map.get(OS.CURRENT_OS.checkedName)!!
|
||||||
|
else if (map.containsKey(OS.UNKNOWN.checkedName))
|
||||||
|
url = map.get(OS.UNKNOWN.checkedName)!!
|
||||||
|
try {
|
||||||
|
java.awt.Desktop.getDesktop().browse(URI(url))
|
||||||
|
} catch (e: URISyntaxException) {
|
||||||
|
LOG.log(Level.SEVERE, "Failed to browse uri: " + url, e)
|
||||||
|
OS.setClipboard(url)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
LOG.log(Level.SEVERE, "Failed to browse uri: " + url, e)
|
||||||
|
OS.setClipboard(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
null
|
||||||
|
}.execute()
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppDataUpgraderPackGzTask(downloadLink: String, private val newestVersion: String, private val expectedHash: String) : Task() {
|
||||||
|
private val tempFile: File = File.createTempFile("hmcl", ".pack.gz")
|
||||||
|
override val dependents = listOf(FileDownloadTask(downloadLink.toURL(), tempFile, expectedHash))
|
||||||
|
|
||||||
|
init {
|
||||||
|
onDone += { event -> if (event.failed) tempFile.delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun execute() {
|
||||||
|
val json = HashMap<String, String>()
|
||||||
|
var f = getSelf(newestVersion)
|
||||||
|
if (!f.parentFile.makeDirectory())
|
||||||
|
throw IOException("Failed to make directories: " + f.parent)
|
||||||
|
|
||||||
|
var i = 0
|
||||||
|
while (f.exists()) {
|
||||||
|
f = File(BASE_FOLDER, "HMCL-" + newestVersion + (if (i > 0) "-" + i else "") + ".jar")
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
if (!f.createNewFile())
|
||||||
|
throw IOException("Failed to create new file: " + f)
|
||||||
|
|
||||||
|
JarOutputStream(f.outputStream()).use { jos -> Pack200.newUnpacker().unpack(GZIPInputStream(tempFile.inputStream()), jos) }
|
||||||
|
json.put("ver", newestVersion)
|
||||||
|
json.put("loc", f.absolutePath)
|
||||||
|
val result = GSON.toJson(json)
|
||||||
|
HMCL_VER_FILE.writeText(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
val info: String
|
||||||
|
get() = "Upgrade"
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
|
||||||
|
val BASE_FOLDER = Main.getWorkingDirectory("hmcl")
|
||||||
|
val HMCL_VER_FILE = File(BASE_FOLDER, "hmclver.json")
|
||||||
|
|
||||||
|
fun getSelf(ver: String): File {
|
||||||
|
return File(BASE_FOLDER, "HMCL-$ver.jar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
class AppDataUpgraderJarTask(downloadLink: String, private val newestVersion: String, expectedHash: String) : Task() {
|
||||||
|
override var title = "Upgrade"
|
||||||
|
set(value) {}
|
||||||
|
private val tempFile = File.createTempFile("hmcl", ".jar")
|
||||||
|
|
||||||
|
init {
|
||||||
|
onDone += { event -> if (event.failed) tempFile.delete() }
|
||||||
|
}
|
||||||
|
|
||||||
|
override val dependents = listOf(FileDownloadTask(downloadLink.toURL(), tempFile, expectedHash))
|
||||||
|
|
||||||
|
override fun execute() {
|
||||||
|
val json = HashMap<String, String>()
|
||||||
|
val f = getSelf(newestVersion)
|
||||||
|
tempFile.copyTo(f)
|
||||||
|
json.put("ver", newestVersion)
|
||||||
|
json.put("loc", f.absolutePath)
|
||||||
|
val result = GSON.toJson(json)
|
||||||
|
HMCL_VER_FILE.writeText(result)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val BASE_FOLDER = Main.getWorkingDirectory("hmcl")
|
||||||
|
val HMCL_VER_FILE = File(BASE_FOLDER, "hmclver.json")
|
||||||
|
|
||||||
|
fun getSelf(ver: String): File {
|
||||||
|
return File(BASE_FOLDER, "HMCL-$ver.jar")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val URL_PUBLISH = "http://www.mcbbs.net/thread-142335-1-1.html"
|
||||||
|
}
|
||||||
|
}
|
49
HMCL/src/main/kotlin/org/jackhuang/hmcl/upgrade/IUpgrader.kt
Normal file
49
HMCL/src/main/kotlin/org/jackhuang/hmcl/upgrade/IUpgrader.kt
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
/*
|
||||||
|
* Hello Minecraft! Launcher.
|
||||||
|
* Copyright (C) 2017 huangyuhui <huanghongxun2008@126.com>
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see {http://www.gnu.org/licenses/}.
|
||||||
|
*/
|
||||||
|
package org.jackhuang.hmcl.upgrade
|
||||||
|
|
||||||
|
import org.jackhuang.hmcl.util.VersionNumber
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @author huangyuhui
|
||||||
|
*/
|
||||||
|
interface IUpgrader {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paring arguments to decide on whether the upgrade is needed.
|
||||||
|
*
|
||||||
|
* @param nowVersion now launcher version
|
||||||
|
* @param args Application CommandLine Arguments
|
||||||
|
*/
|
||||||
|
fun parseArguments(nowVersion: VersionNumber, args: Array<String>)
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Just download the new app.
|
||||||
|
*
|
||||||
|
* @param checker Should be VersionChecker
|
||||||
|
* @param versionNumber the newest version
|
||||||
|
*
|
||||||
|
* @return should return true
|
||||||
|
*/
|
||||||
|
fun download(checker: UpdateChecker, versionNumber: VersionNumber)
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
val NOW_UPGRADER: IUpgrader = AppDataUpgrader()
|
||||||
|
}
|
||||||
|
}
|
110
HMCL/src/main/kotlin/org/jackhuang/hmcl/upgrade/UpdateChecker.kt
Normal file
110
HMCL/src/main/kotlin/org/jackhuang/hmcl/upgrade/UpdateChecker.kt
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
/*
|
||||||
|
* Hello Minecraft! Launcher.
|
||||||
|
* Copyright (C) 2017 huangyuhui <huanghongxun2008@126.com>
|
||||||
|
*
|
||||||
|
* This program is free software: you can redistribute it and/or modify
|
||||||
|
* it under the terms of the GNU General Public License as published by
|
||||||
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
|
* (at your option) any later version.
|
||||||
|
*
|
||||||
|
* This program is distributed in the hope that it will be useful,
|
||||||
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
|
* GNU General Public License for more details.
|
||||||
|
*
|
||||||
|
* You should have received a copy of the GNU General Public License
|
||||||
|
* along with this program. If not, see {http://www.gnu.org/licenses/}.
|
||||||
|
*/
|
||||||
|
package org.jackhuang.hmcl.upgrade
|
||||||
|
|
||||||
|
import java.io.IOException
|
||||||
|
import com.google.gson.JsonSyntaxException
|
||||||
|
import org.jackhuang.hmcl.task.TaskResult
|
||||||
|
import org.jackhuang.hmcl.util.*
|
||||||
|
import java.util.logging.Level
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @author huangyuhui
|
||||||
|
*/
|
||||||
|
class UpdateChecker(var base: VersionNumber, var type: String) {
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var isOutOfDate = false
|
||||||
|
private set
|
||||||
|
var versionString: String? = null
|
||||||
|
private var download_link: Map<String, String>? = null
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the <b>cached</b> newest version number, use "process" method to
|
||||||
|
* download!
|
||||||
|
*
|
||||||
|
* @return the newest version number
|
||||||
|
*
|
||||||
|
* @see process
|
||||||
|
*/
|
||||||
|
var newVersion: VersionNumber? = null
|
||||||
|
internal set
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Download the version number synchronously. When you execute this method
|
||||||
|
* first, should leave "showMessage" false.
|
||||||
|
*
|
||||||
|
* @param showMessage If it is requested to warn the user that there is a
|
||||||
|
* new version.
|
||||||
|
*
|
||||||
|
* @return the process observable.
|
||||||
|
*/
|
||||||
|
fun process(showMessage: Boolean): TaskResult<VersionNumber> {
|
||||||
|
return object : TaskResult<VersionNumber>() {
|
||||||
|
override val id = "update_checker.process"
|
||||||
|
override fun execute() {
|
||||||
|
if (newVersion == null) {
|
||||||
|
versionString = ("http://huangyuhui.duapp.com/info.php?type=$type").toURL().doGet()
|
||||||
|
newVersion = VersionNumber.asVersion(versionString!!)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (newVersion == null) {
|
||||||
|
LOG.warning("Failed to check update...")
|
||||||
|
} else if (base < newVersion!!)
|
||||||
|
isOutOfDate = true
|
||||||
|
if (isOutOfDate)
|
||||||
|
result = newVersion
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the download links.
|
||||||
|
*
|
||||||
|
* @return a JSON, which contains the server response.
|
||||||
|
*/
|
||||||
|
@Synchronized
|
||||||
|
fun requestDownloadLink(): TaskResult<Map<String, String>> {
|
||||||
|
return object : TaskResult<Map<String, String>>() {
|
||||||
|
override val id = "update_checker.request_download_link"
|
||||||
|
override fun execute() {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
if (download_link == null)
|
||||||
|
try {
|
||||||
|
download_link = GSON.fromJson(("http://huangyuhui.duapp.com/update_link.php?type=$type").toURL().doGet(), Map::class.java) as Map<String, String>
|
||||||
|
} catch (e: JsonSyntaxException) {
|
||||||
|
LOG.log(Level.WARNING, "Failed to get update link.", e)
|
||||||
|
} catch (e: IOException) {
|
||||||
|
LOG.log(Level.WARNING, "Failed to get update link.", e)
|
||||||
|
}
|
||||||
|
|
||||||
|
result = download_link
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
/*
|
||||||
|
val upgrade: EventHandler<SimpleEvent<VersionNumber>> = EventHandler()
|
||||||
|
|
||||||
|
fun checkOutdate() {
|
||||||
|
if (isOutOfDate)
|
||||||
|
if (EVENT_BUS.fireChannelResulted(OutOfDateEvent(this, newVersion)))
|
||||||
|
upgrade.fire(SimpleEvent(this, newVersion))
|
||||||
|
}*/
|
||||||
|
}
|
@ -132,6 +132,7 @@ abstract class Task {
|
|||||||
fun executor() = TaskExecutor(this)
|
fun executor() = TaskExecutor(this)
|
||||||
fun executor(taskListener: TaskListener) = TaskExecutor(this).apply { this.taskListener = taskListener }
|
fun executor(taskListener: TaskListener) = TaskExecutor(this).apply { this.taskListener = taskListener }
|
||||||
fun start() = executor().start()
|
fun start() = executor().start()
|
||||||
|
fun test() = executor().test()
|
||||||
fun subscribe(subscriber: Task) = TaskExecutor(with(subscriber)).apply { start() }
|
fun subscribe(subscriber: Task) = TaskExecutor(with(subscriber)).apply { start() }
|
||||||
|
|
||||||
fun subscribe(scheduler: Scheduler = Scheduler.DEFAULT, closure: (AutoTypingMap<String>) -> Unit) = subscribe(task(scheduler, closure))
|
fun subscribe(scheduler: Scheduler = Scheduler.DEFAULT, closure: (AutoTypingMap<String>) -> Unit) = subscribe(task(scheduler, closure))
|
||||||
|
@ -47,6 +47,20 @@ class TaskExecutor(private val task: Task) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Throws(InterruptedException::class)
|
||||||
|
fun test(): Boolean {
|
||||||
|
var flag = true
|
||||||
|
val future = Scheduler.NEW_THREAD.schedule {
|
||||||
|
if (!executeTasks(listOf(task))) {
|
||||||
|
taskListener?.onTerminate()
|
||||||
|
flag = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
workerQueue.add(future)
|
||||||
|
future!!.get()
|
||||||
|
return flag
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Cancel the subscription ant interrupt all tasks.
|
* Cancel the subscription ant interrupt all tasks.
|
||||||
*/
|
*/
|
||||||
|
@ -28,23 +28,23 @@ import java.util.*
|
|||||||
/**
|
/**
|
||||||
* Represents the operating system.
|
* Represents the operating system.
|
||||||
*/
|
*/
|
||||||
enum class OS {
|
enum class OS(val checkedName: String) {
|
||||||
/**
|
/**
|
||||||
* Microsoft Windows.
|
* Microsoft Windows.
|
||||||
*/
|
*/
|
||||||
WINDOWS,
|
WINDOWS("windows"),
|
||||||
/**
|
/**
|
||||||
* Linux and Unix like OS, including Solaris.
|
* Linux and Unix like OS, including Solaris.
|
||||||
*/
|
*/
|
||||||
LINUX,
|
LINUX("linux"),
|
||||||
/**
|
/**
|
||||||
* Mac OS X.
|
* Mac OS X.
|
||||||
*/
|
*/
|
||||||
OSX,
|
OSX("osx"),
|
||||||
/**
|
/**
|
||||||
* Unknown operating system.
|
* Unknown operating system.
|
||||||
*/
|
*/
|
||||||
UNKNOWN;
|
UNKNOWN("universal");
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/**
|
/**
|
||||||
|
@ -75,6 +75,8 @@ class StringVersionNumber internal constructor(val version: String): VersionNumb
|
|||||||
*/
|
*/
|
||||||
class IntVersionNumber internal constructor(val version: List<Int>): VersionNumber() {
|
class IntVersionNumber internal constructor(val version: List<Int>): VersionNumber() {
|
||||||
|
|
||||||
|
operator fun get(index: Int) = version[index]
|
||||||
|
|
||||||
override fun compareTo(other: VersionNumber): Int {
|
override fun compareTo(other: VersionNumber): Int {
|
||||||
if (other !is IntVersionNumber) return 0
|
if (other !is IntVersionNumber) return 0
|
||||||
val len = minOf(this.version.size, other.version.size)
|
val len = minOf(this.version.size, other.version.size)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user