Compare commits

..

14 Commits

Author SHA1 Message Date
wei 5acc033cad Update version. 2026-06-07 13:24:00 +08:00
wei 7f5c4e11b7 Fix restart bug. 2026-06-07 13:23:33 +08:00
wei 09256ee9f3 Fix timeout error. 2026-06-07 00:12:48 +08:00
wei fed9e3de71 Update paper api new latest version. 2026-06-06 22:29:48 +08:00
wei 196322b49f Update missing packages 2026-06-06 21:59:45 +08:00
wei 0f4af9e1f0 Add startup order support. 2026-06-06 21:42:31 +08:00
wei d422f5e0df Update missing packages. 2026-06-06 17:42:13 +08:00
wei dd079bdbc7 Update missing packages. 2026-06-06 17:40:27 +08:00
wei b7ff3cba4c Update scripts. 2026-06-06 17:24:47 +08:00
wei 7d4846225f Add execute permission to scripts. 2026-06-06 17:23:26 +08:00
wei 91ce4c32ff Update requirements.txt and add init script. 2026-06-06 17:21:35 +08:00
wei 79a97cfd06 Add jvm search, java download support (thanks KiteeLauncher), build and install script. 2026-06-06 17:18:25 +08:00
wei 3d80fab562 Add jvm search and download support (thanks KiteeLauncher) 2026-05-19 23:51:55 +08:00
wei c50603a47f Add connection history and some automatic server create process. 2026-05-19 23:30:00 +08:00
14 changed files with 1827 additions and 52 deletions
Executable
+16
View File
@@ -0,0 +1,16 @@
if [ ! -d "$DIR" ]; then
uv venv
fi
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
exit 1
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."
+141 -4
View File
@@ -26,7 +26,9 @@ SERVER_JAR_DIR = Path.home() / ".serverjar"
CLIENT_CERT_DIR = Path.home() / ".serverjar" / "client" / "cert"
CLIENT_CERT_SUFFIXES = {".pem", ".crt", ".cer"}
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"
DEFAULT_CONNECT_TIMEOUT = 10.0
def add_client_cert(cert_path):
source = Path(cert_path).expanduser()
@@ -94,6 +96,68 @@ def create_client_tls_context(log=None, warn=None):
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):
if not CLIENT_HISTORY_FILE.exists():
@@ -227,6 +291,7 @@ class ServerJarClient(Application):
self.cmds = []
self.current_index = None
self.history_draft = ""
self.connection_history = []
@self.kb.add("c-c")
def closing_kb(event):
@@ -426,6 +491,43 @@ class ServerJarClient(Application):
self._log("History cleared")
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 = {
"_exit": {
"func": _shutdown,
@@ -459,10 +561,23 @@ class ServerJarClient(Application):
"func": _clear,
"description": "Clear the log area",
},
"_retry": {
"func": _enable_retry_mode,
"description": "Reconnect to the remote server if connection fails",
},
"_help": {
"func": _help,
"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():
@@ -513,6 +628,8 @@ class ServerJarClient(Application):
required=False)
parser.add_argument('-r', '--retry', help="Retry when disconnect", action='store_true', default=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)
args = parser.parse_args()
@@ -541,6 +658,7 @@ class ServerJarClient(Application):
# Save history
save_history(self.cmds, self._log, self._warn)
save_connection_history(self.connection_history, self._log, self._warn)
# Exit ui event loop
self.full_exit()
@@ -600,6 +718,17 @@ class ServerJarClient(Application):
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):
ptk_clear()
@@ -627,13 +756,16 @@ class ServerJarClient(Application):
# Create connect
if self.args.no_tls:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((self.host, self.port))
s = socket.create_connection((self.host, self.port), timeout=self.args.connect_timeout)
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()
s = context.wrap_socket(raw, server_hostname=self.host)
# The connection timeout should not become an idle read timeout.
# Keep recv() blocking so quiet but healthy connections stay open.
s.settimeout(None)
with self.sock_lock:
self.sock = s
@@ -649,6 +781,10 @@ class ServerJarClient(Application):
data = s.recv(4096)
if not data:
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")
while "\n" in buffer:
line, buffer = buffer.split("\n", 1)
@@ -721,7 +857,7 @@ class ServerJarClient(Application):
except (ConnectionError, OSError) as e:
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...")
else:
self._err(f"Disconnected: {e}")
@@ -757,6 +893,7 @@ class ServerJarClient(Application):
self.args = self.arguments_parser()
self.layout.focus(self.input_area)
self.cmds = get_history(self._log, self._warn)
self.connection_history = get_connection_history(self._log, self._warn)
asyncio.create_task(self.consume_incoming())
self.client_thread.start()
Executable
+13
View File
@@ -0,0 +1,13 @@
echo "Checking environment..."
if command -v uv &> /dev/null; then
echo "uv installed"
else
echo "uv not installed. Install it via \"dnf install uv\" or \"apt install uv\" !" >&2
exit 1
fi
if [ ! -d "$DIR" ]; then
uv venv
fi
uv pip install -r requirements.txt
Executable
+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 "Install completed"
echo "sarclient -> /usr/local/bin/sarclient"
echo "serverjar -> /usr/local/bin/serverjar"
+410 -38
View File
@@ -3,6 +3,7 @@ ServerJar
Wei - 2026
"""
import platform
import re
import signal
import socketserver
@@ -17,13 +18,21 @@ import time
import datetime
import base64
import hmac
import ipaddress
import shlex
import shutil
from pathlib import Path
import click
import yaml
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 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.x509.oid import NameOID
from cryptography.hazmat.primitives import hashes
@@ -32,10 +41,11 @@ from cryptography.hazmat.primitives import serialization
ROOT_DIR = Path(os.getcwd())
SERVER_CONFIG_PATH = ROOT_DIR / "config" / "server.yml"
VERSION = "1.1"
VERSION = "1.2"
LOG_DIR_NAME = "logs"
SERVERJAR_LOG_FILE = "serverjar.log"
LOG_DOWNLOAD_CHUNK_SIZE = 4096
JVM_RUNTIME_DIR = ROOT_DIR / "data" / "jvm"
def exit(message):
click.echo(click.style(message, fg='green'))
@@ -72,7 +82,7 @@ def load_settings():
"workDir": "",
"port": 25565,
"host": "127.0.0.1",
"enable": True
"enable": True,
},
use_same_form=True,
)
@@ -89,6 +99,282 @@ def load_settings():
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()
@click.option("--name", "-d", default="Unnamed Server", show_default=True, help="Server name")
@click.option("--mc-version", "-m",
@@ -112,7 +398,10 @@ def load_settings():
@click.option("--custom-args", "-ce",
help="Custom arguments (command)", type=str, default="")
@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,
help="Initial allocation size of the memory for server",
type=str, default="1G")
@@ -126,10 +415,15 @@ def load_settings():
help="Hostname of the server", required=True)
@click.option("--server-port", "-srp",
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.")
@click.option("--order", "-o", help="Startup order when starting the server", type=int, default=None)
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, order):
server_dir = Path("servers", name)
server_type = server_type.lower()
property_overrides = parse_server_property_overrides(server_properties)
def prepare_server_dir():
if server_dir.exists():
@@ -167,7 +461,7 @@ def create_server(name, mc_version, build, server_type, snapshot, latest, list_b
else:
if mc_version is None:
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:
builds = get_specific_version_paper_builds(mc_version)
@@ -210,22 +504,42 @@ def create_server(name, mc_version, build, server_type, snapshot, latest, list_b
exit("User aborted.")
return
extra_args += "nogui" if nogui else ""
selected_mc_version = latest_ver if latest_ver is not None else mc_version
if not custom_args:
java_exec_path = resolve_java_executable(
java_exec_path,
selected_mc_version,
release,
install_java,
x_memory_maximum,
)
args = [
java_exec_path,
java_exec_path or "java",
"-Xms{}".format(x_memory_initial),
"-Xmx{}".format(x_memory_maximum),
"-jar",
out.absolute().as_posix(),
extra_args,
]
if extra_args:
args.extend(shlex.split(extra_args))
if nogui:
args.append("nogui")
if custom_args:
print("Will use custom commands as replacement.")
args = custom_args
args = shlex.split(custom_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:
print("Saving...")
s["servers"].append({
@@ -237,6 +551,7 @@ def create_server(name, mc_version, build, server_type, snapshot, latest, list_b
"port": server_port,
"host": server_host,
"enable": True,
"order": order
})
print("Done")
@@ -292,6 +607,8 @@ class SocketServer:
self._ssl_context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
self._ssl_context.load_cert_chain(certfile=self.certfile, keyfile=self.keyfile)
self.commands = {}
# -------------------------
# Socket Server
# -------------------------
@@ -339,11 +656,12 @@ class SocketServer:
"__help": "Display this help message",
"__list": "List available server shells",
"__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",
"__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",
"__download_log": "Download the attached server's saved log file",
"__system_info": "Show system information",
}
attached_commands = {
"__status": "Show target server process status",
@@ -597,12 +915,18 @@ class SocketServer:
self.request.sendall(b"[SYS] bye\n")
return
elif cmd == "__stop_all":
self.request.sendall(
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.stop_event.set()
return
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(
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.stop_event.set()
return
elif cmd.startswith("__sync_log"):
mgr.send_sync_log(self.request, current_server, cmd)
elif cmd == "__download_log":
@@ -633,6 +957,14 @@ class SocketServer:
f"[SYS] Disconnected from current server \"{current_server}\"'s shell.\n".encode(
"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:
if current_server is not None:
receiver = mgr.get_command_receiver(current_server)
@@ -676,6 +1008,16 @@ class SocketServer:
except (ConnectionResetError, OSError):
mgr.logger.info(
"[SYS] Client disconnected. From {}:{}".format(self.client_address[0], self.client_address[1]))
except Exception as e:
mgr.logger.exception(
"[SYS] Client handler error. From %s:%s",
self.client_address[0],
self.client_address[1],
)
try:
self.request.sendall(f"[SYS:ERR] Server handler error: {e}\n".encode("utf-8"))
except OSError:
pass
finally:
stop_evt.set()
if log_q is not None:
@@ -887,12 +1229,12 @@ class Server:
def _stdout_reader_loop(self):
self.logger.info("[PROC] stdout reader started")
while self.running:
while True:
with self.proc_lock:
proc = self.proc
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")
break
@@ -915,8 +1257,11 @@ class Server:
if not self.proc.stdin:
return False
self.proc.stdin.write(command + "\n")
self.proc.stdin.flush()
try:
self.proc.stdin.write(command + "\n")
self.proc.stdin.flush()
except OSError:
return False
return True
def stop_process(self, timeout: float = 10.0):
@@ -959,9 +1304,9 @@ class Server:
return
self.stopping = True
self.running = False
self.stop_process()
self.running = False
def restart(self):
if self.running:
@@ -995,13 +1340,16 @@ class Server:
return True, f"Server \"{self.name}\" process started"
elif command == "__info":
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"arguments: {self.args}\n"
f"host: {self.host}\n"
f"port: {self.port}\n"
f"version: {self.version}\n"
f"status: {"Running" if self.is_process_alive() else "Died"}")
elif command == "__restart":
self.restart()
return True, f"Server \"{self.name}\" process restarted"
else:
return False, f"Unknown command: {command}"
@@ -1012,24 +1360,33 @@ class Server:
if not self.proc.stdin:
return False, "Process standard input are not available."
self.proc.stdin.write(command + "\n")
self.proc.stdin.flush()
try:
self.proc.stdin.write(command + "\n")
self.proc.stdin.flush()
except OSError as e:
return False, f"Unable to write process standard input: {e}"
return True, "Command sent."
def load_all_server_from_settings(settings: FileSettings):
servers = []
with settings.edit() as s:
for server_conf in s.get("servers", []):
servers.append(Server(
name=server_conf.get("name"),
version=server_conf.get("version"),
description=server_conf.get("description"),
args=server_conf.get("args",[]),
work_dir=server_conf.get("workDir"),
port=server_conf.get("port"),
host=server_conf.get("host"),
enable=server_conf.get("enable")
))
order = server_conf.get("order", None)
server = Server(
name=server_conf.get("name"),
version=server_conf.get("version"),
description=server_conf.get("description"),
args=server_conf.get("args", []),
work_dir=server_conf.get("workDir"),
port=server_conf.get("port"),
host=server_conf.get("host"),
enable=server_conf.get("enable")
)
if order is None or order-1<0:
servers.append(server)
else:
servers.insert(order-1, server)
return servers
@@ -1138,7 +1495,8 @@ def runserver(keep_running: bool):
logger.info("Stopped!")
@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...")
key = rsa.generate_private_key(
public_exponent=65537,
@@ -1150,9 +1508,23 @@ def generate_tls_key():
x509.NameAttribute(NameOID.STATE_OR_PROVINCE_NAME, u"California"),
x509.NameAttribute(NameOID.LOCALITY_NAME, u"San Francisco"),
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(
subject
).issuer_name(
@@ -1167,7 +1539,7 @@ def generate_tls_key():
# Valid for 1 year
datetime.datetime.now(datetime.UTC) + datetime.timedelta(days=365)
).add_extension(
x509.SubjectAlternativeName([x509.DNSName(u"localhost")]),
x509.SubjectAlternativeName(alt_names),
critical=False,
).sign(key, hashes.SHA256())
+6
View File
@@ -1,2 +1,8 @@
prompt-toolkit==3.0.52
cryptography==48.0.0
jproperties==2.1.2
nuitka==4.1.2
click==8.4.1
pyyaml==6.0.3
requests==2.34.2
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)
+163 -9
View File
@@ -1,13 +1,66 @@
import hashlib
import warnings
from pathlib import Path
import requests
import click
import os
from jproperties import Properties
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_VERSION_API = "https://fill.papermc.io/v3/projects/paper/versions/{}"
PAPER_BUILD_API = "https://fill.papermc.io/v3/projects/paper/versions/{}/builds/{}"
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:
if filename:
name = filename
@@ -18,7 +71,7 @@ def jar_filename(filename: str | None, default: str) -> str:
return default
def download_file(url: str, destination: Path, chunk_size: int = 1024 * 512):
def download_file(url: str, destination: Path, chunk_size: int = 1024 * 512, sha256=None):
destination.parent.mkdir(parents=True, exist_ok=True)
with requests.get(url, stream=True, timeout=30) as r:
@@ -40,6 +93,19 @@ def download_file(url: str, destination: Path, chunk_size: int = 1024 * 512):
if chunk:
f.write(chunk)
if sha256:
sha256_hash = hashlib.sha256()
with destination.open(mode="rb", buffering=chunk_size) as f:
for chunk in iter(lambda: f.read(chunk_size), b""):
sha256_hash.update(chunk)
hexdigest = sha256_hash.hexdigest()
if hexdigest == sha256:
click.echo(f"File {os.path.basename(destination)} verified.")
else:
raise RuntimeError(f"Download {os.path.basename(destination)} failed. Hash does not match.")
def get_specific_version_paper_builds(minecraft_version: str) -> list[dict[str, str]]:
"""
@@ -134,22 +200,59 @@ def get_latest_version_minecraft(release=True):
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 get_paper_server_jar_info(minecraft_version: str, build_version: str):
url = PAPER_BUILD_API.format(minecraft_version, build_version)
try:
r = requests.get(url)
if r.status_code == 200:
return r.json().get("downloads", {}).get("server:default", None)
else:
raise Exception(f"Unable to fetch paper server jar information. (VER:{minecraft_version},BUILD:{build_version})\n")
except requests.exceptions.RequestException as e:
raise Exception(f"Unable to get paper version information. Exec: {e}\n")
def download_server_jar(minecraft_version: str, build_version: str, destination: Path, filename: str | None = None):
"""
Download server jar (paper server only)
"""
url = PAPER_SERVER_JAR_API.format(minecraft_version, build_version, minecraft_version, build_version)
data = get_paper_server_jar_info(minecraft_version, build_version)
jar_name = jar_filename(filename, os.path.basename(url))
download_url = data.get("url", None)
sha256 = data.get("checksums", {}).get("sha256", None)
jar_name = jar_filename(filename, os.path.basename(download_url))
destination.parent.mkdir(parents=True, exist_ok=True)
destination = Path(destination, jar_name)
if not sha256:
warnings.warn("Unable to verify server jar due to hash from server is invalid.", UserWarning)
try:
download_file(url, destination)
download_file(download_url, destination, sha256=sha256)
return destination
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, download_url, e))
def get_latest_build_of_version(minecraft_version: str) -> str:
@@ -164,6 +267,7 @@ def download_latest_build_paper_jar(minecraft_version: str, destination_dir: Pat
build = get_latest_build_of_version(minecraft_version)
return download_server_jar(minecraft_version, build, destination_dir, filename=filename)
def version_exist_from_paper(minecraft_version: str) -> bool:
try:
get_specific_version_paper_builds(minecraft_version)
@@ -178,14 +282,14 @@ def get_latest_paper_version(release) -> str:
latest_paper_support_ver = None
while latest_paper_support_ver is None:
if len(vers) < index+1:
if len(vers) < index + 1:
raise Exception("No supported Minecraft version available for Paper support.")
if version_exist_from_paper(minecraft_version=vers[index]):
latest_paper_support_ver = vers[index]
break
index+=1
index += 1
return latest_paper_support_ver
@@ -228,3 +332,53 @@ def download_vanilla_server_jar(minecraft_version: str, destination: Path, filen
url,
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)