Testing the quickest way to retrieve the macOS name (Sonoma, etc.)

I was talking with @_jims about his 𝗌.𝘂⇾PlatformVersions, v2.0 subroutine, and how Apple makes it hard to get the macOS code name (Sonoma, Ventura, etc.) from any non-GUI source.

It being a Saturday morning, one thing led to another and I wondered what might be the quickest way to get that tidbit, amongst the methods I either knew about or could figure out. The end result? This little macro that contains five different ways to get the name of the installed macOS version:

Download Macro(s): macosversion.kmmacros (27 KB)

Macro screenshot

Macro notes
  • Macros are always disabled when imported into the Keyboard Maestro Editor.
    • The user must ensure the macro is enabled.
    • The user must also ensure the macro's parent macro-group is enabled.
System information
  • macOS 14.4.1
  • Keyboard Maestro v11.0.2

If you're going to use this to test or play around with, you'll either need to disable the purple calls to a missing macro, or install myMultiTimer—that's what does the timing.

Also, enable just one method at a time (the green boxes), as they all use the same variable name. I've found that if you're testing a bunch of routines back to back in one macro the timing can change based on the order things run, so I always run them one at a time.

The five methods I tested were:

  • a shell case statement that looks up the value from a list of releases
  • a shell grep of a variable containing the list of releases
  • a KM regex search of a variable containing a list of releases
  • Jim's original KM Case action
  • a shell grep (found here) of the license agreement file, which doesn't rely on a lookup but will break if Apple changes the layout of the license agreement

And here's how things came out...

Yes, Jim's original multi-case Case statement is the quickest. I was very surprised to see that the KM Case statement is quicker than any of the shell alternatives. It's a close second with the regex, which is probably how I'd implement it if I were using a list lookup—only because I'm lazy and like editing a simple list more than I like editing a long Case statement.

In my own use, though, I'm using the non-lookup solution, because I'm even lazier than lazy, and this way, I won't have to update my macros unless/until Apple changes their license agreement text :).

I was wondering if there might still be a quicker way out there. You can get the name via AppleScript in System Settings, but there's no way that's quicker. Maybe JXA, but I know almost nothing of that. In any event, even the slowest of slow methods was under a tenth of a second, which is why this was just a Saturday morning diversion :).

-rob.

1 Like

AFAIK, this still works:

awk '/SOFTWARE LICENSE AGREEMENT FOR macOS/' '/System/Library/CoreServices/Setup Assistant.app/Contents/Resources/en.lproj/OSXSoftwareLicense.rtf' | awk -F 'macOS ' '{print $NF}' | awk '{print substr($0, 0, length($0)-1)}'

Hi, @Nige_S. It seems to on my system with Sonoma.

I prefer to use the hardcoded switch/case method for three reasons:

  1. It's fast.

  2. Unlike the above, it works for beta versions of macOS.

  3. Won't break if Apple changes the name or format of OSXSoftwareLicense.rtf.


Yes I'll need to update 𝗌.𝘂⇾PlatformVersions, each time Apple changes the name, but it's very easy to change the one subroutine at this low frequency.

1 Like

Just to be clear, it does work, in its own way :). It returns PRE-RELEASE SEED SOFTWARE instead of the version's name.

-rob.

1 Like

Yes, that's the grep_lic option in the results above.

-rob.

2 Likes

Apologies, should have looked closer -- saw grep and thought "I've got one that uses awk...".

No overhead from instantiating a shell, I guess.

Good point.

I thought I might have a new winner with a JXA script that CharGPT helped me get working, but it too is slow. I’ll post it later just for completeness’ sake.

-rob.

And here's the JavaScript for Automation. It works, but it's the second slowest method, taking about 0.060 seconds to run:

// Get the list of numbers and names from the Keyboard Maestro variable
const osListString = kmvar.local_theNames;
const osList = osListString.trim().split('\n');

// Define the function to find the OS name
const findOSName = (version) => {
    for (const pair of osList) {
        const [versionNumber, osName] = pair.split('#');
        if (version === versionNumber) {
            return osName;
        }
    }
    return "Not found";
};

// Get the input version from another Keyboard Maestro variable
const inputVersion = kmvar.local_productVersion;

