const std = @import("std"); const builtin = @import("builtin"); const log = std.log.scoped(.mcl); /// A very simple command line minecraft launcher /// Its only special thing is support for linux-aarch64 pub fn main() !void { var gpa = std.heap.GeneralPurposeAllocator(.{}){}; defer _ = gpa.deinit(); const allocator = gpa.allocator(); var client = try HttpClient.init(allocator); defer client.deinit(); var root_dir_path = try get_minecraft_root_dir(allocator); defer allocator.free(root_dir_path); var manifest_file_path = try std.fs.path.join(allocator, &[_][]const u8{root_dir_path, "manifest.json"}); defer allocator.free(manifest_file_path); var manifest_file_new_path = try std.fs.path.join(allocator, &[_][]const u8{root_dir_path, "manifest.json.new"}); defer allocator.free(manifest_file_new_path); { log.info("downloading manifest from {s}", .{Manifest.uri}); if (download_file(&client, manifest_file_new_path, Manifest.uri, null)) { try std.fs.renameAbsolute(manifest_file_new_path, manifest_file_path); } else |e| { log.err("can't download manifest {} maybe offline?", .{e}); } } var manifest_file = try std.fs.openFileAbsolute(manifest_file_path, .{}); defer manifest_file.close(); const manifest_wrap = try read_json_file(allocator, manifest_file, Manifest); defer manifest_wrap.deinit(); const manifest = manifest_wrap.value; log.info("release version: {s}", .{ manifest.latest.release }); // Find the release version const release_version_stub: Manifest.VersionStub = for (manifest.versions) |v| { if (std.mem.eql(u8, v.id, manifest.latest.release)) { break v; } } else { return error.NoReleaseVersionFound; }; const release_version_dir_path = try version_dir_path(allocator, release_version_stub.id, root_dir_path); defer allocator.free(release_version_dir_path); const release_version_manifest_path = try std.fs.path.join(allocator, &[_][]const u8{release_version_dir_path, "client.json"}); defer allocator.free(release_version_manifest_path); try download_file(&client, release_version_manifest_path, release_version_stub.url, release_version_stub.sha1); const release_version_manifest_file = try std.fs.openFileAbsolute(release_version_manifest_path, .{}); defer release_version_manifest_file.close(); const release_version_manifest_wrap = try read_json_file(allocator, release_version_manifest_file, Version); defer release_version_manifest_wrap.deinit(); const release_version_manifest: Version = release_version_manifest_wrap.value; try download_version(allocator, &client, root_dir_path, release_version_manifest); try play_version(allocator, root_dir_path, release_version_manifest); } /// Wrapper over http client impl /// I'd love this to be the zig stdlib one but it doesn't support TLSv1.2 which /// mojang are using (and not supporting v1.3 :( ). const HttpClient = struct { const curl = @cImport(@cInclude("curl/curl.h")); allocator: std.mem.Allocator, c_curl: *curl.CURL, errbuf: [curl.CURL_ERROR_SIZE]u8 = [_]u8{0}**curl.CURL_ERROR_SIZE, fn init(allocator: std.mem.Allocator) !HttpClient { const c_curl = curl.curl_easy_init() orelse return error.CurlInitFailed; return .{ .allocator = allocator, .c_curl = c_curl, }; } fn deinit(self: *HttpClient) void { curl.curl_easy_cleanup(self.c_curl); } const Request = struct { url: []const u8, }; const Response = struct { response_buf: std.ArrayList(u8), did_oom: bool = false, status: std.http.Status = .teapot, fn init(allocator: std.mem.Allocator) Response { return .{ .response_buf = std.ArrayList(u8).init(allocator), }; } fn deinit(self: *Response) void { self.response_buf.deinit(); } // size_t write_data(char *buffer, size_t size, size_t nmemb, void *userp); // https://curl.se/libcurl/c/CURLOPT_WRITEDATA.html fn write_data(buffer: [*c]u8, _: usize, nmemb: usize, userp: *anyopaque) callconv(.C) usize { var self: *Response = @ptrCast(@alignCast(userp)); self.response_buf.appendSlice(buffer[0..nmemb]) catch { self.did_oom = true; return 0; }; return nmemb; } }; fn do(self: *HttpClient, request: Request) !Response { const urlz = try self.allocator.dupeZ(u8, request.url); defer self.allocator.free(urlz); var response = Response.init(self.allocator); errdefer response.deinit(); if (curl.curl_easy_setopt(self.c_curl, curl.CURLOPT_ERRORBUFFER, &self.errbuf) != curl.CURLE_OK) { log.err("cURL error: {s}", .{self.errbuf}); return error.CurlError; } if (curl.curl_easy_setopt(self.c_curl, curl.CURLOPT_URL, urlz.ptr) != curl.CURLE_OK) { log.err("cURL error: {s}", .{self.errbuf}); return error.CurlError; } if (curl.curl_easy_setopt(self.c_curl, curl.CURLOPT_WRITEFUNCTION, Response.write_data) != curl.CURLE_OK) { log.err("cURL error: {s}", .{self.errbuf}); return error.CurlError; } if (curl.curl_easy_setopt(self.c_curl, curl.CURLOPT_WRITEDATA, &response) != curl.CURLE_OK) { log.err("cURL error: {s}", .{self.errbuf}); return error.CurlError; } if (curl.curl_easy_perform(self.c_curl) != curl.CURLE_OK) { if (response.did_oom) { log.err("OOM receiving data from cURL", .{}); return error.OutOfMemory; } else { log.err("cURL error: {s}", .{self.errbuf}); return error.CurlError; } } return response; } }; /// Root of minecraft data directory. /// Calling ensures the directory exists. /// Caller owns the result and must free it fn get_minecraft_root_dir(allocator: std.mem.Allocator) ![]const u8 { const home_dir = std.os.getenv("HOME") orelse { return error.AppDataDirUnavailable; }; const root_dir_path = try std.fs.path.join(allocator, &[_][]const u8{home_dir, ".minecraft"}); _ = std.fs.openDirAbsolute(root_dir_path, .{}) catch |e| switch (e) { error.FileNotFound => { try std.fs.makeDirAbsolute(root_dir_path); }, else => return e, }; return root_dir_path; } fn download_file(client: *HttpClient, file_path: []const u8, url: []const u8, maybe_expect_sha1_base64: ?[]const u8) !void { var hash_match = false; if (maybe_expect_sha1_base64) |expect_sha1| { if (std.fs.openFileAbsolute(file_path, .{})) |file| { defer file.close(); var sha1 = std.crypto.hash.Sha1.init(.{}); var buf = [_]u8{0} ** 1024; while (true) { const sz = try file.read(&buf); if (sz == 0) break; sha1.update(buf[0..sz]); } var digest = [_]u8{0} ** std.crypto.hash.Sha1.digest_length; sha1.final(&digest); const hexdigest = try std.fmt.allocPrint(client.allocator, "{}", .{std.fmt.fmtSliceHexLower(&digest)}); defer client.allocator.free(hexdigest); hash_match = std.mem.eql(u8, expect_sha1, hexdigest); log.info("file {s} hash_match {} expect {s} actual {s}", .{file_path, hash_match, expect_sha1, hexdigest}); } else |e| switch(e) { error.FileNotFound => { log.info("file {s} not found", .{file_path}); }, else => return e, } if (hash_match) return; } log.info("downloading file {s} to {s}", .{url, file_path}); var file = try std.fs.createFileAbsolute(file_path, .{}); defer file.close(); const a = client.allocator; var r = try client.do(.{.url= url}); defer r.deinit(); const sl = try r.response_buf.toOwnedSlice(); defer a.free(sl); try file.writeAll(sl); // TODO check the hash again? } /// Caller should call .deinit on the response fn read_json_file(allocator: std.mem.Allocator, file: std.fs.File, comptime T: type) !std.json.Parsed(T) { var r2 = std.json.reader(allocator, file.reader()); var diags = std.json.Diagnostics{}; r2.enableDiagnostics(&diags); defer r2.deinit(); var res = std.json.parseFromTokenSource(T, allocator, &r2, .{ .ignore_unknown_fields = true, .allocate = .alloc_always }) catch |e| { log.err("parse failed {} line {} col {}", .{ e, diags.getLine(), diags.getColumn() }); return e; }; return res; } /// https://minecraft.fandom.com/wiki/Version_manifest.json const Manifest = struct { const uri = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; const VersionStub = struct { id: []const u8, type: []const u8, url: []const u8, releaseTime: []const u8, sha1: []const u8, complianceLevel: i64, }; latest: struct { release: []const u8, snapshot: []const u8, }, versions: []VersionStub, }; test "Manifest" { const x = @embedFile("manifest_test.json"); var f = std.io.fixedBufferStream(x); var jr = std.json.reader(std.testing.allocator, f.reader()); defer jr.deinit(); var d = std.json.Diagnostics{}; jr.enableDiagnostics(&d); var v_w = std.json.parseFromTokenSource(Manifest, std.testing.allocator, &jr, .{.ignore_unknown_fields = true}) catch |e| { log.err("parse failed {} line {} col {}", .{ e, d.getLine(), d.getColumn() }); return e; }; defer v_w.deinit(); const v = v_w.value; _ = v; } /// Caller must free the result fn version_dir_path(allocator: std.mem.Allocator, version_id: []const u8, root_dir_path: []const u8) ![]const u8 { return try std.fs.path.join(allocator, &[_][]const u8{root_dir_path, "versions", version_id}); } /// https://minecraft.fandom.com/wiki/Client.json const Version = struct { const Rule = struct { const Feature = struct { is_demo_user: bool = false, has_custom_resolution: bool = false, is_quick_play_multiplayer: bool = false, is_quick_play_realms: bool = false, }; const Os = struct { name: ?enum{osx,linux,windows} = null, arch: ?enum{x86} = null, }; action: enum{allow}, features: ?Feature = null, os: ?Os = null, }; const Arg = union(enum) { const Extended = struct { const Value = union(enum) { single: []const u8, many: []const []const u8, pub fn jsonParse(allocator: std.mem.Allocator, source: anytype, options: std.json.ParseOptions) !Value { switch (try source.peekNextTokenType()) { .array_begin => { return .{ .many = try std.json.innerParse([]const []const u8, allocator, source, options) }; }, .string => { return .{ .single = try std.json.innerParse([]const u8, allocator, source, options) }; }, else => return error.UnexpectedToken, } } }; rules: []Rule, value: Value, }; plain: []const u8, extended: Extended, pub fn jsonParse(allocator: std.mem.Allocator, source: anytype, options: std.json.ParseOptions) !Arg { switch (try source.peekNextTokenType()) { .object_begin => { return .{ .extended = try std.json.innerParse(Extended, allocator, source, options), }; }, .string => { return .{ .plain = try std.json.innerParse([]const u8, allocator, source, options), }; }, else => return error.UnexpectedToken, } } }; const Download = struct { sha1: []const u8, size: u64, url: []const u8, }; const Library = struct { downloads: struct { artifact: struct { path: []const u8, sha1: []const u8, size: u64, url: []const u8, }, }, name: []const u8, rules: ?[]Rule = null, }; arguments: struct { game: []Arg, jvm: []Arg, }, assetIndex: struct { id:[]const u8, sha1: []const u8, size: u64, totalSize: u64, url: []const u8, }, assets: []const u8, complianceLevel: u8, downloads: struct { client: Download, client_mappings: Download, server: Download, server_mappings: Download, }, id: []const u8, javaVersion: struct { component: []const u8, majorVersion: u8, }, libraries: []Library, logging: struct { client: struct { argument: []const u8, file: struct { id: []const u8, sha1: []const u8, size: u64, url: []const u8, }, type: []const u8, }, }, mainClass: []const u8, minimumLauncherVersion: u8, releaseTime: []const u8, time: []const u8, type: []const u8, }; test "Version" { const x = @embedFile("version_test.json"); var f = std.io.fixedBufferStream(x); var jr = std.json.reader(std.testing.allocator, f.reader()); defer jr.deinit(); var d = std.json.Diagnostics{}; jr.enableDiagnostics(&d); var v_w = std.json.parseFromTokenSource(Version, std.testing.allocator, &jr, .{.ignore_unknown_fields = true}) catch |e| { log.err("parse failed {} line {} col {}", .{ e, d.getLine(), d.getColumn() }); return e; }; defer v_w.deinit(); const v = v_w.value; _ = v; } fn download_version(allocator: std.mem.Allocator, client: *HttpClient, root_dir_path: []const u8, version: Version) !void { const dir_path = try version_dir_path(allocator, version.id, root_dir_path); defer allocator.free(dir_path); std.fs.makeDirAbsolute(dir_path) catch |e| switch (e) { error.PathAlreadyExists => {}, else => return e, }; log.info("downloading version {s} to {s}", .{version.id, dir_path}); const client_jar_path = try std.fs.path.join(allocator, &[_][]const u8{dir_path, "client.jar"}); defer allocator.free(client_jar_path); try download_file(client, client_jar_path, version.downloads.client.url, version.downloads.client.sha1); const client_map_path = try std.fs.path.join(allocator, &[_][]const u8{dir_path, "client.map"}); defer allocator.free(client_map_path); try download_file(client, client_map_path, version.downloads.client_mappings.url, version.downloads.client_mappings.sha1); lib: for (version.libraries) |library| { if (library.rules) |rules| for (rules) |rule| { // TODO rule.features if (rule.os) |os| { if (os.name) |osname| { if (osname == .windows and builtin.os.tag != .windows) { continue :lib; } else if (osname == .osx and builtin.os.tag != .macos) { continue :lib; } else if (osname == .linux and builtin.os.tag != .linux) { continue :lib; } } if (os.arch) |osarch| { if (osarch == .x86 and builtin.cpu.arch != .x86) { continue :lib; } } } }; const file_path = try std.fs.path.join(allocator, &[_][]const u8{dir_path, std.fs.path.basename(library.downloads.artifact.path)}); defer allocator.free(file_path); var url = try tweak_download_url(allocator, library.downloads.artifact.url); defer allocator.free(url); try download_file(client, file_path, url, library.downloads.artifact.sha1); } // TODO assets } /// Caller frees the result fn tweak_download_url(allocator: std.mem.Allocator, original_url: []const u8) ![]const u8 { if (builtin.target.os.tag == .linux and builtin.cpu.arch == .aarch64) { // builds for aarch64 are not supplied by mojang, fetch them direct from lwjgl // https://libraries.minecraft.net/org/lwjgl/(lwjgl-glfw)/(3.3.1)/lwjgl-glfw-3.3.1-(natives-linux).jar // https://build.lwjgl.org/release/(3.3.1)/bin/(lwjgl-glfw)/lwjgl-glfw-natives-linux-aarch64.jar var spl = std.mem.splitScalar(u8, original_url, '/'); while (spl.next()) |nxt| { if (std.mem.eql(u8, nxt, "lwjgl")) { const lib = spl.next() orelse break; const ver = spl.next() orelse break; const jar = spl.next() orelse break; if (std.mem.indexOf(u8, jar, "natives-linux") != null) { var fbs = std.ArrayList(u8).init(allocator); defer fbs.deinit(); var w = fbs.writer(); try w.writeAll("https://build.lwjgl.org/release/"); try w.writeAll(ver); try w.writeAll("/bin/"); try w.writeAll(lib); try w.writeAll("/"); var new_jar_z = try std.mem.replaceOwned(u8, allocator, jar, ver, ""); defer allocator.free(new_jar_z); var new_jar_y = try std.mem.replaceOwned(u8, allocator, new_jar_z, "-natives-linux.jar", "natives-linux-arm64.jar"); defer allocator.free(new_jar_y); try w.writeAll(new_jar_y); const new_url = try fbs.toOwnedSlice(); log.info("aarch64 linux: replacing download {s} with {s}", .{original_url, new_url}); return new_url; } } } } return try allocator.dupe(u8, original_url); } test "download url" { const new_url = try tweak_download_url(std.testing.allocator, "https://libraries.minecraft.net/org/lwjgl/lwjgl-glfw/3.3.1/lwjgl-glfw-3.3.1-natives-linux.jar"); defer std.testing.allocator.free(new_url); try std.testing.expectEqualStrings("https://build.lwjgl.org/release/3.3.1/bin/lwjgl-glfw/lwjgl-glfw-natives-linux-arm64.jar", new_url); } fn play_version(allocator: std.mem.Allocator, root_dir_path: []const u8, version: Version) !void { const dir_path = try version_dir_path(allocator, version.id, root_dir_path); defer allocator.free(dir_path); var dir = try std.fs.openDirAbsolute(dir_path, .{}); defer dir.close(); var cmd_buf = std.ArrayList([]const u8).init(allocator); try cmd_buf.append("java"); try cmd_buf.append("-cp"); try cmd_buf.append("*"); // TODO jvm args from version manifest // try cmd_buf.append(version.mainClass); // TODO something is wrong with this element try cmd_buf.append("net.minecraft.client.main.Main"); // TODO game args from version manifest // TODO auth try cmd_buf.append("-accessToken=foo"); try cmd_buf.append("-version=1.20.1"); const cmd = try cmd_buf.toOwnedSlice(); defer allocator.free(cmd); const cmd_log = try std.mem.join(allocator, " ", cmd); defer allocator.free(cmd_log); log.info("running minecraft cmd [{s}] in cwd {s}", .{cmd_log, dir_path}); var proc = std.ChildProcess.init(cmd, allocator); proc.stdin_behavior = .Ignore; proc.cwd = dir_path; proc.cwd_dir = null; proc.env_map = null; proc.expand_arg0 = .no_expand; _ = try proc.spawnAndWait(); }