mcl

A terrible minecraft launcher
Log | Files | Refs | README

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 }