// Get the OS name based on the input version
const osName = findOSName(inputVersion);

// Return the OS name
return osName;

ㅤ-rob.

:slight_smile:

You guys are fascinated by speed. Perhaps because it's measurable ?

Fun to watch hot-rod cars, but for longer journeys, (or for even for much shorter journeys with family on board), I personally wouldn't rush buy an automobile (or buy a plane ticket) from a speed-preoccupied company.

Reliability, maintainability, production costs – do any of these correlate usefully with the youthful joys of the hot-rod championship ?

( I suspect that the latter has played a role in many of the more famous computation catastrophes of this and the previous century)


It seems that deriving an OS version marketing label does need one or more lookup tables, and there are lots of ways of setting those up.

To get the component indices with JavaScript for Automation, you can write:


osVersion.kmactions (851 Octets)

For direct access to each component with Keyboard Maestro's %JSONValue% token, you can serialize that as JSON to:

{
  "majorVersion": "14",
  "minorVersion": "4",
  "patchVersion": "1"
}

by writing:

return JSON.stringify(
    ObjC.unwrap(
        $.NSProcessInfo.processInfo
    .    operatingSystemVersion
    ),
    null, 2
)

and if you want those values as Numbers (rather than Strings), then you could obtain

{
  "majorVersion": 14,
  "minorVersion": 4,
  "patchVersion": 1
}

by mapping the Number constructor over them:

Expand disclosure triangle to view JS source
return (() => {
    "use strict";

    const main = () =>
        JSON.stringify(
            fmapDict(Number)(
                ObjC.unwrap(
                    $.NSProcessInfo.processInfo
                    .operatingSystemVersion
                )
            ),
            null, 2
        );


    // fmapDict :: (a -> b) ->
    // {String :: a} -> {String :: b}
    const fmapDict = f =>
        // A map of f over every value
        // in the given dictionary.
        dict => Object.entries(dict).reduceRight(
            (a, [k, v]) => Object.assign(
                {[k]: f(v)},
                a
            ),
            {}
        );

    return main();
})();


osVersionIndices.kmactions (1,4 Ko)

We can, of course, use a JS Object as a lookup table,

Expand disclosure triangle to view JS Object
{
  "11": "Big Sur",
  "12": "Monterey",
  "13": "Ventura",
  "14": "Sonoma",
  "10.0": "Cheetah",
  "10.1": "Puma",
  "10.2": "Jaguar",
  "10.3": "Panther",
  "10.4": "Tiger",
  "10.5": "Leopard",
  "10.6": "Snow Leopard",
  "10.7": "Lion",
  "10.8": "Mountain Lion",
  "10.9": "Mavericks",
  "10.10": "Yosemite",
  "10.11": "El Capitan",
  "10.12": "Sierra",
  "10.13": "High Sierra",
  "10.14": "Mojave",
  "10.15": "Catalina"
}

perhaps obtaining it directly, in JSON format, from a wiki page with a script like this:

Lookup table obtained- as JSON- from WikiPage.kmmacros (9.2 KB)


