# SFDeploy for Chrome

SFDeploy for Chrome is one shared, private extension for StudyFetch apps. Users
click the extension, choose an app, and work in Chrome's side panel beside their
current website. Each app can call its published scripts to read or change page
content and continue across pages. Redeploy the app to ship new scripts and UI.

## When it helps

- Add a widget to a website, such as background colors or inline notes.
- Read a visible table or page summary into your app without copying it manually.
- Fill a form from an internal tool, with a clear user-triggered button.
- Follow a sequence of pages while keeping progress in the side panel.

Use a normal hosted app when you only need a dashboard or API. Use a Chrome app
when the workflow needs the page the person is viewing. The user sees one **App**;
`actions` is the configuration name for the scripts its buttons call, not a
second product mode. Scripts can read/write the DOM; the panel cannot reach into
the tab directly or submit arbitrary code at runtime.

## Download and setup

Send users to **[Get SFDeploy for Chrome](https://hub.studyfetchdeploy.com/chrome-extension)**.
The private Hub page includes the ZIP and screenshot instructions. Users sign in
with StudyFetch in the same Chrome profile, unzip to a permanent folder, turn on
Developer mode at `chrome://extensions`, choose **Load unpacked**, then enable
**Allow user scripts** in SFDeploy's Details. Chrome 138+ is required. They install
the extension once for all apps and do not need the CLI.

Builders also install the CLI with `npm i -g @studyfetch/sfdeploy` and run
`sfdeploy login`. CLI login and the browser's Hub sign-in are separate.

## Start with a working app

CLI 0.12.2+ includes the complete Page Colors app. No repository checkout,
dependencies, or build step is needed for this starter:

```bash
mkdir my-chrome-app
cd my-chrome-app
sfdeploy init my-chrome-app --example chrome-colors
sfdeploy projects create my-chrome-app --visibility private
sfdeploy deploy
```

Open an ordinary website, click SFDeploy in Chrome, choose **Page Colors**, and
click **Apply to page**. Grant site access if prompted. The floating widget changes
the background; **Reset** restores it. Navigate to another allowed page to see
the selected color persist. The starter is private; share with named viewers, or
choose org visibility only when team-wide access is intended.

`sfdeploy --example-doc chrome-colors` prints every file and the deployment steps.
Edit `public/extension/` for the interface and `extension/widget.js` for the page
script, then redeploy. The broad HTTP(S) matches are intentional for this demo;
narrow them for a tool that works on specific sites.

## Add Chrome support to an existing app

Keep the existing project's settings and add `chrome_extension` to `sfdeploy.json`.
Create `chrome/highlight.js` below, then run `sfdeploy deploy`. This minimal version
uses built-in controls; add your own `panel` route when you need a custom interface.

```json
{
  "name": "my-app",
  "visibility": "private",
  "chrome_extension": {
    "title": "My browser tool",
    "description": "A short explanation for the extension catalog.",
    "actions": [{
      "id": "highlight",
      "title": "Highlight this page",
      "description": "Mark the main heading.",
      "matches": ["https://example.com/*"],
      "script": "chrome/highlight.js",
      "persist": true,
      "options": [{ "id": "color", "label": "Highlight color", "type": "color", "default": "#ffca98" }]
    }]
  }
}
```

`panel` is an optional path, such as `/extension/`, to a page you actually serve.
`actions` may be empty for an app that only has an embedded
interface. Script paths are relative to the project and cannot escape it through
symlinks. Scripts are self-contained async function bodies, not ES modules: bundle
dependencies first. `await` and `return` work. Maximum: 20 actions, 128 KB per script,
512 KB per plugin. Option types: `text`, `color`, `select` (with `choices` and a
default belonging to them). IDs use lowercase letters, digits, hyphens, underscores.

```js
// chrome/highlight.js — receives the sfdeploy object, never extension APIs.
const heading = document.querySelector('h1');
if (heading) {
  const previous = heading.style.backgroundColor;
  heading.style.backgroundColor = sfdeploy.input.color || '#ffca98';
  sfdeploy.onStop(() => { heading.style.backgroundColor = previous; });
}
await sfdeploy.log('Heading highlighted');
return { title: document.title, heading: heading?.textContent ?? null };
```

Deploying publishes the scripts alongside the app. The extension fetches the latest
published version when an action starts. No extension reinstall is needed. Preview
deploys do not replace the published plugin. Set `chrome_extension: null` and deploy
to remove it; omitting the field leaves the existing plugin unchanged.

The control-plane endpoint is `PUT /v1/projects/:name/chrome-extension`, authenticated
with the usual CLI token. Its body is the same object, replacing each `script` file
path with `code` containing its contents, or JSON `null` to disable. Only owners and
deployers can change it. This is also useful for clients that upload deployments
directly rather than invoking the CLI.

## Runtime

Scripts run after a user starts work from the app and grants the requested sites.
Code runs through `chrome.userScripts` in an isolated `USER_SCRIPT` world, with DOM access.
It is never evaluated in the privileged extension page or service worker.

- `sfdeploy.input`: configured option values for this run.
- `sfdeploy.state`: a snapshot of state saved across page navigations in this run.
- `await sfdeploy.setState(value)`: save JSON state (maximum 64 KB).
- `await sfdeploy.log(message)`: show progress in the side panel.
- `await sfdeploy.navigate(url)`: navigate this run’s tab to another allowed URL.
  Save state **before** navigating; the old document and script context disappear.
- `await sfdeploy.finish()`: end the run and trigger its cleanup.
- `sfdeploy.signal`: AbortSignal for fetches, event listeners and your own loops.
- `sfdeploy.onStop(cleanup)`: remove your widget/styles/listeners when stopped.

`persist: true` runs again in each new document of the same tab. The tab stays bound
even if the user switches tabs. Unsupported pages pause the action. Closing the tab
or the browser ends the run. Background service-worker suspension does not lose it.
New documents re-check Hub access. If the app changes while running, restart the
action to use the new version. Chrome-internal pages, the Chrome Web Store, and
other browser-protected pages cannot run user scripts.

Stop cancels continuation and aborts the script's signal. Scripts should register
cleanup and honor that signal: Chrome cannot forcibly undo arbitrary changes a
script has already made. There are no scheduled or automatically started runs.

## Continue across pages

Set `persist: true` and declare the destination sites in `matches`. Save progress
before navigating. This example follows a site's **Next** links for up to five
pages, then finishes. Replace the match pattern with the site you intend to use.

```js
// chrome/walk-pages.js
const visited = sfdeploy.state.visited || [];
if (visited.includes(location.href)) {
  await sfdeploy.log('Already visited this page.');
  await sfdeploy.finish();
  return;
}
const pages = [...visited, location.href];
await sfdeploy.setState({ visited: pages });
await sfdeploy.log(`Visited ${pages.length} pages`);
const next = document.querySelector('a[rel="next"]')?.href;
if (next && pages.length < 5) {
  await sfdeploy.navigate(next);
  return; // The new document runs this script again with the saved state.
}
await sfdeploy.log(`Done: ${pages.length} pages`);
await sfdeploy.finish();
```

For long-running work on one page, pass `sfdeploy.signal` into `fetch()` and event
listeners, and check `sfdeploy.signal.throwIfAborted()` inside asynchronous loops.
Never use an unbounded synchronous loop: it blocks the page and prevents cleanup.

## An app interface in the side panel

`panel` points to a path on the deployed app. Build that page responsively at about
300–450 px wide. It runs as a normal website inside a sandboxed iframe and retains
its app sign-in. If Access needs a fresh login, use **Sign in / open app**, complete
sign-in in a regular tab, then Reload the embedded app.

An app must permit framing itself by the extension: avoid `X-Frame-Options: DENY`
or `SAMEORIGIN` on this route and include the extension origin in its CSP
`frame-ancestors`. The extension origin is `chrome-extension://hjidmfchoeghbbdniflokhmhgghnbjmb`
and is stable across ZIP installs. The extension does not remove another app's framing policy.

The extension opens your interface at full height with a back button. No separate
actions tab is shown. Use the app SDK to call your own published scripts:

```js
import { sfdeploy } from 'https://api.studyfetchdeploy.com/chrome-extension/sdk.js';

// Connect to normal app buttons; never start a script just because the UI loaded.
const apply = document.querySelector('#apply');
const reset = document.querySelector('#reset');
const status = document.querySelector('#status');
apply.disabled = reset.disabled = !sfdeploy.connected;
apply.addEventListener('click', async () => {
  apply.disabled = true;
  try {
    const result = await sfdeploy.run('highlight', { color: '#ffca98' });
    status.textContent = `Highlighted ${result.heading || result.title}`;
  } catch (error) { status.textContent = error.message; }
  finally { apply.disabled = !sfdeploy.connected; }
});
reset.addEventListener('click', async () => {
  try { await sfdeploy.stop(); status.textContent = 'Page restored.'; }
  catch (error) { status.textContent = error.message; }
});
```

Load this as a module from an HTML page with `apply` and `reset` buttons and a
`<p id="status" role="status"></p>`. The starter contains the complete HTML and
Worker setup. Keep the UI brief and task-focused; user-facing screens do not need
manifest examples, SDK documentation, or separate Actions/App tabs.

Your published script can read and write the selected page's DOM and return data:

```js
// chrome/highlight.js
const heading = document.querySelector('h1');
if (heading) {
  const previous = heading.style.backgroundColor;
  heading.style.backgroundColor = sfdeploy.input.color;
  sfdeploy.onStop(() => { heading.style.backgroundColor = previous; });
}
return { title: document.title, heading: heading?.textContent ?? null };
```

Return values must be JSON-serializable and at most 64 KB. `run()` waits up to two
minutes for a result. For intentionally long-running work, use
`const run = await sfdeploy.start('my-action', input)` to return the started run
object (`run.id`) without waiting for the result, then `sfdeploy.context()` for
status. A `run()` timeout does not stop the script; use `sfdeploy.stop()` to cancel.
Stop remains available in the extension toolbar.

Use `sfdeploy.connected` to disable controls when your page is opened outside the
extension. The first use of a new site may show **Allow & run** so Chrome can obtain
a real permission-granting click; subsequent calls run directly from your app.
Calls use only scripts published by that app. They cannot submit arbitrary code,
change the installed extension, or read another app's results.

The SDK handles request IDs and origin validation. Its underlying bridge checks
both the selected iframe's window and exact app origin. Existing
`sfdeploy:chrome:v1` postMessage clients remain supported; methods are `run`,
`stop`, `context`, and `result` (with `runId`). Replies have `ok` or `error`;
`run` replies include the started run, and `result` includes only that app's result.

## Verify your app

Test the deployed app inside the extension, not only in a normal tab:

1. Confirm it appears for an allowed account, opens at full height, and Back works.
2. Run it on a supported site; exercise permission approval and cancellation.
3. Check the actual DOM change and returned JSON, then Stop/Reset and verify cleanup.
4. If persistent, navigate through supported pages and verify progress survives.
5. Redeploy a script change and start a fresh run to check the new behavior.
6. Open the interface outside the extension and verify browser controls are disabled
   with a short link to installation, rather than an unexplained failure.

The repository's `examples/chrome-colors` is the source of the packaged starter
and the extension's real-browser integration test. The CLI bundles those same
files with your project name and private visibility.

## Updating the extension itself

Use the **[screenshot update guide](https://hub.studyfetchdeploy.com/chrome-extension/update)**
for the latest download and reload steps. The Hub ZIP is a development-mode private
distribution, not a Chrome Web Store installer. Users replace files in their existing unpacked directory and click
Reload to update the extension shell. App/script updates are dynamic and do not
require this. A private Chrome Web Store release or managed-browser distribution can distribute
shell updates automatically.

Version 0.1.1 adds a required-update screen and a visible installed version. The
extension checks on opening, before new scripts/navigation, once a minute while
open, and periodically in the background. Unsupported versions cannot fetch new
scripts, and the new client stops active runs when it learns an update is required.
An unavailable check prevents new work without clearing an already known block.
People on the original 0.1.0 ZIP must update once to get this screen.

Platform maintainers: bump `packages/chrome-extension/src/manifest.json` and the
extension package version for each new shell. Set `minimumVersion` in
`packages/chrome-extension/release.json` only when older clients must stop working.
Deploying sf-api publishes the matching ZIP and release policy together. The build
rejects a minimum newer than the ZIP. Raising the latest version alone allows
compatible older clients to continue. No consumer-facing developer settings are
needed.

## Configuration reference

| Field | Purpose |
| --- | --- |
| `title`, `description` | App name and one short sentence shown in the catalog. |
| `panel` | Optional path on your deployment, such as `/extension/`. |
| `actions` | Published scripts callable by the app SDK; built-in controls are used when no panel is set. Use `[]` for an interface-only app. |
| `actions[].id` | Stable identifier used by the embedded interface. |
| `actions[].matches` | HTTP(S) Chrome match patterns, such as `https://*.example.com/*`. |
| `actions[].script` | Project-relative JavaScript file. Read and uploaded by the CLI. |
| `actions[].persist` | `true` to continue in new documents of the selected tab. Default: `false`. |
| `actions[].options` | Default inputs for built-in controls. An app can pass inputs directly with `run(id, input)`; scripts receive `sfdeploy.input`. |

A select input looks like this:

```json
{
  "id": "mode",
  "label": "Mode",
  "type": "select",
  "default": "Headings",
  "choices": ["Headings", "Links", "Paragraphs"]
}
```

## Troubleshooting

| What you see | What to do |
| --- | --- |
| No apps | Sign in to the Hub in this Chrome profile and refresh. Deploy an app with `chrome_extension`; an undeployed project does not appear. |
| Update required | Follow https://hub.studyfetchdeploy.com/chrome-extension/update, replace files in the existing unpacked folder, then reload the extension. Redeploying an app or updating the CLI does not update the installed extension. |
| An app is missing | Check its visibility/membership. Private apps appear only to their owner and members; other tenants do not appear. |
| Enable scripts | Open `chrome://extensions`, find SFDeploy, choose Details, enable Allow user scripts, and refresh the sidebar. |
| This action does not support this page | Check the action's match patterns and switch to an ordinary HTTP(S) website. Chrome settings, New Tab and the Web Store restrict injection. |
| Site access is missing | Click Run again and accept Chrome's request for the app's declared sites. |
| The embedded app is blank or asks to sign in | Use Open app, finish sign-in, then Reload. Ensure the app's CSP permits the extension origin and its X-Frame-Options header does not block framing. |
| An app control does not run | Use the SDK, check `sfdeploy.connected`, and call an action ID declared by that app. Grant site access if prompted. |
| App updated while running | Run again to use the latest version. Running actions keep their original version until restarted. |
| Widget stays after Stop | Register cleanup with `sfdeploy.onStop()` and use `sfdeploy.signal` for listeners, fetches and loops. |
| Deployment says the app shipped but its plugin failed | Correct the reported manifest error and redeploy. App deployment and plugin publication are separate operations. |

## Access and data

The download, app catalog, and script reads require StudyFetch sign-in. Public and
org apps appear to the team; private apps follow existing project membership.
Only owners and deployers can publish a plugin. The extension stores run state
in browser-session storage and clears it when Chrome closes. It does not receive
or store a CLI token or shared service credential.

Downloaded scripts can read and modify the pages where the user runs them. Keep
match patterns as narrow as your workflow allows. Embedded apps can request their
own published actions, stop their own run, and ask whether they are running; they
cannot submit arbitrary code or read another app's run state through the bridge.

Chrome references: [user scripts and Allow user scripts](https://developer.chrome.com/docs/extensions/reference/api/userScripts)
and [extension distribution](https://developer.chrome.com/docs/extensions/how-to/distribute).
