Skip to content

Commit

Permalink
Allow panes to execute JS repeatedly against a static URL
Browse files Browse the repository at this point in the history
  • Loading branch information
mr-pennyworth committed Jul 15, 2024
1 parent b0c3afc commit 6f3719e
Show file tree
Hide file tree
Showing 12 changed files with 234 additions and 9 deletions.
4 changes: 2 additions & 2 deletions AlfredExtraPane.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -409,7 +409,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
MARKETING_VERSION = 0.1.9;
MARKETING_VERSION = 0.2.0;
ONLY_ACTIVE_ARCH = NO;
OTHER_CODE_SIGN_FLAGS = "--deep";
PRODUCT_BUNDLE_IDENTIFIER = mr.pennyworth.AlfredExtraPane;
Expand All @@ -430,7 +430,7 @@
"@executable_path/../Frameworks",
);
MACOSX_DEPLOYMENT_TARGET = 11.0;
MARKETING_VERSION = 0.1.9;
MARKETING_VERSION = 0.2.0;
ONLY_ACTIVE_ARCH = NO;
OTHER_CODE_SIGN_FLAGS = "--deep";
PRODUCT_BUNDLE_IDENTIFIER = mr.pennyworth.AlfredExtraPane;
Expand Down
25 changes: 24 additions & 1 deletion AlfredExtraPane/Pane.swift
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,21 @@ public struct WorkflowPaneConfig {
}
}

/// While a non-static pane renders the URL in Alfred item's `quicklookurl`,
/// a static pane renders the URL in the pane's configuration once, and then uses
/// the `quicklookurl` as a text file containing the input to be passed
/// to the JavaScript `function`.
public struct StaticPaneConfig: Codable, Equatable {
let initURL: URL
let function: String
}

public struct PaneConfig: Codable, Equatable {
let alignment: PanePosition
let customUserAgent: String?
let customCSSFilename: String?
let customJSFilename: String?
let staticPaneConfig: StaticPaneConfig?
}

