Compare commits

...

3 Commits

13 changed files with 1729 additions and 34 deletions
+11
View File
@@ -0,0 +1,11 @@
echo "Checking environment..."
if command -v nuitka &> /dev/null; then
echo "Nuitka installed"
else
echo "Nuitka not installed. Install it via \"uv pip install nuitka\" !" >&2
fi
nuitka --onefile --standalone --output-filename=serverjar main.py --output-filename=serverjar
nuitka --onefile --standalone --output-filename=sarclent client.py --output-filename=sarclient
echo "Done."
+137 -4
View File
@@ -26,7 +26,9 @@ SERVER_JAR_DIR = Path.home() / ".serverjar"
CLIENT_CERT_DIR = Path.home() / ".serverjar" / "client" / "cert" CLIENT_CERT_DIR = Path.home() / ".serverjar" / "client" / "cert"
CLIENT_CERT_SUFFIXES = {".pem", ".crt", ".cer"} CLIENT_CERT_SUFFIXES = {".pem", ".crt", ".cer"}
CLIENT_HISTORY_FILE = Path.home() / ".serverjar" / "history" / "history.txt" CLIENT_HISTORY_FILE = Path.home() / ".serverjar" / "history" / "history.txt"
CLIENT_CONNECTION_HISTORY_FILE = Path.home() / ".serverjar" / "history" / "connection_history.txt"
CLIENT_LOG_DIR = Path.home() / ".serverjar" / "client" / "logs" CLIENT_LOG_DIR = Path.home() / ".serverjar" / "client" / "logs"
DEFAULT_CONNECT_TIMEOUT = 10.0
def add_client_cert(cert_path): def add_client_cert(cert_path):
source = Path(cert_path).expanduser() source = Path(cert_path).expanduser()
@@ -94,6 +96,68 @@ def create_client_tls_context(log=None, warn=None):
return context return context
def get_connection_history(log=None, warn=None):
connection_history = []
if not CLIENT_CONNECTION_HISTORY_FILE.exists():
return []
if callable(log):
log("Searching existing connections history...")
try:
with CLIENT_CONNECTION_HISTORY_FILE.open(mode="r", encoding="utf-8") as f:
for line in f:
try:
line = line.strip()
if line.count(":") < 2:
continue
server_name, host, port = line.rsplit(":", 2)
# Ignore unknown format line record
if len(server_name) == 0 or len(host) == 0 or len(port) == 0:
continue
int(port)
connection_history.append({
"serverName": server_name,
"host": host,
"port": port,
})
except ValueError:
continue
except Exception as e:
if callable(warn):
warn(f"Unable to load connection history file: {e}")
return connection_history
def save_connection_history(connection_history, log=None, warn=None):
if not connection_history:
return
CLIENT_CONNECTION_HISTORY_FILE.parent.mkdir(parents=True, exist_ok=True)
if callable(log):
log("Saving connection history file...")
try:
with CLIENT_CONNECTION_HISTORY_FILE.open(mode="w", encoding="utf-8") as f:
for server in connection_history:
server_name = server.get("serverName")
host = server.get("host")
port = server.get("port")
if not server_name or not host or not port:
continue
f.write(f"{server_name}:{host}:{port}\n")
except Exception as e:
if callable(warn):
warn(f"Unable to save connection history file: {e}")
def get_history(log=None, warn=None): def get_history(log=None, warn=None):
if not CLIENT_HISTORY_FILE.exists(): if not CLIENT_HISTORY_FILE.exists():
@@ -227,6 +291,7 @@ class ServerJarClient(Application):
self.cmds = [] self.cmds = []
self.current_index = None self.current_index = None
self.history_draft = "" self.history_draft = ""
self.connection_history = []
@self.kb.add("c-c") @self.kb.add("c-c")
def closing_kb(event): def closing_kb(event):
@@ -426,6 +491,43 @@ class ServerJarClient(Application):
self._log("History cleared") self._log("History cleared")
return True return True
def _enable_retry_mode(cmd):
self.args.retry = True
self._log("Auto-retry mode enabled")
return True
def _list_connection_history(cmd):
if len(self.connection_history) == 0:
self._log("No connection history exists.")
return True
for index, server in enumerate(self.connection_history):
self._log("{}: {}".format(index, server.get("serverName")))
return True
def _connect_to_record_server(cmd):
parts = cmd.split()
if len(parts) != 2:
self._err("Usage: _hc ${index}")
return True
try:
index = int(parts[1])
server = self.connection_history[index]
host = server.get("host")
port = int(server.get("port"))
if not host:
raise ValueError("empty host")
except (ValueError, TypeError, IndexError):
self._err("Invalid index")
return True
connect_to_server(host, port)
return True
cmd_map = { cmd_map = {
"_exit": { "_exit": {
"func": _shutdown, "func": _shutdown,
@@ -459,10 +561,23 @@ class ServerJarClient(Application):
"func": _clear, "func": _clear,
"description": "Clear the log area", "description": "Clear the log area",
}, },
"_retry": {
"func": _enable_retry_mode,
"description": "Reconnect to the remote server if connection fails",
},
"_help": { "_help": {
"func": _help, "func": _help,
"description": "Display the help message", "description": "Display the help message",
}, },
"_lhc": {
"func": _list_connection_history,
"description": "List the connection history (Only list the server that have one connection is successful)",
}
,
"_hc": {
"func": _connect_to_record_server,
"description": "Connect to the server listed in the connection history. (Usage: _hc ${index})",
}
} }
if not command.strip(): if not command.strip():
@@ -513,6 +628,8 @@ class ServerJarClient(Application):
required=False) required=False)
parser.add_argument('-r', '--retry', help="Retry when disconnect", action='store_true', default=False, parser.add_argument('-r', '--retry', help="Retry when disconnect", action='store_true', default=False,
required=False) required=False)
parser.add_argument('--connect-timeout', type=float, default=DEFAULT_CONNECT_TIMEOUT,
help="Socket connection timeout in seconds", required=False)
parser.add_argument('--add-cert', help="Add server certificate", type=str, default=None, required=False) parser.add_argument('--add-cert', help="Add server certificate", type=str, default=None, required=False)
args = parser.parse_args() args = parser.parse_args()
@@ -541,6 +658,7 @@ class ServerJarClient(Application):
# Save history # Save history
save_history(self.cmds, self._log, self._warn) save_history(self.cmds, self._log, self._warn)
save_connection_history(self.connection_history, self._log, self._warn)
# Exit ui event loop # Exit ui event loop
self.full_exit() self.full_exit()
@@ -600,6 +718,17 @@ class ServerJarClient(Application):
self.invalidate() self.invalidate()
def save_server(self, host, port):
for server in self.connection_history:
if server.get("host") == host and str(server.get("port")) == str(port):
return
self.connection_history.append({
"serverName": f"{host}:{port}",
"host": host,
"port": port,
})
def client(self): def client(self):
ptk_clear() ptk_clear()
@@ -627,10 +756,9 @@ class ServerJarClient(Application):
# Create connect # Create connect
if self.args.no_tls: if self.args.no_tls:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM) s = socket.create_connection((self.host, self.port), timeout=self.args.connect_timeout)
s.connect((self.host, self.port))
else: else:
raw = socket.create_connection((self.host, self.port)) raw = socket.create_connection((self.host, self.port), timeout=self.args.connect_timeout)
context = self.get_tls_context() context = self.get_tls_context()
s = context.wrap_socket(raw, server_hostname=self.host) s = context.wrap_socket(raw, server_hostname=self.host)
@@ -649,6 +777,10 @@ class ServerJarClient(Application):
data = s.recv(4096) data = s.recv(4096)
if not data: if not data:
raise ConnectionError("Server closed") raise ConnectionError("Server closed")
# Only save the server that have at least one connection is successful
self.save_server(self.host, self.port)
buffer += data.decode("utf-8", errors="replace") buffer += data.decode("utf-8", errors="replace")
while "\n" in buffer: while "\n" in buffer:
line, buffer = buffer.split("\n", 1) line, buffer = buffer.split("\n", 1)
@@ -721,7 +853,7 @@ class ServerJarClient(Application):
except (ConnectionError, OSError) as e: except (ConnectionError, OSError) as e:
if not self.closing_event.is_set(): if not self.closing_event.is_set():
if self.args.retry: if self.args.retry and self.host is not None and self.port is not None:
self._warn(f"Disconnected: {e}, retrying...") self._warn(f"Disconnected: {e}, retrying...")
else: else:
self._err(f"Disconnected: {e}") self._err(f"Disconnected: {e}")
@@ -757,6 +889,7 @@ class ServerJarClient(Application):
self.args = self.arguments_parser() self.args = self.arguments_parser()
self.layout.focus(self.input_area) self.layout.focus(self.input_area)
self.cmds = get_history(self._log, self._warn) self.cmds = get_history(self._log, self._warn)
self.connection_history = get_connection_history(self._log, self._warn)
asyncio.create_task(self.consume_incoming()) asyncio.create_task(self.consume_incoming())
self.client_thread.start() self.client_thread.start()
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
set -e
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
sudo install -Dm755 "$SCRIPT_DIR/sarclient" /usr/local/bin/sarclient
sudo install -Dm755 "$SCRIPT_DIR/serverjar" /usr/local/bin/serverjar
echo "安裝完成"
echo "sarclient -> /usr/local/bin/sarclient"
echo "serverjar -> /usr/local/bin/serverjar"
+368 -17
View File
@@ -3,6 +3,7 @@ ServerJar
Wei - 2026 Wei - 2026
""" """
import platform
import re import re
import signal import signal
import socketserver import socketserver
@@ -17,13 +18,21 @@ import time
import datetime import datetime
import base64 import base64
import hmac import hmac
import ipaddress
import shlex
import shutil
from pathlib import Path from pathlib import Path
import click import click
import yaml import yaml
from utils.common import get_latest_version_minecraft, get_specific_version_paper_builds, \ from utils.common import get_latest_version_minecraft, get_specific_version_paper_builds, \
download_server_jar, download_latest_build_paper_jar, get_latest_paper_version, download_vanilla_server_jar download_server_jar, download_latest_build_paper_jar, get_latest_paper_version, download_vanilla_server_jar, \
read_server_properties, save_server_properties, activate_eula_file, \
get_specific_version_minecraft_require_java_version, find_system_java_executables
from utils.explorer_library import get_java_version_by_execute, search_available_java_runtimes_in_directory
from utils.file_settings import FileSettings from utils.file_settings import FileSettings
from utils.file_settings import required_list, required_value from utils.file_settings import required_list, required_value
from utils.java.java_info import create_java_version_info
from utils.java.jvm_installer import install_azul_build_version_jvm
from cryptography import x509 from cryptography import x509
from cryptography.x509.oid import NameOID from cryptography.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes from cryptography.hazmat.primitives import hashes
@@ -36,6 +45,7 @@ VERSION = "1.1"
LOG_DIR_NAME = "logs" LOG_DIR_NAME = "logs"
SERVERJAR_LOG_FILE = "serverjar.log" SERVERJAR_LOG_FILE = "serverjar.log"
LOG_DOWNLOAD_CHUNK_SIZE = 4096 LOG_DOWNLOAD_CHUNK_SIZE = 4096
JVM_RUNTIME_DIR = ROOT_DIR / "data" / "jvm"
def exit(message): def exit(message):
click.echo(click.style(message, fg='green')) click.echo(click.style(message, fg='green'))
@@ -89,6 +99,282 @@ def load_settings():
return s return s
def run_initial_server_start(args, server_dir: Path, timeout: float = 120.0):
click.echo("Starting server once to generate initial files...")
proc = subprocess.Popen(
args,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
cwd=server_dir,
encoding="utf-8",
errors="replace",
)
try:
output, _ = proc.communicate(timeout=timeout)
except subprocess.TimeoutExpired:
click.echo("Initial start is still running. Sending stop command...")
try:
if proc.stdin:
proc.stdin.write("stop\n")
proc.stdin.flush()
except OSError:
pass
try:
output, _ = proc.communicate(timeout=30)
except subprocess.TimeoutExpired:
click.echo("Server did not stop in time. Killing initial start process...")
proc.kill()
output, _ = proc.communicate()
if output:
for line in output.splitlines():
click.echo(f"[BOOT] {line}")
return proc.returncode
def configure_initial_server_files(server_dir: Path, server_name: str, server_host: str, server_port: str,
property_overrides=None):
try:
properties = read_server_properties(server_dir)
except FileNotFoundError as e:
click.echo(click.style(f"Unable to read server.properties: {e}", fg="yellow"))
else:
property_values = {
"server-name": str(server_name),
"server-ip": str(server_host),
"server-port": str(server_port),
}
if property_overrides:
property_values.update(property_overrides)
for key, value in property_values.items():
properties[key] = (value, {})
save_server_properties(properties, server_dir)
click.echo("server.properties updated.")
try:
if activate_eula_file(server_dir):
click.echo("EULA activated.")
else:
click.echo(click.style("EULA was not activated.", fg="yellow"))
except FileNotFoundError as e:
click.echo(click.style(f"Unable to activate EULA: {e}", fg="yellow"))
def parse_server_property_overrides(values):
overrides = {}
for value in values:
key, separator, property_value = value.partition("=")
key = key.strip()
if separator == "" or key == "":
raise click.ClickException(f"Invalid server property override: {value}. Use key=value.")
overrides[key] = property_value.strip()
return overrides
def normalize_machine_arch(machine=None):
machine = (machine or platform.machine()).lower()
if machine in ("amd64", "x86_64", "x64"):
return "amd64"
if machine in ("arm64", "aarch64"):
return "arm64"
if machine in ("x86", "i386", "i686"):
return "i686"
return machine
def java_executable_names():
return ["java.exe", "javaw.exe"] if os.name == "nt" else ["java"]
def java_runtime_search_roots():
roots = [JVM_RUNTIME_DIR]
java_home = os.environ.get("JAVA_HOME")
if java_home:
roots.append(Path(java_home))
return roots
def collect_java_executable_candidates():
candidates = []
for root in java_runtime_search_roots():
if root.exists():
candidates.extend(search_available_java_runtimes_in_directory(str(root)))
candidates.extend(find_system_java_executables(java_executable_names()))
for java_name in java_executable_names():
candidates.append(java_name)
unique_candidates = []
seen = set()
for candidate in candidates:
candidate_key = str(candidate).lower()
if candidate_key in seen:
continue
seen.add(candidate_key)
unique_candidates.append(str(candidate))
return unique_candidates
def resolve_executable_path(executable_path):
resolved = shutil.which(str(executable_path))
if resolved:
return Path(resolved)
return Path(executable_path)
def is_x86_directory_java(executable_path):
path = resolve_executable_path(executable_path)
return any("(x86)" in part.lower() for part in path.parts)
def parse_memory_size_to_mb(value):
match = re.fullmatch(r"\s*(\d+(?:\.\d+)?)\s*([kmgtKMGT]?)\s*", str(value))
if not match:
raise click.ClickException(f"Invalid memory size: {value}")
amount = float(match.group(1))
unit = match.group(2).upper() or "M"
multiplier = {
"K": 1 / 1024,
"M": 1,
"G": 1024,
"T": 1024 * 1024,
}[unit]
return amount * multiplier
def inspect_java_candidate(java_executable_path):
status, major_version, error = get_java_version_by_execute(java_executable_path)
if not status:
return None, error, is_x86_directory_java(java_executable_path)
try:
major_version = int(major_version)
except (TypeError, ValueError):
return None, f"Invalid Java major version: {major_version}", is_x86_directory_java(java_executable_path)
return major_version, None, is_x86_directory_java(java_executable_path)
def find_compatible_java(required_major_version, max_memory_mb):
required_major_version = int(required_major_version)
compatible = []
checked = []
avoid_32bit = max_memory_mb >= 4096
for candidate in collect_java_executable_candidates():
major_version, error, is_32bit = inspect_java_candidate(candidate)
if major_version is None:
checked.append((candidate, None, is_32bit, error))
continue
if is_32bit and avoid_32bit:
checked.append((candidate, major_version, is_32bit, "Skipped 32-bit Java because Xmx is 4G or higher."))
continue
checked.append((candidate, major_version, is_32bit, None))
if major_version >= required_major_version:
compatible.append((candidate, major_version, is_32bit))
compatible.sort(key=lambda item: (item[1], item[0]))
if compatible:
return compatible[0][0], compatible[0][1], compatible[0][2], checked
return None, None, None, checked
def install_compatible_java(required_major_version):
arch = normalize_machine_arch()
platform_name = platform.system().lower()
install_dir = JVM_RUNTIME_DIR / f"java_{required_major_version}_{arch}"
click.echo(f"Installing Azul Java {required_major_version} into {install_dir} ...")
status, error = install_azul_build_version_jvm(
str(required_major_version),
str(install_dir),
arch,
platform_name,
)
if not status:
raise click.ClickException(f"Unable to install Azul Java {required_major_version}: {error}")
create_java_version_info(str(required_major_version), arch, str(install_dir))
java_executable = install_dir / "bin" / ("java.exe" if os.name == "nt" else "java")
if not java_executable.exists():
raise click.ClickException(f"Installed JVM does not contain a Java executable: {java_executable}")
return java_executable.absolute().as_posix()
def resolve_java_executable(java_exec_path, minecraft_version, release, auto_install_java, x_memory_maximum):
max_memory_mb = parse_memory_size_to_mb(x_memory_maximum)
required_major_version = get_specific_version_minecraft_require_java_version(minecraft_version, release=release)
if required_major_version is None:
click.echo(click.style("Unable to determine required Java version. Using configured Java executable.", fg="yellow"))
return java_exec_path or "java"
click.echo(f"Minecraft {minecraft_version} requires Java {required_major_version}.")
if java_exec_path:
major_version, error, is_32bit = inspect_java_candidate(java_exec_path)
if major_version is None:
raise click.ClickException(f"Unable to use Java executable '{java_exec_path}': {error}")
if is_32bit and max_memory_mb >= 4096:
raise click.ClickException(
f"Java executable '{java_exec_path}' is in an (x86) directory and is treated as 32-bit. "
f"It cannot be used with -Xmx{x_memory_maximum}; use less than 4G, or choose a 64-bit Java."
)
if major_version < int(required_major_version):
raise click.ClickException(
f"Java executable '{java_exec_path}' is Java {major_version}, "
f"but Minecraft {minecraft_version} requires Java {required_major_version}."
)
bitness = ", 32-bit path" if is_32bit else ""
click.echo(f"Using configured Java executable: {java_exec_path} (Java {major_version}{bitness})")
return java_exec_path
java_path, major_version, is_32bit, checked = find_compatible_java(required_major_version, max_memory_mb)
if java_path:
bitness = ", 32-bit path" if is_32bit else ""
click.echo(f"Using compatible Java executable: {java_path} (Java {major_version}{bitness})")
return java_path
click.echo(click.style(f"No compatible Java runtime found for Java {required_major_version}.", fg="yellow"))
if checked:
click.echo("Checked Java candidates:")
for candidate, candidate_major, is_32bit, error in checked:
bitness = ", 32-bit path" if is_32bit else ""
if candidate_major is None:
click.echo(f"- {candidate}: unavailable{bitness} ({error})")
else:
suffix = f" ({error})" if error else ""
click.echo(f"- {candidate}: Java {candidate_major}{bitness}{suffix}")
should_install = auto_install_java
if not should_install:
result = input("Install a compatible Azul JVM now? [y/N] ")
should_install = result.strip().lower() == "y"
if should_install:
return install_compatible_java(required_major_version)
raise click.ClickException(
f"No compatible Java runtime is available. Install Java {required_major_version}+ "
"or rerun with --install-java."
)
@main.command() @main.command()
@click.option("--name", "-d", default="Unnamed Server", show_default=True, help="Server name") @click.option("--name", "-d", default="Unnamed Server", show_default=True, help="Server name")
@click.option("--mc-version", "-m", @click.option("--mc-version", "-m",
@@ -112,7 +398,10 @@ def load_settings():
@click.option("--custom-args", "-ce", @click.option("--custom-args", "-ce",
help="Custom arguments (command)", type=str, default="") help="Custom arguments (command)", type=str, default="")
@click.option("--java-exec-path", "-p", show_default=True, @click.option("--java-exec-path", "-p", show_default=True,
help="The destination of the java executable", default="java") help="The destination of the java executable. If omitted, ServerJar searches for a compatible Java.",
default=None)
@click.option("--install-java", is_flag=True,
help="Install a compatible Azul JVM automatically when no local compatible Java is found.")
@click.option("--x-memory-initial", "-xms", show_default=True, @click.option("--x-memory-initial", "-xms", show_default=True,
help="Initial allocation size of the memory for server", help="Initial allocation size of the memory for server",
type=str, default="1G") type=str, default="1G")
@@ -126,10 +415,14 @@ def load_settings():
help="Hostname of the server", required=True) help="Hostname of the server", required=True)
@click.option("--server-port", "-srp", @click.option("--server-port", "-srp",
help="Port of the server", required=True) help="Port of the server", required=True)
@click.option("--server-property", "-sp", "server_properties", multiple=True,
help="server.properties override written after the first startup. Use key=value; can be repeated.")
def create_server(name, mc_version, build, server_type, snapshot, latest, list_builds, filename, extra_args, java_exec_path, def create_server(name, mc_version, build, server_type, snapshot, latest, list_builds, filename, extra_args, java_exec_path,
x_memory_initial, x_memory_maximum, nogui, custom_args, server_port, server_host): install_java, x_memory_initial, x_memory_maximum, nogui, custom_args, server_port, server_host,
server_properties):
server_dir = Path("servers", name) server_dir = Path("servers", name)
server_type = server_type.lower() server_type = server_type.lower()
property_overrides = parse_server_property_overrides(server_properties)
def prepare_server_dir(): def prepare_server_dir():
if server_dir.exists(): if server_dir.exists():
@@ -167,7 +460,7 @@ def create_server(name, mc_version, build, server_type, snapshot, latest, list_b
else: else:
if mc_version is None: if mc_version is None:
click.echo("The mc-version is not specified. Fetching latest Minecraft release version...") click.echo("The mc-version is not specified. Fetching latest Minecraft release version...")
mc_version = get_latest_version_minecraft(release=release) mc_version = get_latest_paper_version(release=release)
if list_builds: if list_builds:
builds = get_specific_version_paper_builds(mc_version) builds = get_specific_version_paper_builds(mc_version)
@@ -210,22 +503,42 @@ def create_server(name, mc_version, build, server_type, snapshot, latest, list_b
exit("User aborted.") exit("User aborted.")
return return
extra_args += "nogui" if nogui else "" selected_mc_version = latest_ver if latest_ver is not None else mc_version
args = [ if not custom_args:
java_exec_path = resolve_java_executable(
java_exec_path, java_exec_path,
selected_mc_version,
release,
install_java,
x_memory_maximum,
)
args = [
java_exec_path or "java",
"-Xms{}".format(x_memory_initial), "-Xms{}".format(x_memory_initial),
"-Xmx{}".format(x_memory_maximum), "-Xmx{}".format(x_memory_maximum),
"-jar", "-jar",
out.absolute().as_posix(), out.absolute().as_posix(),
extra_args,
] ]
if extra_args:
args.extend(shlex.split(extra_args))
if nogui:
args.append("nogui")
if custom_args: if custom_args:
print("Will use custom commands as replacement.") print("Will use custom commands as replacement.")
args = custom_args args = shlex.split(custom_args)
print(f"Server command: {' '.join(args)}") print(f"Server command: {' '.join(args)}")
try:
return_code = run_initial_server_start(args, server_dir)
if return_code not in (0, None):
click.echo(click.style(f"Initial server start exited with code {return_code}.", fg="yellow"))
configure_initial_server_files(server_dir, name, server_host, server_port, property_overrides)
except Exception as e:
raise click.ClickException(f"Unable to bootstrap server files: {e}") from e
with settings.edit() as s: with settings.edit() as s:
print("Saving...") print("Saving...")
s["servers"].append({ s["servers"].append({
@@ -292,6 +605,8 @@ class SocketServer:
self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
self._ssl_context.load_cert_chain(certfile=self.certfile, keyfile=self.keyfile) self._ssl_context.load_cert_chain(certfile=self.certfile, keyfile=self.keyfile)
self.commands = {}
# ------------------------- # -------------------------
# Socket Server # Socket Server
# ------------------------- # -------------------------
@@ -339,11 +654,12 @@ class SocketServer:
"__help": "Display this help message", "__help": "Display this help message",
"__list": "List available server shells", "__list": "List available server shells",
"__exit": "Close the current socket connection", "__exit": "Close the current socket connection",
"__stop_all": "Stop all servers and close the socket server", "__stop_all": "Stop all servers and close the socket server (TLS Support must be enabled)",
"__c <server name>": "Connect to a server shell", "__c <server name>": "Connect to a server shell",
"__d": "Disconnect from the current server shell", "__d": "Disconnect from the current server shell",
"__sync_log [fromDate:endDate]": "Sync saved log lines from the attached server. Empty syncs the latest 300 lines", "__sync_log [fromDate:endDate]": "Sync saved log lines from the attached server. Empty syncs the latest 300 lines",
"__download_log": "Download the attached server's saved log file", "__download_log": "Download the attached server's saved log file",
"__system_info": "Show system information",
} }
attached_commands = { attached_commands = {
"__status": "Show target server process status", "__status": "Show target server process status",
@@ -597,10 +913,16 @@ class SocketServer:
self.request.sendall(b"[SYS] bye\n") self.request.sendall(b"[SYS] bye\n")
return return
elif cmd == "__stop_all": elif cmd == "__stop_all":
if not mgr.enable_tls:
self.request.sendall(
f"[SYS] Command \"__stop_all\" only works when tls support is enabled.\n".encode("utf-8")
)
else:
self.request.sendall( self.request.sendall(
f"[SYS] Stopping all servers...bye\n".encode("utf-8") f"[SYS] Stopping all servers...bye\n".encode("utf-8")
) )
mgr.broadcast_all(mgr.tcp_server.clients, "[SYS] Server stopping... (Stop by remote client)") mgr.broadcast_all(mgr.tcp_server.clients,
"[SYS] Server stopping... (Stop by remote client)")
mgr.stop_event.set() mgr.stop_event.set()
return return
elif cmd.startswith("__sync_log"): elif cmd.startswith("__sync_log"):
@@ -633,6 +955,14 @@ class SocketServer:
f"[SYS] Disconnected from current server \"{current_server}\"'s shell.\n".encode( f"[SYS] Disconnected from current server \"{current_server}\"'s shell.\n".encode(
"utf-8") "utf-8")
) )
elif cmd == "__system_info":
self.request.sendall(
f"[SYS] Connected clients number: {len(self.current_server_record)}\n"
f"[SYS] Hostname: {mgr.host}\n"
f"[SYS] Port: {mgr.port}\n"
f"[SYS] Working Directory: {os.getcwd()}\n"
f"[SYS] Platform: {platform.system()} ({platform.architecture()[0]})\n"
f"[SYS] Version: {VERSION}\n".encode("utf-8"))
else: else:
if current_server is not None: if current_server is not None:
receiver = mgr.get_command_receiver(current_server) receiver = mgr.get_command_receiver(current_server)
@@ -887,12 +1217,12 @@ class Server:
def _stdout_reader_loop(self): def _stdout_reader_loop(self):
self.logger.info("[PROC] stdout reader started") self.logger.info("[PROC] stdout reader started")
while self.running: while True:
with self.proc_lock: with self.proc_lock:
proc = self.proc proc = self.proc
out = proc.stdout if proc else None out = proc.stdout if proc else None
if not proc or proc.poll() is not None or not out: if not proc or not out:
self.logger.info("[PROC] process ended / stdout closed") self.logger.info("[PROC] process ended / stdout closed")
break break
@@ -915,8 +1245,11 @@ class Server:
if not self.proc.stdin: if not self.proc.stdin:
return False return False
try:
self.proc.stdin.write(command + "\n") self.proc.stdin.write(command + "\n")
self.proc.stdin.flush() self.proc.stdin.flush()
except OSError:
return False
return True return True
def stop_process(self, timeout: float = 10.0): def stop_process(self, timeout: float = 10.0):
@@ -959,9 +1292,9 @@ class Server:
return return
self.stopping = True self.stopping = True
self.running = False
self.stop_process() self.stop_process()
self.running = False
def restart(self): def restart(self):
if self.running: if self.running:
@@ -995,7 +1328,7 @@ class Server:
return True, f"Server \"{self.name}\" process started" return True, f"Server \"{self.name}\" process started"
elif command == "__info": elif command == "__info":
return True, (f"serverName: {self.name}\n" return True, (f"serverName: {self.name}\n"
f"serverPID: {self.proc.pid}\n" f"serverPID: {self.proc.pid if self.proc else None}\n"
f"description: {self.description}\n" f"description: {self.description}\n"
f"arguments: {self.args}\n" f"arguments: {self.args}\n"
f"host: {self.host}\n" f"host: {self.host}\n"
@@ -1012,8 +1345,11 @@ class Server:
if not self.proc.stdin: if not self.proc.stdin:
return False, "Process standard input are not available." return False, "Process standard input are not available."
try:
self.proc.stdin.write(command + "\n") self.proc.stdin.write(command + "\n")
self.proc.stdin.flush() self.proc.stdin.flush()
except OSError as e:
return False, f"Unable to write process standard input: {e}"
return True, "Command sent." return True, "Command sent."
def load_all_server_from_settings(settings: FileSettings): def load_all_server_from_settings(settings: FileSettings):
@@ -1138,7 +1474,8 @@ def runserver(keep_running: bool):
logger.info("Stopped!") logger.info("Stopped!")
@main.command() @main.command()
def generate_tls_key(): @click.option("--hostname", default="localhost", show_default=True, help="Hostname or IP address of the server.")
def generate_tls_key(hostname: str):
print("Generating TLS key...") print("Generating TLS key...")
key = rsa.generate_private_key( key = rsa.generate_private_key(
public_exponent=65537, public_exponent=65537,
@@ -1150,9 +1487,23 @@ def generate_tls_key():
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, u"California"), x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, u"California"),
x509.NameAttribute(NameOID.LOCALITY_NAME, u"San Francisco"), x509.NameAttribute(NameOID.LOCALITY_NAME, u"San Francisco"),
x509.NameAttribute(NameOID.ORGANIZATION_NAME, u"My Dev Org"), x509.NameAttribute(NameOID.ORGANIZATION_NAME, u"My Dev Org"),
x509.NameAttribute(NameOID.COMMON_NAME, u"localhost"), x509.NameAttribute(NameOID.COMMON_NAME, u"{}".format(hostname)),
]) ])
alt_names = [x509.DNSName(u"localhost")]
def add_alt_name(name):
if name not in alt_names:
alt_names.append(name)
try:
add_alt_name(x509.IPAddress(ipaddress.ip_address(hostname)))
except ValueError:
add_alt_name(x509.DNSName(hostname))
for loopback in ("127.0.0.1", "::1"):
add_alt_name(x509.IPAddress(ipaddress.ip_address(loopback)))
cert = x509.CertificateBuilder().subject_name( cert = x509.CertificateBuilder().subject_name(
subject subject
).issuer_name( ).issuer_name(
@@ -1167,7 +1518,7 @@ def generate_tls_key():
# Valid for 1 year # Valid for 1 year
datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=365) datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=365)
).add_extension( ).add_extension(
x509.SubjectAlternativeName([x509.DNSName(u"localhost")]), x509.SubjectAlternativeName(alt_names),
critical=False, critical=False,
).sign(key, hashes.SHA256()) ).sign(key, hashes.SHA256())
+1
View File
@@ -1,2 +1,3 @@
prompt-toolkit==3.0.52 prompt-toolkit==3.0.52
cryptography==48.0.0 cryptography==48.0.0
jproperties==2.1.2
+39
View File
@@ -0,0 +1,39 @@
"""
bk_core
Copyright (c) 2024~2025 Techarerm/TedKai
Copyright (c) 2026 Kitee Contributors. All rights reserved.
"""
import hashlib
def verify_checksum(file_path, expected_sha1):
sha1 = hashlib.sha1()
with open(file_path, "rb") as f:
while True:
data = f.read(65536) # Read in 64KB chunks
if not data:
break
sha1.update(data)
file_sha1 = sha1.hexdigest()
return file_sha1 == expected_sha1
def verify_checksum_v2(file_path, expected_hash, crypto_type):
hash_dict = {
'sha1': hashlib.sha1,
'sha256': hashlib.sha256,
'md5': hashlib.md5
}
if crypto_type not in hash_dict:
raise ValueError(f"Unsupported hash type: {crypto_type}")
hash_obj = hash_dict[crypto_type]()
with open(file_path, "rb") as f:
for chunk in iter(lambda: f.read(65536), b""):
hash_obj.update(chunk)
file_sha1 = hash_obj.hexdigest()
return file_sha1 == expected_hash
+29
View File
@@ -0,0 +1,29 @@
"""
bk_core
Copyright (c) 2024~2025 Techarerm/TedKai
"""
import os
from pathlib import Path
def deduplicate_java_classpath(classpath, classpath_separator):
paths = classpath.split(classpath_separator)
cleaned_classpath = [str(Path(p.strip())) for p in paths if p.strip()]
return classpath_separator.join(cleaned_classpath)
def delete_missing_java_classpath(classpath, classpath_separator):
paths = classpath.split(classpath_separator)
existing_classpath = [p for p in paths if os.path.exists(p)]
return classpath_separator.join(existing_classpath)
def replace_specified_value_to_target_string_in_java_classpath(classpath, classpath_separator, value, string):
paths = classpath.split(classpath_separator)
new_paths = [path.replace(value, string) for path in paths]
return classpath_separator.join(new_paths)
+310
View File
@@ -0,0 +1,310 @@
"""
bk_core
Copyright (c) 2024~2025 Techarerm/TedKai
Copyright (c) 2026 Kitee Contributors. All rights reserved.
"""
import contextlib
import copy
import json
from pathlib import Path
def validation_rule(
default=None,
*,
children=None,
write_back_if_not_exist=False,
recover_missing_items=False,
):
rule = {
"writeBackIfNotExist": write_back_if_not_exist,
"recoverMissingItems": recover_missing_items,
}
if children is not None:
rule["children"] = children
else:
rule["default"] = default
return rule
def required_value(default, *, recover_missing_items=False):
return validation_rule(
default,
write_back_if_not_exist=True,
recover_missing_items=recover_missing_items,
)
def required_section(children):
return validation_rule(children=children, write_back_if_not_exist=True)
class FileSettings:
"""
Simple Settings Object
IMPORTANT: Settings always use self.data as the main operate data, NOT FROM disk settings file!!!
Usage:
FileSettings.create() -> Create settings file (path is self.path)
FileSettings.load() - > Load settings file
FileSettings.save() -> Save settings file
FileSettings.edit() -> Edit settings file (Auto save when with block completes)
Example:
with FileSettings.edit() as settings:
settings["hello"] = "world"
"""
def __init__(self, path, default, validation_rules=None,
dumps_func=json.dumps, load_func=json.load,
settings_change_callback=None):
self.data = copy.deepcopy(default)
self.default = copy.deepcopy(default)
self.path: Path = Path(path)
self.validation_rules = validation_rules
self.dumps_func = dumps_func
self.load_func = load_func
self.settings_change_callback = settings_change_callback
def reset(self):
"""Replace self.data with self.default and save"""
self.data = copy.deepcopy(self.default)
self.save()
if callable(self.settings_change_callback):
self.settings_change_callback(self.data)
def __repr__(self):
return f"<FileSettings At {self.path.as_posix()}>"
def create(self, exist_ok=False):
"""
Create settings file (path is self.path, data use default value)
:param exist_ok: If not True, raise exception if file already exists
:return:
"""
self.path.parent.mkdir(parents=True, exist_ok=True)
if not exist_ok and self.path.exists():
raise FileExistsError(f'{self.path} already exists')
self.path.write_text(self._dumps(self.default))
def read_from_exist(self):
self.data = self.load()
def mload(self):
"""Get data from memory"""
return copy.deepcopy(self.data)
def load(self):
"""
Load settings file data into self.data (path is self.path)
:return:
"""
if not self.path.exists():
raise FileNotFoundError(f'{self.path} does not exist')
with self.path.open("rb") as settings_file:
data = self.load_func(settings_file)
if isinstance(self.validation_rules, dict):
data = self.validate_data(data, self.default, self.validation_rules)
return data
def save(self):
"""
Save self.data (data in memory) into settings file
:return:
"""
if not self.path.exists():
raise FileNotFoundError(f'{self.path} does not exist. Create it before saving.')
self.path.write_text(self._dumps(self.data))
def get(self, key, default=None):
return self.data.get(key, default)
def dget(self, key, default=None): # dget -> get_default
return self.default.get(key, default)
def exists(self):
return self.path.exists()
@contextlib.contextmanager
def edit(self):
"""
Edit settings (With auto save)
:return:
"""
if not self.exists():
self.create()
yield self
self.save()
if callable(self.settings_change_callback):
self.settings_change_callback(self)
def validate_data(self, data, default, rules):
"""
Validates data by validating rules.
:param data: dict data
:param default: default sample
:param rules: rules of all keys
:return:
"""
if not isinstance(data, dict):
data = {}
if not isinstance(default, dict):
default = {}
if not isinstance(rules, dict):
return copy.deepcopy(data)
validated = copy.deepcopy(data)
for key, rule in rules.items():
rule_default, options = self._parse_validation_rule(rule)
default_value = copy.deepcopy(default.get(key, rule_default))
if key not in validated:
if options.get("writeBackIfNotExist"):
validated[key] = self._validate_value(
default_value,
default_value,
rule_default,
options,
)
continue
validated[key] = self._validate_value(
validated[key],
default_value,
rule_default,
options,
)
return validated
def update(self, settings):
"""Update self.data with new settings values."""
if not isinstance(settings, dict):
raise TypeError("settings must be a dict")
def update_inner(new, old):
if not isinstance(old, dict):
return
for n_k, n_v in new.items():
if isinstance(n_v, dict) and (n_k in old and isinstance(old[n_k], dict)):
update_inner(n_v, old[n_k])
else:
old[n_k] = copy.deepcopy(n_v)
update_inner(settings, self.data)
if callable(self.settings_change_callback):
self.settings_change_callback(self)
def update_new_settings(self, new_default_settings):
"""Add missing settings to self.default and self.data."""
if not isinstance(new_default_settings, dict):
raise TypeError("new_default_settings must be a dict")
def add_missing(new, old):
if not isinstance(old, dict):
return
for n_k, n_v in new.items():
if isinstance(n_v, dict) and (n_k in old and isinstance(old[n_k], dict)):
add_missing(n_v, old[n_k])
if n_k not in old:
old[n_k] = copy.deepcopy(n_v)
add_missing(new_default_settings, self.default)
add_missing(new_default_settings, self.data)
if callable(self.settings_change_callback):
self.settings_change_callback(self)
@staticmethod
def _parse_validation_rule(rule):
if isinstance(rule, dict) and ("default" in rule or "children" in rule):
options = {
"writeBackIfNotExist": rule.get("writeBackIfNotExist", False),
"recoverMissingItems": rule.get("recoverMissingItems", False), # Recover missing item (only for list)
}
if "children" in rule:
return rule["children"], options
return rule.get("default"), options
if (
isinstance(rule, (list, tuple))
and len(rule) == 2
and isinstance(rule[1], dict)
):
return rule[0], rule[1]
return rule, {}
def _validate_value(self, value, default_value, rule_default, options):
if isinstance(rule_default, dict):
if not isinstance(value, dict):
value = {}
if not isinstance(default_value, dict):
default_value = {}
return self.validate_data(value, default_value, rule_default)
if isinstance(default_value, list):
if not isinstance(value, list):
return copy.deepcopy(default_value)
validated = copy.deepcopy(value)
if options.get("recoverMissingItems"):
for item in default_value:
if item not in validated:
validated.append(copy.deepcopy(item))
return validated
if default_value is None:
return value
if type(value) is not type(default_value):
return copy.deepcopy(default_value)
return value
def _dumps(self, data):
if not callable(self.dumps_func):
raise TypeError(f"dumps_func must be callable")
return self.dumps_func(data)
def __getitem__(self, key):
return self.data[key]
def __setitem__(self, key, value):
self.data[key] = value
if callable(self.settings_change_callback):
self.settings_change_callback(self)
def __eq__(self, other):
if isinstance(other, FileSettings):
return self.path == other.path and self.data == other.data
if isinstance(other, dict):
return self.data == other
return NotImplemented
+155
View File
@@ -0,0 +1,155 @@
"""
bk_core
Copyright (c) 2024~2025 Techarerm/TedKai
Copyright (c) 2026 Kitee Contributors. All rights reserved.
"""
import zipfile
import requests
import os
def create_download_task(
url,
dest_path,
sha1=None,
with_verify=True,
crypto_type="sha1",
chunk_size=8192,
):
return {
"url": url,
"dest": dest_path,
"sha1": sha1,
"with_verify": with_verify,
"crypto_type": crypto_type,
"chunk_size": chunk_size,
}
def flatten_download_queue(nested_urls_and_paths):
return [
create_download_task(url, dest_path)
for sublist in nested_urls_and_paths
for url, dest_path in sublist
]
def download_file(url, dest_path, with_verify=True, sha1=None, no_output=False, custom_chunk_size=8192):
"""
Downloads a file from a URL and saves it to dest_path.
"""
return [
create_download_task(
url,
dest_path,
sha1=sha1,
with_verify=with_verify,
chunk_size=custom_chunk_size,
)
]
def n_download_file(url, dest_path, enable_hash_check=False, sha1=None, no_download_output=False, chunk_size=8192):
"""
Downloads a file from a URL and saves it to dest_path.
"""
return [
create_download_task(
url,
dest_path,
sha1=sha1,
with_verify=enable_hash_check,
chunk_size=chunk_size,
)
]
def extract_zip(zip_path, extract_to):
try:
with zipfile.ZipFile(zip_path, 'r') as zip_ref:
zip_ref.extractall(extract_to)
print(f"Extracted {zip_path} to {extract_to}")
except zipfile.BadZipFile as e:
print(f"[ERR] Error extracting {zip_path}: {e}")
def multi_thread_download(nested_urls_and_paths, name, max_workers=5, retries=1, download_with_progress_bar=True):
"""
Downloads multiple files using multiple threads with retry attempts.
nested_urls_and_paths should be a nested list where each element is a list containing a tuple of (url, dest_path).
"""
return flatten_download_queue(nested_urls_and_paths)
def multithread_download(
download_url_list,
file_dest_list,
progress_name,
max_workers=8,
with_verify_checksum=False,
file_hash_list=None,
download_with_progress_bar=False,
no_output=None,
crypto_type="sha1",
):
# parameters
if file_hash_list is None:
file_hash_list = []
if no_output is None:
no_output = download_with_progress_bar
if not with_verify_checksum:
file_hash_list = [None for _ in download_url_list]
return [
create_download_task(
file_url,
file_dest,
sha1=file_hash,
with_verify=with_verify_checksum,
crypto_type=crypto_type,
)
for file_url, file_dest, file_hash in zip(download_url_list, file_dest_list, file_hash_list)
]
def find_jar_file_main_class(jar_file_path):
manifest_path = 'META-INF/MANIFEST.MF'
try:
with zipfile.ZipFile(jar_file_path, 'r') as jar:
if not manifest_path in jar.namelist():
return None
manifest = jar.read(manifest_path).decode('utf-8')
for line in manifest.splitlines():
if line.startswith('Main-Class:'):
# Return the class name specified in the Main-Class entry
return line.split(':')[1].strip()
return None
except Exception as e:
return None
def check_url_status(url):
try:
# Send a HEAD request to save bandwidth
response = requests.head(url, allow_redirects=True, timeout=5)
if response.status_code == 200:
return True
elif response.status_code == 404:
return False
else:
return False
except Exception as e:
return False
def pause():
command = None
if os.name == "posix":
command = 'read -p "Press enter to continue..."'
elif os.name == "nt":
command = "pause"
if command is not None: os.system(command)
+123 -1
View File
@@ -2,12 +2,63 @@ from pathlib import Path
import requests import requests
import click import click
import os import os
from jproperties import Properties
PAPER_VERSION_API = "https://api.papermc.io/v2/projects/paper/versions/{}" PAPER_VERSION_API = "https://api.papermc.io/v2/projects/paper/versions/{}"
PAPER_SERVER_JAR_API = "https://api.papermc.io/v2/projects/paper/versions/{}/builds/{}/downloads/paper-{}-{}.jar" PAPER_SERVER_JAR_API = "https://api.papermc.io/v2/projects/paper/versions/{}/builds/{}/downloads/paper-{}-{}.jar"
MOJANG_VERSION_MANIFEST_V2 = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json" MOJANG_VERSION_MANIFEST_V2 = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"
def read_server_properties(server_dir: Path) -> Properties:
if not server_dir.exists():
raise FileNotFoundError("Server directory does not exist")
props = Properties()
with open(server_dir / 'server.properties', 'rb') as config_file:
props.load(config_file, encoding='utf-8')
return props
def save_server_properties(properties: Properties, server_dir: Path) -> None:
path = server_dir / 'server.properties'
with path.open("wb") as config_file:
properties.store(config_file, encoding='utf-8')
def activate_eula_file(server_dir: Path) -> bool:
path = server_dir / 'eula.txt'
if not path.exists():
raise FileNotFoundError("EULA file does not exist. Did you run the server?")
with open(path, 'r', encoding='utf-8') as file:
lines = file.readlines()
for line in lines:
if line.strip() == "eula=true":
print("EULA file has been activated.")
return True
print(f"{line}")
print("To activate eula file please read above text. If you agree this license, enter Y [not agree enter N]")
result = str(input("Agree? :"))
if result.strip().lower() != "y":
return False
with open(path, 'w', encoding='utf-8') as file:
for line in lines:
if line.strip() == "eula=false":
file.write("eula=true\n")
continue
file.write(line)
return True
def jar_filename(filename: str | None, default: str) -> str: def jar_filename(filename: str | None, default: str) -> str:
if filename: if filename:
name = filename name = filename
@@ -134,6 +185,25 @@ def get_latest_version_minecraft(release=True):
return ver return ver
def get_specific_version_minecraft_require_java_version(minecraft_version, release=True):
version_list = get_version_list(release=release)
if release:
version_exists = minecraft_version in version_list
else:
version_exists = any(version.get("id") == minecraft_version for version in version_list)
if not version_exists:
raise Exception("Specified Minecraft version does not exist.")
metadata = get_minecraft_version_metadata(minecraft_version)
if not metadata:
raise Exception("Unable to get Minecraft version metadata for {}.\n".format(minecraft_version))
return metadata.get("javaVersion", {}).get("majorVersion", None)
def download_server_jar(minecraft_version: str, build_version: str, destination: Path, filename: str | None = None): def download_server_jar(minecraft_version: str, build_version: str, destination: Path, filename: str | None = None):
""" """
Download server jar (paper server only) Download server jar (paper server only)
@@ -149,7 +219,8 @@ def download_server_jar(minecraft_version: str, build_version: str, destination:
download_file(url, destination) download_file(url, destination)
return destination return destination
except Exception as e: except Exception as e:
raise Exception("Unable to download server jar for version {}\nURL: {}\nError: {}".format(minecraft_version, url, e)) raise Exception(
"Unable to download server jar for version {}\nURL: {}\nError: {}".format(minecraft_version, url, e))
def get_latest_build_of_version(minecraft_version: str) -> str: def get_latest_build_of_version(minecraft_version: str) -> str:
@@ -164,6 +235,7 @@ def download_latest_build_paper_jar(minecraft_version: str, destination_dir: Pat
build = get_latest_build_of_version(minecraft_version) build = get_latest_build_of_version(minecraft_version)
return download_server_jar(minecraft_version, build, destination_dir, filename=filename) return download_server_jar(minecraft_version, build, destination_dir, filename=filename)
def version_exist_from_paper(minecraft_version: str) -> bool: def version_exist_from_paper(minecraft_version: str) -> bool:
try: try:
get_specific_version_paper_builds(minecraft_version) get_specific_version_paper_builds(minecraft_version)
@@ -228,3 +300,53 @@ def download_vanilla_server_jar(minecraft_version: str, destination: Path, filen
url, url,
e, e,
)) ))
def find_system_java_executables(java_names):
"""
Find system Java executables
:param java_names:
:return:
"""
roots = []
if os.name == "nt":
for env_name in ("ProgramFiles", "ProgramFiles(x86)"):
root = os.environ.get(env_name)
if root:
roots.append(os.path.join(root, "Java"))
elif os.name == "posix":
roots.extend(("/Library/Java/JavaVirtualMachines", "/opt/java", "/usr/lib/jvm", "/usr/local/java"))
found = []
for root in roots:
if not os.path.isdir(root):
continue
for current_root, _, files in os.walk(root):
for java_name in java_names:
if java_name in files and os.path.basename(current_root).lower() == "bin":
found.append(os.path.join(current_root, java_name))
break
return found
def major_version_from_runtime_dir(runtime_dir):
"""
Get major version from target runtime directory
(Only works if this runtime is created by launcher)
:param runtime_dir:
:return:
"""
name = runtime_dir.name
if name.lower().startswith("java_"):
return name.split("_", 1)[1]
info_path = runtime_dir / "java.version.info"
if info_path.exists():
try:
for line in info_path.read_text(encoding="utf-8").splitlines():
if line.strip().startswith("JavaMajorVersion") and "=" in line:
return line.split("=", 1)[1].strip().strip('"').strip("'")
except Exception:
return ""
return ""
+102
View File
@@ -0,0 +1,102 @@
"""
Source are from KiteeLauncher.
"""
import os
import re
import subprocess
import tempfile
def check_java_executable_and_major_version(java_executable_path):
# test java runtimes are executable
try:
subprocess.run([java_executable_path], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
return True, None
except PermissionError:
return False, "Could not execute the Java executable. Permission denied."
except Exception as e:
return False, "Unknown error occurred while executing the Java executable. {}".format(e)
def get_java_version_by_execute(java_executable_path):
# test java runtimes are executable
# executable it
try:
result = subprocess.run([java_executable_path, '-version'], stderr=subprocess.PIPE, stdout=subprocess.PIPE,
text=True)
except PermissionError:
return False, None, "Could not execute the Java executable. Permission denied."
except Exception as e:
return False, None, "Unknown error occurred while executing the Java executable. {}".format(e)
# Get output
output = result.stderr
# Get major version (e.g., "21.0.3") and full version in the output
match = re.search(r'java version "(\d+)(?:\.(\d+))?', output)
# Is for installation by launcher runtimes (Because is openjdk not oracle java....)
if not match:
match = re.search(r'openjdk version "(\d+)(?:\.(\d+))?', output)
if not match:
return False, None, "Unsupported Java runtime build or current path is not a valid java executable."
try:
major_version = match.group(1)
# Special case for Java 8 where we need to use the second part (8) instead of 1
if major_version == "1" and match.group(2):
major_version = match.group(2)
return True, major_version, None
except IndexError:
return False, None, "The target Java runtime version is too old or is not supported by this method."
def convert_java_version_tuple_to_major_version(java_version_tuple):
try:
_, major_version, *_ = java_version_tuple.split(".")
return True, major_version
except ValueError:
return False, None
def get_java_version_by_checkmyduke(java_executable_path):
checkmyduke_jar = os.path.join("jar_files", "CheckMyDuke.jar")
if not os.path.exists(checkmyduke_jar):
return False, None, "CheckMyDuke.jar not found"
# executable it
try:
result = subprocess.run([java_executable_path, '-jar', checkmyduke_jar], stderr=subprocess.PIPE, stdout=subprocess.PIPE,
text=True)
except PermissionError:
return False, None, "Could not execute the Java executable. Permission denied."
except Exception as e:
return False, None, "Unknown error occurred while executing the Java executable. {}".format(e)\
status, major_version = convert_java_version_tuple_to_major_version(result.stdout.strip())
if status:
return True, major_version, None
else:
return False, None, "Unsupported Java runtime build or current path is not a valid java executable."
def search_available_java_runtimes_in_directory(target_directory, java_executable_name="java.exe" if os.name == "nt" else "java"):
"""
Using this function may take ~1min to search for available Java executable files.
"""
found_java_runtimes = []
for root, dirs, files in os.walk(target_directory):
for file in files:
if file == java_executable_name:
found_java_runtimes.append(os.path.join(root, file))
return found_java_runtimes
+232
View File
@@ -0,0 +1,232 @@
"""
bk_core
Copyright (c) 2024~2025 Techarerm/TedKai
Copyright (c) 2026 Kitee Contributors. All rights reserved.
"""
import os.path
import textwrap
import requests
azul_packages_api = "https://api.azul.com/metadata/v1/zulu/packages"
java_manifest_url = 'https://launchermeta.mojang.com/v1/products/java-runtime/2ec0cc96c44e5a76b9c8b7c39df7210883d12871/all.json'
def get_java_build_download_url_from_azul(platform_name: str, full_arch: str, java_major_version: str):
full_arch = full_arch.lower()
platform_name = platform_name.lower()
arch_map = {
"arm64": "aarch64",
"i686": "i686",
"amd64": "amd64",
"i386": "i686"
}
platform_map = {
"macos": "macos",
"darwin": "macos",
"windows": "windows",
"linux": "linux"
}
real_arch = arch_map.get(full_arch, full_arch)
platform = platform_map.get(platform_name, platform_name)
version_type_list = ["jre", "jdk"]
jre_url = (
f"/?java_version={java_major_version}&os={platform}&arch={real_arch}&java_package_type=jre&javafx_bundled=true"
f"&release_status=ga&availability_types=CA&certifications=tck&page=1&page_size=100")
full_jre_url = azul_packages_api + jre_url
jdk_url = (f"/?java_version={java_major_version}&os={platform}&arch={real_arch}&java_package_type=jdk"
f"&javafx_bundled=true"
f"&release_status=ga&availability_types=CA&certifications=tck&page=1&page_size=100")
full_jdk_url = azul_packages_api + jdk_url
version_url_list = [full_jre_url, full_jdk_url]
for url, ver_type in zip(version_url_list, version_type_list):
try:
response = requests.get(url)
data = response.json()
java_ver_data = data[0]
download_url = java_ver_data.get("download_url", None)
if download_url is not None:
return True, download_url, ver_type
except Exception as e:
print("[ERROR] Getting support jvm download url failed: ", e)
return False, None, None
return False, None, None
def get_java_version_manifest_data():
# Get java manifest
try:
response = requests.get(java_manifest_url)
if response.status_code == 200:
manifest_data = response.json()
return manifest_data
else:
return None
except Exception as e:
return None
def get_support_java_version(version_data):
"""
:param version_data: Special minecraft version data
:return Status, component, major_version
"""
try:
java_version_info = version_data['javaVersion']
component = java_version_info['component']
major_version = java_version_info['majorVersion']
return True, component, major_version
except KeyError:
return False, None, None
def get_support_java_version_from_java_version_manifest(platform, full_arch):
"""
:return SupportList
"""
platform_map = {
"darwin": "mac-os",
"windows": "windows",
"linux": "linux"
}
architecture_map = {
"amd64": ["x64"],
"i586": ["x86", "i386"],
"x86": ["i386", "x86"],
"arm64": ["arm64"],
"aarch64": ["arm64"]
}
platform_name = platform_map.get(platform.lower(), platform.lower())
architecture_list = architecture_map.get(full_arch.lower(), full_arch.lower())
try:
response = requests.get(java_manifest_url)
except Exception as e:
return False, None
java_manifest_data = response.json()
java_manifest_data_cleaned = {}
organized_manifest_data_list = []
# Get all platform data (except item "gamecore")
for arch_and_platform, support_java_data in java_manifest_data.items():
if arch_and_platform == "gamecore":
continue
java_manifest_data_cleaned.update({arch_and_platform: support_java_data})
# Separate platform name and arch from arch_and_platform
for arch_and_platform, support_java_data in java_manifest_data_cleaned.items():
if arch_and_platform == "mac-os":
# For mac os (x86-64)
java_platform = "mac-os"
java_arch = "amd64"
elif "-" in arch_and_platform:
parts = arch_and_platform.split("-")
java_platform = '-'.join(parts[:-1])
java_arch = parts[-1]
else:
java_platform = arch_and_platform
java_arch = "amd64"
for java_runtime_name, runtime_data in support_java_data.items():
if not java_runtime_name.startswith("java"):
if not java_runtime_name.startswith("jre"):
continue
# Skip some empty data
if len(runtime_data) == 0:
continue
# If the platform and architecture are same, append data to list
if not java_platform.lower() == platform_name:
continue
if java_arch.lower() not in architecture_list:
continue
# Convert data to dict
result = {}
for item in runtime_data:
result.update(item)
organized_manifest_data_list.append(result)
return organized_manifest_data_list
def get_support_java_runtime_version_data(organized_manifest_data_list, major_version):
"""
Warning: Require organized_manifest_data_list
"""
java_version_url = None
for data in organized_manifest_data_list:
name = data.get("version", {}).get("name", None)
url = data.get("manifest", {}).get("url", None)
if name is None:
continue
if name.startswith(str(major_version)):
if url is not None:
java_version_url = url
if java_version_url is not None:
try:
response = requests.get(java_version_url)
data = response.json()
return True, data
except Exception as e:
return False, None
return False, None
def create_java_version_info(java_major_version, java_arch, jvm_runtimes_path):
java_info_data = textwrap.dedent(f"""\
# Java Runtime Info
# This configuration is automatically generated by the BakeLauncher.
# Configuration stores Java for the launcher (Example: major_version, architecture).
# Do NOT edit this file or delete it!
JavaMajorVersion = "{java_major_version}"
Architecture = "{java_arch}"
""")
if not os.path.exists(jvm_runtimes_path):
os.makedirs(jvm_runtimes_path)
java_info_path = os.path.join(jvm_runtimes_path, "java.version.info")
if not os.path.exists(java_info_path):
with open(java_info_path, 'w') as file:
file.write(java_info_data)
def read_java_info(info_file_path, item):
with open(info_file_path, "r") as file:
lines = file.readlines()
for line in lines:
line = line.strip()
if item in line and '=' in line:
data = line.split('=', 1)[1].strip().strip("")
return str(data)
return None
+198
View File
@@ -0,0 +1,198 @@
"""
bk_core
Copyright (c) 2024~2025 Techarerm/TedKai
Copyright (c) 2026 Kitee Contributors. All rights reserved.
"""
import shutil
import os
import platform
import requests
from ..bk_utils.utils import download_file, extract_zip
from ..java.java_info import get_java_build_download_url_from_azul
from ..bk_utils.crypto import verify_checksum
def download_java_file(file_info, file_path, destination_folder):
download_type = "raw"
file_url = file_info["downloads"][download_type]["url"]
full_file_path = os.path.join(destination_folder, file_path)
expected_sha1 = file_info["downloads"][download_type].get("sha1")
directory = os.path.join(destination_folder, os.path.dirname(file_path))
os.makedirs(directory, exist_ok=True)
if os.path.exists(full_file_path) and expected_sha1 and verify_checksum(full_file_path, expected_sha1):
return []
return download_file(
file_url,
full_file_path,
with_verify=True,
sha1=expected_sha1,
)
def download_java_runtime_files(manifest, install_path):
if not os.path.exists(install_path):
return False, "InstallFolderAreNotExist"
files = manifest.get("files", {})
download_tasks = []
for file_path, file_info in files.items():
if file_info.get("type") == "directory":
os.makedirs(os.path.join(install_path, file_path), exist_ok=True)
continue
if "downloads" in file_info:
download_tasks.extend(download_java_file(file_info, file_path, install_path))
return True, download_tasks
def install_azul_build_version_jvm(java_major_version, install_dir, full_architecture, platform_name):
Status, download_url, version_type = get_java_build_download_url_from_azul(platform_name, full_architecture,
java_major_version)
if not Status:
return False, "Get download url failed. Unsupported platform"
tmp = os.path.join(os.path.dirname(install_dir), ".jvm_installer_tmp")
os.makedirs(tmp, exist_ok=True)
jvm_zip_file_path = os.path.join(tmp, f"jvm-azul-{java_major_version}.zip")
if os.path.exists(jvm_zip_file_path):
try:
os.remove(jvm_zip_file_path)
except Exception as e:
return False, "Cannot delete tmp file. ERR: {}".format(e)
jvm_unzip_dest = os.path.join(tmp, f"jvm-azul-{java_major_version}-unzipped")
if os.path.exists(jvm_unzip_dest):
try:
shutil.rmtree(jvm_unzip_dest)
except Exception as e:
return False, "Cannot delete unzip tmp file. ERR: {}".format(e)
if not os.path.exists(jvm_zip_file_path):
try:
_download_file_now(download_url, jvm_zip_file_path)
except Exception as e:
return False, "Download file failed. ERR: {}".format(e)
extract_zip(jvm_zip_file_path, jvm_unzip_dest)
if os.path.exists(install_dir):
try:
shutil.rmtree(install_dir)
except Exception as e:
return False, f"Cleaning install dir failed. ERR: {e}"
if platform_name.lower() == "darwin":
unzip_list = os.listdir(jvm_unzip_dest)
jvm_app_folder_name = unzip_list[0]
home_folder_path = os.path.join(jvm_unzip_dest, jvm_app_folder_name, f"zulu-{java_major_version}.jre",
"Contents", "Home")
if not os.path.isdir(home_folder_path):
home_folder_path = _find_java_home(jvm_unzip_dest)
if home_folder_path is None:
return False, "JAVA_HOME not found in the unzip folder."
else:
unzip_list = os.listdir(jvm_unzip_dest)
home_folder_name = unzip_list[0]
home_folder_path = os.path.join(jvm_unzip_dest, home_folder_name)
if not os.path.isdir(home_folder_path):
home_folder_path = _find_java_home(jvm_unzip_dest)
if home_folder_path is None:
return False, "JAVA_HOME not found in the unzip folder."
install_root_dir = os.path.dirname(install_dir)
home_name = os.path.basename(home_folder_path)
dest_path = os.path.join(install_root_dir, home_name)
try:
shutil.move(home_folder_path, install_root_dir)
except Exception as e:
return False, f"Move home folder to install dir failed. ERR: {e}"
try:
os.rename(dest_path, install_dir)
except Exception as e:
return False, "Rename dest_path to require name failed. ERR: {}".format(e)
return True, None
def _find_java_home(root):
for current_root, _, files in os.walk(root):
executable_names = {"java.exe", "javaw.exe"} if os.name == "nt" else {"java"}
if os.path.basename(current_root).lower() == "bin" and any(name in executable_names for name in files):
return os.path.dirname(current_root)
return None
def find_java_home_in_extracted_runtime(unzip_dir):
candidates = []
for java_name in ("javaw.exe", "java.exe", "java"):
candidates.extend(unzip_dir.rglob(java_name))
for java_path in candidates:
parent = java_path.parent
if parent.name.lower() == "bin":
return parent.parent
return None
def runtime_java_executable(install_dir):
bin_dir = install_dir / "bin"
if os.name == "nt":
javaw = bin_dir / "javaw.exe"
if javaw.exists():
return javaw
return bin_dir / "java.exe"
return bin_dir / "java"
def find_java_runtime_candidates(java_manifest, manifest_platform, java_major_version):
platform_data = java_manifest.get(manifest_platform, {})
candidates = []
for runtime_entries in platform_data.values():
for runtime_entry in runtime_entries:
version_name = str(runtime_entry.get("version", {}).get("name") or "")
if version_name.startswith(str(java_major_version)):
candidates.append(runtime_entry)
return candidates
def mojang_java_platform_key():
system = platform.system().lower()
machine = platform.machine().lower()
if system == "windows":
if "arm" in machine or "aarch64" in machine:
return "windows-arm64"
if "64" in machine or "amd64" in machine:
return "windows-x64"
return "windows-x86"
if system == "darwin":
return "mac-os-arm64" if "arm" in machine or "aarch64" in machine else "mac-os"
if system == "linux":
return "linux-arm64" if "arm" in machine or "aarch64" in machine else "linux"
return None
def _download_file_now(url, dest_path):
os.makedirs(os.path.dirname(dest_path), exist_ok=True)
response = requests.get(url, stream=True, timeout=30)
response.raise_for_status()
with open(dest_path, "wb") as file:
for chunk in response.iter_content(chunk_size=1024 * 128):
if chunk:
file.write(chunk)