11const std = @import ("std" );
2+ const builtin = @import ("builtin" );
23const mem = std .mem ;
34const Allocator = mem .Allocator ;
45const HttpClient = @import ("http.zig" ).HttpClient ;
@@ -19,7 +20,16 @@ pub const CaskInfo = struct {
1920
2021/// Parse a JSON array of cask objects into a slice of CaskInfo.
2122/// The caller owns the returned slice and must free each entry with freeCask.
23+ /// Uses the current machine's macOS for variation resolution.
2224pub fn parseCaskJson (allocator : Allocator , json_bytes : []const u8 ) ! []CaskInfo {
25+ var tag_buf : [64 ]u8 = undefined ;
26+ const tag = currentMacOSVariationTag (& tag_buf );
27+ return parseCaskJsonWithTag (allocator , json_bytes , tag );
28+ }
29+
30+ /// Same as parseCaskJson but with the variation tag passed explicitly.
31+ /// Useful for tests and for callers that want to resolve for a non-current OS.
32+ pub fn parseCaskJsonWithTag (allocator : Allocator , json_bytes : []const u8 , variation_tag : []const u8 ) ! []CaskInfo {
2333 const parsed = try std .json .parseFromSlice (std .json .Value , allocator , json_bytes , .{
2434 .allocate = .alloc_always ,
2535 });
@@ -44,15 +54,30 @@ pub fn parseCaskJson(allocator: Allocator, json_bytes: []const u8) ![]CaskInfo {
4454 else = > continue ,
4555 };
4656
47- const info = parseOneCask (allocator , obj ) catch continue ;
57+ const info = parseOneCask (allocator , obj , variation_tag ) catch continue ;
4858 result .appendAssumeCapacity (info );
4959 }
5060
5161 return try result .toOwnedSlice (allocator );
5262}
5363
54- /// Parse a single cask JSON object into a CaskInfo.
55- fn parseOneCask (allocator : Allocator , obj : std.json.ObjectMap ) ! CaskInfo {
64+ /// Parse a single cask JSON object's bytes into a CaskInfo, applying the given
65+ /// variation tag. Public so tests can drive parseOneCask directly.
66+ pub fn parseSingleCaskJson (allocator : Allocator , json_bytes : []const u8 , variation_tag : []const u8 ) ! CaskInfo {
67+ const parsed = try std .json .parseFromSlice (std .json .Value , allocator , json_bytes , .{
68+ .allocate = .alloc_always ,
69+ });
70+ defer parsed .deinit ();
71+ const obj = switch (parsed .value ) {
72+ .object = > | o | o ,
73+ else = > return error .InvalidJson ,
74+ };
75+ return parseOneCask (allocator , obj , variation_tag );
76+ }
77+
78+ /// Parse a single cask JSON object into a CaskInfo. Applies the platform
79+ /// variation override iff `variation_tag` is present in `variations`.
80+ fn parseOneCask (allocator : Allocator , obj : std.json.ObjectMap , variation_tag : []const u8 ) ! CaskInfo {
5681 const token = try allocator .dupe (u8 , jsonStr (obj , "token" ) orelse return error .MissingField );
5782 errdefer allocator .free (token );
5883
@@ -81,13 +106,23 @@ fn parseOneCask(allocator: Allocator, obj: std.json.ObjectMap) !CaskInfo {
81106 const homepage = try allocator .dupe (u8 , jsonStr (obj , "homepage" ) orelse "" );
82107 errdefer allocator .free (homepage );
83108
84- const version = try allocator .dupe (u8 , jsonStr (obj , "version" ) orelse "" );
109+ // Start from top-level, then override with the current-OS variation iff present.
110+ var version_src : []const u8 = jsonStr (obj , "version" ) orelse "" ;
111+ var url_src : []const u8 = jsonStr (obj , "url" ) orelse "" ;
112+ var sha256_src : []const u8 = jsonStr (obj , "sha256" ) orelse "" ;
113+ if (variationOverrideObject (obj , variation_tag )) | tag_obj | {
114+ if (jsonStr (tag_obj , "version" )) | v | version_src = v ;
115+ if (jsonStr (tag_obj , "url" )) | u | url_src = u ;
116+ if (jsonStr (tag_obj , "sha256" )) | s | sha256_src = s ;
117+ }
118+
119+ const version = try allocator .dupe (u8 , version_src );
85120 errdefer allocator .free (version );
86121
87- const url = try allocator .dupe (u8 , jsonStr ( obj , "url" ) orelse "" );
122+ const url = try allocator .dupe (u8 , url_src );
88123 errdefer allocator .free (url );
89124
90- const sha256 = try allocator .dupe (u8 , jsonStr ( obj , "sha256" ) orelse "" );
125+ const sha256 = try allocator .dupe (u8 , sha256_src );
91126 // No errdefer needed for the last allocation before the return.
92127
93128 const deprecated = jsonBool (obj , "deprecated" ) orelse false ;
@@ -168,30 +203,53 @@ pub const ResolvedCask = struct {
168203 apps : []AppArtifact ,
169204};
170205
171- /// Platform tags to try when resolving cask variations, in priority order.
172- /// Tries the most specific first (arch + OS version), then falls back to
173- /// less specific tags. The first match wins.
174- fn platformVariationTags () []const []const u8 {
175- const arch = @import ("builtin" ).target .cpu .arch ;
176-
177- if (arch == .aarch64 ) {
178- return &.{
179- "arm64_tahoe" ,
180- "arm64_sequoia" ,
181- "arm64_sonoma" ,
182- "arm64_ventura" ,
183- "arm64_monterey" ,
184- "arm64_big_sur" ,
185- };
186- }
187- return &.{
188- "tahoe" ,
189- "sequoia" ,
190- "sonoma" ,
191- "ventura" ,
192- "monterey" ,
193- "big_sur" ,
206+ /// Map a Darwin kernel major version and CPU arch to the Homebrew cask
207+ /// variation tag for the user's actual macOS. Returns "" when the kernel
208+ /// release is unknown — callers treat empty as "no variation, use top-level".
209+ ///
210+ /// Darwin → macOS mapping:
211+ /// 25 → Tahoe (macOS 26) 22 → Ventura (13)
212+ /// 24 → Sequoia (15) 21 → Monterey (12)
213+ /// 23 → Sonoma (14) 20 → Big Sur (11)
214+ fn darwinReleaseToVariationTag (major : u32 , arch : std.Target.Cpu.Arch , buf : []u8 ) []const u8 {
215+ const name : []const u8 = switch (major ) {
216+ 25 = > "tahoe" ,
217+ 24 = > "sequoia" ,
218+ 23 = > "sonoma" ,
219+ 22 = > "ventura" ,
220+ 21 = > "monterey" ,
221+ 20 = > "big_sur" ,
222+ else = > return "" ,
194223 };
224+ const prefix : []const u8 = if (arch == .aarch64 ) "arm64_" else "" ;
225+ return std .fmt .bufPrint (buf , "{s}{s}" , .{ prefix , name }) catch "" ;
226+ }
227+
228+ /// Return the cask-variation tag for THIS machine (e.g. "arm64_tahoe"),
229+ /// or "" if it cannot be determined. Uses uname() — no subprocess.
230+ ///
231+ /// Match policy: a variation must match this exact tag to override the
232+ /// top-level fields. Previously the resolver walked all known tags new-to-old
233+ /// and applied the first match, which downgraded current-macOS users when a
234+ /// cask had variations only for older OSes (e.g. raycast: top-level 1.104.18,
235+ /// arm64_monterey override 1.94.4 — a Tahoe user was incorrectly handed 1.94.4).
236+ pub fn currentMacOSVariationTag (buf : []u8 ) []const u8 {
237+ const uname = std .posix .uname ();
238+ const release = std .mem .sliceTo (& uname .release , 0 );
239+ var it = std .mem .splitScalar (u8 , release , '.' );
240+ const major_str = it .next () orelse return "" ;
241+ const major = std .fmt .parseInt (u32 , major_str , 10 ) catch return "" ;
242+ return darwinReleaseToVariationTag (major , builtin .target .cpu .arch , buf );
243+ }
244+
245+ /// If `obj.variations[variation_tag]` exists and is an object, return it.
246+ /// Otherwise null — caller falls back to top-level fields.
247+ fn variationOverrideObject (obj : std.json.ObjectMap , variation_tag : []const u8 ) ? std.json.ObjectMap {
248+ if (variation_tag .len == 0 ) return null ;
249+ const var_val = obj .get ("variations" ) orelse return null ;
250+ const variations = asObject (var_val ) orelse return null ;
251+ const tag_val = variations .get (variation_tag ) orelse return null ;
252+ return asObject (tag_val );
195253}
196254
197255/// Fetch and resolve a cask from the per-cask API.
@@ -209,8 +267,18 @@ pub fn fetchAndResolveCask(allocator: Allocator, http_client: *HttpClient, token
209267 return try parseResolvedCask (allocator , json_bytes );
210268}
211269
212- /// Parse a per-cask API JSON response into a ResolvedCask.
270+ /// Parse a per-cask API JSON response, applying the current machine's macOS
271+ /// variation. See parseResolvedCaskWithTag for tag semantics.
213272pub fn parseResolvedCask (allocator : Allocator , json_bytes : []const u8 ) ! ResolvedCask {
273+ var tag_buf : [64 ]u8 = undefined ;
274+ const tag = currentMacOSVariationTag (& tag_buf );
275+ return parseResolvedCaskWithTag (allocator , json_bytes , tag );
276+ }
277+
278+ /// Parse a per-cask API JSON response into a ResolvedCask, applying the
279+ /// variation block for `variation_tag` iff it exists. Pass "" to skip
280+ /// variations entirely.
281+ pub fn parseResolvedCaskWithTag (allocator : Allocator , json_bytes : []const u8 , variation_tag : []const u8 ) ! ResolvedCask {
214282 const parsed = try std .json .parseFromSlice (std .json .Value , allocator , json_bytes , .{
215283 .allocate = .alloc_always ,
216284 });
@@ -224,46 +292,25 @@ pub fn parseResolvedCask(allocator: Allocator, json_bytes: []const u8) !Resolved
224292 const result_token = try allocator .dupe (u8 , jsonStr (obj , "token" ) orelse return error .MissingField );
225293 errdefer allocator .free (result_token );
226294
227- // Start with top-level url/sha256/version, then override from variations.
228- var url = try allocator .dupe (u8 , jsonStr (obj , "url" ) orelse "" );
295+ // Resolve url/sha256/version against the variation for this macOS, if any.
296+ var url_src : []const u8 = jsonStr (obj , "url" ) orelse "" ;
297+ var sha256_src : []const u8 = jsonStr (obj , "sha256" ) orelse "" ;
298+ var version_src : []const u8 = jsonStr (obj , "version" ) orelse "" ;
299+ if (variationOverrideObject (obj , variation_tag )) | tag_obj | {
300+ if (jsonStr (tag_obj , "url" )) | v | url_src = v ;
301+ if (jsonStr (tag_obj , "sha256" )) | v | sha256_src = v ;
302+ if (jsonStr (tag_obj , "version" )) | v | version_src = v ;
303+ }
304+
305+ const url = try allocator .dupe (u8 , url_src );
229306 errdefer allocator .free (url );
230307
231- var sha256 = try allocator .dupe (u8 , jsonStr ( obj , "sha256" ) orelse "" );
308+ const sha256 = try allocator .dupe (u8 , sha256_src );
232309 errdefer allocator .free (sha256 );
233310
234- var version = try allocator .dupe (u8 , jsonStr ( obj , "version" ) orelse "" );
311+ const version = try allocator .dupe (u8 , version_src );
235312 errdefer allocator .free (version );
236313
237- // Check variations for platform-specific overrides.
238- // Allocate new values before freeing old ones to avoid double-free on OOM.
239- if (obj .get ("variations" )) | var_val | {
240- if (asObject (var_val )) | variations | {
241- const tags = platformVariationTags ();
242- for (tags ) | tag | {
243- if (variations .get (tag )) | tag_val | {
244- if (asObject (tag_val )) | tag_obj | {
245- if (jsonStr (tag_obj , "url" )) | v_url | {
246- const new_url = try allocator .dupe (u8 , v_url );
247- allocator .free (url );
248- url = new_url ;
249- }
250- if (jsonStr (tag_obj , "sha256" )) | v_sha | {
251- const new_sha = try allocator .dupe (u8 , v_sha );
252- allocator .free (sha256 );
253- sha256 = new_sha ;
254- }
255- if (jsonStr (tag_obj , "version" )) | v_ver | {
256- const new_ver = try allocator .dupe (u8 , v_ver );
257- allocator .free (version );
258- version = new_ver ;
259- }
260- break ;
261- }
262- }
263- }
264- }
265- }
266-
267314 // Parse name (first element of name array).
268315 const result_name = blk : {
269316 const name_val = obj .get ("name" ) orelse break :blk try allocator .dupe (u8 , "" );
@@ -685,3 +732,150 @@ test "cleanArtifactPath strips HOMEBREW_PREFIX/Caskroom prefix" {
685732test "cleanArtifactPath preserves plain paths" {
686733 try std .testing .expectEqualStrings ("studio" , cleanArtifactPath ("studio" ));
687734}
735+
736+ // ---------------------------------------------------------------------------
737+ // Bug 2: variation-tag selection must match the user's actual macOS.
738+ // Previously the code iterated platformVariationTags() new-to-old and applied
739+ // the first match, which downgraded users on a recent macOS when a cask had
740+ // variations only for older OSes (e.g. raycast 1.99.3 -> 1.94.4).
741+ // ---------------------------------------------------------------------------
742+
743+ test "parseSingleCaskJson without variations keeps top-level fields" {
744+ const allocator = std .testing .allocator ;
745+ const json_bytes =
746+ \\{
747+ \\ "token": "raycast",
748+ \\ "name": ["Raycast"],
749+ \\ "url": "https://example.com/raycast-1.104.18.dmg",
750+ \\ "version": "1.104.18",
751+ \\ "sha256": "newhash"
752+ \\}
753+ ;
754+ const info = try parseSingleCaskJson (allocator , json_bytes , "arm64_tahoe" );
755+ defer freeCask (allocator , info );
756+ try std .testing .expectEqualStrings ("1.104.18" , info .version );
757+ try std .testing .expectEqualStrings ("https://example.com/raycast-1.104.18.dmg" , info .url );
758+ try std .testing .expectEqualStrings ("newhash" , info .sha256 );
759+ }
760+
761+ test "parseSingleCaskJson applies variation only when tag matches user OS" {
762+ const allocator = std .testing .allocator ;
763+ // Top-level is the current Raycast; variations cap older macOS at 1.94.4.
764+ // A Tahoe user must NOT pick up monterey's downgrade.
765+ const json_bytes =
766+ \\{
767+ \\ "token": "raycast",
768+ \\ "name": ["Raycast"],
769+ \\ "url": "https://example.com/raycast-1.104.18.dmg",
770+ \\ "version": "1.104.18",
771+ \\ "sha256": "newhash",
772+ \\ "variations": {
773+ \\ "arm64_monterey": {
774+ \\ "url": "https://example.com/raycast-1.94.4.dmg",
775+ \\ "version": "1.94.4",
776+ \\ "sha256": "oldhash"
777+ \\ }
778+ \\ }
779+ \\}
780+ ;
781+ const tahoe = try parseSingleCaskJson (allocator , json_bytes , "arm64_tahoe" );
782+ defer freeCask (allocator , tahoe );
783+ try std .testing .expectEqualStrings ("1.104.18" , tahoe .version );
784+ try std .testing .expectEqualStrings ("newhash" , tahoe .sha256 );
785+ }
786+
787+ test "parseSingleCaskJson uses variation when tag matches" {
788+ const allocator = std .testing .allocator ;
789+ const json_bytes =
790+ \\{
791+ \\ "token": "raycast",
792+ \\ "name": ["Raycast"],
793+ \\ "url": "https://example.com/raycast-1.104.18.dmg",
794+ \\ "version": "1.104.18",
795+ \\ "sha256": "newhash",
796+ \\ "variations": {
797+ \\ "arm64_monterey": {
798+ \\ "url": "https://example.com/raycast-1.94.4.dmg",
799+ \\ "version": "1.94.4",
800+ \\ "sha256": "oldhash"
801+ \\ }
802+ \\ }
803+ \\}
804+ ;
805+ const monterey = try parseSingleCaskJson (allocator , json_bytes , "arm64_monterey" );
806+ defer freeCask (allocator , monterey );
807+ try std .testing .expectEqualStrings ("1.94.4" , monterey .version );
808+ try std .testing .expectEqualStrings ("oldhash" , monterey .sha256 );
809+ }
810+
811+ test "parseResolvedCaskWithTag ignores non-matching variation" {
812+ const allocator = std .testing .allocator ;
813+ const json_bytes =
814+ \\{
815+ \\ "token": "raycast",
816+ \\ "name": ["Raycast"],
817+ \\ "url": "https://example.com/raycast-1.104.18.dmg",
818+ \\ "version": "1.104.18",
819+ \\ "sha256": "newhash",
820+ \\ "variations": {
821+ \\ "arm64_monterey": {
822+ \\ "url": "https://example.com/raycast-1.94.4.dmg",
823+ \\ "version": "1.94.4",
824+ \\ "sha256": "oldhash"
825+ \\ }
826+ \\ },
827+ \\ "artifacts": [{"app": ["Raycast.app"]}]
828+ \\}
829+ ;
830+ const resolved = try parseResolvedCaskWithTag (allocator , json_bytes , "arm64_tahoe" );
831+ defer freeResolvedCask (allocator , resolved );
832+ try std .testing .expectEqualStrings ("1.104.18" , resolved .version );
833+ }
834+
835+ test "parseResolvedCaskWithTag picks matching variation" {
836+ const allocator = std .testing .allocator ;
837+ const json_bytes =
838+ \\{
839+ \\ "token": "raycast",
840+ \\ "name": ["Raycast"],
841+ \\ "url": "https://example.com/raycast-1.104.18.dmg",
842+ \\ "version": "1.104.18",
843+ \\ "sha256": "newhash",
844+ \\ "variations": {
845+ \\ "arm64_monterey": {
846+ \\ "url": "https://example.com/raycast-1.94.4.dmg",
847+ \\ "version": "1.94.4",
848+ \\ "sha256": "oldhash"
849+ \\ }
850+ \\ },
851+ \\ "artifacts": [{"app": ["Raycast.app"]}]
852+ \\}
853+ ;
854+ const resolved = try parseResolvedCaskWithTag (allocator , json_bytes , "arm64_monterey" );
855+ defer freeResolvedCask (allocator , resolved );
856+ try std .testing .expectEqualStrings ("1.94.4" , resolved .version );
857+ }
858+
859+ test "currentMacOSVariationTag identifies Tahoe from Darwin 25" {
860+ var buf : [64 ]u8 = undefined ;
861+ const tag = darwinReleaseToVariationTag (25 , .aarch64 , & buf );
862+ try std .testing .expectEqualStrings ("arm64_tahoe" , tag );
863+ }
864+
865+ test "currentMacOSVariationTag identifies Sequoia from Darwin 24" {
866+ var buf : [64 ]u8 = undefined ;
867+ const tag = darwinReleaseToVariationTag (24 , .aarch64 , & buf );
868+ try std .testing .expectEqualStrings ("arm64_sequoia" , tag );
869+ }
870+
871+ test "currentMacOSVariationTag handles intel arch" {
872+ var buf : [64 ]u8 = undefined ;
873+ const tag = darwinReleaseToVariationTag (23 , .x86_64 , & buf );
874+ try std .testing .expectEqualStrings ("sonoma" , tag );
875+ }
876+
877+ test "currentMacOSVariationTag returns empty for unknown release" {
878+ var buf : [64 ]u8 = undefined ;
879+ const tag = darwinReleaseToVariationTag (99 , .aarch64 , & buf );
880+ try std .testing .expectEqualStrings ("" , tag );
881+ }
0 commit comments