Dan, you are a wizard. Sincerely, thank you very much for going to the effort to share a custom macro to solve this issue; what you provided is incredibly helpful.
Since you originally posted the solution, I've spent the time since deconstructing it (and the previous series of macros you shared that are attached to it) and trying to piece it all back together myself just to understand how it works. I'm a programming newbie, and KM is my conduit for learning, so being able to study tricks you're using, like dynamically creating a KM action in memory and executing it, is fascinating.
I've taken what you've done and distilled it into a solution that works for me, specifically for triggering KM Macros from a Custom HTML Prompt and returning a value to the prompt.
The solution consists of the following:
- A module of helper javascript functions to be used in a Custom HTML Prompt, designed to trigger a KM Macro with the function "window.KeyboardMaestro.Trigger".
- These then wait for the response from the KM macro detailed below, which is responsible for returning the response to the HTML prompt.
Javascript helper functions:
/* Custom HTML Prompt Helper Function Module:
Extended Functions for async Macro Trigger + Response
------------------------------------------------------
These functions allow you to trigger a Keyboard Maestro macro asynchronously
and then await a response (injected from KM) via a custom event.
1) run_km_macro_htmlprompt() - triggers the macro and awaits a response
2) getResponse() - awaits a custom event "kmResponse"
3) insertResponse() - dispatches that custom event
Ensure that:
- "run_km_macro_htmlprompt()" includes the window.KeyboardMaestro.Trigger call
- The macro you trigger eventually calls the `Inject Response Into Custom HTML Prompt`
subroutine with the correct code to invoke "insertResponse(...)".
*/
/**
* Triggers a Keyboard Maestro (KM) macro asynchronously and awaits its response.
*
* @async
* @function run_km_macro_htmlprompt
* @param {string} macroIdentifier - The identifier of the KM macro to trigger.
* @param {Object|string} param - The parameter to pass to the KM macro. Can be an object or a string.
* @returns {Promise<any|null>} - Resolves with the macro's response data or `null` if an error occurs.
*
* @throws {Error} Throws an error if awaiting the macro's response fails or times out.
*
* @example
* const response = await run_km_macro_htmlprompt('MacroID', { key: 'value' });
* console.log(response);
*/
async function run_km_macro_htmlprompt(macroIdentifier, param) {
try {
// Log macro details
log_message("Macro to execute: " + macroIdentifier);
log_message("Parameter to use: " + JSON.stringify(param));
// Trigger the KM macro with the provided identifier and parameter
window.KeyboardMaestro.Trigger(macroIdentifier, param);
// Await the macro's response with a 6-second timeout
const macroResult = await getResponse(6000);
log_message("Macro returned: " + macroResult);
return macroResult;
} catch (err) {
log_message("Error in run_km_macro_htmlprompt: " + err.message);
return null;
}
}
/**
* Waits for a custom "kmResponse" event and resolves with its data.
*
* @function getResponse
* @param {number} timeoutMs - The maximum time to wait for the response, in milliseconds.
* @returns {Promise<any>} - Resolves with the event's detail data or rejects if timed out.
*
* @throws {Error} Throws an error if the response times out.
*
* @example
* getResponse(5000)
* .then(data => console.log('Received:', data))
* .catch(err => console.error(err));
*/
function getResponse(timeoutMs) {
// Creates a Promise that resolves or rejects based on a custom event or timeout.
return new Promise((resolve, reject) => {
const timer = setTimeout(() => {
document.removeEventListener("kmResponse", handleResponse);
log_message("Response timed out");
reject(new Error("Timed out waiting for macro response"));
}, timeoutMs);
/**
* Handles the "kmResponse" event by resolving the Promise with its data.
*
* @param {CustomEvent} evt - The custom event containing the response data.
*/
function handleResponse(evt) {
clearTimeout(timer);
document.removeEventListener("kmResponse", handleResponse);
log_message("Received 'kmResponse' event with data:", evt.detail);
resolve(evt.detail);
}
document.addEventListener("kmResponse", handleResponse);
});
}
/**
* Dispatches a custom "kmResponse" event with the provided response data.
*
* This function should be called by the KM macro's `[Utility] Inject Response Into Custom HTML Prompt`
* subroutine to send data back to the awaiting function.
*
* @function insertResponse
* @param {any} response - The response data to dispatch with the event.
*
* @example
* insertResponse({ success: true, data: {...} });
*/
function insertResponse(response) {
// This is called by the subroutine macro's
// "ExecuteJavaScriptForCustomPrompt" injection.
log_message("insertResponse called with:", response);
// Dispatch an event that getResponse() is waiting for.
// 'detail' can contain any data you'd like.
document.dispatchEvent(new CustomEvent("kmResponse", { detail: response }));
}
The KM Macro "Inject Response Into Custom HTML Prompt"
This is a subroutine macro that accepts two inputs: "InjectJavascript" (i.e. the value to inject into an HTML prompt) and "InjectWindowID" (the ID of the HTML Prompt). The script within is a distilled version of yours but only works to inject back into a prompt rather than handling additional potential contexts (e.g. Safari and Chrome).
The only real additions I have made are logging and additional escaping. Many of my macros return JSON Objects, for which this additional escaping function compensates. I also unintentionally butchered your KM variable reading of local & instance variables, which I'll come back and fix once I figure that out for JS (I was very tired at that point and just needed to ensure I had a solution that worked, hence why the attached macro is using global variables. Please feel free to correct my code there if you know what's wrong?)
The javascript for the "Inject Response Into Custom HTML Prompt" macro:
/**
* Keyboard Maestro Variables Management Module
*
* This module provides functions to interact with Keyboard Maestro (KM) variables,
* manage macro executions, and handle responses within custom HTML prompts.
*/
// Define global variables
const kme = Application("Keyboard Maestro Engine");
const currentApp = Application.currentApplication();
currentApp.includeStandardAdditions = true;
const kmInstance = currentApp.systemAttribute("KMINSTANCE");
/**
* Determines if a variable name is an instance or local variable.
*
* @function isInstanceVariableName
* @param {string} name - The name of the KM variable to check.
* @returns {boolean} - Returns `true` if the variable is an instance or local variable, otherwise `false`.
*
* @example
* isInstanceVariableName("InstanceVariable"); // true
* isInstanceVariableName("GlobalVariable"); // false
*/
function isInstanceVariableName(name) {
return name.match(/^Instance|^Local/) != null;
}
/**
* Retrieves the value of a Keyboard Maestro (KM) variable.
*
* @function getKMVariable
* @param {string} name - The name of the KM variable to retrieve.
* @returns {string|undefined} - The value of the KM variable, or `undefined` if not found.
*
* @example
* const value = getKMVariable("GlobalVar");
* console.log(value);
*/
function getKMVariable(name) {
if (isInstanceVariableName(name)) {
result = kme.getvariable(name, { instance: kmInstance });
} else {
result = kme.getvariable(name);
}
return result;
}
/**
* Sets the value of a Keyboard Maestro (KM) variable.
*
* @function setKMVariable
* @param {string} name - The name of the KM variable to set.
* @param {string} value - The value to assign to the KM variable.
*
* @example
* setKMVariable("GlobalVar", "New Value");
*/
function setKMVariable(name, value) {
if (isInstanceVariableName(name)) {
kme.setvariable(name, { to: value, instance: kmInstance });
} else {
kme.setvariable(name, { to: value });
}
}
/**
* Escapes characters that would break a JavaScript string literal.
*
* @function escapeForJSString
* @param {string} str - The string to escape.
* @returns {string} - The escaped string safe for JavaScript string literals.
*
* @example
* const safeStr = escapeForJSString('He said, "Hello\nWorld!"');
* console.log(safeStr); // He said, \"Hello\nWorld!\"
*/
function escapeForJSString(str) {
// Escapes characters that would break a JavaScript string literal
return str
.replace(/\\/g, "\\\\") // backslashes
.replace(/"/g, "\\\"") // double quotes
.replace(/\r/g, "\\r") // carriage returns
.replace(/\n/g, "\\n"); // newlines
}
/**
* Escapes special XML characters to ensure valid XML construction.
*
* @function escapeForXml
* @param {string} str - The string to escape.
* @returns {string} - The escaped string safe for XML.
*
* @example
* const safeXml = escapeForXml('<tag>Value & "Quotes"</tag>');
* console.log(safeXml); // <tag>Value & "Quotes"</tag>
*/
function escapeForXml(str) {
// Escapes special XML characters to ensure valid XML construction
return str.replace(/[<>&'"]/g, function (c) {
switch (c) {
case '<': return '<';
case '>': return '>';
case '&': return '&';
case '\'': return ''';
case '"': return '"';
}
});
}
/**
* Appends a message to the InjectJavascriptLog variable if logging is enabled.
*
* @function logMessage
* @param {string} message - The message to log.
*
* @example
* logMessage("Injection started.");
*/
function logMessage(message) {
if (isLoggingEnabled) {
const currentLog = getKMVariable("InjectJavascriptLog") || "";
setKMVariable("InjectJavascriptLog", currentLog + message + "\n");
}
}
// Check if logging is enabled
const isLoggingEnabled = getKMVariable("InjectJavascriptLogWrite") === "true";
/**
* Injects JavaScript code into a Custom HTML Prompt window and retrieves the result.
*
* This function constructs an ephemeral KM action to execute the JavaScript code,
* dispatches the code to the prompt, and retrieves the result from KM variables.
*
* @function injectResponse
* @returns {string} - The result of the JavaScript injection, or a JSON string containing an error message.
*
* @example
* const result = injectResponse();
* console.log(result);
*/
function injectResponse() {
try {
logMessage("Starting injectResponse function.");
// Retrieve the JavaScript code to inject from KM variable
const jsToInject = getKMVariable("InjectJavascript");
logMessage(`Retrieved jsToInject: ${jsToInject}`);
if (!jsToInject) {
throw new Error("No code found in InjectJavascript");
}
// Take the javascript to inject and escape it
const safeJS = 'insertResponse("' + escapeForJSString(jsToInject) + '")';
// Retrieve the Custom HTML Prompt window ID from KM variable
const windowID = getKMVariable("InjectWindowID");
logMessage(`Retrieved windowID: ${windowID}`);
// Define the variable name to return results to
const resultVariableName = "InjectionResult";
// Define the variable name to return errors to
const errorVariableName = "InjectionError";
if (!windowID) {
throw new Error("No Custom HTML Prompt window ID found in InjectWindowID");
}
// Construct the ExecuteJavaScriptForCustomPrompt action XML
const ephemeralActionXML = `
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>MacroActionType</key>
<string>ExecuteJavaScriptForCustomPrompt</string>
<key>Text</key>
<string>${escapeForXml(safeJS)}</string>
<key>WindowID</key>
<string>${escapeForXml(windowID)}</string>
<key>Variable</key>
<string>${resultVariableName}</string>
<key>UseText</key>
<true/>
<key>TimeOutAbortsMacro</key>
<true/>
</dict>
</plist>
`.trim();
logMessage("Constructed ephemeralActionXML:\n" + ephemeralActionXML);
// Execute ephemeral action
kme.doScript(ephemeralActionXML);
logMessage("Executed ephemeral action via doScript.");
// Give KM a tiny delay to finish writing the result variable
delay(0.2);
const injectionResult = getKMVariable("InjectionResult");
logMessage("injectionResult => " + injectionResult);
return injectionResult;
} catch (err) {
logMessage("Error in injectResponseEphemeral: " + err.message);
return JSON.stringify({ error: err.message });
}
}
// Main script execution
try {
return injectResponse();
} catch (err) {
logMessage(`Error in script execution: ${err.message}`);
// Return error message if injection fails
return JSON.stringify({ error: err.message });
}
All credit for the methodologies used in the above go to Dan.
The macro
Inject Response Into Custom HTML Prompt copy Macro (v11.0.3)
Inject Response Into Custom HTML Prompt copy.kmmacros (19 KB)
I use this subroutine as an alternative 'Return' action. As a contextual example in my own workflow, I have a macro that programmatically edits data in a spreadsheet. The macro expects a JSON object to be used as the %TriggerValue%. I parse that object for various points of information. When I intend to execute this macro from a HTML prompt, I add a key-value pair to the JSON object to send to KM labelled "WindowID" to both indicate the intended use case and also provide the ID of the Custom HTML Prompt:
const param_obj = {
EntityTag: entity_tag || "",
ConfigurationPreset: configuration_preset,
TargetUUID: "UUID hidden for privacy",
WindowID: "CSVEditorWindow",
};
In my spreadsheet macro, at the end of the script, I have an "If All Conditions Met Execute Actions" look for the presence of this key value in the variable I've set to capture the %TriggerValue%. If it detects it, it provides the python's result and WindowID value to the injection macro. Otherwise, it uses the 'Return' action, sufficient for other non-HTML-environment scripts and macros. The filter for JSON Compact reduces the complexity of the escaping operation needed in the injection macro.
Note: I've shared this "If" action below. This forum's formatting makes it appear as a macro rather than as just an action.
Using the injection macro as an alternative 'return' action
Select Return Method Based On Execution Context Macro (v11.0.3)
Select Return Method Based On Execution Context.kmmacros (3.2 KB)
There is something to note - I had issues with your injection script. I would appreciate your insight on the following:
In your "Execute Dynamic Javascript in Browser" macro's javascript, you defined the in-memory KM actions by using Action UID's:
<dict>
<key>ActionUID</key>
<integer>199489</integer> <--- the Action UID
<key>DisplayKind</key>
<string>Variable</string>
<key>MacroActionType</key>
<string>ExecuteJavaScriptForCustomPrompt</string>
<key>Path</key>
<string></string>
<key>Text</key>
<string>${escapeForXml(javascript)}</string>
<key>TimeOutAbortsMacro</key>
<true/>
<key>UseText</key>
<true/>
<key>Variable</key>
<string>${resultVariableName}</string>
<key>WindowID</key>
<string>${escapeForXml(kmwindowid)}</string>
</dict>
This gave me a lot of issues. I originally thought that Action UIDs in this context were an ID for the 'generic' action type. That is to say, I thought "199489" would always refer to the action "Execute a JavaScript in all Custom Prompts". But it's actually a unique identifier that relates to a specific instance of that action. If you create three new actions of the same type in the KM editor, they'll all have different UIDs.
I thought you might be referencing some 'hidden' action or action held in a sort-of 'bank' macro that contained blank versions to then programmatically configure. I couldn't find anything of the sort.
I tried making my own, looking through the XML, taking the UIDs, and using those in the script, to no avail.
After a lot of trial and error, I realised you could use generic names for these actions, and the execution began to work:
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>MacroActionType</key>
<string>ExecuteJavaScriptForCustomPrompt</string>
<key>Text</key>
<string>${escapeForXml(safeJS)}</string>
<key>WindowID</key>
<string>${escapeForXml(windowID)}</string>
<key>Variable</key>
<string>${resultVariableName}</string>
<key>UseText</key>
<true/>
<key>TimeOutAbortsMacro</key>
<true/>
</dict>
</plist>
Using this fixed the issue. But I'm genuinely curious how UIDs work in your scenario, given that seemingly trying the same approach to generating and using my own UIDs didn't help.