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
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,8 @@ __pycache__/
*.so
.Python
build/
!desktop/src/build/
!desktop/src/build/**
dist/
*.egg-info/
*.egg
Expand Down
3 changes: 3 additions & 0 deletions dashboard/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { LayoutModeProvider } from "./context/LayoutModeContext";
import { VoiceOutputProvider } from "./context/VoiceOutputContext";
import { useIsMobile } from "./hooks/useIsMobile";
import { useUnauthorizedRedirect } from "./hooks/useUnauthorizedRedirect";
import { installDesktopExternalLinks } from "./utils/desktopExternalLinks";
import { brandTokensFor } from "./styles/themePalettes";
import "./styles/theme-vars.css";
import "./styles/layout.css";
Expand All @@ -44,6 +45,8 @@ function ThemedApp() {

useUnauthorizedRedirect();

useEffect(() => installDesktopExternalLinks(), []);

// Set document title based on current language
useEffect(() => {
document.title = t("app.pageTitle");
Expand Down
113 changes: 113 additions & 0 deletions dashboard/src/utils/desktopExternalLinks.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
const INSTALL_FLAG = "__OCTOP_EXTERNAL_LINKS_INSTALLED__";
const OPEN_URL_EVENT_PREFIX = "desktop:open-url:";

type DesktopWindow = Window & {
__OCTOP_EXTERNAL_LINKS_INSTALLED__?: boolean;
_wails?: { invoke?: (message: string) => void };
};

export function isDesktopExternalURL(
raw: string,
base = window.location.href,
): boolean {
try {
const parsed = new URL(raw, base);
const scheme = parsed.protocol.replace(":", "").toLowerCase();
if (scheme === "mailto") {
return Boolean(parsed.pathname || parsed.href.slice("mailto:".length));
}
return scheme === "http" || scheme === "https";
} catch {
return false;
}
}

function wailsInvoke(): ((message: string) => void) | undefined {
const invoke = (window as DesktopWindow)._wails?.invoke;
return typeof invoke === "function" ? invoke : undefined;
}

let lastUrl = "";
let lastAt = 0;

function openExternal(url: string): boolean {
const invoke = wailsInvoke();
if (!invoke || !isDesktopExternalURL(url)) return false;
const now = Date.now();
if (url === lastUrl && now - lastAt < 800) return true;
lastUrl = url;
lastAt = now;
invoke("wails:event:emit:" + OPEN_URL_EVENT_PREFIX + encodeURIComponent(url));
return true;
}

function linkFromEvent(event: Event): HTMLAnchorElement | null {
const raw = event.target;
const node =
raw instanceof Element
? raw
: raw instanceof Node
? raw.parentElement
: null;
if (!node) return null;
const link = node.closest("a[href][target]");
if (!(link instanceof HTMLAnchorElement)) return null;
if (link.target.toLowerCase() !== "_blank") return null;
if (link.hasAttribute("download")) return null;
return link;
}

function onActivate(event: Event): void {
if (event instanceof MouseEvent && event.button !== 0) return;
const link = linkFromEvent(event);
if (!link) return;
if (!openExternal(link.href)) return;
event.preventDefault();
}

function patchWindowOpen(): () => void {
const original = window.open.bind(window);
window.open = ((url?: string | URL, target?: string, features?: string) => {
const href = url == null ? "" : String(url);
const name = target == null ? "_blank" : String(target);
if (href && name.toLowerCase() === "_blank" && openExternal(href)) {
return null;
}
return original(url, target, features);
}) as typeof window.open;
return () => {
window.open = original;
};
}

export function tryInstallDesktopExternalLinks(): (() => void) | undefined {
const w = window as DesktopWindow;
if (!wailsInvoke() || w[INSTALL_FLAG]) return undefined;
w[INSTALL_FLAG] = true;
document.addEventListener("click", onActivate, true);
document.addEventListener("pointerdown", onActivate, true);
const restoreOpen = patchWindowOpen();
return () => {
document.removeEventListener("click", onActivate, true);
document.removeEventListener("pointerdown", onActivate, true);
restoreOpen();
w[INSTALL_FLAG] = false;
};
}

export function installDesktopExternalLinks(): () => void {
let cancelled = false;
let uninstall: (() => void) | undefined;
const tryInstall = () => {
if (cancelled || uninstall) return;
uninstall = tryInstallDesktopExternalLinks();
};
tryInstall();
const timer = window.setInterval(tryInstall, 250);
window.setTimeout(() => window.clearInterval(timer), 12_000);
return () => {
cancelled = true;
window.clearInterval(timer);
uninstall?.();
};
}
82 changes: 82 additions & 0 deletions desktop/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,82 @@
# Octop desktop (Wails v3 + green portable)

All desktop-client code lives here. This is **not** `src/octop/infra/desktop`
(remote desktop streaming).

| Path | Role |
|------|------|
| [`portable/`](portable/) | Green zip packaging (was `scripts/green/`) |
| [`src/`](src/) | Wails v3 shell: load bundled zip, spawn Octop, tray/settings |
| [`package-release.sh`](package-release.sh) | Native end-to-end portable + Wails release build |

## Data directory

Same as the Octop CLI/server default:

- `OCTOP_HOME` → `~/.octop` (or the existing `OCTOP_HOME` env)
- Green runtime extract → `~/.octop/portable/`
- Shell prefs → `~/.octop/desktop-settings.json`

## Build green zip

From repo root:

```bash
make -f desktop/portable/Makefile green
```

CI: `.github/workflows/octop-portable.yml` builds all six native platform/arch
variants. Each job first creates the green zip and then packages the matching
Wails application.

## Build a complete desktop release

Run the end-to-end script on the matching native host. It builds the Dashboard,
creates and verifies the portable runtime, embeds it into Wails, and produces
the final native package:

```bash
desktop/package-release.sh
# Reuse an existing desktop/portable/release/Octop-<plat>.zip:
desktop/package-release.sh darwin-arm64 --reuse-portable
```

Wails requires native packaging, so all six variants are produced by the CI
matrix on macOS, Linux, and Windows runners rather than cross-compiled locally.

## Build the Wails shell

Run these from **`desktop/src`** (that directory contains `Taskfile.yml` and
`build/config.yml`). Requires **Go 1.25+**, [Wails v3](https://v3.wails.io/)
`v3.0.0-beta.13`.

```bash
go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.13
cd desktop/src
go mod tidy
wails3 build # development binary under desktop/src/bin/
wails3 task package ARCH=arm64 \
PORTABLE_ZIP=../portable/release/Octop-darwin-arm64.zip
```

Dev against an already-running Octop (skips the bundled green zip):

```bash
cd desktop/src
OCTOP_DESKTOP_URL=http://127.0.0.1:8088 wails3 dev
```

Without `OCTOP_DESKTOP_URL`, first launch uses `~/.octop/portable/` if valid,
otherwise extracts the matching zip shipped with the desktop package (embedded
in the Windows and Linux binaries, under `Contents/Resources` on macOS). The
Wails shell never downloads Octop. For local runtime debugging, set
`OCTOP_DESKTOP_PORTABLE_ZIP=/absolute/path/Octop-<plat>.zip`.

Desktop outputs are native GUI packages: `Octop-Desktop-<plat>.dmg` (macOS),
`Octop-Desktop-<plat>.exe` (Windows), and `Octop-Desktop-<plat>.tar` (Linux).
The Linux tar contains only the GUI binary; it has no separate portable zip or
server terminal process. Runtime upgrades remain owned by Octop:
the shell sets `OCTOP_GREEN_PACKAGES`, so `octop update` upgrades the extracted
`packages/` directory through Octop's existing `--target` logic.

Linux also needs GTK4 + WebKitGTK 6 to link. macOS 12+.
116 changes: 116 additions & 0 deletions desktop/package-release.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# Build one native Octop desktop release: Dashboard → portable runtime → Wails package.
# Run once per native platform; the GitHub Actions matrix runs all six variants.
set -euo pipefail

REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
# shellcheck source=desktop/portable/_common.sh
source "${REPO_ROOT}/desktop/portable/_common.sh"

if ! command -v npm >/dev/null 2>&1 && [[ -s "${HOME}/.nvm/nvm.sh" ]]; then
# shellcheck disable=SC1091
source "${HOME}/.nvm/nvm.sh"
nvm use 24 >/dev/null
fi

usage() {
cat <<'EOF'
Usage: desktop/package-release.sh [platform] [--reuse-portable]

Platforms:
darwin-arm64 darwin-amd64 linux-arm64 linux-amd64
windows-arm64 windows-amd64

The platform defaults to the current native host. --reuse-portable skips
rebuilding desktop/portable/release/Octop-<platform>.zip when it already exists.
EOF
}

plat=""
reuse_portable=0
for arg in "$@"; do
case "$arg" in
--reuse-portable) reuse_portable=1 ;;
-h|--help) usage; exit 0 ;;
-*) echo "unknown option: $arg" >&2; usage >&2; exit 2 ;;
*)
if [[ -n "$plat" ]]; then
echo "only one platform may be specified" >&2
exit 2
fi
plat="$arg"
;;
esac
done

if [[ -z "$plat" ]]; then
plat="$(host_plat)"
fi
if ! is_known_plat "$plat"; then
echo "unknown platform: $plat" >&2
exit 2
fi
host="$(host_plat)"
if [[ "$plat" != "$host" ]]; then
echo "Wails release packages require a native runner: requested=${plat}, host=${host}" >&2
exit 2
fi
if ! command -v wails3 >/dev/null 2>&1; then
echo "wails3 is required: go install github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-beta.13" >&2
exit 1
fi

arch="${plat##*-}"
portable_zip="${REPO_ROOT}/desktop/portable/release/Octop-${plat}.zip"

echo "[desktop-release] platform=${plat}"
echo "[desktop-release] building Dashboard"
make -C "$REPO_ROOT" build-frontend

if [[ "$reuse_portable" == 1 && -f "$portable_zip" ]]; then
echo "[desktop-release] reusing ${portable_zip}"
else
echo "[desktop-release] building portable runtime"
bash "${REPO_ROOT}/desktop/portable/bootstrap-runtime.sh" "$plat"
bash "${REPO_ROOT}/desktop/portable/package.sh" "$plat"
fi

staging="${REPO_ROOT}/desktop/portable/release/Octop-${plat}"
requirements="${REPO_ROOT}/desktop/portable/requirements-${plat}.txt"
if [[ "$plat" == windows-* ]]; then
portable_python="${staging}/runtime/python.exe"
else
portable_python="${staging}/runtime/bin/python3"
fi
if [[ ! -f "$portable_python" || ! -f "$requirements" ]]; then
echo "portable staging is incomplete for ${plat}" >&2
exit 1
fi

verify_args=(--packages "${staging}/packages" --requirements "$requirements")
override_file="${REPO_ROOT}/desktop/portable/overrides-${plat}.txt"
if [[ -f "$override_file" ]]; then
verify_args+=(--overrides "$override_file")
fi
echo "[desktop-release] verifying portable imports"
PYTHONNOUSERSITE=1 "$portable_python" \
"${REPO_ROOT}/desktop/portable/verify_imports.py" "${verify_args[@]}"

echo "[desktop-release] packaging Wails application"
(
cd "${REPO_ROOT}/desktop/src"
wails3 task package "ARCH=${arch}" "PORTABLE_ZIP=${portable_zip}"
)

case "$plat" in
darwin-*) output="${REPO_ROOT}/desktop/src/bin/Octop-Desktop-${plat}.dmg" ;;
windows-*) output="${REPO_ROOT}/desktop/src/bin/Octop-Desktop-${plat}.exe" ;;
linux-*) output="${REPO_ROOT}/desktop/src/bin/Octop-Desktop-${plat}.tar" ;;
esac
if [[ ! -s "$output" ]]; then
echo "desktop release was not created: ${output}" >&2
exit 1
fi

echo "[desktop-release] complete"
echo "$output"
6 changes: 6 additions & 0 deletions desktop/portable/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/.cache/
/release/
/runtimes/
/wheels/
/requirements-*.txt
/overrides-*.txt
Loading
Loading