main.zig (20107B)
1 const std = @import("std"); 2 const builtin = @import("builtin"); 3 const log = std.log.scoped(.mcl); 4 5 /// A very simple command line minecraft launcher 6 /// Its only special thing is support for linux-aarch64 7 pub fn main() !void { 8 var gpa = std.heap.GeneralPurposeAllocator(.{}){}; 9 defer _ = gpa.deinit(); 10 const allocator = gpa.allocator(); 11 var client = try HttpClient.init(allocator); 12 defer client.deinit(); 13 14 var root_dir_path = try get_minecraft_root_dir(allocator); 15 defer allocator.free(root_dir_path); 16 17 var manifest_file_path = try std.fs.path.join(allocator, &[_][]const u8{root_dir_path, "manifest.json"}); 18 defer allocator.free(manifest_file_path); 19 var manifest_file_new_path = try std.fs.path.join(allocator, &[_][]const u8{root_dir_path, "manifest.json.new"}); 20 defer allocator.free(manifest_file_new_path); 21 { 22 log.info("downloading manifest from {s}", .{Manifest.uri}); 23 if (download_file(&client, manifest_file_new_path, Manifest.uri, null)) { 24 try std.fs.renameAbsolute(manifest_file_new_path, manifest_file_path); 25 } else |e| { 26 log.err("can't download manifest {} maybe offline?", .{e}); 27 } 28 } 29 30 var manifest_file = try std.fs.openFileAbsolute(manifest_file_path, .{}); 31 defer manifest_file.close(); 32 const manifest_wrap = try read_json_file(allocator, manifest_file, Manifest); 33 defer manifest_wrap.deinit(); 34 const manifest = manifest_wrap.value; 35 log.info("release version: {s}", .{ manifest.latest.release }); 36 37 // Find the release version 38 const release_version_stub: Manifest.VersionStub = for (manifest.versions) |v| { 39 if (std.mem.eql(u8, v.id, manifest.latest.release)) { 40 break v; 41 } 42 } else { 43 return error.NoReleaseVersionFound; 44 }; 45 46 const release_version_dir_path = try version_dir_path(allocator, release_version_stub.id, root_dir_path); 47 defer allocator.free(release_version_dir_path); 48 const release_version_manifest_path = try std.fs.path.join(allocator, &[_][]const u8{release_version_dir_path, "client.json"}); 49 defer allocator.free(release_version_manifest_path); 50 51 try download_file(&client, release_version_manifest_path, release_version_stub.url, release_version_stub.sha1); 52 const release_version_manifest_file = try std.fs.openFileAbsolute(release_version_manifest_path, .{}); 53 defer release_version_manifest_file.close(); 54 const release_version_manifest_wrap = try read_json_file(allocator, release_version_manifest_file, Version); 55 defer release_version_manifest_wrap.deinit(); 56 const release_version_manifest: Version = release_version_manifest_wrap.value; 57 58 try download_version(allocator, &client, root_dir_path, release_version_manifest); 59 try play_version(allocator, root_dir_path, release_version_manifest); 60 } 61 62 /// Wrapper over http client impl 63 /// I'd love this to be the zig stdlib one but it doesn't support TLSv1.2 which 64 /// mojang are using (and not supporting v1.3 :( ). 65 const HttpClient = struct { 66 const curl = @cImport(@cInclude("curl/curl.h")); 67 68 allocator: std.mem.Allocator, 69 c_curl: *curl.CURL, 70 errbuf: [curl.CURL_ERROR_SIZE]u8 = [_]u8{0}**curl.CURL_ERROR_SIZE, 71 72 fn init(allocator: std.mem.Allocator) !HttpClient { 73 const c_curl = curl.curl_easy_init() orelse return error.CurlInitFailed; 74 return .{ 75 .allocator = allocator, 76 .c_curl = c_curl, 77 }; 78 } 79 fn deinit(self: *HttpClient) void { 80 curl.curl_easy_cleanup(self.c_curl); 81 } 82 83 const Request = struct { 84 url: []const u8, 85 }; 86 const Response = struct { 87 response_buf: std.ArrayList(u8), 88 did_oom: bool = false, 89 status: std.http.Status = .teapot, 90 fn init(allocator: std.mem.Allocator) Response { 91 return .{ 92 .response_buf = std.ArrayList(u8).init(allocator), 93 }; 94 } 95 fn deinit(self: *Response) void { 96 self.response_buf.deinit(); 97 } 98 // size_t write_data(char *buffer, size_t size, size_t nmemb, void *userp); 99 // https://curl.se/libcurl/c/CURLOPT_WRITEDATA.html 100 fn write_data(buffer: [*c]u8, _: usize, nmemb: usize, userp: *anyopaque) callconv(.C) usize { 101 var self: *Response = @ptrCast(@alignCast(userp)); 102 self.response_buf.appendSlice(buffer[0..nmemb]) catch { 103 self.did_oom = true; 104 return 0; 105 }; 106 return nmemb; 107 } 108 }; 109 fn do(self: *HttpClient, request: Request) !Response { 110 const urlz = try self.allocator.dupeZ(u8, request.url); 111 defer self.allocator.free(urlz); 112 var response = Response.init(self.allocator); 113 errdefer response.deinit(); 114 if (curl.curl_easy_setopt(self.c_curl, curl.CURLOPT_ERRORBUFFER, &self.errbuf) != curl.CURLE_OK) { 115 log.err("cURL error: {s}", .{self.errbuf}); 116 return error.CurlError; 117 } 118 if (curl.curl_easy_setopt(self.c_curl, curl.CURLOPT_URL, urlz.ptr) != curl.CURLE_OK) { 119 log.err("cURL error: {s}", .{self.errbuf}); 120 return error.CurlError; 121 } 122 if (curl.curl_easy_setopt(self.c_curl, curl.CURLOPT_WRITEFUNCTION, Response.write_data) != curl.CURLE_OK) { 123 log.err("cURL error: {s}", .{self.errbuf}); 124 return error.CurlError; 125 } 126 if (curl.curl_easy_setopt(self.c_curl, curl.CURLOPT_WRITEDATA, &response) != curl.CURLE_OK) { 127 log.err("cURL error: {s}", .{self.errbuf}); 128 return error.CurlError; 129 } 130 if (curl.curl_easy_perform(self.c_curl) != curl.CURLE_OK) { 131 if (response.did_oom) { 132 log.err("OOM receiving data from cURL", .{}); 133 return error.OutOfMemory; 134 } else { 135 log.err("cURL error: {s}", .{self.errbuf}); 136 return error.CurlError; 137 } 138 } 139 return response; 140 } 141 }; 142 143 /// Root of minecraft data directory. 144 /// Calling ensures the directory exists. 145 /// Caller owns the result and must free it 146 fn get_minecraft_root_dir(allocator: std.mem.Allocator) ![]const u8 { 147 const home_dir = std.os.getenv("HOME") orelse { 148 return error.AppDataDirUnavailable; 149 }; 150 const root_dir_path = try std.fs.path.join(allocator, &[_][]const u8{home_dir, ".minecraft"}); 151 _ = std.fs.openDirAbsolute(root_dir_path, .{}) catch |e| switch (e) { 152 error.FileNotFound => { 153 try std.fs.makeDirAbsolute(root_dir_path); 154 }, 155 else => return e, 156 }; 157 return root_dir_path; 158 } 159 160 fn download_file(client: *HttpClient, file_path: []const u8, url: []const u8, maybe_expect_sha1_base64: ?[]const u8) !void { 161 var hash_match = false; 162 if (maybe_expect_sha1_base64) |expect_sha1| { 163 if (std.fs.openFileAbsolute(file_path, .{})) |file| { 164 defer file.close(); 165 var sha1 = std.crypto.hash.Sha1.init(.{}); 166 var buf = [_]u8{0} ** 1024; 167 while (true) { 168 const sz = try file.read(&buf); 169 if (sz == 0) break; 170 sha1.update(buf[0..sz]); 171 } 172 var digest = [_]u8{0} ** std.crypto.hash.Sha1.digest_length; 173 sha1.final(&digest); 174 const hexdigest = try std.fmt.allocPrint(client.allocator, "{}", .{std.fmt.fmtSliceHexLower(&digest)}); 175 defer client.allocator.free(hexdigest); 176 hash_match = std.mem.eql(u8, expect_sha1, hexdigest); 177 log.info("file {s} hash_match {} expect {s} actual {s}", .{file_path, hash_match, expect_sha1, hexdigest}); 178 } else |e| switch(e) { 179 error.FileNotFound => { 180 log.info("file {s} not found", .{file_path}); 181 }, 182 else => return e, 183 } 184 if (hash_match) return; 185 } 186 187 log.info("downloading file {s} to {s}", .{url, file_path}); 188 var file = try std.fs.createFileAbsolute(file_path, .{}); 189 defer file.close(); 190 const a = client.allocator; 191 var r = try client.do(.{.url= url}); 192 defer r.deinit(); 193 const sl = try r.response_buf.toOwnedSlice(); 194 defer a.free(sl); 195 try file.writeAll(sl); 196 // TODO check the hash again? 197 } 198 199 /// Caller should call .deinit on the response 200 fn read_json_file(allocator: std.mem.Allocator, file: std.fs.File, comptime T: type) !std.json.Parsed(T) { 201 var r2 = std.json.reader(allocator, file.reader()); 202 var diags = std.json.Diagnostics{}; 203 r2.enableDiagnostics(&diags); 204 defer r2.deinit(); 205 var res = std.json.parseFromTokenSource(T, allocator, &r2, .{ .ignore_unknown_fields = true, .allocate = .alloc_always }) catch |e| { 206 log.err("parse failed {} line {} col {}", .{ e, diags.getLine(), diags.getColumn() }); 207 return e; 208 }; 209 return res; 210 } 211 212 /// https://minecraft.fandom.com/wiki/Version_manifest.json 213 const Manifest = struct { 214 const uri = "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json"; 215 216 const VersionStub = struct { 217 id: []const u8, 218 type: []const u8, 219 url: []const u8, 220 releaseTime: []const u8, 221 sha1: []const u8, 222 complianceLevel: i64, 223 }; 224 225 latest: struct { 226 release: []const u8, 227 snapshot: []const u8, 228 }, 229 versions: []VersionStub, 230 }; 231 232 test "Manifest" { 233 const x = @embedFile("manifest_test.json"); 234 var f = std.io.fixedBufferStream(x); 235 var jr = std.json.reader(std.testing.allocator, f.reader()); 236 defer jr.deinit(); 237 var d = std.json.Diagnostics{}; 238 jr.enableDiagnostics(&d); 239 var v_w = std.json.parseFromTokenSource(Manifest, std.testing.allocator, &jr, .{.ignore_unknown_fields = true}) catch |e| { 240 log.err("parse failed {} line {} col {}", .{ e, d.getLine(), d.getColumn() }); 241 return e; 242 }; 243 defer v_w.deinit(); 244 const v = v_w.value; 245 _ = v; 246 } 247 248 /// Caller must free the result 249 fn version_dir_path(allocator: std.mem.Allocator, version_id: []const u8, root_dir_path: []const u8) ![]const u8 { 250 return try std.fs.path.join(allocator, &[_][]const u8{root_dir_path, "versions", version_id}); 251 } 252 253 /// https://minecraft.fandom.com/wiki/Client.json 254 const Version = struct { 255 const Rule = struct { 256 const Feature = struct { 257 is_demo_user: bool = false, 258 has_custom_resolution: bool = false, 259 is_quick_play_multiplayer: bool = false, 260 is_quick_play_realms: bool = false, 261 }; 262 const Os = struct { 263 name: ?enum{osx,linux,windows} = null, 264 arch: ?enum{x86} = null, 265 }; 266 action: enum{allow}, 267 features: ?Feature = null, 268 os: ?Os = null, 269 }; 270 271 const Arg = union(enum) { 272 const Extended = struct { 273 const Value = union(enum) { 274 single: []const u8, 275 many: []const []const u8, 276 pub fn jsonParse(allocator: std.mem.Allocator, source: anytype, options: std.json.ParseOptions) !Value { 277 switch (try source.peekNextTokenType()) { 278 .array_begin => { 279 return .{ .many = try std.json.innerParse([]const []const u8, allocator, source, options) }; 280 }, 281 .string => { 282 return .{ .single = try std.json.innerParse([]const u8, allocator, source, options) }; 283 }, 284 else => return error.UnexpectedToken, 285 } 286 } 287 }; 288 289 rules: []Rule, 290 value: Value, 291 }; 292 293 plain: []const u8, 294 extended: Extended, 295 296 pub fn jsonParse(allocator: std.mem.Allocator, source: anytype, options: std.json.ParseOptions) !Arg { 297 switch (try source.peekNextTokenType()) { 298 .object_begin => { 299 return .{ 300 .extended = try std.json.innerParse(Extended, allocator, source, options), 301 }; 302 }, 303 .string => { 304 return .{ 305 .plain = try std.json.innerParse([]const u8, allocator, source, options), 306 }; 307 }, 308 else => return error.UnexpectedToken, 309 } 310 } 311 }; 312 const Download = struct { 313 sha1: []const u8, 314 size: u64, 315 url: []const u8, 316 }; 317 const Library = struct { 318 downloads: struct { 319 artifact: struct { 320 path: []const u8, 321 sha1: []const u8, 322 size: u64, 323 url: []const u8, 324 }, 325 }, 326 name: []const u8, 327 rules: ?[]Rule = null, 328 }; 329 330 arguments: struct { 331 game: []Arg, 332 jvm: []Arg, 333 }, 334 assetIndex: struct { 335 id:[]const u8, 336 sha1: []const u8, 337 size: u64, 338 totalSize: u64, 339 url: []const u8, 340 }, 341 assets: []const u8, 342 complianceLevel: u8, 343 downloads: struct { 344 client: Download, 345 client_mappings: Download, 346 server: Download, 347 server_mappings: Download, 348 }, 349 id: []const u8, 350 javaVersion: struct { 351 component: []const u8, 352 majorVersion: u8, 353 }, 354 libraries: []Library, 355 logging: struct { 356 client: struct { 357 argument: []const u8, 358 file: struct { 359 id: []const u8, 360 sha1: []const u8, 361 size: u64, 362 url: []const u8, 363 }, 364 type: []const u8, 365 }, 366 }, 367 mainClass: []const u8, 368 minimumLauncherVersion: u8, 369 releaseTime: []const u8, 370 time: []const u8, 371 type: []const u8, 372 }; 373 374 test "Version" { 375 const x = @embedFile("version_test.json"); 376 var f = std.io.fixedBufferStream(x); 377 var jr = std.json.reader(std.testing.allocator, f.reader()); 378 defer jr.deinit(); 379 var d = std.json.Diagnostics{}; 380 jr.enableDiagnostics(&d); 381 var v_w = std.json.parseFromTokenSource(Version, std.testing.allocator, &jr, .{.ignore_unknown_fields = true}) catch |e| { 382 log.err("parse failed {} line {} col {}", .{ e, d.getLine(), d.getColumn() }); 383 return e; 384 }; 385 defer v_w.deinit(); 386 const v = v_w.value; 387 _ = v; 388 } 389 390 fn download_version(allocator: std.mem.Allocator, client: *HttpClient, root_dir_path: []const u8, version: Version) !void { 391 const dir_path = try version_dir_path(allocator, version.id, root_dir_path); 392 defer allocator.free(dir_path); 393 std.fs.makeDirAbsolute(dir_path) catch |e| switch (e) { 394 error.PathAlreadyExists => {}, 395 else => return e, 396 }; 397 log.info("downloading version {s} to {s}", .{version.id, dir_path}); 398 399 const client_jar_path = try std.fs.path.join(allocator, &[_][]const u8{dir_path, "client.jar"}); 400 defer allocator.free(client_jar_path); 401 try download_file(client, client_jar_path, version.downloads.client.url, version.downloads.client.sha1); 402 403 const client_map_path = try std.fs.path.join(allocator, &[_][]const u8{dir_path, "client.map"}); 404 defer allocator.free(client_map_path); 405 try download_file(client, client_map_path, version.downloads.client_mappings.url, version.downloads.client_mappings.sha1); 406 407 lib: for (version.libraries) |library| { 408 if (library.rules) |rules| for (rules) |rule| { 409 // TODO rule.features 410 if (rule.os) |os| { 411 if (os.name) |osname| { 412 if (osname == .windows and builtin.os.tag != .windows) { 413 continue :lib; 414 } else if (osname == .osx and builtin.os.tag != .macos) { 415 continue :lib; 416 } else if (osname == .linux and builtin.os.tag != .linux) { 417 continue :lib; 418 } 419 } 420 421 if (os.arch) |osarch| { 422 if (osarch == .x86 and builtin.cpu.arch != .x86) { 423 continue :lib; 424 } 425 } 426 } 427 }; 428 const file_path = try std.fs.path.join(allocator, &[_][]const u8{dir_path, std.fs.path.basename(library.downloads.artifact.path)}); 429 defer allocator.free(file_path); 430 var url = try tweak_download_url(allocator, library.downloads.artifact.url); 431 defer allocator.free(url); 432 try download_file(client, file_path, url, library.downloads.artifact.sha1); 433 } 434 // TODO assets 435 } 436 437 /// Caller frees the result 438 fn tweak_download_url(allocator: std.mem.Allocator, original_url: []const u8) ![]const u8 { 439 if (builtin.target.os.tag == .linux and builtin.cpu.arch == .aarch64) { 440 // builds for aarch64 are not supplied by mojang, fetch them direct from lwjgl 441 // https://libraries.minecraft.net/org/lwjgl/(lwjgl-glfw)/(3.3.1)/lwjgl-glfw-3.3.1-(natives-linux).jar 442 // https://build.lwjgl.org/release/(3.3.1)/bin/(lwjgl-glfw)/lwjgl-glfw-natives-linux-aarch64.jar 443 var spl = std.mem.splitScalar(u8, original_url, '/'); 444 while (spl.next()) |nxt| { 445 if (std.mem.eql(u8, nxt, "lwjgl")) { 446 const lib = spl.next() orelse break; 447 const ver = spl.next() orelse break; 448 const jar = spl.next() orelse break; 449 if (std.mem.indexOf(u8, jar, "natives-linux") != null) { 450 var fbs = std.ArrayList(u8).init(allocator); 451 defer fbs.deinit(); 452 var w = fbs.writer(); 453 try w.writeAll("https://build.lwjgl.org/release/"); 454 try w.writeAll(ver); 455 try w.writeAll("/bin/"); 456 try w.writeAll(lib); 457 try w.writeAll("/"); 458 var new_jar_z = try std.mem.replaceOwned(u8, allocator, jar, ver, ""); 459 defer allocator.free(new_jar_z); 460 var new_jar_y = try std.mem.replaceOwned(u8, allocator, new_jar_z, "-natives-linux.jar", "natives-linux-arm64.jar"); 461 defer allocator.free(new_jar_y); 462 try w.writeAll(new_jar_y); 463 const new_url = try fbs.toOwnedSlice(); 464 log.info("aarch64 linux: replacing download {s} with {s}", .{original_url, new_url}); 465 return new_url; 466 } 467 } 468 } 469 } 470 return try allocator.dupe(u8, original_url); 471 } 472 473 test "download url" { 474 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"); 475 defer std.testing.allocator.free(new_url); 476 try std.testing.expectEqualStrings("https://build.lwjgl.org/release/3.3.1/bin/lwjgl-glfw/lwjgl-glfw-natives-linux-arm64.jar", new_url); 477 } 478 479 fn play_version(allocator: std.mem.Allocator, root_dir_path: []const u8, version: Version) !void { 480 const dir_path = try version_dir_path(allocator, version.id, root_dir_path); 481 defer allocator.free(dir_path); 482 var dir = try std.fs.openDirAbsolute(dir_path, .{}); 483 defer dir.close(); 484 var cmd_buf = std.ArrayList([]const u8).init(allocator); 485 try cmd_buf.append("java"); 486 try cmd_buf.append("-cp"); 487 try cmd_buf.append("*"); 488 // TODO jvm args from version manifest 489 // try cmd_buf.append(version.mainClass); // TODO something is wrong with this element 490 try cmd_buf.append("net.minecraft.client.main.Main"); 491 // TODO game args from version manifest 492 // TODO auth 493 try cmd_buf.append("-accessToken=foo"); 494 try cmd_buf.append("-version=1.20.1"); 495 const cmd = try cmd_buf.toOwnedSlice(); 496 defer allocator.free(cmd); 497 const cmd_log = try std.mem.join(allocator, " ", cmd); 498 defer allocator.free(cmd_log); 499 log.info("running minecraft cmd [{s}] in cwd {s}", .{cmd_log, dir_path}); 500 var proc = std.ChildProcess.init(cmd, allocator); 501 proc.stdin_behavior = .Ignore; 502 proc.cwd = dir_path; 503 proc.cwd_dir = null; 504 proc.env_map = null; 505 proc.expand_arg0 = .no_expand; 506 _ = try proc.spawnAndWait(); 507 }