Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 34 additions & 13 deletions packager/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,8 @@ only run ChainForge while someone is using it.
- **Double-click** the app (or its Desktop shortcut): ChainForge starts on this
Mac only (`127.0.0.1`) and opens in your default browser, in a normal tab.
- A **ChainForge icon in the menu bar** shows while it runs, with *Open
ChainForge*, *Show Log* and *Quit ChainForge*. Quitting stops the server.
ChainForge*, *Update ChainForge*, *Show Log* and *Quit ChainForge*. Quitting
stops the server.
- If you forget, it **stops by itself**: each open ChainForge tab sends a
heartbeat every 5 minutes, and the server stops once none has arrived for 20
minutes (a notification says so). This also covers the launcher being
Expand All @@ -27,17 +28,14 @@ with `--idle-shutdown MINUTES`, as this app does, stops by itself.
## Install

1. Install [uv](https://docs.astral.sh/uv/getting-started/installation/).
2. Install ChainForge as a uv tool. It needs a version with `--idle-shutdown`
(check with `chainforge serve --help`). Until that is released, install
this repository, after building its front end
(`cd chainforge/react-server && npm install --legacy-peer-deps && npm run build`):
2. Install ChainForge as a uv tool (version 0.3.7.7 or later):

```bash
uv tool install --force "chainforge[rag] @ /path/to/ChainForge"
uv tool install "chainforge[rag]"
```

Once released: `uv tool install "chainforge[rag]"`.
3. Build the app, with a Desktop shortcut:
3. Build the app, with a Desktop shortcut, from a copy of this repository
(`git clone https://github.com/ianarawjo/ChainForge.git`):

```bash
packager/build_app.sh --desktop-shortcut
Expand All @@ -52,15 +50,36 @@ with `--idle-shutdown MINUTES`, as this app does, stops by itself.
The app is built on your machine, so macOS doesn't block it. Copying the built
app to another Mac would need signing; build it there instead.

## Updates

The app never updates ChainForge by itself, so a shared machine doesn't change
under the people using it. Instead:

- Once a day, when it starts, it checks PyPI for a newer ChainForge. If there
is one, a notification says so (once per version), and the menu item reads
*Update to ChainForge 0.3.7.8…*. The menu's first line shows the version
running.
- *Update ChainForge…* checks right away. If there's a newer version, it asks
first, then stops ChainForge (and anything running in it), runs
`uv tool upgrade chainforge`, and starts it again. Flows are kept. Tabs
opened before the update need closing: the updated server doesn't recognise
them. If the update fails, the version you had starts again, and the log
says why.

This needs ChainForge installed with uv, as above. Apps built before updates
were added have no *Update* item: rebuild them with `build_app.sh`.

## When something goes wrong

- **Logs:** *Show Log* in the menu, or `~/Library/Logs/ChainForge Launcher/`
(`chainforge.log` for the server, `launcher.log` for the launcher itself).
- **"Something is already using port 8000":** another ChainForge (say, one
started in a terminal) is running. Stop it, or build with a different
`--port` -- but note the browser keeps ChainForge's saved state per port.
- **Upgrading ChainForge:** `uv tool upgrade chainforge`, then rebuild the app
if the `chainforge` command moved.
- **Updating from a terminal instead:** `uv tool upgrade chainforge`, then
rebuild the app if the `chainforge` command moved.
- **"Can't update ChainForge from here":** ChainForge wasn't installed with
uv (e.g. with pip). Update it the way it was installed.

## Uninstall

Expand All @@ -73,10 +92,12 @@ ChainForge keeps them.

- `launcher_core.py` -- the parts that need no GUI: settings, a single-instance
lock, starting `chainforge serve --idle-shutdown` in its own process group,
waiting for it, stopping the whole group, and telling an idle stop from a
crash. Tested in `tests/test_packager_launcher.py`.
waiting for it, stopping the whole group, telling an idle stop from a
crash, and checking for and installing updates. Tested in
`tests/test_packager_launcher.py`.
- `chainforge_menubar.py` -- the menu-bar app, using
[rumps](https://github.com/jaredks/rumps), run by uv.
[rumps](https://github.com/jaredks/rumps), run by uv. Its update flow is
tested, with rumps stood in for, in `tests/test_packager_menubar.py`.
- `build_app.sh` -- writes the app bundle: settings, icons, and a start script
that runs the launcher with uv.
- The idle shutdown itself is in ChainForge, used only with `--idle-shutdown`:
Expand Down
5 changes: 3 additions & 2 deletions packager/build_app.sh
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ cat > "$RES/launcher.json" <<JSON
"host": "127.0.0.1",
"port": $PORT,
"idle_shutdown_minutes": $IDLE,
"flows_dir": $FLOWS_JSON
"flows_dir": $FLOWS_JSON,
"uv": "$UV"
}
JSON

Expand Down Expand Up @@ -138,4 +139,4 @@ if [[ "$DESKTOP" == 1 ]]; then
fi

echo "Built $APP"
echo "Double-click it to start ChainForge. Quit from the ChainForge menu-bar icon."
echo "Double-click it to start ChainForge. Quit, or update ChainForge, from its menu-bar icon."
139 changes: 133 additions & 6 deletions packager/chainforge_menubar.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,17 +6,22 @@

Launching it starts `chainforge serve` on this machine only, opens ChainForge
in the default browser, and puts a ChainForge icon in the menu bar with Open,
Show Log and Quit. Quitting stops the server. The server is started with
--idle-shutdown, so it also stops by itself once no ChainForge tab has been
open for a while -- including if this app is force-quit.
Update, Show Log and Quit. Quitting stops the server. The server is started
with --idle-shutdown, so it also stops by itself once no ChainForge tab has
been open for a while -- including if this app is force-quit.

It never updates ChainForge by itself: once a day it checks for a newer
version and says so, and Update ChainForge installs it when chosen.

Built into ChainForge.app by build_app.sh, which runs it with `uv run`.
"""

import atexit
import queue
import signal
import subprocess
import sys
import threading
import time
from pathlib import Path

Expand All @@ -28,6 +33,7 @@
CONFIG_PATH = HERE / "launcher.json"
ICON_PATH = HERE / "menubar-icon.png"
LOG_PATH = core.LOG_DIR / "chainforge.log"
UPDATE_CHECK_PATH = core.SUPPORT_DIR / "update-check.json"


def open_in_browser(url: str) -> None:
Expand Down Expand Up @@ -56,14 +62,28 @@ def __init__(self):
self.state = "starting"
self.started_at = time.monotonic()
self.status = rumps.MenuItem("Starting ChainForge…")
self.menu = [
# Results of work done off the main thread (checking for and
# installing updates), for tick() to act on: rumps menus and
# alerts only work on the main thread
self.events = queue.Queue()
self.installed = None # this ChainForge's version, once known
self.available = None # a newer version on PyPI, once known
self.checking = False
self.updated_to = None # the version just installed, to say so once it's up
self.update_item = rumps.MenuItem("Update ChainForge…", callback=self.update_chainforge)
items = [
self.status,
None,
rumps.MenuItem("Open ChainForge", callback=self.open_chainforge),
]
if config.uv: # an app built before updates were added has no uv
items.append(self.update_item)
items += [
rumps.MenuItem("Show Log", callback=self.show_log),
None,
rumps.MenuItem("Quit ChainForge", callback=self.quit_chainforge),
]
self.menu = items
self.timer = rumps.Timer(self.tick, 1)

def begin(self):
Expand All @@ -78,9 +98,110 @@ def begin(self):
self.process = core.start_server(config, LOG_PATH)
atexit.register(self.stop_server)
self.timer.start()
if config.uv:
self.check_for_update(chosen=False)

# --- updates ---------------------------------------------------

def check_for_update(self, chosen: bool):
"""Looks up the installed and newest versions, off the main thread.
Unless someone chose to, at most once a day."""
if self.checking:
return
record = core.UpdateCheck(UPDATE_CHECK_PATH)
if not chosen and not record.due():
# Still worth knowing the version, for the menu
threading.Thread(target=self._find_installed, daemon=True).start()
return
self.checking = True

def work():
installed = core.installed_version(config.uv)
latest = core.latest_version()
if latest is not None:
record.checked()
self.events.put(("checked", chosen, installed, latest))

threading.Thread(target=work, daemon=True).start()

def _find_installed(self):
self.events.put(("checked", False, core.installed_version(config.uv), None))

def on_checked(self, chosen, installed, latest):
self.checking = False
self.installed = installed or self.installed
if self.state == "running" and self.installed:
self.status.title = f"ChainForge {self.installed} is running"
if core.is_newer(latest, self.installed):
self.available = latest
self.update_item.title = f"Update to ChainForge {latest}…"
record = core.UpdateCheck(UPDATE_CHECK_PATH)
if not chosen and record.should_announce(latest):
record.announced(latest)
notify(f"ChainForge {latest} is available. "
"Choose Update in the ChainForge menu-bar menu to install it.")
if not chosen:
return
if latest is None:
rumps.alert(title="Couldn't check for updates",
message="PyPI, where ChainForge is published, didn't answer. "
"Check the internet connection and try again.")
elif self.installed is None:
rumps.alert(title="Can't update ChainForge from here",
message="This ChainForge wasn't installed with uv, so update it the "
"way it was installed. The newest version is " + latest + ".")
elif not core.is_newer(latest, self.installed):
rumps.alert(title="ChainForge is up to date",
message=f"You have the newest version, {self.installed}.")
else:
answer = rumps.alert(
title=f"Update to ChainForge {latest}?",
message=f"You have {self.installed}. Updating restarts ChainForge, "
"which stops anything running in it. Your flows are kept.",
ok="Update", cancel="Not Now")
if answer == 1:
self.install_update(latest)

def update_chainforge(self, _item):
if self.state == "running":
self.check_for_update(chosen=True)

def install_update(self, version):
"""Stops ChainForge, updates it with uv off the main thread, and
starts it again (the old version, if the update failed)."""
self.state = "updating"
self.status.title = f"Updating to ChainForge {version}…"
self.stop_server()

def work():
self.events.put(("updated", version, core.upgrade(config.uv, LOG_PATH)))

threading.Thread(target=work, daemon=True).start()

def on_updated(self, version, ok):
if ok:
self.installed, self.available = version, None
self.update_item.title = "Update ChainForge…"
else:
rumps.alert(title="ChainForge wasn't updated",
message="The update didn't work, so ChainForge will start as it was. "
"See the log for details: " + str(LOG_PATH))
self.status.title = "Starting ChainForge…"
self.state = "starting"
self.started_at = time.monotonic()
self.updated_to = version if ok else None
self.process = core.start_server(config, LOG_PATH)

# --- the server --------------------------------------------------

def tick(self, _timer):
if self.state == "stopping":
while not self.events.empty():
event, *args = self.events.get()
if event == "checked":
self.on_checked(*args)
elif event == "updated":
self.on_updated(*args)
if self.state in ("stopping", "updating"):
return
exited = self.process.poll() is not None
if self.state == "starting":
Expand All @@ -92,8 +213,14 @@ def tick(self, _timer):
self.finish()
elif core.server_responds(config.url, timeout=0.5):
self.state = "running"
self.status.title = "ChainForge is running"
self.status.title = (f"ChainForge {self.installed} is running"
if self.installed else "ChainForge is running")
open_in_browser(config.url)
if self.updated_to:
# The restarted server doesn't know tabs opened before it
notify(f"ChainForge is updated to {self.updated_to}. "
"Close any ChainForge tabs opened before the update.")
self.updated_to = None
elif time.monotonic() - self.started_at > 180:
rumps.alert(
title="ChainForge is taking too long to start",
Expand Down
Loading
Loading