|
| 1 | +//! Periodic update check for zvm self-updates. |
| 2 | +//! Caches the latest version from GitHub Releases in the cache directory. |
| 3 | +//! Only re-checks if the cache is older than 24 hours to avoid unnecessary |
| 4 | +//! network requests. Prints a non-intrusive hint when a newer version exists. |
| 5 | + |
| 6 | +const std = @import("std"); |
| 7 | +const build_options = @import("build_options"); |
| 8 | +const zvm_mod = @import("zvm.zig"); |
| 9 | +const http_client = @import("../network/http_client.zig"); |
| 10 | +const terminal = @import("terminal.zig"); |
| 11 | + |
| 12 | +/// Cache file name for the update check result. |
| 13 | +const cache_filename = "_update_check"; |
| 14 | + |
| 15 | +/// How often to re-check for updates (24 hours in seconds). |
| 16 | +const check_interval_secs: i64 = 86400; |
| 17 | + |
| 18 | +/// Current zvm version, injected at build time. |
| 19 | +const VERSION = build_options.version; |
| 20 | + |
| 21 | +/// Compare two semver version strings (without 'v' prefix). |
| 22 | +/// Returns true if a > b. |
| 23 | +pub fn versionGt(a: []const u8, b: []const u8) bool { |
| 24 | + var a_iter = std.mem.splitScalar(u8, a, '.'); |
| 25 | + var b_iter = std.mem.splitScalar(u8, b, '.'); |
| 26 | + while (true) { |
| 27 | + const a_part = a_iter.next(); |
| 28 | + const b_part = b_iter.next(); |
| 29 | + if (a_part == null and b_part == null) return false; |
| 30 | + const a_val: u64 = if (a_part) |p| std.fmt.parseInt(u64, p, 10) catch 0 else 0; |
| 31 | + const b_val: u64 = if (b_part) |p| std.fmt.parseInt(u64, p, 10) catch 0 else 0; |
| 32 | + if (a_val > b_val) return true; |
| 33 | + if (a_val < b_val) return false; |
| 34 | + } |
| 35 | +} |
| 36 | + |
| 37 | +/// Strip leading 'v' from a version string if present. |
| 38 | +pub fn stripVPrefix(version: []const u8) []const u8 { |
| 39 | + if (version.len > 0 and version[0] == 'v') return version[1..]; |
| 40 | + return version; |
| 41 | +} |
| 42 | + |
| 43 | +/// Extract the actual version from a GitHub release JSON body. |
| 44 | +/// The "latest" release has tag_name="latest", so we extract from body: |
| 45 | +/// "Latest stable release (vX.Y.Z)." |
| 46 | +pub fn extractVersionFromRelease(json: std.json.Value) ?[]const u8 { |
| 47 | + const obj = switch (json) { |
| 48 | + .object => |o| o, |
| 49 | + else => return null, |
| 50 | + }; |
| 51 | + |
| 52 | + // Try body first: "Latest stable release (vX.Y.Z)." |
| 53 | + if (obj.get("body")) |body_val| { |
| 54 | + if (body_val == .string) { |
| 55 | + const body = body_val.string; |
| 56 | + if (std.mem.indexOf(u8, body, "(")) |open| { |
| 57 | + if (std.mem.indexOf(u8, body[open..], ")")) |close| { |
| 58 | + return body[open + 1 .. open + close]; |
| 59 | + } |
| 60 | + } |
| 61 | + } |
| 62 | + } |
| 63 | + |
| 64 | + // Fallback to tag_name if it looks like a version |
| 65 | + if (obj.get("tag_name")) |tag| { |
| 66 | + if (tag == .string) { |
| 67 | + const t = tag.string; |
| 68 | + if (t.len > 0 and t[0] == 'v') return t; |
| 69 | + } |
| 70 | + } |
| 71 | + |
| 72 | + return null; |
| 73 | +} |
| 74 | + |
| 75 | +/// Fetch the latest zvm version from GitHub Releases API. |
| 76 | +/// Returns an owned string that the caller must free. |
| 77 | +pub fn fetchLatestVersion( |
| 78 | + allocator: std.mem.Allocator, |
| 79 | + io: std.Io, |
| 80 | + environ_map: *std.process.Environ.Map, |
| 81 | + proxy: []const u8, |
| 82 | +) ![]const u8 { |
| 83 | + const release_json = try http_client.downloadToMemoryWithProxy( |
| 84 | + allocator, |
| 85 | + io, |
| 86 | + environ_map, |
| 87 | + "https://api.github.com/repos/lispking/zvm/releases/latest", |
| 88 | + proxy, |
| 89 | + ); |
| 90 | + defer allocator.free(release_json); |
| 91 | + |
| 92 | + const parsed = try std.json.parseFromSlice(std.json.Value, allocator, release_json, .{ |
| 93 | + .ignore_unknown_fields = true, |
| 94 | + }); |
| 95 | + defer parsed.deinit(); |
| 96 | + |
| 97 | + const version = extractVersionFromRelease(parsed.value) orelse return error.InvalidResponse; |
| 98 | + return allocator.dupe(u8, version); |
| 99 | +} |
| 100 | + |
| 101 | +/// Read the cached latest version and check timestamp. |
| 102 | +/// Returns the cached version string (slice into `content_buf`) if valid, null otherwise. |
| 103 | +fn readCache(allocator: std.mem.Allocator, io: std.Io, cache_path: []const u8) ?[]const u8 { |
| 104 | + const file = std.Io.Dir.cwd().openFile(io, cache_path, .{}) catch return null; |
| 105 | + defer file.close(io); |
| 106 | + |
| 107 | + var read_buf: [256]u8 = undefined; |
| 108 | + var reader = file.reader(io, &read_buf); |
| 109 | + const content = reader.interface.allocRemaining(allocator, .limited(256)) catch return null; |
| 110 | + |
| 111 | + // Format: "<timestamp>\n<version>" |
| 112 | + const newline = std.mem.indexOfScalar(u8, content, '\n') orelse return null; |
| 113 | + const ts_str = content[0..newline]; |
| 114 | + const cached_version = std.mem.trim(u8, content[newline + 1 ..], " \n\r"); |
| 115 | + if (cached_version.len == 0) return null; |
| 116 | + |
| 117 | + const timestamp = std.fmt.parseInt(i64, ts_str, 10) catch return null; |
| 118 | + const now = std.Io.Clock.Timestamp.now(io, .real).raw.toSeconds(); |
| 119 | + if (now - timestamp > check_interval_secs) return null; |
| 120 | + |
| 121 | + return cached_version; |
| 122 | +} |
| 123 | + |
| 124 | +/// Write the latest version and current timestamp to the cache file. |
| 125 | +fn writeCache(io: std.Io, cache_path: []const u8, version: []const u8) void { |
| 126 | + const file = std.Io.Dir.cwd().createFile(io, cache_path, .{}) catch return; |
| 127 | + defer file.close(io); |
| 128 | + |
| 129 | + const now = std.Io.Clock.Timestamp.now(io, .real).raw.toSeconds(); |
| 130 | + |
| 131 | + var buf: [512]u8 = undefined; |
| 132 | + var writer = file.writer(io, &buf); |
| 133 | + writer.interface.print("{d}\n{s}\n", .{ now, version }) catch {}; |
| 134 | + writer.interface.flush() catch {}; |
| 135 | +} |
| 136 | + |
| 137 | +/// Check if a newer zvm version is available. |
| 138 | +/// Uses a 24-hour cache to avoid hammering the GitHub API. |
| 139 | +/// Returns an owned string (latest version) if an update is available, null otherwise. |
| 140 | +pub fn checkForUpdate( |
| 141 | + allocator: std.mem.Allocator, |
| 142 | + io: std.Io, |
| 143 | + environ_map: *std.process.Environ.Map, |
| 144 | + cache_dir: []const u8, |
| 145 | + proxy: []const u8, |
| 146 | +) ?[]const u8 { |
| 147 | + var path_buf: [std.fs.max_path_bytes * 2]u8 = undefined; |
| 148 | + const cache_path = std.fmt.bufPrint(&path_buf, "{s}/{s}", .{ cache_dir, cache_filename }) catch return null; |
| 149 | + |
| 150 | + // Try reading from cache first |
| 151 | + var content_buf: [256]u8 = undefined; |
| 152 | + _ = &content_buf; |
| 153 | + if (readCache(allocator, io, cache_path)) |cached_version| { |
| 154 | + const current = stripVPrefix(VERSION); |
| 155 | + const latest = stripVPrefix(cached_version); |
| 156 | + if (versionGt(latest, current)) { |
| 157 | + return allocator.dupe(u8, cached_version) catch null; |
| 158 | + } |
| 159 | + return null; |
| 160 | + } |
| 161 | + |
| 162 | + // Cache miss or stale — fetch from GitHub |
| 163 | + const latest = fetchLatestVersion(allocator, io, environ_map, proxy) catch return null; |
| 164 | + |
| 165 | + // Update cache |
| 166 | + writeCache(io, cache_path, latest); |
| 167 | + |
| 168 | + // Compare with current version |
| 169 | + const current = stripVPrefix(VERSION); |
| 170 | + const latest_stripped = stripVPrefix(latest); |
| 171 | + if (versionGt(latest_stripped, current)) { |
| 172 | + return latest; |
| 173 | + } |
| 174 | + |
| 175 | + allocator.free(latest); |
| 176 | + return null; |
| 177 | +} |
| 178 | + |
| 179 | +/// Print a non-intrusive update hint if a newer version is available. |
| 180 | +/// Designed to be called after commands that already use the network. |
| 181 | +pub fn printUpdateHint( |
| 182 | + allocator: std.mem.Allocator, |
| 183 | + io: std.Io, |
| 184 | + environ_map: *std.process.Environ.Map, |
| 185 | + cache_dir: []const u8, |
| 186 | + proxy: []const u8, |
| 187 | + stdout: *std.Io.Writer, |
| 188 | +) void { |
| 189 | + const latest = checkForUpdate(allocator, io, environ_map, cache_dir, proxy) orelse return; |
| 190 | + defer allocator.free(latest); |
| 191 | + |
| 192 | + terminal.println(stdout, .yellow, "A new version of zvm is available: {s} (current: v{s})", .{ latest, VERSION }) catch {}; |
| 193 | + stdout.print("Run `zvm upgrade` to update.\n\n", .{}) catch {}; |
| 194 | + stdout.flush() catch {}; |
| 195 | +} |
0 commit comments