class Pane {
Expand All @@ -51,6 +61,10 @@ class Pane {
self.workflowUID = workflowPaneConfig.workflowUID
window.contentView!.addSubview(webView)

if let staticConf = self.config.staticPaneConfig {
webView.load(URLRequest(url: staticConf.initURL))
}

Alfred.onHide { self.hide() }
Alfred.onFrameChange { self.alfredFrame = $0 }
}
Expand All @@ -62,7 +76,16 @@ class Pane {
}

func render(_ url: URL) {
if url.isFileURL {
if let staticConf = config.staticPaneConfig {
if let arg = try? String(contentsOf: url) {
let safeArg = arg.replacingOccurrences(of: "`", with: "\\`")
let js = "\(staticConf.function)(`\(safeArg)`)"
log("evaluating JS: \(js)")
webView.evaluateJavaScript(js)
} else {
log("failed to read '\(url)' as text file")
}
} else if url.isFileURL {
if url.absoluteString.hasSuffix(".html") {
let dir = url.deletingLastPathComponent()
webView.loadFileURL(url, allowingReadAccessTo: dir)
Expand Down
3 changes: 2 additions & 1 deletion AlfredExtraPane/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ let globalConfigFile: URL = {
alignment: .horizontal(placement: .right, width: 300, minHeight: 400),
customUserAgent: nil,
customCSSFilename: nil,
customJSFilename: nil
customJSFilename: nil,
staticPaneConfig: nil
)
if !fs.fileExists(atPath: conf.path) {
write([defaultConfig], to: conf)
Expand Down
21 changes: 16 additions & 5 deletions AlfredExtraPaneTests/AlfredExtraPaneTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,11 @@ final class AlfredExtraPaneTests: XCTestCase {
"customJSFilename": "script.js"
}, {
"alignment" : {
"vertical" : {"placement" : "bottom", "height" : 200}}
"vertical" : {"placement" : "bottom", "height" : 200}},
"staticPaneConfig": {
"initURL": "https://example.com",
"function": "render"
}
}]
""".data(using: .utf8)!

Expand All @@ -27,25 +31,32 @@ final class AlfredExtraPaneTests: XCTestCase {
alignment: .horizontal(placement: .right, width: 300, minHeight: 400),
customUserAgent: "agent of S.H.I.E.L.D.",
customCSSFilename: nil,
customJSFilename: nil
customJSFilename: nil,
staticPaneConfig: nil
),
AlfredExtraPane.PaneConfig(
alignment: .horizontal(placement: .left, width: 300, minHeight: nil),
customUserAgent: nil,
customCSSFilename: "style.css",
customJSFilename: nil
customJSFilename: nil,
staticPaneConfig: nil
),
AlfredExtraPane.PaneConfig(
alignment: .vertical(placement: .top, height: 100),
customUserAgent: nil,
customCSSFilename: nil,
customJSFilename: "script.js"
customJSFilename: "script.js",
staticPaneConfig: nil
),
AlfredExtraPane.PaneConfig(
alignment: .vertical(placement: .bottom, height: 200),
customUserAgent: nil,
customCSSFilename: nil,
customJSFilename: nil
customJSFilename: nil,
staticPaneConfig: StaticPaneConfig(
initURL: URL(string: "https://example.com")!,
function: "render"
)
)
]
let decoded = try! JSONDecoder().decode(
Expand Down
190 changes: 190 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,14 @@ Configurable parameters are:
- `customJSFilename` (optional): Name of the JavaScript file to be loaded
in the pane. The file should be in the same directory as the JSON
config file.
- `staticPaneConfig` (optional):
`{"initURL": "https://fixed-url.com", "function": "jsFunctionName"}`
This pairs well with `customJSFilename`. In this mode, when the pane is
created for the first time, it loads `initURL`. Then the workflow
is expected to produce a text file containing the input it wants to
send to the pane, and set the full path of this text file as the
`quicklookurl`. The pane will execute the JavaScript function
`jsFunctionName` with the contents of the text file as the argument.

Here's an example with four panes configured:
```json
Expand Down Expand Up @@ -278,3 +286,185 @@ Add the `customCSSFilename` key to the JSON config:
Restarting the app will show the pane with the Google logo and search
bar hidden:
![](media/tutorial-images/google-mobile-compact.png)

## Tutorial: Meta AI Image Generation as you type
In "[Tutorial: Google as you type](#tutorial-search-google-as-you-type)"
we saw how to customize position of the pane and style of the webpage. We
could Google as we type, because the search query was a part of the
URL. That meant the workflow only needed to generate the URL, and the
pane would show the preview.

There are websites where the desired action isn't controlled by the URL.
https://www.meta.ai/ is one such example. It has a text box where you
type the prompt (the prompt must begin with the word "imagine"), and the
AI generates an image based on that prompt, as you type. All this while,
the URL remains the same.

In this tutorial, we will build a workflow and configure the pane such
that the pane loads the URL once, and then listens for the prompt in the
workflow's output, and simulates typing it in the text box. The end
result: ![](media/imagine.gif)

### Prerequisites
1. You have [installed `AlfredExtraPane`](#installation).
2. You have read
"[Tutorial: Google as you type](#tutorial-search-google-as-you-type)"
and are familiar with configuring the pane.
3. You have a basic understanding of JavaScript (in the web context).
4. You have a Facebook account to log in to Meta AI.

### Configuring Static Pane
Here's the pane configuration for the workflow:
```json
[{
"alignment" : {"vertical" : {"placement" : "bottom", "height" : 570}},
"customJSFilename": "flashImagine.js",
"staticPaneConfig": {
"initURL": "https://www.meta.ai/",
"function": "flashImagine"
}
}]
```
This configuration tells the pane to load `https://www.meta.ai/` once,
and insert the JavaScript from `flashImagine.js` into the loaded webpage.
Then, every time the workflow script filter runs, read query from file
whose path is set as the `quicklookurl`, and pass the query
to the JavaScript function `flashImagine`.

Here's `flashImagine.js`, which defines the `flashImagine` function:
```javascript
function flashImagine(query) {
let textArea = document.getElementsByTagName('textarea')[0];

// Calling textArea.value = query won't do as the webpage uses the
// ReactJS framework.
// See https://stackoverflow.com/a/46012210 for details.
// The following code is conceptually equivalent to setting the value
// of the text area:
Object.getOwnPropertyDescriptor(
window.HTMLTextAreaElement.prototype,
'value'
).set.call(textArea, "imagine " + query);

// When a user manually types in the text area, an "input" event
// is generated. There's code in the webpage that listens to this
// event to load the AI generated image. Since in this script, we
// are setting the value in code (as opposed to manual entry by user),
// we need to generate the "input" event in code too.
textArea.dispatchEvent(
new Event('input', {bubbles: true, cancelable: true})
);
}
```

### Workflow Script Filter
We have configured the pane, but we still need to create a script filter
that takes the query, writes it to a file, and then produces an item with
the path of the file as the `quicklookurl`:
```shell
q="$1"
input_file="/tmp/meta_ai_input.txt"

echo -n "$q" > "$input_file"

cat << EOF
{"items" : [
{"title": "$q",
"quicklookurl": "$input_file"}
]}
EOF
```

That's it! That's the entire workflow (also thrown in a hotkey trigger for
convenience):
![](media/tutorial-images/imagine-workflow.png)

### Facebook Login
Running the workflow, we see that the query is being typed into the text
box, but the image isn't showing up:
![](media/tutorial-images/imagine-no-login.png)

Meta AI requires you to log in to generate images. Click on the top
left corner of the pane, and log in to Meta AI:
![](media/tutorial-images/imagine-login.png)
![](media/tutorial-images/imagine-login-2.png)

Running the workflow now, we should see the image being generated:
![](media/tutorial-images/imagine-vanilla.png)

### Refining the Appearance
I don't like how the text box and the padding around the image is taking
up so much space. I don't need to see what's in the textbox as it is the
same as what I've entered in Alfred.

When I looked into the HTML of the webpage, I couldn't figure out how to
style the image so that it covers the entire pane. The `<img>` tags are
deeply buried into many `<div>` tags, whose style prevents us from
applying the absolute positioning to the `<img>` tag.

So, here's a way to do that using JavaScript, where we grab the latest AI
generated image, insert it in a new `<img>` tag, which isn't deeply nested
in the `<div>` tags, and thus, whose style we can control.

Create `style.css` in the same directory as the JSON config file, to
style the new `<img>` tag:
```css
#finalImg {
position: absolute !important;
top: 0 !important;
left: 0 !important;
width: 100%;
margin: 0px !important;
padding: 0px !important;
z-index: 99999 !important;
}
```

Update the JavaScript with code to create the stylable `<img>` tag:
```javascript
// Create an image tag with "finalImg" as ID, and then
// every 20 milliseconds, look for the latest AI generated image,
// and copy it over to the "finalImg".
(function() {
var img = document.getElementById("finalImg");
if (img == null) {
img = document.createElement("img");
img.setAttribute("id", "finalImg");
document.body.insertBefore(img, document.body.firstChild);
setInterval(function() {
// The latest image happens to be the last image tag in the webpage.
let genImgSrc = [...document.getElementsByTagName('img')].reverse()[0].src;

// handle the case where there aren't any AI generated images.
if (genImgSrc.startsWith("data:")) {
img.setAttribute('src', genImgSrc);

// scroll to the top of the webpage, not really sure what's
// causing the scrolling down in the first place, but always
// scrolling to the top means we don't have to worry about it.
window.scrollTo(0, 0);
}
}, 20);
}
})();

function flashImagine(query) {
...
}
```

Add the CSS file to the JSON config:
```json
[{
"alignment" : {"vertical" : {"placement" : "bottom", "height" : 570}},
"customJSFilename": "flashImagine.js",
"customCSSFilename": "style.css",
"staticPaneConfig": {
"initURL": "https://www.meta.ai/",
"function": "flashImagine"
}
}]
```

Here's the result:
![](media/tutorial-images/imagine-final.png)
Binary file added media/imagine.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added media/tutorial-images/imagine-final.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added media/tutorial-images/imagine-login-2.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added media/tutorial-images/imagine-login.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added media/tutorial-images/imagine-no-login.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added media/tutorial-images/imagine-vanilla.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added media/tutorial-images/imagine-workflow.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.

0 comments on commit 6f3719e

Please sign in to comment.