diff --git a/src/Config.zig b/src/Config.zig index c5c574f8b..cc2a0e5ad 100644 --- a/src/Config.zig +++ b/src/Config.zig @@ -262,7 +262,9 @@ const Commands = cli.Builder(.{ }, .shared_options = CommonOptions, }, - .{ .name = "version", .options = .{} }, + .{ .name = "version", .options = .{ + .{ .name = "check", .type = bool }, + } }, }); pub const RunMode = Commands.Enum; @@ -274,7 +276,11 @@ exec_name: []const u8, http_headers: HttpHeaders, fn modeNeedsHttp(mode: Mode) bool { - return mode != .help and mode != .version; + return switch (mode) { + .help => false, + .version => |opts| opts.check, + else => true, + }; } pub fn init(allocator: Allocator, exec_name: []const u8, mode: Mode) !Config { @@ -307,6 +313,8 @@ pub fn interactive(self: *const Config) bool { pub fn tlsVerifyHost(self: *const Config) bool { return switch (self.mode) { inline .serve, .fetch, .mcp, .agent => |opts| !opts.insecure_disable_tls_host_verification, + // `version --check` talks to the release endpoint; always verify. + .version => true, else => unreachable, }; } @@ -356,6 +364,7 @@ pub fn v8MaxHeapMb(self: *const Config) ?u32 { pub fn httpProxy(self: *const Config) ?[:0]const u8 { return switch (self.mode) { inline .serve, .fetch, .mcp, .agent => |opts| opts.http_proxy, + .version => null, else => unreachable, }; } @@ -363,7 +372,7 @@ pub fn httpProxy(self: *const Config) ?[:0]const u8 { pub fn proxyBearerToken(self: *const Config) ?[:0]const u8 { return switch (self.mode) { inline .serve, .fetch, .mcp, .agent => |opts| opts.proxy_bearer_token, - .help, .version => null, + else => null, }; } @@ -384,6 +393,7 @@ pub fn httpMaxHostOpen(self: *const Config) u8 { pub fn httpConnectTimeout(self: *const Config) u31 { return switch (self.mode) { inline .serve, .fetch, .mcp, .agent => |opts| opts.http_connect_timeout orelse 0, + .version => 0, else => unreachable, }; } @@ -391,6 +401,7 @@ pub fn httpConnectTimeout(self: *const Config) u31 { pub fn httpTimeout(self: *const Config) u31 { return switch (self.mode) { inline .serve, .fetch, .mcp, .agent => |opts| opts.http_timeout orelse 5000, + .version => 5000, else => unreachable, }; } @@ -465,14 +476,14 @@ pub fn logFilterScopes(self: *const Config) std.ArrayList(log.FilterRule) { pub fn userAgentSuffix(self: *const Config) ?[]const u8 { return switch (self.mode) { inline .serve, .fetch, .mcp, .agent => |opts| opts.user_agent_suffix, - .help, .version => null, + else => null, }; } pub fn userAgent(self: *const Config) ?[]const u8 { return switch (self.mode) { inline .serve, .fetch, .mcp, .agent => |opts| opts.user_agent, - .help, .version => null, + else => null, }; } @@ -520,7 +531,7 @@ pub fn webBotAuth(self: *const Config) ?WebBotAuthConfig { .keyid = opts.web_bot_auth_keyid orelse return null, .domain = opts.web_bot_auth_domain orelse return null, }, - .help, .version => null, + else => null, }; } diff --git a/src/Updater.zig b/src/Updater.zig new file mode 100644 index 000000000..97bb50a2a --- /dev/null +++ b/src/Updater.zig @@ -0,0 +1,101 @@ +// Copyright (C) 2023-2026 Lightpanda (Selecy SAS) +// +// Francis Bouvier +// Pierre Tachoire +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU Affero General Public License as +// published by the Free Software Foundation, either version 3 of the +// License, or (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU Affero General Public License for more details. +// +// You should have received a copy of the GNU Affero General Public License +// along with this program. If not, see . + +const std = @import("std"); +const json = std.json; +const SemanticVersion = std.SemanticVersion; +const Allocator = std.mem.Allocator; +const lp = @import("lightpanda"); + +const Network = @import("network/Network.zig"); +const http = @import("network/http.zig"); +const libcurl = @import("sys/libcurl.zig"); +const crypto = @import("sys/libcrypto.zig"); +const Config = @import("Config.zig"); +const log = @import("log.zig"); + +/// Sole purpose of this client is to do updates; hence, its very minimal. +const Updater = @This(); +x509_store: *crypto.X509_STORE, +config: *const Config, + +/// Initializes the update client; meant to be used as singleton. +pub fn init(allocator: Allocator, config: *const Config) !Updater { + Network.globalInit(allocator); + errdefer Network.globalDeinit(); + const x509_store = try Network.createX509Store(allocator); + + return .{ + .x509_store = x509_store, + .config = config, + }; +} + +pub fn deinit(self: *Updater) void { + Network.globalDeinit(); + crypto.X509_STORE_free(self.x509_store); +} + +/// Sends running Lightpanda version to remote to get update information. +/// Outputs directly to given `Writer`. +pub fn inform(self: *Updater, writer: *std.Io.Writer) !void { + const conn = try http.Connection.init(self.x509_store, self.config, null); + defer conn.deinit(); + + const url = std.fmt.comptimePrint("https://telemetry.lightpanda.io/v/{s}", .{lp.build_config.version}); + // Prepare the request. + try conn.setURL(url); + try conn.setGetMode(); + try conn.setFollowLocation(true); + + // Wraps everything needed to receive bytes. + const ReceiverContext = struct { + writer: *std.Io.Writer, + err: std.Io.Writer.Error!void = {}, + + /// curl -> writer. + fn drain( + buffer: [*]const u8, + buf_count: usize, + buf_len: usize, + raw_ctx: *anyopaque, + ) callconv(.c) usize { + const ctx: *@This() = @ptrCast(@alignCast(raw_ctx)); + const chunk = buffer[0 .. buf_count * buf_len]; + ctx.writer.writeAll(chunk) catch |err| { + ctx.err = err; + return 0; + }; + + return chunk.len; + } + }; + + // Set receiver context. + var ctx = ReceiverContext{ .writer = writer }; + try libcurl.curl_easy_setopt(conn._easy, .write_data, &ctx); + try libcurl.curl_easy_setopt(conn._easy, .write_function, ReceiverContext.drain); + + // Make a request. + const status_int = conn.perform() catch |err| { + ctx.err catch |ctx_err| return ctx_err; + return err; + }; + _ = status_int; + return writer.flush(); +} diff --git a/src/help.zon b/src/help.zon index 2be7e812f..08eb8c7fc 100644 --- a/src/help.zon +++ b/src/help.zon @@ -238,9 +238,15 @@ \\API key. , .version = - \\usage: {0s} version + \\usage: {0s} version [OPTIONS] \\ \\Displays the version of {0s}. + \\ + \\options: + \\ --check + \\ Checks whether a newer release of {0s} is available. When one is + \\ found, prints the running version, the latest release, and how to + \\ update. , .help = \\usage: {0s} help diff --git a/src/lightpanda.zig b/src/lightpanda.zig index 4dfdbc437..b9e9d9cc6 100644 --- a/src/lightpanda.zig +++ b/src/lightpanda.zig @@ -56,6 +56,8 @@ pub const build_config = @import("build_config"); pub const crash_handler = @import("crash_handler.zig"); pub const core_dump = @import("core_dump.zig"); +pub const Updater = @import("Updater.zig"); + pub const FetchOpts = struct { wait_ms: u32 = 5000, wait_until: ?Config.WaitUntil = null, @@ -223,6 +225,17 @@ fn dumpContent(app: *App, mode: Config.DumpFormat, dump_opts: dump.Opts, frame: } } +pub fn checkVersion(allocator: std.mem.Allocator, config: *const Config) !void { + var client = try Updater.init(allocator, config); + defer client.deinit(); + + var stdout = std.fs.File.stdout(); + var buf: [4096]u8 = undefined; + var writer = stdout.writer(&buf); + const w = &writer.interface; + try client.inform(w); +} + // Writes a single page's result object. Framing (the enclosing array and any // separators / trailing newline) is the caller's responsibility. fn writeJsonEnvelope(writer: *std.Io.Writer, frame: ?*Frame, dump_mode: ?Config.DumpFormat, content: []const u8) !void { diff --git a/src/main.zig b/src/main.zig index ca0b75325..4799eb3ef 100644 --- a/src/main.zig +++ b/src/main.zig @@ -71,9 +71,13 @@ fn run(allocator: Allocator, main_arena: Allocator) !void { switch (args.mode) { .help => |tag| return args.printUsageAndExit(tag, true), - .version => { - var stdout = std.fs.File.stdout().writer(&.{}); - try stdout.interface.print("{s}\n", .{lp.build_config.version}); + .version => |opts| { + if (opts.check) { + try lp.checkVersion(allocator, &args); + } else { + var stdout = std.fs.File.stdout().writer(&.{}); + try stdout.interface.print("{s}\n", .{lp.build_config.version}); + } return std.process.cleanExit(); }, .agent => |opts| if (opts.list_models) { diff --git a/src/network/Network.zig b/src/network/Network.zig index f637c872d..3b5d27b90 100644 --- a/src/network/Network.zig +++ b/src/network/Network.zig @@ -138,7 +138,9 @@ cdp_start: usize, /// Optional IP filter for blocking requests to private/internal networks (--block-private-networks). ip_filter: ?*IpFilter = null, -fn globalInit(allocator: Allocator) void { +/// Calling `init` also calls this function; only marked public for situations +/// networking is needed without `App`. +pub fn globalInit(allocator: Allocator) void { // Only route curl's own allocations through our allocator in Debug, so the // leak detector sees them. In Release it'd just wrap c_allocator (curl's // default malloc anyway) at the cost of a per-allocation header. @@ -152,7 +154,9 @@ fn globalInit(allocator: Allocator) void { }; } -fn globalDeinit() void { +/// Calling `deinit` also calls this function; only marked public for situations +/// networking is needed without `App`. +pub fn globalDeinit() void { libcurl.curl_global_cleanup(); } @@ -720,7 +724,7 @@ const CreateX509StoreError = std.crypto.Certificate.Bundle.RescanError || error{ /// NEVER give full ownership of store to `SSL_CTX`, always rely on ref counting. /// Allocations made through passed `allocator` are freed before this function returns. -fn createX509Store(allocator: Allocator) CreateX509StoreError!*crypto.X509_STORE { +pub fn createX509Store(allocator: Allocator) CreateX509StoreError!*crypto.X509_STORE { const store = crypto.X509_STORE_new() orelse return error.FailedToCreateX509Store; errdefer crypto.X509_STORE_free(store);