Expand disclosure triangle to view JS source
return (() => {
    "use strict";

    // Deriving a JS lookup table from the
    // Wiki table at:

    // [macOS version history - Wikipedia](
    //   https://en.wikipedia.org/wiki/MacOS_version_history#Releases
    // )
    // Xpath: //*[@id="mw-content-text"]/div[1]/table[2]

    ObjC.import("AppKit");

    const
        main = () => {
            const uw = ObjC.unwrap;

            return either(
                alert("Reading macOS version list from Wikipedia")
            )(
                Object.fromEntries
            )(
                fmapLR(
                    xs => xs
                    .map(x => uw(x.stringValue)).slice(0, -1)
                    .map(
                        x => {
                            const [a, b] = lines(x).slice(0, 2);

                            return [
                                a.split(/X |macOS /u)[1],
                                b.split(/[\d\\[]/u)[0]
                            ];
                        }
                    )
                    .slice(5)


                )(
                    bindLR(
                        htmlSourceFromWebUrlLR(
                            [
                                "https://en.wikipedia.org/",
                                "wiki/macOS_version_history"
                            ]
                            .join("")
                        )
                    )(
                        xPathMatchesFromXmlLR(
                            [
                                "//*[@id=\"mw-content-text\"]",
                                "/div[1]/table[2]/tbody/tr"
                            ]
                            .join("")
                        )
                    )
                )
            );
        };


    // ----------------------- JXA -----------------------

    // alert :: String => String -> IO String
    const alert = title =>
        s => {
            const sa = Object.assign(
                Application("System Events"), {
                    includeStandardAdditions: true
                });

            return (
                sa.activate(),
                sa.displayDialog(s, {
                    withTitle: title,
                    buttons: ["OK"],
                    defaultButton: "OK"
                }),
                s
            );
        };


    // htmlSourceFromWebUrlLR :: URL -> Either String String
    const htmlSourceFromWebUrlLR = url => {
        // Either a message or the HTML source of the
        // supplied Web url.
        const
            uw = ObjC.unwrap,
            e = $(),
            r = $(),
            data = $.NSURLConnection
            .sendSynchronousRequestReturningResponseError(
                $.NSURLRequest.requestWithURL(
                    $.NSURL.URLWithString(url)
                ), r, e
            );

        return data.isNil()
            ? Left(uw(e.localizedDescription))
            : Right(uw(
                $.NSString.alloc
                .initWithDataEncoding(
                    data, $.NSUTF8StringEncoding
                )
            ));
    };

    // xPathMatchesFromXmlLR :: String ->
    // String -> Either String [NSXMLElement]
    const xPathMatchesFromXmlLR = xpath =>
        xml => {
            const
                uw = ObjC.unwrap,
                error = $(),
                xmlDoc = $.NSXMLDocument.alloc
                .initWithXMLStringOptionsError(
                    xml, $.NSXMLDocumentTidyHTML, error
                );

            return bindLR(
                xmlDoc.isNil()
                    ? Left(uw(error.localizedDescription))
                    : Right(xmlDoc)
            )(
                doc => {
                    const
                        e = $(),
                        matches = (
                            doc.documentContentKind = (
                                $.NSXMLDocumentHTMLKind
                            ),
                            doc.nodesForXPathError(
                                xpath, e
                            )
                        );

                    return matches.isNil()
                        ? Left(uw(e.localizedDescription))
                        : Right(uw(matches));
                }
            );
        };


    // --------------------- GENERIC ---------------------

    // Left :: a -> Either a b
    const Left = x => ({
        type: "Either",
        Left: x
    });


    // Right :: b -> Either a b
    const Right = x => ({
        type: "Either",
        Right: x
    });


    // bindLR (>>=) :: Either a ->
    // (a -> Either b) -> Either b
    const bindLR = lr =>
        // Bind operator for the Either option type.
        // If lr has a Left value then lr unchanged,
        // otherwise the function mf applied to the
        // Right value in lr.
        mf => "Left" in lr
            ? lr
            : mf(lr.Right);


    // either :: (a -> c) -> (b -> c) -> Either a b -> c
    const either = fl =>
        // Application of the function fl to the
        // contents of any Left value in e, or
        // the application of fr to its Right value.
        fr => e => "Left" in e
            ? fl(e.Left)
            : fr(e.Right);


    // fmapLR (<$>) :: (b -> c) -> Either a b -> Either a c
    const fmapLR = f =>
        // Either f mapped into the contents of any Right
        // value in e, or e unchanged if is a Left value.
        e => "Left" in e
            ? e
            : Right(f(e.Right));

    // lines :: String -> [String]
    const lines = s =>
    // A list of strings derived from a single string
    // which is delimited by \n or by \r\n or \r.
        0 < s.length
            ? s.split(/\r\n|\n|\r/u)
            : [];

    // --------------------- LOGGING ---------------------

    // sj :: a -> String
    const sj = (...args) =>
    // Abbreviation of showJSON for quick testing.
    // Default indent size is two, which can be
    // overriden by any integer supplied as the
    // first argument of more than one.
        JSON.stringify.apply(
            null,
            1 < args.length && !isNaN(args[0])
                ? [args[1], null, args[0]]
                : [args[0], null, 2]
        );

    return sj(main());
})();

and then using it in this kind of pattern:

macOS version name from canonical version string.kmmacros (2.6 KB)


Expand disclosure triangle to view JS source
return ({
    "11": "Big Sur",
    "12": "Monterey",
    "13": "Ventura",
    "14": "Sonoma",
    "10.0": "Cheetah",
    "10.1": "Puma",
    "10.2": "Jaguar",
    "10.3": "Panther",
    "10.4": "Tiger",
    "10.5": "Leopard",
    "10.6": "Snow Leopard",
    "10.7": "Lion",
    "10.8": "Mountain Lion",
    "10.9": "Mavericks",
    "10.10": "Yosemite",
    "10.11": "El Capitan",
    "10.12": "Sierra",
    "10.13": "High Sierra",
    "10.14": "Mojave",
    "10.15": "Catalina"
})[kmvar.local_VersionString];

i.e. perhaps, for example:

Marketing name of system macOS.kmmacros (4.3 KB)


Expand disclosure triangle to view JS source
const
    macosBuild = ObjC.unwrap(
        $.NSProcessInfo.processInfo
        .operatingSystemVersion
    ),


    major = macosBuild.majorVersion,

    marketName = {
        "11": "Big Sur",
        "12": "Monterey",
        "13": "Ventura",
        "14": "Sonoma",
        "10.0": "Cheetah",
        "10.1": "Puma",
        "10.2": "Jaguar",
        "10.3": "Panther",
        "10.4": "Tiger",
        "10.5": "Leopard",
        "10.6": "Snow Leopard",
        "10.7": "Lion",
        "10.8": "Mountain Lion",
        "10.9": "Mavericks",
        "10.10": "Yosemite",
        "10.11": "El Capitan",
        "10.12": "Sierra",
        "10.13": "High Sierra",
        "10.14": "Mojave",
        "10.15": "Catalina"
    }[
    "10" === major
        ? `${major}.${macosBuild.minorVersion}`
        : `${major}`
    ] || "Kodiak ?",

    dotVersion = Object.values(macosBuild).join(".");


return JSON.stringify(
    Object.assign(
        {
            marketName,
            dotVersion
        },
        macosBuild
    ),
    null, 2
);

As I closed with....

In any event, even the slowest of slow methods was under a tenth of a second, which is why this was just a Saturday morning diversion :).

