diff --git a/Justfile b/Justfile
index 22aaf7c..44cd273 100644
--- a/Justfile
+++ b/Justfile
@@ -240,6 +240,23 @@ mac action="build":
*) printf '%s\n' "unknown action: {{action}} (build|app|run|clean)" >&2; exit 2 ;;
esac
+# Fresh web install in a sandbox: runs `curl cot.run/install | sh` with HOME in a
+# temp dir, so the collector container, ~/.cot and hook files never touch yours.
+# just fresh collector built from origin/main
+# just fresh release the published image users pull today
+# just fresh down remove the container and sandbox
+# COT_FRESH_HISTORY=1 imports your real transcripts read-only.
+# Fresh web install in a sandbox: up | release | down
+fresh action="up":
+ #!/usr/bin/env sh
+ set -eu
+ case "{{action}}" in
+ up) scripts/fresh-install.sh ;;
+ release) scripts/fresh-install.sh --release ;;
+ down) scripts/fresh-install.sh down ;;
+ *) printf '%s\n' "unknown action: {{action}} (up|release|down)" >&2; exit 2 ;;
+ esac
+
# Desktop app (Tauri): cot.app that runs the frozen collector, self-updates.
# just desktop dev run the shell in dev mode (stages the collector once)
# just desktop build build cot.app (dashboard + frozen collector + shell)
@@ -248,9 +265,10 @@ mac action="build":
# just desktop verify check the built app's collector answers /health
# just desktop install install the built app to /Applications (old one kept in ~/cot-app-backups)
# just desktop deploy build, verify, install
+# just desktop prod install origin/main like a release: clean build, install, bridge + hooks
# just desktop test Rust unit tests
# just desktop clean drop build output and staged resources
-# Desktop app: dev | build | shell | dmg | verify | install | deploy | test | clean
+# Desktop app: dev | build | shell | dmg | verify | install | deploy | prod | test | clean
desktop action="build":
#!/usr/bin/env sh
set -eu
@@ -264,9 +282,10 @@ desktop action="build":
verify) scripts/verify-bundle.sh ;;
install) scripts/install.sh ;;
deploy) scripts/build.sh && scripts/verify-bundle.sh && scripts/install.sh ;;
+ prod) scripts/install-main.sh ;;
test) cargo test --manifest-path src-tauri/Cargo.toml ;;
clean) rm -rf build src-tauri/target src-tauri/resources && printf '%s\n' "removed desktop build output" ;;
- *) printf '%s\n' "unknown action: {{action}} (dev|build|shell|dmg|verify|install|deploy|test|clean)" >&2; exit 2 ;;
+ *) printf '%s\n' "unknown action: {{action}} (dev|build|shell|dmg|verify|install|deploy|prod|test|clean)" >&2; exit 2 ;;
esac
# Cut a release locally: bump backend/app/__init__.py, test, commit, annotated tag.
diff --git a/desktop/scripts/install-main.sh b/desktop/scripts/install-main.sh
new file mode 100755
index 0000000..a5621e2
--- /dev/null
+++ b/desktop/scripts/install-main.sh
@@ -0,0 +1,37 @@
+#!/usr/bin/env bash
+# Install what's on origin/main the way a release would land on this Mac:
+# build cot.app from a clean checkout of main, install it, then refresh the
+# bridge and agent hooks from the new collector (what "Repair Agent Hooks…" runs).
+#
+# scripts/install-main.sh
+#
+# Builds in a dedicated worktree next to the main checkout (../cot-release, or
+# COT_RELEASE_DIR), reset to origin/main each run, so branches and uncommitted
+# work in your own checkouts never reach the build. Ignored caches (node_modules,
+# desktop/build/venv) are kept between runs. The app is unsigned, unlike CI's.
+set -euo pipefail
+
+REPO="$(git -C "$(dirname "${BASH_SOURCE[0]}")" rev-parse --path-format=absolute --git-common-dir)"
+RELEASE_DIR="${COT_RELEASE_DIR:-$(dirname "$(dirname "${REPO}")")/cot-release}"
+ENDPOINT="${COT_ENDPOINT:-http://127.0.0.1:31337}"
+
+step() { printf '\n\033[1m› %s\033[0m\n' "$1"; }
+
+step "Fetching origin/main"
+git --git-dir="${REPO}" fetch origin main
+
+if [ ! -d "${RELEASE_DIR}" ]; then
+ step "Creating release worktree at ${RELEASE_DIR}"
+ git --git-dir="${REPO}" worktree add --detach "${RELEASE_DIR}" origin/main
+fi
+cd "${RELEASE_DIR}"
+git checkout -q --detach origin/main
+git reset -q --hard origin/main
+git clean -fdq
+printf 'building %s\n' "$(git log -1 --format='%h %s')"
+
+# main's own recipes, so the build matches the commit being installed.
+just desktop deploy
+
+step "Refreshing the bridge and agent hooks"
+curl -fsSL "${ENDPOINT}/install.sh" | sh -s -- --repair
diff --git a/desktop/scripts/make-app-icon-svg.py b/desktop/scripts/make-app-icon-svg.py
new file mode 100644
index 0000000..493830c
--- /dev/null
+++ b/desktop/scripts/make-app-icon-svg.py
@@ -0,0 +1,75 @@
+"""Outline the "cot." wordmark into src-tauri/icons/icon.svg, the app icon source.
+
+Newsreader Bold Italic at opsz 72 (what the app's wordmark renders at display
+sizes), shaped with HarfBuzz so kerning matches the browser, on the macOS icon
+grid: an 824px Forest tile inset 100px on a 1024 canvas, r185, soft drop shadow.
+Run from desktop/ (needs network for the font):
+ uv run --with fonttools --with brotli --with uharfbuzz python scripts/make-app-icon-svg.py
+Then scripts/make-app-icon.sh renders every size from the SVG.
+"""
+import io, sys, tempfile, urllib.request
+from pathlib import Path
+from fontTools.ttLib import TTFont
+from fontTools.varLib import instancer
+from fontTools.pens.svgPathPen import SVGPathPen
+from fontTools.pens.boundsPen import BoundsPen
+from fontTools.pens.transformPen import TransformPen
+import uharfbuzz as hb
+
+FONT_URL = "https://fonts.gstatic.com/s/newsreader/v26/cY9XfjOCX1hbuyalUrK439vogqCz_goCYw7oRTmOFYYzbARA_n8.woff2"
+OUT = Path(__file__).resolve().parent.parent / "src-tauri" / "icons" / "icon.svg"
+with tempfile.TemporaryDirectory() as tmp:
+ woff2 = Path(tmp) / "newsreader-bold-italic.woff2"
+ urllib.request.urlretrieve(FONT_URL, woff2)
+ font = TTFont(woff2)
+ font.ensureDecompiled()
+if "fvar" in font:
+ font = instancer.instantiateVariableFont(font, {a.axisTag: (72 if a.axisTag == "opsz" else 700 if a.axisTag == "wght" else a.defaultValue) for a in font["fvar"].axes})
+font.flavor = None
+buf = io.BytesIO(); font.save(buf); data = buf.getvalue()
+upem = font["head"].unitsPerEm
+gs = font.getGlyphSet()
+
+hbfont = hb.Font(hb.Face(data))
+b = hb.Buffer(); b.add_str("cot."); b.guess_segment_properties()
+hb.shape(hbfont, b, {"kern": True, "liga": True})
+order = font.getGlyphOrder()
+
+# Lay glyphs out in font units (y up), record per-glyph paths.
+x = 0; glyphs = []
+for info, pos in zip(b.glyph_infos, b.glyph_positions):
+ glyphs.append((order[info.codepoint], x + pos.x_offset, pos.y_offset)); x += pos.x_advance
+
+SIZE, INSET, TILE, RADIUS = 1024, 100, 824, 185
+FONT_PX = 340
+scale = FONT_PX / upem
+# Ink bounds of the whole word.
+bp = BoundsPen(gs)
+for name, gx, gy in glyphs:
+ gs[name].draw(TransformPen(bp, (1, 0, 0, 1, gx, gy)))
+xmin, ymin, xmax, ymax = bp.bounds
+cx, cy = (xmin + xmax) / 2, (ymin + ymax) / 2
+# Map font units -> canvas: flip y, center ink on canvas.
+def tf(gx, gy): return (scale, 0, 0, -scale, SIZE / 2 + (gx - cx) * scale, SIZE / 2 + (cy - gy) * scale)
+paths = []
+for name, gx, gy in glyphs:
+ pen = SVGPathPen(gs)
+ gs[name].draw(TransformPen(pen, tf(gx, gy)))
+ paths.append((name, pen.getCommands()))
+
+word = " ".join(d for n, d in paths if n != "period")
+dot = " ".join(d for n, d in paths if n == "period")
+svg = f'''
+'''
+OUT.write_text(svg)
+print("wrote", OUT)
diff --git a/desktop/scripts/make-app-icon.sh b/desktop/scripts/make-app-icon.sh
new file mode 100755
index 0000000..40c7261
--- /dev/null
+++ b/desktop/scripts/make-app-icon.sh
@@ -0,0 +1,31 @@
+#!/usr/bin/env bash
+# Render src-tauri/icons/icon.svg into every app icon Tauri bundles: the PNGs,
+# icon.icns (macOS) and icon.ico (Windows). Needs rsvg-convert and ImageMagick
+# (brew install librsvg imagemagick). Edit the SVG with make-app-icon-svg.py.
+set -euo pipefail
+
+ICONS="$(cd "$(dirname "${BASH_SOURCE[0]}")/../src-tauri/icons" && pwd)"
+SVG="${ICONS}/icon.svg"
+TMP="$(mktemp -d)"
+trap 'rm -rf "${TMP}"' EXIT
+
+render() { rsvg-convert -w "$1" -h "$1" "${SVG}" -o "$2"; }
+
+render 32 "${ICONS}/32x32.png"
+render 64 "${ICONS}/64x64.png"
+render 128 "${ICONS}/128x128.png"
+render 256 "${ICONS}/128x128@2x.png"
+render 512 "${ICONS}/icon.png"
+
+SET="${TMP}/icon.iconset"
+mkdir "${SET}"
+for size in 16 32 128 256 512; do
+ render "${size}" "${SET}/icon_${size}x${size}.png"
+ render "$((size * 2))" "${SET}/icon_${size}x${size}@2x.png"
+done
+iconutil -c icns "${SET}" -o "${ICONS}/icon.icns"
+
+for size in 16 24 32 48 64 256; do render "${size}" "${TMP}/ico-${size}.png"; done
+magick "${TMP}"/ico-{16,24,32,48,64,256}.png "${ICONS}/icon.ico"
+
+ls -la "${ICONS}"/*.png "${ICONS}"/icon.icns "${ICONS}"/icon.ico
diff --git a/desktop/src-tauri/icons/128x128.png b/desktop/src-tauri/icons/128x128.png
index f15f228..01ac358 100644
Binary files a/desktop/src-tauri/icons/128x128.png and b/desktop/src-tauri/icons/128x128.png differ
diff --git a/desktop/src-tauri/icons/128x128@2x.png b/desktop/src-tauri/icons/128x128@2x.png
index 4965c40..c8185df 100644
Binary files a/desktop/src-tauri/icons/128x128@2x.png and b/desktop/src-tauri/icons/128x128@2x.png differ
diff --git a/desktop/src-tauri/icons/32x32.png b/desktop/src-tauri/icons/32x32.png
index 5e9e903..2a714dd 100644
Binary files a/desktop/src-tauri/icons/32x32.png and b/desktop/src-tauri/icons/32x32.png differ
diff --git a/desktop/src-tauri/icons/64x64.png b/desktop/src-tauri/icons/64x64.png
index 2917736..bd4feb3 100644
Binary files a/desktop/src-tauri/icons/64x64.png and b/desktop/src-tauri/icons/64x64.png differ
diff --git a/desktop/src-tauri/icons/icon.icns b/desktop/src-tauri/icons/icon.icns
index cf1996e..7105242 100644
Binary files a/desktop/src-tauri/icons/icon.icns and b/desktop/src-tauri/icons/icon.icns differ
diff --git a/desktop/src-tauri/icons/icon.ico b/desktop/src-tauri/icons/icon.ico
index ac81b44..5973e68 100644
Binary files a/desktop/src-tauri/icons/icon.ico and b/desktop/src-tauri/icons/icon.ico differ
diff --git a/desktop/src-tauri/icons/icon.png b/desktop/src-tauri/icons/icon.png
index 9be3fec..9b52bc3 100644
Binary files a/desktop/src-tauri/icons/icon.png and b/desktop/src-tauri/icons/icon.png differ
diff --git a/desktop/src-tauri/icons/icon.svg b/desktop/src-tauri/icons/icon.svg
new file mode 100644
index 0000000..22397ba
--- /dev/null
+++ b/desktop/src-tauri/icons/icon.svg
@@ -0,0 +1,11 @@
+
diff --git a/scripts/fresh-install.sh b/scripts/fresh-install.sh
new file mode 100755
index 0000000..e47d03f
--- /dev/null
+++ b/scripts/fresh-install.sh
@@ -0,0 +1,61 @@
+#!/usr/bin/env bash
+# Try the web install the way a new user gets it, without touching your own
+# cot: `curl -fsSL https://cot.run/install | sh` runs with HOME pointed at a
+# throwaway directory, so the collector container, ~/.cot (database, bridge,
+# config) and the agent hook files all land in the sandbox.
+#
+# scripts/fresh-install.sh collector image built from origin/main
+# scripts/fresh-install.sh --release the published image (what users pull today)
+# scripts/fresh-install.sh down remove the container and the sandbox
+#
+# COT_FRESH_HISTORY=1 import your real Claude/Cursor/Codex transcripts (read only)
+# COT_FRESH_PORT=31390 collector port; COT_FRESH_DIR sandbox path
+#
+# Your agents still read your real hook files, so live sessions keep going to
+# your own collector; the sandbox shows onboarding, import and the dashboard.
+set -euo pipefail
+
+REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+SANDBOX="${COT_FRESH_DIR:-${TMPDIR:-/tmp}/cot-fresh}"
+PORT="${COT_FRESH_PORT:-31390}"
+CONTAINER="cot-fresh"
+LOCAL_IMAGE="cot-fresh:main"
+RELEASE_IMAGE="ghcr.io/cot-intelligence/cot:latest"
+
+step() { printf '\n\033[1m› %s\033[0m\n' "$1"; }
+
+down() {
+ docker rm -f "${CONTAINER}" >/dev/null 2>&1 || true
+ rm -rf "${SANDBOX}"
+ printf 'removed %s and %s\n' "${CONTAINER}" "${SANDBOX}"
+}
+
+case "${1:-}" in
+ down) down; exit 0 ;;
+ --release) image="${RELEASE_IMAGE}" ;;
+ "")
+ step "Building the collector image from origin/main"
+ git -C "${REPO_ROOT}" fetch origin main
+ git -C "${REPO_ROOT}" archive origin/main | docker build -q -f Dockerfile.app -t "${LOCAL_IMAGE}" -
+ image="${LOCAL_IMAGE}"
+ ;;
+ *) echo "usage: $0 [--release|down]" >&2; exit 2 ;;
+esac
+
+step "Resetting the sandbox at ${SANDBOX}"
+down
+mkdir -p "${SANDBOX}/home"
+
+if [ "${COT_FRESH_HISTORY:-0}" = 1 ]; then
+ # Transcript roots only; hooks and ~/.cot still go to the sandbox HOME.
+ export COT_CLAUDE_HOME="${HOME}/.claude" COT_CURSOR_HOME="${HOME}/.cursor" COT_CODEX_HOME="${HOME}/.codex"
+fi
+
+step "Running the cot.run installer as a new user"
+curl -fsSL https://cot.run/install \
+ | HOME="${SANDBOX}/home" COT_CONTAINER="${CONTAINER}" COT_IMAGE="${image}" COT_PORT="${PORT}" sh
+
+printf '\n\033[1mFresh install running\033[0m\n'
+printf ' dashboard %s\n' "$(cat "${SANDBOX}/home/.cot/config.json" 2>/dev/null | sed -n 's/.*"endpoint"[^"]*"\([^"]*\)".*/\1/p' | head -n1)"
+printf ' sandbox %s\n' "${SANDBOX}/home"
+printf ' remove just fresh down\n'