From d155c57a898b70bd6689e83afa288127820b2921 Mon Sep 17 00:00:00 2001 From: Martin Ashby Date: Mon, 11 Sep 2023 22:39:12 +0100 Subject: Reorganize declarations in order of appearance. Add hash checking to avoid repeat downloads. --- src/main.zig | 524 +++++++++++++++++++++++++++++------------------------------ 1 file changed, 257 insertions(+), 267 deletions(-) (limited to 'src') diff --git a/src/main.zig b/src/main.zig index fd2aa5c..cc78561 100644 --- a/src/main.zig +++ b/src/main.zig @@ -2,8 +2,65 @@ 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 stdlig one but it doesn't support TLSv1.2 which +/// 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")); @@ -71,7 +128,6 @@ const HttpClient = struct { 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; @@ -84,160 +140,116 @@ const HttpClient = struct { } }; -/// Minecraft launcher! -pub fn main() !void { - var gpa = std.heap.GeneralPurposeAllocator(.{ .safety = false }){}; // TODO turn safety back on - defer _ = gpa.deinit(); - const allocator = gpa.allocator(); - var client = try HttpClient.init(allocator); - defer client.deinit(); - - log.info("downloading manifest from {s}", .{Manifest.uri}); - const manifest_wrap = try download_json(&client, Manifest, Manifest.uri); - defer manifest_wrap.deinit(); - const manifest = manifest_wrap.value; - log.info("release version: {s}", .{ manifest.latest.release }); - - // Find the release version - const release_version_url = for (manifest.versions) |v| { - if (std.mem.eql(u8, v.id, manifest.latest.release)) { - break v.url; - } - } else { - return error.NoReleaseVersionFound; +/// 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; }; - - log.info("downloading release manifest from {s}", .{release_version_url}); - var release_version_wrap = try download_json(&client, Version, release_version_url); - defer release_version_wrap.deinit(); - const release_version: Version = release_version_wrap.value; - - - // Download all the required files - var root_dir_path = try get_minecraft_root_dir(allocator); - defer allocator.free(root_dir_path); - // try download_version(allocator, &client, root_dir_path, release_version); - try play_version(allocator, root_dir_path, release_version); -} - -fn play_version(allocator: std.mem.Allocator, root_dir_path: []const u8, version: Version) !void { - // version dir - const dir_path = try version.dir_path(allocator, 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("*"); - // try cmd_buf.append(version.mainClass); - try cmd_buf.append("net.minecraft.client.main.Main"); - 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(); -} - -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, root_dir_path); - defer allocator.free(dir_path); - std.fs.makeDirAbsolute(dir_path) catch |e| switch (e) { - error.PathAlreadyExists => {}, // fine, swallow the error + 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, }; - log.info("downloading version {s} to {s}", .{version.id, dir_path}); - const dir = try std.fs.openDirAbsolute(dir_path, .{}); - var client_jar = try dir.createFile("client.jar", .{}); - defer client_jar.close(); - try download_file(client, &client_jar, version.downloads.client.url); - - var client_map = try dir.createFile("client.map", .{}); - defer client_map.close(); - try download_file(client, &client_map, version.downloads.client_mappings.url); - // TODO server - - // Dependencies... - 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 = std.fs.path.basename(library.downloads.artifact.path); - var file = try dir.createFile(file_path, .{}); - defer file.close(); - try download_file(client, &file, library.downloads.artifact.url); - } + return root_dir_path; } -// Caller frees the result -fn fix_https(allocator: std.mem.Allocator, url_maybe_https: []const u8) ![]const u8 { - // This particular URL doesn't support TLSv1.3 :( - // and zig doesn't support TLSv1.2. Booooo. Hack it and use http for now. - if (std.mem.indexOf(u8, url_maybe_https, "piston-meta.mojang.com") != null - or std.mem.indexOf(u8, url_maybe_https, "piston-data.mojang.com") != null) { - const sz = std.mem.replacementSize(u8, url_maybe_https, "https://", "http://"); - const url = try allocator.alloc(u8, sz); - _ = std.mem.replace(u8, url_maybe_https, "https://", "http://", url); - return url; - } else { - return try allocator.dupe(u8, url_maybe_https); +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; } -} -fn download_file(client: *HttpClient, file: *std.fs.File, url: []const u8) !void { - log.info("downloading file {s}", .{url}); + 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"; - latest: struct { - release: []const u8, - snapshot: []const u8, - }, - versions: []struct { + 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 { @@ -356,134 +368,9 @@ const Version = struct { minimumLauncherVersion: u8, releaseTime: []const u8, time: []const u8, - type: []const u8, - - /// Caller must free the result - fn dir_path(self: Version, allocator: std.mem.Allocator, root_dir_path: []const u8) ![]const u8 { - return try std.fs.path.join(allocator, &[_][]const u8{root_dir_path, "versions", self.id}); - } + type: []const u8, }; - -/// Caller is responsible for calling deinit on the resulting Parsed struct. -fn download_json(client: *HttpClient, comptime T: type, url: []const u8) !std.json.Parsed(T) { - const a = client.allocator; - var r = try client.do(.{.url = url}); - var sl = try r.response_buf.toOwnedSlice(); - defer a.free(sl); - // TODO check status - // if (r.response.status != .ok) { - // log.err("request failed {}:{s}", .{ r.response.status, r.response.reason }); - // return error.HttpStatusError; - // } - var f = std.io.fixedBufferStream(sl); - var r2 = std.json.reader(a, f.reader()); - var diags = std.json.Diagnostics{}; - r2.enableDiagnostics(&diags); - defer r2.deinit(); - var res = std.json.parseFromTokenSource(T, a, &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; -} - -/// 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; -} - -test "heterogenous array" { - const x = - \\ { - \\ "y": [ - \\ { "z": "yay" }, - \\ "nay" - \\ ] - \\ } - ; - const IX = union(enum) { - // What to name union tags? - const P = struct { z: []const u8 }; - p: P, - q: []const u8, - pub fn jsonParse(allocator: std.mem.Allocator, source: anytype, options: std.json.ParseOptions) !@This() { - switch (try source.peekNextTokenType()) { - .object_begin => { - return .{ - .p = try std.json.innerParse(P, allocator, source, options), - }; - }, - .string => { - return .{ - .q = try std.json.innerParse([]const u8, allocator, source, options), - }; - }, - else => return error.UnexpectedToken, - } - } - }; - const X = struct { y: []const IX }; - const xx = try std.json.parseFromSlice(X, std.testing.allocator, x, .{}); - defer xx.deinit(); - try std.testing.expectEqualDeep(X{ - .y = &[_]IX{ - .{ .p = .{ .z = "yay" } }, - .{ .q = "nay" }, - }, - }, xx.value); -} - -test "string list" { - const x = - \\["ond", "two", "three", "four"] - ; - const v_w = try std.json.parseFromSlice([]const []const u8, std.testing.allocator, x, .{}); - defer v_w.deinit(); - const expect: []const []const u8 = &[_][]const u8{ "ond", "two", "three", "four" }; - try std.testing.expectEqualDeep(expect, v_w.value); -} - -test "string or string list" { - const x1 = - \\ "testing" - ; - const x2 = - \\ ["one", "two"] - ; - const X = union(enum) { - one: []const u8, - many: []const []const u8, - pub fn jsonParse(allocator: std.mem.Allocator, source: anytype, options: std.json.ParseOptions) !@This() { - switch (try source.peekNextTokenType()) { - .array_begin => { - return .{ .many = try std.json.innerParse([]const []const u8, allocator, source, options) }; - }, - .string => { - return .{ .one = try std.json.innerParse([]const u8, allocator, source, options) }; - }, - else => return error.UnexpectedToken, - } - } - }; - var ax1_w = try std.json.parseFromSlice(X, std.testing.allocator, x1, .{}); - defer ax1_w.deinit(); - var ax2_w = try std.json.parseFromSlice(X, std.testing.allocator, x2, .{}); - defer ax2_w.deinit(); -} - test "Version" { const x = @embedFile("version_test.json"); var f = std.io.fixedBufferStream(x); @@ -500,18 +387,121 @@ test "Version" { _ = v; } -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; +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, }; - defer v_w.deinit(); - const v = v_w.value; - _ = v; + 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(); +} \ No newline at end of file -- cgit v1.2.3-ZIG