This was nothing more than me wondering if there were any dramatic differences in how long the task takes, given the many ways to accomplish it. And it was never meant to say "you should use method X because it's the fastest bestest way to do this thing." It was mainly a lark just for the sake of playing around with KM and its various methods of answering the question.

Clearly when any of the methods take under a tenth of a second, the best solution is the one that for each user (a) solves their problem, (b) is easiest to implement and—as you noted—(c) is easiest to understand and maintain.

In that realm, for me personally, it'd be either Jim's original method or the regular expression (the two quickest methods), except that neither matches one of my requirements, which is to not have to update my macros each time there's a macOS release. So for now, I'm personally using the method that picks the name out of a the license agreement.

I wouldn't argue that any of these methods are better than any other of these methods—especially not on the basis of speed! Heck, the easiest and most sustainable way for a given user might be just asking the question…

parse %SystemVersion% token extracting local_MajorVersion
If Dictionary macOSMarketingNames does not contain local_MajorVersion (
   prompt for input "What does Apple call the latest macOS release?" to local_newVersion
   set new entry in Dictionary macOSMarketingNames to local_MajorVersion and local_newVersion
)

If definitely wouldn't be the quickest, but it might be the easiest for that particular user to implement, maintain, and understand :).

—————————————————————

I put your first JXA (the simple list lookup/split with VersionString) into my timer, and it finished in about 0.03, give or take. It seems the overhead of launching any engine (JS or shell) slows those solutions enough that the built-in one is quicker.)

I do like how simple that one is in terms of implementation, and learned a bit more about JXA from all of this!

EDIT Changed the time, as I think my Mac was having issues this morning. Testing earlier, the average for the JXA was around 0.06, but not it's nearly twice as fast, at 0.03.

—————————————————————

For completeness sake, here's the final macro (I'm done with this diversion :slight_smile: :slight_smile: ) with all of the above tests in it (well, the only the first of @ComplexPoint's examples).

Download Macro(s): macosversion.kmmacros (32 KB)

Macro screenshot

Macro notes
  • Macros are always disabled when imported into the Keyboard Maestro Editor.
    • The user must ensure the macro is enabled.
    • The user must also ensure the macro's parent macro-group is enabled.
System information
  • macOS 14.4.1
  • Keyboard Maestro v11.0.2

-rob.

2 Likes

The trick is usually to check which built-in Array methods can help you more directly.

(Those for (x of y) loops tend to impose redundant state-change book-keeping on you, and go off the rails much more easily, than than things like Array.map, Array.reduce. Array.filter, Array.forEach, and, in this case, Array.findIndex, which do it all for you)

Array.prototype.findIndex() - JavaScript | MDN

const
    xs = kmvar.local_theNames.split("\n"),
    k = kmvar.local_productVersion,
    i = xs.findIndex(x => x.startsWith(k));

return -1 !== i
    ? xs[i].split("#")[1] 
    :  `No macOS version name found for "${k}".`;

Index of prefix in a list of lines.kmmacros (3.0 KB)

1 Like

Works fine.

Here's my take on the problem. No Javascript. No JSON. I even cover for the Apple bug that Big Sur has two possible version numbers. Naturally, I didn't test this code on each version of macOS; it's more a proof of concept to see how simple a solution can be.

Actions (v11.0.2)

Keyboard Maestro Actions.kmactions (2.7 KB)

Keyboard Maestro Export

I'm actually working on a much simpler version of this, but I'm not sure if I can get it to work. EDIT: I got my newest approach to work, but it's not simpler and not as reliable. Basically, it sends a request to the internet along the lines of "What is the name for macOS 10.11?" and fetches the result, and extracts the name. It's a wonderful macro, and will work with future releases that haven't even been named yet,(!) but because it's only 90% reliable, I won't upload it. I'll use the techniques for future macros.

In an attempt to make ammends for my previous lack of attention, this might be the easiest way when working from an ordered list (which could, of course, be populated with @ComplexPoint's macro above). No messing around with minor version numbers, the Big Sur issue, etc -- I don't know how it will cope with the latest betas though, you might want to add a final "catch" line at the end of the list.

Get Friendly OS Name.kmmacros (6.7 KB)

Image

That uses the same approach as my approach above.

Does it? It looks like yours uses grep and the "major" portion of the sw_vers result, while mine does string comparison of sw_vers -productVersion against the "Latest version" numbers (from this page).

All I meant was that we both used the sw_vers command and used a fixed list of macOS versions to find the best match. That seems to be the case in both macros. Sorry if the comparison bothers you.

I have a question for you. I like the way you used "is before." According to the KM documentation, that is supposed to mean "is alphabetically before." but look at the following: (notice it evaluates to TRUE)

image

Alphabetically, 2 is not before 14, as you can see here: (notice it evaluates to FALSE)

image

So this seems to show that KM is using numerical comparisons, not alphabetical comparisons, for "is before". If they were alphabetical comparisons, then 14 would be before 2, just as "AD" is before "B." But digits are resulting in a different result than alphabet characters!

I had no idea this feature existed. It is a great feature, and I'm amazed that you found it. How did you know about that? I have to resurrect my idea that you must be an alias for The Architect, because only an architect would know about undocumented features, and use them to solve problems. You are using an undocumented feature here to write your code. I'm very impressed.

It doesn't bother me. The point is that we used completely different methods to search the list, arguably the "meat" of the macro.

We're writing a macro, we've got our programmer's head on -- naturally we're going to head for grep or similar to find something in a list. But now imagine you've a long, ordered, list on a piece of paper -- would you search search each line in turn looking for a complete match, or would you scan down to match the first digit then continue down to match the second etc?

I just thought it would fun to present the second approach :wink: -- I've no idea if it is better or worse, though!

My guess is that KM is using the OS string comparison framework -- the same one the Finder uses that puts "2 Folder" before "13 Folder" when you sort by name. And yes, that means that 10.9 is before 10.10...