diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 65964e3..2788b59 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,21 +27,31 @@ jobs: working-directory: frontend run: npm ci && npm run build + # Wails v3 Linux defaults to gtk4/webkitgtk6; this project targets the + # gtk3/webkit2gtk 4.1 stack (EXTRA_TAGS=gtk3) for broader distro support. + - name: Install webview deps + run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev + - name: golangci-lint # built from source with the runner's Go so its language version # always matches go.mod (prebuilt binaries lag behind) run: | go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest - golangci-lint run ./... + golangci-lint run --build-tags gtk3 ./... - name: gofmt check - run: test -z "$(gofmt -l .)" || { gofmt -l .; exit 1; } + run: | + files=$(find . -name '*.go' \ + -not -path './frontend/node_modules/*' \ + -not -path './e2e/node_modules/*' \ + -not -path './bin/*') + test -z "$(gofmt -l $files)" || { gofmt -l $files; exit 1; } - name: go vet - run: go vet ./... + run: go vet -tags gtk3 ./... - name: go test - run: go test -timeout 300s ./... + run: go test -tags gtk3 -timeout 300s ./... frontend: name: Frontend — format, lint, typecheck, build @@ -95,8 +105,8 @@ jobs: cache: npm cache-dependency-path: frontend/package-lock.json - - name: Install Wails CLI - run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.13.0 + - name: Install Wails v3 CLI + run: go install -tags gtk3 github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha2.119 - name: Install webview and display deps run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev xvfb diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44a331a..8210cf2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -44,15 +44,26 @@ jobs: cache: npm cache-dependency-path: frontend/package-lock.json - - name: Install Wails CLI - run: go install github.com/wailsapp/wails/v2/cmd/wails@v2.13.0 + - name: Install Wails v3 CLI + # gtk3 tag: CLI itself links webkit; needed on Linux runners without gtk4 + run: go install -tags gtk3 github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha2.119 - name: Install Linux webview deps if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y libgtk-3-dev libwebkit2gtk-4.1-dev imagemagick - - name: Build - run: wails build -platform ${{ matrix.platform }} -tags webkit2_41 -o ${{ matrix.bin }} + # Wails v3 builds for the host OS via Taskfile; matrix runners are + # native (ubuntu → linux, macos → darwin, windows → windows). + # Linux uses EXTRA_TAGS=gtk3 (webkit2gtk 4.1) so CI doesn't need gtk4. + - name: Build (Linux) + if: runner.os == 'Linux' + run: wails3 build -tags gtk3 + env: + EXTRA_TAGS: gtk3 + + - name: Build (macOS / Windows) + if: runner.os != 'Linux' + run: wails3 build # ---------- Linux: AppImage ---------- @@ -66,7 +77,8 @@ jobs: if: runner.os == 'Linux' run: | mkdir -p AppDir/usr/bin - cp build/bin/croc-desktop AppDir/usr/bin/ + # v3 places host builds in bin/ (Taskfile BIN_DIR) + cp bin/croc-desktop AppDir/usr/bin/ # linuxdeploy only accepts standard icon resolutions (max 512px) # and deploys the icon by basename, which must match Icon=croc-desktop convert build/appicon.png -resize 512x512 /tmp/croc-desktop.png @@ -84,11 +96,20 @@ jobs: - name: Ad-hoc sign and package DMG if: runner.os == 'macOS' run: | - cd build/bin - codesign --deep --force --sign - croc-desktop.app - mkdir -p dmg - cp -R croc-desktop.app dmg/ - hdiutil create -volname croc -srcfolder dmg -ov -format UDZO ../../${{ matrix.artifact }} + # wails3 package lays out a .app under bin/; fall back to a bare binary + APP_PATH="bin/croc-desktop.app" + if [ ! -d "$APP_PATH" ]; then + wails3 package + fi + if [ -d "$APP_PATH" ]; then + codesign --deep --force --sign - "$APP_PATH" + mkdir -p dmg + cp -R "$APP_PATH" dmg/ + else + mkdir -p dmg + cp bin/croc-desktop dmg/ + fi + hdiutil create -volname croc -srcfolder dmg -ov -format UDZO ${{ matrix.artifact }} # ---------- Windows: MSI ---------- diff --git a/.gitignore b/.gitignore index 484f7dc..3823d6f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,11 @@ # Binaries / build output +bin/ build/bin/ /croc-gui +/croc-desktop + +# Task (taskfile.dev) cache +.task/ # Test binary, built with `go test -c` *.test @@ -9,5 +14,16 @@ build/bin/ frontend/node_modules/ frontend/dist/ -# Wails generated bindings +# Wails v2 generated bindings (legacy) frontend/wailsjs/ + +# Wails v3 binding generation temps +frontend/.bindings-tmp-*/ + +# Mobile build outputs (generated by wails3 task ios:* / android:*) +build/ios/xcode/ +bin/*.a +bin/*.app +*.apk +*.aab +*.ipa diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..12d610d --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,134 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, caste, color, religion, or sexual +identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the overall + community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or advances of + any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email address, + without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official email address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement by opening a +confidential report via GitHub Security Advisories on this repository, or by +contacting the maintainers through a private GitHub message. + +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series of +actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or permanent +ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within the +community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.1, available at +[https://www.contributor-covenant.org/version/2/1/code_of_conduct.html][v2.1]. + +Community Impact Guidelines were inspired by +[Mozilla's code of conduct enforcement ladder][Mozilla CoC]. + +For answers to common questions about this code of conduct, see the FAQ at +[https://www.contributor-covenant.org/faq][FAQ]. Translations are available at +[https://www.contributor-covenant.org/translations][translations]. + +[homepage]: https://www.contributor-covenant.org +[v2.1]: https://www.contributor-covenant.org/version/2/1/code_of_conduct.html +[Mozilla CoC]: https://github.com/mozilla/diversity +[FAQ]: https://www.contributor-covenant.org/faq +[translations]: https://www.contributor-covenant.org/translations diff --git a/README.md b/README.md index a74bd8c..2753254 100644 --- a/README.md +++ b/README.md @@ -1,175 +1,338 @@
-
+
- Desktop GUI for croc — encrypted, - peer-to-peer file and text transfer on Linux, macOS, and Windows. - No cloud upload. No terminal required. + Send files and text between devices — simply, privately, and without the cloud.
-
-
-
-
+ A friendly app for
+ croc:
+ end-to-end encrypted, peer-to-peer transfer.
+
+ Desktop: Linux · macOS · Windows ·
+ Mobile: iOS · Android
+
+ Built with Wails v3 (not v2)
+ Install desktop · + Mobile · + How to use · + Features · + FAQ · + Contributing · + Development +
-## Features +--- -**Send & receive** - -- Send files and folders by picker or drag & drop; send text snippets -- Transfer code phrase with QR code, copy buttons for the code and the CLI command -- Receive by pasting anything: a bare code, `croc`, `CROC_SECRET=… croc`, or a share link
-- Receive by pasting or uploading a screenshot of the sender's QR code
-- Save favorite codes and re-receive with one click
-- Accept/decline and overwrite/resume dialogs — never blind `--yes --overwrite`
-- Live progress with file counts, "verifying" state, and stall hints
-- Cancel any time (button or `Esc`), resume interrupted receives
-- After a receive, previews of images, video, audio, and text inline
-- "Send same files/text again" for repeat transfers
-- Desktop notifications when a transfer completes or fails
-
-**History & logs**
-
-- Every transfer (completed, cancelled, failed) is recorded with files, sizes,
- and destination — searchable in the History tab, persisted across restarts
-- Centralized leveled log (debug / info / warn / error) streamed live to the
- Logs tab with level filtering
-
-**Power options**
-
-- Run a croc relay from the app
-- Custom relays, relay password, encryption curve, hash algorithm
-- Local-only / disable-local modes, compression and overwrite defaults
-- SOCKS5 and HTTP proxy support
-- Zip folders before sending, exclude patterns, upload rate limit, manual
- sender address
-- Dark theme (system-following or pinned)
-- Multi-language UI (English, 简体中文, 繁體中文, Español, Français, Deutsch,
- 日本語) — see *Contributing translations*
+
+
+
+
+
-
-
+
+
+
-## Install
+## Why croc-desktop?
+
+Moving a file to another computer should not mean uploading it to someone else’s
+servers — or learning a command line.
+
+**croc-desktop** is a full GUI for [croc](https://github.com/schollz/croc):
+
+| | |
+| --- | --- |
+| **Private by default** | End-to-end encrypted, peer-to-peer. Nothing is stored in the cloud. |
+| **One code (or QR)** | Share a short phrase or scan a QR — done. |
+| **You stay in control** | Accept or decline each transfer. Overwrite and resume when you choose. |
+| **Works with the CLI** | Codes are the same as `croc` on the command line. Mix and match devices. |
+| **No terminal required** | Point, click, drop, paste. |
+
+Unlike thin wrappers that only launch the `croc` CLI in a window, this app runs
+croc **inside the process**, so you get real dialogs, live progress, history,
+and relay hosting.
+
+> **Stack note:** this project uses **[Wails v3](https://v3.wails.io)**
+> (`github.com/wailsapp/wails/v3`). Older docs or the `main` branch may still
+> mention Wails v2 — that is obsolete here.
-Download from [Releases](https://github.com/SihanTeng/croc-desktop/releases):
+## Install (desktop)
-| Platform | File | Notes |
+Download the latest **desktop** build for your system:
+
+**[Releases →](https://github.com/SihanTeng/croc-desktop/releases/latest)**
+
+| Platform | File | Tips |
| --- | --- | --- |
-| Linux | `croc-desktop_*_linux-amd64.AppImage` | `chmod +x`, run |
-| macOS (Apple Silicon) | `croc-desktop_*_darwin-arm64.dmg` | ad-hoc signed — Gatekeeper warns on first launch until the app is notarized |
-| Windows | `croc-desktop_*_windows-amd64.msi` | WiX installer |
+| **Linux** | `croc-desktop_*_linux-amd64.AppImage` | `chmod +x` the file, then double-click or run it |
+| **macOS** (Apple Silicon) | `croc-desktop_*_darwin-arm64.dmg` | Open the DMG and drag the app to Applications. First open may need **System Settings → Privacy & Security** (ad-hoc signed) |
+| **Windows** | `croc-desktop_*_windows-amd64.msi` | Run the installer |
-## Develop
+> Both sides of a transfer need either this app or the [croc CLI](https://github.com/schollz/croc).
-Prerequisites: Go (see `go.mod`), Node.js + npm, the Wails v2 CLI
-(`go install github.com/wailsapp/wails/v2/cmd/wails@latest`), and platform
-webview deps (`wails doctor`; on Linux: GTK3 + webkit2gtk dev packages, e.g.
-`webkit2gtk4.1-devel` on Fedora, `libwebkit2gtk-4.1-dev` on Debian/Ubuntu).
+## Mobile (iOS · Android)
-```sh
-wails dev # hot-reload; on distros with webkit2gtk 4.1:
- # WEBKIT_DISABLE_DMABUF_RENDERER=1 wails dev -tags webkit2_41
-```
+Same app, same UI (bottom tabs on a phone-sized screen). Powered by **Wails v3**
+native mobile targets.
+
+| Client | Status | Where it lives | How to run |
+| --- | --- | --- | --- |
+| **iOS** | Experimental — build from source | Scaffold in [`build/ios/`](build/ios/) | Simulator / device (needs full **Xcode** on macOS) |
+| **Android** | Experimental — build from source | Scaffold in [`build/android/`](build/android/) | Emulator / device (needs **Android SDK + NDK**) |
-(`WEBKIT_DISABLE_DMABUF_RENDERER=1` works around a WebKitGTK crash on some
-Wayland compositors.)
+**There are no App Store / Play Store packages yet** — mobile is for people who
+build from this repo (or a CI you set up). Desktop remains the supported
+download path on [Releases](https://github.com/SihanTeng/croc-desktop/releases).
-## Build
+### Build & run mobile clients
+
+Prerequisites: same as [Development](#-development) (Go, Node, Wails v3 CLI),
+plus the platform SDK.
```sh
-wails build # add -tags webkit2_41 where applicable
-# binary lands in build/bin/
+# Install Wails v3 CLI (pin matches go.mod)
+go install -tags gtk3 github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha2.119
+
+# Frontend assets (embedded into the app)
+npm --prefix frontend ci && npm --prefix frontend run build
+
+# ── iOS (macOS + Xcode only) ─────────────────────────────────
+wails3 task ios:run # Simulator
+# Device:
+# wails3 task ios:package IOS_PLATFORM=device \
+# CODESIGN_IDENTITY="Apple Development: You (TEAMID)"
+
+# ── Android ──────────────────────────────────────────────────
+wails3 task android:run # emulator or adb device
```
-## Test
+| | Value |
+| --- | --- |
+| iOS bundle ID | `com.schollz.croc-desktop` |
+| Android applicationId | `com.schollz.croc-desktop` |
+| Config | [`build/config.yml`](build/config.yml) (`ios:` block) |
+| Platform hooks | [`app_options_ios.go`](app_options_ios.go), [`app_options_android.go`](app_options_android.go) |
-Three layers:
+**UI tip:** resize the desktop window to ~360px wide to preview the mobile
+bottom-tab layout without a phone.
-- **Backend** (`go test .`): unit tests for settings/history/logger/code
- parsing plus headless integration tests that drive the real transfer path
- (`App` methods → `src/croc` → in-process relay) without a window:
+## How to use
- ```sh
- npm --prefix frontend install && npm --prefix frontend run build # frontend/dist for go:embed
- go test .
- ```
+### Send something
-- **Frontend** (`npm --prefix frontend run test`): vitest unit tests for the
- pure helpers (byte formatting, preview-kind mapping, data-URL decoding).
+1. Open **Send**.
+2. Drop files or a folder, browse to pick them, or switch to **Text** for a snippet.
+3. Press **Send**. You’ll get a short **code** and a **QR**.
+4. Share the code (or the QR image) with the person receiving.
+5. Wait for them to accept — progress shows live. Cancel anytime with the button or `Esc`.
-- **Browser E2E** (`./e2e/run.sh`): playwright drives the real app UI in a
- browser against throwaway croc peers — send text, receive with preview,
- decline, Esc cancel, history & logs. Uses the running `wails dev` instance
- if there is one, otherwise boots a hermetic sandbox (local relay + isolated
- config). See `e2e/README.md`.
+### Receive something
-Frontend checks: `npm --prefix frontend run typecheck && npm --prefix frontend run lint`.
+1. Open **Receive**.
+2. Paste the code (or a whole `croc …` command / share link) — or load a **QR screenshot**.
+3. Choose a download folder if you like, then **Receive**.
+4. **Accept** (or decline) when the prompt appears.
+5. When it’s done, open previews for images, video, audio, or text right in the app.
-## Contributing translations
+### Everyday extras
-UI strings live in plain JSON files under `frontend/src/i18n/locales/`
-(`en.json` is the canonical key set). To add or improve a language, copy
-`en.json` to `.json` (e.g. `pt-BR.json`) and translate the values —
-Vite picks the file up automatically, no code changes needed. Keep the keys
-and the `{placeholder}` variables intact.
+- **History** — past sends and receives, searchable.
+- **Favorites** — save codes you reuse often.
+- **Send again** — re-send the same files or text without re-picking.
+- **Settings** — theme (light / dark / system), language, custom relays, proxies, and more.
+- **Relay** — optionally host a relay from the app for locked-down networks.
-## How it works
+## Features
-- `src/croc/hooks.go` (in the croc module) defines `croc.Hooks` —
- progress/state/prompt callbacks installed via `Client.SetHooks`. With hooks
- set, the terminal progress bar is silenced and stdin prompts
- (accept/overwrite/ask) route through the hooks; CLI behavior is unchanged
- when hooks are nil.
-- `transfer.go` bridges hooks to Wails events consumed by the React UI.
- Transfers run with `croc.NewCtx`, so Cancel is a context cancellation plus
- connection teardown.
-- `history.go` persists transfer history and `logger.go` the centralized
- leveled log (both in-process; history on disk next to settings).
-- `relay.go` wraps `tcp.RunCtx` for in-app relays.
-- Settings persist to `/croc-desktop.json`.
+### Transfer
-This is a separate Go module so the CLI's dependency set stays untouched.
-croc is consumed as a plain module dependency — no sibling checkout needed:
+- Files & folders (drag and drop or picker)
+- Text snippets
+- QR code + copy code / copy CLI command
+- Paste codes in any common format (`code`, `croc code`, `CROC_SECRET=…`, share links)
+- Decode codes from QR screenshots
+- Accept / decline and overwrite / resume prompts
+- Live progress, stall hints, cancel anytime
+- Resume interrupted receives
+- Inline previews after receive
+- Desktop notifications on finish or failure
+### App
+
+- Transfer **history** that survives restarts
+- Live **logs** with level filters
+- Optional in-app **relay**
+- Custom relay address & password, crypto curve, hash algorithm
+- Local-only / disable-local modes, compression, proxies (SOCKS5 / HTTP)
+- Zip folders, exclude patterns, upload rate limit
+- Light / dark / system theme
+- Languages: English, 简体中文, 繁體中文, Español, Français, Deutsch, 日本語
+- Responsive layout: side rail on desktop; **bottom tabs on phones / small windows**
+
+## FAQ
+
+
+Is my data uploaded to a server?
+
+
+
+No. Transfers are end-to-end encrypted and peer-to-peer. A public **relay** may
+help peers find each other (same idea as the croc CLI), but file contents are
+not stored there. You can run your own relay from the app or point at a private
+one in Settings.
+
+
+
+
+Do both people need this app?
+
+
+
+No. One side can use **croc-desktop** and the other the official
+[`croc` CLI](https://github.com/schollz/croc) (or vice versa). Codes are
+compatible.
+
+
+
+
+Does it work offline / on a LAN only?
+
+
+
+Yes. Enable **local-only** (or use local discovery) in Settings when both
+devices are on the same network. You can also run a relay yourself under the
+**Relay** tab.
+
+
+
+
+Where are settings and history stored?
+
+
+
+In the same config area as the croc CLI (override with the `CROC_CONFIG_DIR`
+environment variable):
+
+- `croc-desktop.json` — preferences
+- `croc-desktop-history.json` — transfer history
+
+Older installs that used `croc-gui.json` are upgraded automatically on first run.
+
+
+
+
+macOS says the app is from an unidentified developer
+
+
+
+Release builds are currently **ad-hoc signed**, not notarized. Open
+**System Settings → Privacy & Security** and allow the app after the first
+blocked launch, or right-click → **Open**.
+
+
+
+
+Is this still Wails v2?
+
+
+
+**No.** This branch uses **Wails v3** (`github.com/wailsapp/wails/v3`, see
+`go.mod`). Docs: [v3.wails.io](https://v3.wails.io). If you still see “Wails v2”
+on GitHub, you are looking at an older branch (e.g. `main` before merge).
+
+
+
+
+Where do I get the mobile apps?
+
+
+
+See **[Mobile (iOS · Android)](#-mobile-ios--android)** above. They are built
+from this repo with `wails3 task ios:run` / `android:run` — not published as
+store installers yet.
+
+
+
+## Contributing
+
+Contributions are welcome — bug reports, translations, docs, and pull requests.
+
+- Be kind; we follow the [Code of Conduct](CODE_OF_CONDUCT.md).
+- Prefer small, focused pull requests.
+- For UI strings, edit JSON under `frontend/src/i18n/locales/` (`en.json` is the source of truth). Copy it to a new locale file (e.g. `pt-BR.json`) and translate values; keep keys and `{placeholders}` intact.
+
+## Development
+
+
+Build from source (developers)
+
+
+
+**Stack:** Go (see `go.mod`), Node.js, **[Wails v3](https://v3.wails.io)** —
+module `github.com/wailsapp/wails/v3` @ **v3.0.0-alpha2.119** (not the v2 CLI).
+
+```sh
+# Wails v3 CLI (use -tags gtk3 on Linux without gtk4)
+go install -tags gtk3 github.com/wailsapp/wails/v3/cmd/wails3@v3.0.0-alpha2.119
+
+# Linux: webkit2gtk 4.1 + GTK3 (project default)
+# Fedora: webkit2gtk4.1-devel
+# Debian/Ubuntu: libgtk-3-dev libwebkit2gtk-4.1-dev
+
+# Dev with hot reload (desktop)
+WEBKIT_DISABLE_DMABUF_RENDERER=1 wails3 dev -config ./build/config.yml -port 34115
+
+# Production desktop binary → bin/croc-desktop
+wails3 build -tags gtk3 # omit -tags gtk3 on macOS/Windows
+
+# Mobile — see “Mobile (iOS · Android)” section
+wails3 task ios:run
+wails3 task android:run
+
+# Tests
+npm --prefix frontend ci && npm --prefix frontend run build
+go test -tags gtk3 .
+npm --prefix frontend test
+./e2e/run.sh
```
-replace github.com/schollz/croc/v10 => github.com/SihanTeng/croc/v10 v10.0.0-...-51660d6d7730
-```
-The replace points at the `gui-hooks` branch of the `SihanTeng/croc` fork,
-which carries the hooks layer (`src/croc/hooks.go`) this app needs. Once the
-hooks land upstream in `schollz/croc`, the `replace` line can be deleted.
-
-> [!NOTE]
-> When the hooks change, push the croc repo's `main` to the fork branch and
-> refresh the pinned pseudo-version here:
->
-> ```sh
-> cd croc && git push origin main:gui-hooks
-> cd ../croc-desktop && go get github.com/SihanTeng/croc/v10@gui-hooks && go mod tidy
-> ```
-
-## CI & releases
-
-- **CI** (`ci.yml`, every push/PR): golangci-lint, gofmt, `go vet`, `go test`;
- frontend prettier, eslint, `tsc --noEmit`, vite build.
-- **Release** (`release.yml`, tags `v*` or manual dispatch): matrix build
- producing the AppImage / DMG / MSI above, with the tag in the file names,
- attached to the GitHub Release with a downloads table.
+More detail: `e2e/README.md`, `design.md`, and Taskfiles under `build/`.
+
+**How it works (short):** the app depends on a croc fork with a hooks layer so
+progress and prompts are events in the UI, not a spawned CLI. See `go.mod`
+`replace` for the pin.
+
+
+
+## Acknowledgments
+
+- [croc](https://github.com/schollz/croc) by [@schollz](https://github.com/schollz) — the protocol and transfer engine
+- [Wails v3](https://v3.wails.io) — Go + web UI for desktop and mobile
+
+---
+
+
+ Made for people who just want to send a file.
+
diff --git a/Taskfile.yml b/Taskfile.yml
new file mode 100644
index 0000000..867fc6f
--- /dev/null
+++ b/Taskfile.yml
@@ -0,0 +1,76 @@
+version: '3'
+
+vars:
+ APP_NAME: "croc-desktop"
+ BIN_DIR: "bin"
+ PACKAGE_MANAGER: '{{.PACKAGE_MANAGER | default "npm"}}'
+ VITE_PORT: '{{.WAILS_VITE_PORT | default 34115}}'
+ GOOS: '{{.GOOS | default OS}}'
+ # Default to gtk3 on Linux so builds work with webkit2gtk 4.1 (same as v2).
+ # For native gtk4/webkitgtk 6: EXTRA_TAGS= (empty) or install gtk4 + webkitgtk6.
+ EXTRA_TAGS: '{{.EXTRA_TAGS | default "gtk3"}}'
+
+includes:
+ common: ./build/Taskfile.yml
+ windows: ./build/windows/Taskfile.yml
+ darwin: ./build/darwin/Taskfile.yml
+ linux: ./build/linux/Taskfile.yml
+ ios: ./build/ios/Taskfile.yml
+ android: ./build/android/Taskfile.yml
+
+tasks:
+ build:
+ summary: Builds the application for the host desktop OS
+ cmds:
+ - task: "{{.GOOS}}:build"
+ vars:
+ EXTRA_TAGS: "{{.EXTRA_TAGS}}"
+ DEV: "{{.DEV}}"
+ ARCH: "{{.ARCH}}"
+ OUTPUT: "{{.OUTPUT}}"
+
+ package:
+ summary: Packages a production build for the host desktop OS
+ cmds:
+ - task: "{{.GOOS}}:package"
+ vars:
+ EXTRA_TAGS: "{{.EXTRA_TAGS}}"
+
+ run:
+ summary: Runs the desktop application
+ cmds:
+ - task: "{{.GOOS}}:run"
+ vars:
+ EXTRA_TAGS: "{{.EXTRA_TAGS}}"
+
+ dev:
+ summary: Runs the application in development mode (hot reload)
+ cmds:
+ - wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}
+
+ # ---- mobile (Wails v3) ----
+ # Requires platform SDKs: full Xcode for iOS; Android SDK + NDK for Android.
+ # See https://v3.wails.io and build/ios, build/android Taskfiles.
+
+ ios:run:
+ summary: Build and launch on the iOS Simulator
+ cmds:
+ - task: ios:run
+
+ ios:package:
+ summary: Package an iOS .app (simulator by default; IOS_PLATFORM=device for devices)
+ cmds:
+ - task: ios:package
+ vars:
+ IOS_PLATFORM: '{{.IOS_PLATFORM | default "simulator"}}'
+ CODESIGN_IDENTITY: '{{.CODESIGN_IDENTITY | default "-"}}'
+
+ android:run:
+ summary: Build, install, and launch on the Android emulator/device
+ cmds:
+ - task: android:run
+
+ android:package:
+ summary: Package a release Android APK/AAB
+ cmds:
+ - task: android:package
diff --git a/app.go b/app.go
index 4eeccc9..d1729e0 100644
--- a/app.go
+++ b/app.go
@@ -14,11 +14,11 @@ import (
"github.com/schollz/croc/v10/src/croc"
"github.com/schollz/croc/v10/src/utils"
"github.com/skip2/go-qrcode"
- wailsruntime "github.com/wailsapp/wails/v2/pkg/runtime"
+ "github.com/wailsapp/wails/v3/pkg/application"
)
-// App is the Wails-bound backend. All exported methods are callable from the
-// frontend.
+// App is the Wails-bound backend service. All exported methods are callable
+// from the frontend via generated bindings.
type App struct {
tm *transferManager
rm *relayManager
@@ -46,40 +46,55 @@ func NewApp() *App {
}
}
-func (a *App) startup(ctx context.Context) {
- a.lm.setWailsCtx(ctx)
- a.tm.setWailsCtx(ctx)
- a.rm.setWailsCtx(ctx)
- wailsruntime.OnFileDrop(ctx, a.onFileDrop)
+// ServiceStartup wires managers to the Wails event bus. File drops are handled
+// on the window in main.go.
+func (a *App) ServiceStartup(_ context.Context, _ application.ServiceOptions) error {
+ a.wireEmit(func(event string, data interface{}) {
+ if app := application.Get(); app != nil {
+ app.Event.Emit(event, data)
+ }
+ })
a.logInfo("app", "croc-desktop started")
+ return nil
}
-func (a *App) shutdown(ctx context.Context) {
+// ServiceShutdown cancels any in-flight transfer and stops a hosted relay.
+func (a *App) ServiceShutdown() error {
a.tm.cancelTransfer()
a.rm.stop()
+ return nil
}
-// onFileDrop receives native file drops (paths, not web File objects) and
-// forwards them to the frontend.
-func (a *App) onFileDrop(x, y int, paths []string) {
- if a.tm.wailsCtx == nil {
- return
- }
- wailsruntime.EventsEmit(a.tm.wailsCtx, "files:dropped", paths)
+// wireEmit publishes frontend events; tests inject a capture sink instead.
+func (a *App) wireEmit(emit func(event string, data interface{})) {
+ a.tm.setEmit(emit)
+ a.lm.setEmit(emit)
+ a.rm.setEmit(emit)
}
// --- dialogs ---
func (a *App) PickFiles() ([]string, error) {
- return wailsruntime.OpenMultipleFilesDialog(a.tm.wailsCtx, wailsruntime.OpenDialogOptions{
- Title: "Choose files to send",
- })
+ app := application.Get()
+ if app == nil {
+ return nil, fmt.Errorf("application not ready")
+ }
+ return app.Dialog.OpenFile().
+ CanChooseFiles(true).
+ SetTitle("Choose files to send").
+ PromptForMultipleSelection()
}
func (a *App) PickDirectory() (string, error) {
- return wailsruntime.OpenDirectoryDialog(a.tm.wailsCtx, wailsruntime.OpenDialogOptions{
- Title: "Choose a folder",
- })
+ app := application.Get()
+ if app == nil {
+ return "", fmt.Errorf("application not ready")
+ }
+ return app.Dialog.OpenFile().
+ CanChooseDirectories(true).
+ CanChooseFiles(false).
+ SetTitle("Choose a folder").
+ PromptForSingleSelection()
}
// PathsIsDir reports which of the given paths are directories, so the UI can
diff --git a/app_api_test.go b/app_api_test.go
new file mode 100644
index 0000000..599b694
--- /dev/null
+++ b/app_api_test.go
@@ -0,0 +1,256 @@
+package main
+
+import (
+ "encoding/base64"
+ "os"
+ "path/filepath"
+ "strings"
+ "testing"
+ "time"
+)
+
+// Coverage for App methods that the frontend calls frequently but that
+// previously had little or no automated coverage. These must keep working
+// across the Wails v2 → v3 migration.
+
+func TestPathsIsDir(t *testing.T) {
+ dir := t.TempDir()
+ file := filepath.Join(dir, "f.txt")
+ if err := os.WriteFile(file, []byte("x"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ nested := filepath.Join(dir, "sub")
+ if err := os.Mkdir(nested, 0o755); err != nil {
+ t.Fatal(err)
+ }
+
+ a := NewApp()
+ got := a.PathsIsDir([]string{file, nested, filepath.Join(dir, "missing"), dir})
+ want := map[string]bool{nested: true, dir: true}
+ if len(got) != 2 {
+ t.Fatalf("PathsIsDir: got %v, want 2 dirs", got)
+ }
+ for _, p := range got {
+ if !want[p] {
+ t.Errorf("unexpected dir %q in %v", p, got)
+ }
+ }
+ if a.PathsIsDir(nil) != nil && len(a.PathsIsDir(nil)) != 0 {
+ t.Fatalf("nil paths should yield empty slice, got %v", a.PathsIsDir(nil))
+ }
+}
+
+func TestGetQrPng(t *testing.T) {
+ a := NewApp()
+ b64, err := a.GetQrPng("4523-ivan-twist-order")
+ if err != nil {
+ t.Fatalf("GetQrPng: %v", err)
+ }
+ if b64 == "" {
+ t.Fatal("expected non-empty base64 PNG")
+ }
+ raw, err := base64.StdEncoding.DecodeString(b64)
+ if err != nil {
+ t.Fatalf("not valid base64: %v", err)
+ }
+ // PNG magic
+ if len(raw) < 8 || string(raw[:8]) != "\x89PNG\r\n\x1a\n" {
+ t.Fatalf("expected PNG payload, got %d bytes starting %x", len(raw), raw[:min(8, len(raw))])
+ }
+}
+
+func TestGetDefaultDownloadDir(t *testing.T) {
+ a := NewApp()
+ dir, err := a.GetDefaultDownloadDir()
+ if err != nil {
+ t.Fatalf("GetDefaultDownloadDir: %v", err)
+ }
+ if dir == "" {
+ t.Fatal("expected a non-empty download dir")
+ }
+ st, err := os.Stat(dir)
+ if err != nil || !st.IsDir() {
+ t.Fatalf("download dir %q is not a directory: %v", dir, err)
+ }
+}
+
+func TestIsTransferRunningAndConcurrentSend(t *testing.T) {
+ srcDir := t.TempDir()
+ src := filepath.Join(srcDir, "busy.txt")
+ if err := os.WriteFile(src, []byte("payload"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ a, _ := newTestApp()
+ if a.IsTransferRunning() {
+ t.Fatal("expected no transfer before StartSend")
+ }
+ if _, err := a.StartSend([]string{src}); err != nil {
+ t.Fatalf("StartSend: %v", err)
+ }
+ if !a.IsTransferRunning() {
+ t.Fatal("expected transfer to be running after StartSend")
+ }
+ if _, err := a.StartSend([]string{src}); err == nil || !strings.Contains(err.Error(), "already in progress") {
+ t.Fatalf("expected 'already in progress', got %v", err)
+ }
+ if err := a.StartReceive("4523-ivan-twist-order", t.TempDir()); err == nil ||
+ !strings.Contains(err.Error(), "already in progress") {
+ t.Fatalf("expected 'already in progress' on receive, got %v", err)
+ }
+ a.CancelTransfer()
+ // cancel is async; give it a moment to clear the running flag
+ deadline := time.Now().Add(10 * time.Second)
+ for a.IsTransferRunning() && time.Now().Before(deadline) {
+ time.Sleep(50 * time.Millisecond)
+ }
+ if a.IsTransferRunning() {
+ t.Fatal("transfer still running after cancel")
+ }
+}
+
+func TestHistoryAndLogsClear(t *testing.T) {
+ a, l := newTestApp()
+ // seed a completed text transfer so history is non-empty
+ const msg = "history clear test"
+ receiver, receiverLog := newTestApp()
+ receiverLog.onAccept = func() { receiver.RespondAccept(true) }
+
+ code, err := a.StartSendText(msg)
+ if err != nil {
+ t.Fatalf("StartSendText: %v", err)
+ }
+ if err := receiver.StartReceive(code, t.TempDir()); err != nil {
+ t.Fatalf("StartReceive: %v", err)
+ }
+ waitDone(t, receiverLog, "text receive")
+ waitDone(t, l, "text send")
+
+ if len(a.GetHistory()) == 0 {
+ t.Fatal("expected history after transfer")
+ }
+ a.ClearHistory()
+ if h := a.GetHistory(); h == nil || len(h) != 0 {
+ t.Fatalf("ClearHistory left %v", h)
+ }
+
+ // log buffer starts empty under newTestApp (no startup wiring)
+ if logs := a.GetLogs(); logs == nil || len(logs) != 0 {
+ // may have entries from transfer; just ensure Clear works
+ }
+ a.ClearLogs()
+ if logs := a.GetLogs(); logs == nil || len(logs) != 0 {
+ t.Fatalf("ClearLogs left %v", logs)
+ }
+}
+
+func TestEmitWiringDeliversEvents(t *testing.T) {
+ // Verifies the swappable emit path used by tests (and, after the v3
+ // migration, by ServiceStartup) reaches the frontend event surface.
+ a, l := newTestApp()
+ a.tm.emitEvent(eventState, "connecting")
+ select {
+ case s := <-l.states:
+ if s != "connecting" {
+ t.Fatalf("got state %q", s)
+ }
+ case <-time.After(time.Second):
+ t.Fatal("emit did not deliver state event")
+ }
+}
+
+func TestFolderTransfer(t *testing.T) {
+ srcDir := t.TempDir()
+ sub := filepath.Join(srcDir, "folder")
+ if err := os.Mkdir(sub, 0o755); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(sub, "a.txt"), []byte("aaa"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+ if err := os.WriteFile(filepath.Join(sub, "b.txt"), []byte("bbb"), 0o644); err != nil {
+ t.Fatal(err)
+ }
+
+ sender, senderLog := newTestApp()
+ receiver, receiverLog := newTestApp()
+ receiverLog.onAccept = func() { receiver.RespondAccept(true) }
+
+ code, err := sender.StartSend([]string{sub})
+ if err != nil {
+ t.Fatalf("StartSend folder: %v", err)
+ }
+ outDir := t.TempDir()
+ if err := receiver.StartReceive(code, outDir); err != nil {
+ t.Fatalf("StartReceive: %v", err)
+ }
+
+ p := waitDone(t, receiverLog, "folder receive")
+ waitDone(t, senderLog, "folder send")
+ if len(p.Files) != 2 {
+ t.Fatalf("expected 2 files, got %+v", p.Files)
+ }
+ for _, name := range []string{"a.txt", "b.txt"} {
+ found := false
+ for _, f := range p.Files {
+ if strings.HasSuffix(f.Name, name) || filepath.Base(f.Name) == name {
+ found = true
+ break
+ }
+ }
+ if !found {
+ t.Errorf("missing %s in %+v", name, p.Files)
+ }
+ }
+}
+
+func TestGetFileDataURLTooLarge(t *testing.T) {
+ // Don't actually allocate 64 MiB+; override via a sparse-ish check using
+ // a temp file we report as large by writing past the cap if cheap, or
+ // skip when disk is constrained. Write just over maxPreviewBytes only
+ // when short — use a smaller direct size assertion via the constant.
+ if testing.Short() {
+ t.Skip("skipping large-file preview test in short mode")
+ }
+ dir := t.TempDir()
+ path := filepath.Join(dir, "big.bin")
+ // create a sparse file by seeking; falls back to a smaller write if needed
+ f, err := os.Create(path)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if err := f.Truncate(maxPreviewBytes + 1); err != nil {
+ _ = f.Close()
+ t.Fatal(err)
+ }
+ _ = f.Close()
+
+ a := NewApp()
+ if _, err := a.GetFileDataURL(path); err == nil || !strings.Contains(err.Error(), "too large") {
+ t.Fatalf("expected too-large error, got %v", err)
+ }
+}
+
+func TestNormalizeCodeViaApp(t *testing.T) {
+ a := NewApp()
+ code, err := a.NormalizeCode("croc 4523-ivan-twist-order")
+ if err != nil {
+ t.Fatalf("NormalizeCode: %v", err)
+ }
+ if code != "4523-ivan-twist-order" {
+ t.Fatalf("got %q", code)
+ }
+ if _, err := a.NormalizeCode(" "); err == nil {
+ t.Fatal("expected error for empty input")
+ }
+}
+
+func TestSaveSettingsInvalidThrottle(t *testing.T) {
+ t.Setenv("CROC_CONFIG_DIR", t.TempDir())
+ a := NewApp()
+ s := a.GetSettings()
+ s.ThrottleUpload = "not-a-rate"
+ if err := a.SaveSettings(s); err == nil || !strings.Contains(err.Error(), "invalid upload limit") {
+ t.Fatalf("expected invalid throttle error, got %v", err)
+ }
+}
diff --git a/app_options_android.go b/app_options_android.go
new file mode 100644
index 0000000..a423375
--- /dev/null
+++ b/app_options_android.go
@@ -0,0 +1,10 @@
+//go:build android
+
+package main
+
+import "github.com/wailsapp/wails/v3/pkg/application"
+
+// modifyOptionsForPlatform adjusts application options for Android builds.
+func modifyOptionsForPlatform(opts *application.Options) {
+ opts.DisableDefaultSignalHandler = true
+}
diff --git a/app_options_default.go b/app_options_default.go
new file mode 100644
index 0000000..a2fd09a
--- /dev/null
+++ b/app_options_default.go
@@ -0,0 +1,8 @@
+//go:build !ios && !android
+
+package main
+
+import "github.com/wailsapp/wails/v3/pkg/application"
+
+// modifyOptionsForPlatform is a no-op on desktop platforms.
+func modifyOptionsForPlatform(_ *application.Options) {}
diff --git a/app_options_ios.go b/app_options_ios.go
new file mode 100644
index 0000000..772194f
--- /dev/null
+++ b/app_options_ios.go
@@ -0,0 +1,13 @@
+//go:build ios
+
+package main
+
+import "github.com/wailsapp/wails/v3/pkg/application"
+
+// modifyOptionsForPlatform adjusts application options for iOS builds.
+func modifyOptionsForPlatform(opts *application.Options) {
+ // Signal handlers race with UIKit on iOS and can crash the process.
+ opts.DisableDefaultSignalHandler = true
+ opts.IOS.EnableInlineMediaPlayback = true
+ opts.IOS.BackgroundColour = application.NewRGB(248, 250, 253)
+}
diff --git a/build/Taskfile.yml b/build/Taskfile.yml
new file mode 100644
index 0000000..d39d290
--- /dev/null
+++ b/build/Taskfile.yml
@@ -0,0 +1,398 @@
+version: '3'
+
+tasks:
+ go:mod:tidy:
+ summary: Runs `go mod tidy`
+ internal: true
+ # Universal/multi-arch builds invoke this concurrently from parallel deps;
+ # two `go mod tidy` processes racing on go.mod can corrupt it (#4637).
+ run: once
+ cmds:
+ - go mod tidy
+
+ install:frontend:deps:
+ summary: Install frontend dependencies
+ run: once
+ cmds:
+ - task: install:frontend:deps:{{.PACKAGE_MANAGER}}
+
+ install:frontend:deps:npm:
+ dir: frontend
+ sources:
+ - package.json
+ - package-lock.json
+ generates:
+ - node_modules
+ preconditions:
+ - sh: npm version
+ msg: "Looks like npm isn't installed. Npm is part of the Node installer: https://nodejs.org/en/download/"
+ cmds:
+ - npm install
+
+ install:frontend:deps:bun:
+ dir: frontend
+ sources:
+ - package.json
+ - bun.lock
+ - bun.lockb
+ generates:
+ - node_modules
+ preconditions:
+ - sh: bun --version
+ msg: "bun not found"
+ cmds:
+ - bun install
+
+ install:frontend:deps:pnpm:
+ dir: frontend
+ sources:
+ - package.json
+ - pnpm-lock.yaml
+ generates:
+ - node_modules
+ preconditions:
+ - sh: pnpm --version
+ msg: "pnpm not found"
+ cmds:
+ - pnpm install
+
+ install:frontend:deps:yarn:
+ dir: frontend
+ sources:
+ - package.json
+ - yarn.lock
+ status:
+ - test -d node_modules || test -f .pnp.cjs
+ preconditions:
+ - sh: yarn --version
+ msg: "yarn not found"
+ cmds:
+ - yarn install
+
+ build:frontend:
+ label: build:frontend (DEV={{.DEV}} RUNNER={{.PACKAGE_MANAGER}})
+ summary: Build the frontend project
+ # darwin:build:universal runs its per-arch builds as parallel deps, each of
+ # which depends on this task. Without run:once the two executions race:
+ # one regenerates frontend/bindings (-clean deletes it first) while the
+ # other's bundler is reading it, failing intermittently with
+ # 'Could not resolve "./bindings/"' (#4637).
+ run: once
+ dir: frontend
+ sources:
+ - "**/*"
+ - exclude: node_modules/**/*
+ generates:
+ - dist/**/*
+ deps:
+ - task: install:frontend:deps
+ - task: generate:bindings
+ vars:
+ BUILD_FLAGS:
+ ref: .BUILD_FLAGS
+ OBFUSCATED:
+ ref: .OBFUSCATED
+ cmds:
+ - task: frontend:run
+ vars:
+ SCRIPT: '{{if eq .DEV "true"}}build:dev{{else}}build{{end}}'
+ env:
+ PRODUCTION: '{{if eq .DEV "true"}}false{{else}}true{{end}}'
+
+ frontend:run:
+ summary: Run a frontend script with selected runner
+ cmds:
+ - task: frontend:run:{{.PACKAGE_MANAGER}}
+ vars:
+ SCRIPT: "{{.SCRIPT}}"
+ vars:
+ SCRIPT: "{{.SCRIPT}}"
+
+ frontend:run:npm:
+ dir: frontend
+ cmds:
+ - npm run {{.SCRIPT}} -q
+ vars:
+ SCRIPT: "{{.SCRIPT}}"
+
+ frontend:run:yarn:
+ dir: frontend
+ cmds:
+ - yarn {{.SCRIPT}}
+ vars:
+ SCRIPT: "{{.SCRIPT}}"
+
+ frontend:run:pnpm:
+ dir: frontend
+ cmds:
+ - pnpm run {{.SCRIPT}}
+ vars:
+ SCRIPT: "{{.SCRIPT}}"
+
+ frontend:run:bun:
+ dir: frontend
+ cmds:
+ - bun run {{.SCRIPT}}
+ vars:
+ SCRIPT: "{{.SCRIPT}}"
+
+ frontend:vendor:puppertino:
+ summary: Fetches Puppertino CSS into frontend/public for consistent mobile styling
+ sources:
+ - frontend/public/puppertino/puppertino.css
+ generates:
+ - frontend/public/puppertino/puppertino.css
+ cmds:
+ - |
+ set -euo pipefail
+ mkdir -p frontend/public/puppertino
+ # If bundled Puppertino exists, prefer it. Otherwise, try to fetch, but don't fail build on error.
+ if [ ! -f frontend/public/puppertino/puppertino.css ]; then
+ echo "No bundled Puppertino found. Attempting to fetch from GitHub..."
+ if curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/dist/css/full.css -o frontend/public/puppertino/puppertino.css; then
+ curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/LICENSE -o frontend/public/puppertino/LICENSE || true
+ echo "Puppertino CSS downloaded to frontend/public/puppertino/puppertino.css"
+ else
+ echo "Warning: Could not fetch Puppertino CSS. Proceeding without download since template may bundle it."
+ fi
+ else
+ echo "Using bundled Puppertino at frontend/public/puppertino/puppertino.css"
+ fi
+ # Ensure index.html includes Puppertino CSS and button classes
+ INDEX_HTML=frontend/index.html
+ if [ -f "$INDEX_HTML" ]; then
+ if ! grep -q 'href="/puppertino/puppertino.css"' "$INDEX_HTML"; then
+ # Insert Puppertino link tag after style.css link
+ awk '
+ /href="\/style.css"\/?/ && !x { print; print " "; x=1; next }1
+ ' "$INDEX_HTML" > "$INDEX_HTML.tmp" && mv "$INDEX_HTML.tmp" "$INDEX_HTML"
+ fi
+ # Replace default .btn with Puppertino primary button classes if present
+ sed -E -i'' 's/class=\"btn\"/class=\"p-btn p-prim-col\"/g' "$INDEX_HTML" || true
+ fi
+
+
+
+ generate:bindings:
+ summary: Generates bindings for the frontend
+ run: once
+ deps:
+ - task: go:mod:tidy
+ sources:
+ - "**/*.[jt]s"
+ - exclude: frontend/**/*
+ - frontend/bindings/**/* # Rerun when switching between dev/production mode causes changes in output
+ - "**/*.go"
+ - go.mod
+ - go.sum
+ generates:
+ - frontend/bindings/**/*
+ cmds:
+ - wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true -time-type=Date{{if eq .OBFUSCATED "true"}} -obfuscated{{end}} -ts
+
+ generate:icons:
+ summary: Generates Windows `.ico` and Mac `.icns` from an image; on macOS, `-iconcomposerinput appicon.icon -macassetdir darwin` also produces `Assets.car` from a `.icon` file (skipped on other platforms).
+ run: once
+ dir: build
+ sources:
+ - "appicon.png"
+ - "appicon.icon"
+ generates:
+ - "darwin/icons.icns"
+ - "windows/icon.ico"
+ cmds:
+ - wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico -iconcomposerinput appicon.icon -macassetdir darwin
+
+ dev:frontend:
+ summary: Runs the frontend in development mode
+ deps:
+ - task: install:frontend:deps
+ cmds:
+ - task: frontend:dev:{{.PACKAGE_MANAGER}}
+
+ frontend:dev:npm:
+ dir: frontend
+ cmds:
+ - npm run dev -- --port {{.VITE_PORT}} --strictPort
+
+ frontend:dev:yarn:
+ dir: frontend
+ cmds:
+ - yarn dev --port {{.VITE_PORT}} --strictPort
+
+ frontend:dev:pnpm:
+ dir: frontend
+ cmds:
+ - pnpm dev --port {{.VITE_PORT}} --strictPort
+
+ frontend:dev:bun:
+ dir: frontend
+ cmds:
+ - bun run dev --port {{.VITE_PORT}} --strictPort
+
+ update:build-assets:
+ summary: Updates the build assets
+ dir: build
+ cmds:
+ - wails3 update build-assets -name "{{.APP_NAME}}" -binaryname "{{.APP_NAME}}" -config config.yml -dir .
+
+ build:server:
+ summary: Builds the application in server mode (no GUI, HTTP server only)
+ desc: |
+ Builds a production server binary by default: -tags server,production,
+ -trimpath and a stripped binary, mirroring the desktop `build` task.
+ Server mode runs as a pure HTTP server without native GUI dependencies.
+
+ Usage: task build:server [DEV=true] [OBFUSCATED=true] [EXTRA_TAGS=tag1,tag2]
+ DEV=true development server (-tags server, no strip, inlining kept)
+ OBFUSCATED=true obfuscated build via garble (requires garble installed)
+ EXTRA_TAGS additional comma-separated build tags
+ deps:
+ - task: build:frontend
+ vars:
+ DEV:
+ ref: .DEV
+ BUILD_FLAGS:
+ ref: .BUILD_FLAGS
+ OBFUSCATED:
+ ref: .OBFUSCATED
+ preconditions:
+ - sh: '{{if eq .OBFUSCATED "true"}}command -v garble >/dev/null 2>&1{{else}}true{{end}}'
+ msg: "garble is required for obfuscated builds. Install it with: go install mvdan.cc/garble@v0.16.0 (requires Go 1.24+). See https://github.com/burrowers/garble/releases for version/toolchain compatibility."
+ cmds:
+ - '{{if eq .OBFUSCATED "true"}}garble {{.GARBLE_ARGS}} build{{else}}go build{{end}} {{.BUILD_FLAGS}} -o "{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}"'
+ vars:
+ BUILD_FLAGS: '-tags server{{if eq .DEV "true"}}{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -buildvcs=false -gcflags=all="-l"{{else}},production{{if eq .OBFUSCATED "true"}},wails_obfuscated{{end}}{{if .EXTRA_TAGS}},{{.EXTRA_TAGS}}{{end}} -trimpath -buildvcs=false -ldflags="-w -s"{{end}}'
+
+ run:server:
+ summary: Builds and runs a development server (DEV=true)
+ deps:
+ - task: build:server
+ vars:
+ DEV: "true"
+ cmds:
+ - '"./{{.BIN_DIR}}/{{.APP_NAME}}-server{{exeExt}}"'
+
+ build:docker:
+ summary: Builds a Docker image for server mode deployment
+ desc: |
+ Creates a minimal Docker image containing the production server binary.
+ Defaults to a pure-Go static binary on a distroless/static base. The
+ production frontend is built first so the embedded assets are current.
+
+ Usage: task build:docker [TAG=myapp:latest] [CGO_ENABLED=1] [GO_IMAGE=...] [RUNTIME_IMAGE=...]
+ For CGO apps, set CGO_ENABLED=1 with libc-compatible builder/runtime images, e.g.:
+ task build:docker CGO_ENABLED=1 GO_IMAGE=golang:bookworm RUNTIME_IMAGE=gcr.io/distroless/base-debian12
+ deps:
+ # Build the production frontend so frontend/dist (embedded by the Go build
+ # inside the image) is present and current in the Docker build context.
+ # Pass the server,production tags so binding generation analyses the same
+ # build the Docker image compiles, not the default-tag build.
+ - task: build:frontend
+ vars:
+ BUILD_FLAGS: "-tags server,production"
+ cmds:
+ - >-
+ docker build
+ --build-arg CGO_ENABLED={{.CGO_ENABLED | default "0"}}
+ --build-arg GO_IMAGE={{.GO_IMAGE | default "golang:alpine"}}
+ --build-arg RUNTIME_IMAGE={{.RUNTIME_IMAGE | default "gcr.io/distroless/static-debian12"}}
+ -t {{.TAG | default (printf "%s:latest" .APP_NAME)}}
+ -f build/docker/Dockerfile.server .
+ vars:
+ TAG: "{{.TAG}}"
+ preconditions:
+ - sh: docker info > /dev/null 2>&1
+ msg: "Docker is required. Please install Docker first."
+ - sh: test -f build/docker/Dockerfile.server
+ msg: "Dockerfile.server not found. Run 'wails3 update build-assets' to generate it."
+
+ run:docker:
+ summary: Builds and runs the Docker image
+ desc: |
+ Builds the Docker image and runs it, exposing port 8080.
+ Usage: task run:docker [TAG=myapp:latest] [PORT=8080]
+ Note: The internal container port is always 8080. The PORT variable
+ only changes the host port mapping. Ensure your app uses port 8080
+ or modify the Dockerfile to match your ServerOptions.Port setting.
+ deps:
+ - task: build:docker
+ vars:
+ TAG:
+ ref: .TAG
+ cmds:
+ - docker run --rm -p {{.PORT | default "8080"}}:8080 {{.TAG | default (printf "%s:latest" .APP_NAME)}}
+ vars:
+ TAG: "{{.TAG}}"
+ PORT: "{{.PORT}}"
+
+ setup:docker:
+ summary: Builds Docker image for cross-compilation (~800MB download)
+ desc: |
+ Builds the Docker image needed for cross-compiling to any platform.
+ Run this once to enable cross-platform builds from any OS.
+ cmds:
+ - docker build -t wails-cross -f build/docker/Dockerfile.cross build/docker/
+ preconditions:
+ - sh: docker info > /dev/null 2>&1
+ msg: "Docker is required. Please install Docker first."
+
+ ios:device:list:
+ summary: Lists connected iOS devices (UDIDs)
+ cmds:
+ - xcrun xcdevice list
+
+ ios:run:device:
+ summary: Build, install, and launch on a physical iPhone using Apple tools (xcodebuild/devicectl)
+ vars:
+ PROJECT: '{{.PROJECT}}' # e.g., build/ios/xcode/.xcodeproj
+ SCHEME: '{{.SCHEME}}' # e.g., ios.dev
+ CONFIG: '{{.CONFIG | default "Debug"}}'
+ DERIVED: '{{.DERIVED | default "build/ios/DerivedData"}}'
+ UDID: '{{.UDID}}' # from `task ios:device:list`
+ BUNDLE_ID: '{{.BUNDLE_ID}}' # e.g., com.yourco.wails.ios.dev
+ TEAM_ID: '{{.TEAM_ID}}' # optional, if your project is not already set up for signing
+ preconditions:
+ - sh: xcrun -f xcodebuild
+ msg: "xcodebuild not found. Please install Xcode."
+ - sh: xcrun -f devicectl
+ msg: "devicectl not found. Please update to Xcode 15+ (which includes devicectl)."
+ - sh: test -n '{{.PROJECT}}'
+ msg: "Set PROJECT to your .xcodeproj path (e.g., PROJECT=build/ios/xcode/App.xcodeproj)."
+ - sh: test -n '{{.SCHEME}}'
+ msg: "Set SCHEME to your app scheme (e.g., SCHEME=ios.dev)."
+ - sh: test -n '{{.UDID}}'
+ msg: "Set UDID to your device UDID (see: task ios:device:list)."
+ - sh: test -n '{{.BUNDLE_ID}}'
+ msg: "Set BUNDLE_ID to your app's bundle identifier (e.g., com.yourco.wails.ios.dev)."
+ cmds:
+ - |
+ set -euo pipefail
+ echo "Building for device: UDID={{.UDID}} SCHEME={{.SCHEME}} PROJECT={{.PROJECT}}"
+ XCB_ARGS=(
+ -project "{{.PROJECT}}"
+ -scheme "{{.SCHEME}}"
+ -configuration "{{.CONFIG}}"
+ -destination "id={{.UDID}}"
+ -derivedDataPath "{{.DERIVED}}"
+ -allowProvisioningUpdates
+ -allowProvisioningDeviceRegistration
+ )
+ # Optionally inject signing identifiers if provided
+ if [ -n '{{.TEAM_ID}}' ]; then XCB_ARGS+=(DEVELOPMENT_TEAM={{.TEAM_ID}}); fi
+ if [ -n '{{.BUNDLE_ID}}' ]; then XCB_ARGS+=(PRODUCT_BUNDLE_IDENTIFIER={{.BUNDLE_ID}}); fi
+ xcodebuild "${XCB_ARGS[@]}" build | xcpretty || true
+ # If xcpretty isn't installed, run without it
+ if [ "${PIPESTATUS[0]}" -ne 0 ]; then
+ xcodebuild "${XCB_ARGS[@]}" build
+ fi
+ # Find built .app
+ APP_PATH=$(find "{{.DERIVED}}/Build/Products" -type d -name "*.app" -maxdepth 3 | head -n 1)
+ if [ -z "$APP_PATH" ]; then
+ echo "Could not locate built .app under {{.DERIVED}}/Build/Products" >&2
+ exit 1
+ fi
+ echo "Installing: $APP_PATH"
+ xcrun devicectl device install app --device "{{.UDID}}" "$APP_PATH"
+ echo "Launching: {{.BUNDLE_ID}}"
+ xcrun devicectl device process launch --device "{{.UDID}}" --stderr console --stdout console "{{.BUNDLE_ID}}"
diff --git a/build/android/Taskfile.yml b/build/android/Taskfile.yml
new file mode 100644
index 0000000..14f530a
--- /dev/null
+++ b/build/android/Taskfile.yml
@@ -0,0 +1,455 @@
+version: '3'
+
+includes:
+ common: ../Taskfile.yml
+
+vars:
+ APP_ID: '{{.APP_ID | default "com.schollz.croc-desktop"}}'
+ MIN_SDK: '21'
+ TARGET_SDK: '35'
+ # The emulator runs the host architecture; physical devices are arm64
+ HOST_ARCH:
+ sh: '[ "$(uname -m)" = "x86_64" ] && echo "amd64" || echo "arm64"'
+ # System-image ABI for the host, used in the "create an AVD" hint below.
+ ANDROID_ABI:
+ sh: '[ "$(uname -m)" = "arm64" ] && echo "arm64-v8a" || echo "x86_64"'
+ # SDK location: $ANDROID_HOME / $ANDROID_SDK_ROOT, else the per-OS default
+ # (macOS: ~/Library/Android/sdk, Linux/other: ~/Android/Sdk)
+ SDK_ROOT:
+ sh: 'echo "${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$([ -d "$HOME/Library/Android/sdk" ] && echo "$HOME/Library/Android/sdk" || echo "$HOME/Android/Sdk")}}"'
+ ADB:
+ sh: 'command -v adb || echo "${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$([ -d "$HOME/Library/Android/sdk" ] && echo "$HOME/Library/Android/sdk" || echo "$HOME/Android/Sdk")}}/platform-tools/adb"'
+ EMULATOR:
+ sh: 'command -v emulator || echo "${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$([ -d "$HOME/Library/Android/sdk" ] && echo "$HOME/Library/Android/sdk" || echo "$HOME/Android/Sdk")}}/emulator/emulator"'
+ # avdmanager lives under cmdline-tools//bin; used to auto-create an
+ # AVD when none exists (mirrors the iOS `ensure-simulator` auto-create flow).
+ AVDMANAGER:
+ sh: 'command -v avdmanager || ls "${ANDROID_HOME:-${ANDROID_SDK_ROOT:-$([ -d "$HOME/Library/Android/sdk" ] && echo "$HOME/Library/Android/sdk" || echo "$HOME/Android/Sdk")}}"/cmdline-tools/*/bin/avdmanager 2>/dev/null | sort -V | tail -1 || true'
+
+tasks:
+ install:deps:
+ summary: Check and install Android development dependencies
+ cmds:
+ - go run build/android/scripts/deps/install_deps.go
+ env:
+ TASK_FORCE_YES: '{{if .YES}}true{{else}}false{{end}}'
+ prompt: This will check and install Android development dependencies. Continue?
+
+ build:
+ summary: Creates a debug build of the application for Android
+ deps:
+ - task: common:go:mod:tidy
+ - task: generate:android:overlay
+ - task: common:build:frontend
+ vars:
+ BUILD_FLAGS:
+ ref: .BUILD_FLAGS
+ PRODUCTION:
+ ref: .PRODUCTION
+ cmds:
+ - echo "Building Android app {{.APP_NAME}}..."
+ - task: compile:go:shared
+ vars:
+ ARCH: '{{.ARCH | default .HOST_ARCH}}'
+ vars:
+ BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production,android -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-tags android,debug -buildvcs=false -gcflags=all="-l"{{end}}'
+ env:
+ PRODUCTION: '{{.PRODUCTION | default "false"}}'
+
+ compile:go:shared:
+ summary: Compile Go code to shared library (.so)
+ cmds:
+ - |
+ # Locate the NDK: $ANDROID_NDK_HOME, or the newest installed NDK
+ NDK_ROOT="$ANDROID_NDK_HOME"
+ if [ -z "$NDK_ROOT" ]; then
+ SDK_ROOT="{{.SDK_ROOT}}"
+ NDK_ROOT=$(ls -d "$SDK_ROOT"/ndk/* 2>/dev/null | sort -V | tail -1)
+ fi
+ if [ -z "$NDK_ROOT" ] || [ ! -d "$NDK_ROOT" ]; then
+ echo "Error: Android NDK not found"
+ echo "Install one with: sdkmanager 'ndk;26.3.11579264' (or set ANDROID_NDK_HOME)"
+ exit 1
+ fi
+
+ # Determine toolchain based on host OS
+ case "$(uname -s)" in
+ Darwin) HOST_TAG="darwin-x86_64" ;;
+ Linux) HOST_TAG="linux-x86_64" ;;
+ *) echo "Unsupported host OS"; exit 1 ;;
+ esac
+
+ TOOLCHAIN="$NDK_ROOT/toolchains/llvm/prebuilt/$HOST_TAG"
+
+ # Set compiler based on architecture
+ case "{{.ARCH}}" in
+ arm64)
+ export CC="$TOOLCHAIN/bin/aarch64-linux-android{{.MIN_SDK}}-clang"
+ export CXX="$TOOLCHAIN/bin/aarch64-linux-android{{.MIN_SDK}}-clang++"
+ export GOARCH=arm64
+ JNI_DIR="arm64-v8a"
+ ;;
+ amd64|x86_64)
+ export CC="$TOOLCHAIN/bin/x86_64-linux-android{{.MIN_SDK}}-clang"
+ export CXX="$TOOLCHAIN/bin/x86_64-linux-android{{.MIN_SDK}}-clang++"
+ export GOARCH=amd64
+ JNI_DIR="x86_64"
+ ;;
+ *)
+ echo "Unsupported architecture: {{.ARCH}}"
+ exit 1
+ ;;
+ esac
+
+ export CGO_ENABLED=1
+ export GOOS=android
+
+ mkdir -p {{.BIN_DIR}}
+ mkdir -p build/android/app/src/main/jniLibs/$JNI_DIR
+
+ go build -buildmode=c-shared -overlay build/android/overlay.json {{.BUILD_FLAGS}} \
+ -o build/android/app/src/main/jniLibs/$JNI_DIR/libwails.so
+ vars:
+ BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production,android -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-tags android,debug -buildvcs=false -gcflags=all="-l"{{end}}'
+
+ compile:go:all-archs:
+ summary: Compile Go code for all Android architectures (fat APK)
+ cmds:
+ - task: compile:go:shared
+ vars:
+ ARCH: arm64
+ - task: compile:go:shared
+ vars:
+ ARCH: amd64
+
+ package:
+ summary: Packages a production build of the application into a signed release APK
+ deps:
+ - task: build
+ vars:
+ PRODUCTION: "true"
+ ARCH: '{{.ARCH | default .HOST_ARCH}}'
+ cmds:
+ - task: assemble:apk:release
+
+ package:fat:
+ summary: Packages a production build for all architectures (fat APK)
+ deps:
+ - task: build
+ vars:
+ PRODUCTION: "true"
+ ARCH: arm64
+ cmds:
+ - task: compile:go:shared
+ vars:
+ ARCH: amd64
+ PRODUCTION: "true"
+ - task: assemble:apk:release
+
+ bundle:
+ summary: Packages a production AAB (Android App Bundle) for Play Store submission
+ deps:
+ - task: build
+ vars:
+ PRODUCTION: "true"
+ ARCH: '{{.ARCH | default .HOST_ARCH}}'
+ cmds:
+ - task: assemble:aab:release
+
+ bundle:fat:
+ summary: Packages a production AAB for all architectures
+ deps:
+ - task: build
+ vars:
+ PRODUCTION: "true"
+ ARCH: arm64
+ cmds:
+ - task: compile:go:shared
+ vars:
+ ARCH: amd64
+ PRODUCTION: "true"
+ - task: assemble:aab:release
+
+ assemble:apk:
+ summary: Assembles a debug APK using Gradle
+ preconditions:
+ - sh: 'command -v java >/dev/null || [ -n "$JAVA_HOME" ]'
+ msg: "Java not found. Install a JDK (e.g. brew install openjdk@21) and/or set JAVA_HOME"
+ cmds:
+ - |
+ cd build/android
+ # The exec bit is lost when gradlew is extracted from the embedded
+ # build assets, so restore it before invoking the wrapper.
+ chmod +x ./gradlew
+ ./gradlew assembleDebug
+ cp app/build/outputs/apk/debug/app-debug.apk "../../{{.BIN_DIR}}/{{.APP_NAME}}.apk"
+ echo "APK created: {{.BIN_DIR}}/{{.APP_NAME}}.apk"
+
+ assemble:apk:release:
+ summary: Assembles a release APK using Gradle (signed with the debug keystore unless ANDROID_KEYSTORE_FILE is set)
+ preconditions:
+ - sh: 'command -v java >/dev/null || [ -n "$JAVA_HOME" ]'
+ msg: "Java not found. Install a JDK (e.g. brew install openjdk@21) and/or set JAVA_HOME"
+ cmds:
+ - |
+ cd build/android
+ # The exec bit is lost when gradlew is extracted from the embedded
+ # build assets, so restore it before invoking the wrapper.
+ chmod +x ./gradlew
+ ./gradlew assembleRelease
+ cp app/build/outputs/apk/release/app-release.apk "../../{{.BIN_DIR}}/{{.APP_NAME}}.apk"
+ echo "Release APK created: {{.BIN_DIR}}/{{.APP_NAME}}.apk"
+
+ assemble:aab:
+ summary: Assembles a debug AAB (Android App Bundle) using Gradle
+ preconditions:
+ - sh: 'command -v java >/dev/null || [ -n "$JAVA_HOME" ]'
+ msg: "Java not found. Install a JDK (e.g. brew install openjdk@21) and/or set JAVA_HOME"
+ cmds:
+ - |
+ cd build/android
+ # The exec bit is lost when gradlew is extracted from the embedded
+ # build assets, so restore it before invoking the wrapper.
+ chmod +x ./gradlew
+ ./gradlew bundleDebug
+ cp app/build/outputs/bundle/debug/app-debug.aab "../../{{.BIN_DIR}}/{{.APP_NAME}}.aab"
+ echo "AAB created: {{.BIN_DIR}}/{{.APP_NAME}}.aab"
+
+ assemble:aab:release:
+ summary: Assembles a release AAB for Play Store upload (signed with the debug keystore unless ANDROID_KEYSTORE_FILE is set)
+ preconditions:
+ - sh: 'command -v java >/dev/null || [ -n "$JAVA_HOME" ]'
+ msg: "Java not found. Install a JDK (e.g. brew install openjdk@21) and/or set JAVA_HOME"
+ cmds:
+ - |
+ # With Play App Signing, the keystore configured here is your UPLOAD
+ # key: Google verifies the upload with it, then re-signs the app with
+ # the app signing key it manages for distribution.
+ if [ -z "$ANDROID_KEYSTORE_FILE" ]; then
+ echo "WARNING: ANDROID_KEYSTORE_FILE is not set, so this AAB will be"
+ echo "signed with the debug keystore. Google Play rejects debug-signed"
+ echo "bundles. Set ANDROID_KEYSTORE_FILE, ANDROID_KEYSTORE_PASSWORD,"
+ echo "ANDROID_KEY_ALIAS and ANDROID_KEY_PASSWORD before uploading."
+ fi
+ cd build/android
+ # The exec bit is lost when gradlew is extracted from the embedded
+ # build assets, so restore it before invoking the wrapper.
+ chmod +x ./gradlew
+ ./gradlew bundleRelease
+ cp app/build/outputs/bundle/release/app-release.aab "../../{{.BIN_DIR}}/{{.APP_NAME}}.aab"
+ echo "Release AAB created: {{.BIN_DIR}}/{{.APP_NAME}}.aab"
+
+ generate:android:overlay:
+ internal: true
+ summary: Generate Go build overlay that registers the Android main
+ sources:
+ - build/config.yml
+ generates:
+ - build/android/overlay.json
+ - build/android/gen/main_android.gen.go
+ cmds:
+ - wails3 android overlay:gen -out build/android/overlay.json -config build/config.yml
+
+ generate:android:bindings:
+ internal: true
+ summary: Generates bindings for Android
+ sources:
+ - "**/*.go"
+ - go.mod
+ - go.sum
+ generates:
+ - frontend/bindings/**/*
+ cmds:
+ # Bindings are generated from the Go AST; CGO is disabled so the NDK
+ # is not required for this step
+ - wails3 generate bindings -f '-tags android' -clean=true
+ env:
+ GOOS: android
+ CGO_ENABLED: 0
+
+ ensure-emulator:
+ internal: true
+ summary: Ensure Android Emulator is running
+ silent: true
+ cmds:
+ - |
+ # Check if an emulator is already running
+ if "{{.ADB}}" devices | grep -q "emulator"; then
+ echo "Emulator already running"
+ exit 0
+ fi
+
+ # Get first available AVD
+ AVD_NAME=$("{{.EMULATOR}}" -list-avds | tail -1)
+ if [ -z "$AVD_NAME" ]; then
+ # No AVD yet. Mirror the iOS `ensure-simulator` flow and create one
+ # automatically — but ONLY from a system image that is already
+ # installed. We never trigger an sdkmanager download from a `run`
+ # task (that would be a surprise multi-GB download + license prompt).
+ # Pick the highest-API installed image matching the host ABI.
+ SDK_ROOT="{{.SDK_ROOT}}"
+ ABI="{{.ANDROID_ABI}}"
+ IMG=$(ls -d "$SDK_ROOT"/system-images/android-*/*/"$ABI" 2>/dev/null | sort -V | tail -1)
+ AVDMANAGER="{{.AVDMANAGER}}"
+ if [ -n "$IMG" ] && [ -x "$AVDMANAGER" ]; then
+ PKG="system-images;$(echo "$IMG" | sed "s|$SDK_ROOT/system-images/||" | tr '/' ';')"
+ echo "No Android Virtual Devices found. Creating 'wails' from $PKG..."
+ echo "no" | "$AVDMANAGER" create avd --name wails --package "$PKG" --device pixel_7 --force
+ AVD_NAME=wails
+ else
+ echo "No Android Virtual Devices found, and no system image is installed to create one from."
+ echo "Install a system image and create an AVD, e.g.:"
+ echo " sdkmanager 'system-images;android-35;google_apis;$ABI'"
+ echo " avdmanager create avd --name wails --package 'system-images;android-35;google_apis;$ABI' --device pixel_7"
+ exit 1
+ fi
+ fi
+
+ echo "Starting emulator: $AVD_NAME"
+ # Start the emulator daemonized so it outlives this task step. go-task's
+ # shell tracks background jobs by PID and reaps them when the command's
+ # interpreter finishes (which nohup/setsid alone don't prevent — the kill
+ # is direct), so a bare `emulator &` is gone before the later
+ # install/launch steps run. Launch it from a short-lived child shell that
+ # backgrounds the emulator and exits immediately: the emulator is then
+ # reparented to init/launchd and go-task's shell has no handle to reap it.
+ nohup sh -c "'{{.EMULATOR}}' -avd '$AVD_NAME' -no-snapshot-load /dev/null 2>&1 &" >/dev/null 2>&1
+
+ # Wait for emulator to boot (max 120 seconds)
+ echo "Waiting for emulator to boot..."
+ "{{.ADB}}" wait-for-device
+
+ for i in $(seq 1 120); do
+ BOOT_COMPLETED=$("{{.ADB}}" shell getprop sys.boot_completed 2>/dev/null | tr -d '\r')
+ if [ "$BOOT_COMPLETED" = "1" ]; then
+ echo "Emulator booted successfully"
+ exit 0
+ fi
+ sleep 1
+ done
+
+ echo "Emulator boot timeout"
+ exit 1
+ preconditions:
+ - sh: '[ -x "{{.ADB}}" ] || command -v adb'
+ msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)"
+ - sh: '[ -x "{{.EMULATOR}}" ] || command -v emulator'
+ msg: "emulator not found. Install the Android SDK emulator package (or set ANDROID_HOME)"
+
+ deploy-emulator:
+ summary: Deploy the packaged release APK to the Android Emulator
+ deps: [package]
+ cmds:
+ - task: ensure-emulator
+ - '"{{.ADB}}" uninstall {{.APP_ID}} 2>/dev/null || true'
+ - '"{{.ADB}}" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"'
+ - '"{{.ADB}}" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity'
+
+ run:
+ summary: Build, install and launch a debug build in the Android Emulator
+ deps:
+ - task: ensure-emulator
+ - task: build
+ cmds:
+ - task: assemble:apk
+ - '"{{.ADB}}" uninstall {{.APP_ID}} 2>/dev/null || true'
+ - '"{{.ADB}}" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"'
+ - '"{{.ADB}}" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity'
+
+ device:list:
+ summary: Lists connected Android devices and emulators (serials)
+ cmds:
+ - '"{{.ADB}}" devices -l'
+
+ run:device:
+ summary: Build, install and launch a debug build on a connected physical Android device
+ deps:
+ - task: build
+ vars:
+ ARCH: arm64
+ cmds:
+ - task: assemble:apk
+ - |
+ DEVICE='{{.DEVICE_ID | default ""}}'
+ if [ -z "$DEVICE" ]; then
+ DEVICE="${DEVICE_ID:-}"
+ fi
+ if [ -z "$DEVICE" ]; then
+ DEVICE=$("{{.ADB}}" devices | awk 'NR > 1 && $2 == "device" && $1 !~ /^emulator-/ { print $1; exit }')
+ fi
+ if [ -z "$DEVICE" ]; then
+ echo "Error: no connected physical Android device found."
+ echo "Pass DEVICE_ID= to target a device explicitly."
+ echo "Find connected device serials with: {{.ADB}} devices"
+ exit 1
+ fi
+
+ echo "Deploying {{.BIN_DIR}}/{{.APP_NAME}}.apk to device $DEVICE..."
+ "{{.ADB}}" -s "$DEVICE" uninstall {{.APP_ID}} 2>/dev/null || true
+ "{{.ADB}}" -s "$DEVICE" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"
+ "{{.ADB}}" -s "$DEVICE" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity
+ preconditions:
+ - sh: '[ -x "{{.ADB}}" ] || command -v adb'
+ msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)"
+
+ deploy-device:
+ summary: Deploy the packaged release APK to a connected physical Android device
+ deps:
+ - task: package
+ vars:
+ ARCH: arm64
+ cmds:
+ - |
+ DEVICE='{{.DEVICE_ID | default ""}}'
+ if [ -z "$DEVICE" ]; then
+ DEVICE="${DEVICE_ID:-}"
+ fi
+ if [ -z "$DEVICE" ]; then
+ DEVICE=$("{{.ADB}}" devices | awk 'NR > 1 && $2 == "device" && $1 !~ /^emulator-/ { print $1; exit }')
+ fi
+ if [ -z "$DEVICE" ]; then
+ echo "Error: no connected physical Android device found."
+ echo "Pass DEVICE_ID= to target a device explicitly."
+ echo "Find connected device serials with: {{.ADB}} devices"
+ exit 1
+ fi
+
+ echo "Deploying {{.BIN_DIR}}/{{.APP_NAME}}.apk to device $DEVICE..."
+ "{{.ADB}}" -s "$DEVICE" uninstall {{.APP_ID}} 2>/dev/null || true
+ "{{.ADB}}" -s "$DEVICE" install "{{.BIN_DIR}}/{{.APP_NAME}}.apk"
+ "{{.ADB}}" -s "$DEVICE" shell am start -n {{.APP_ID}}/com.wails.app.MainActivity
+ preconditions:
+ - sh: '[ -x "{{.ADB}}" ] || command -v adb'
+ msg: "adb not found. Install the Android SDK platform-tools (or set ANDROID_HOME)"
+
+ studio:
+ summary: Open the generated Android project in Android Studio
+ cmds:
+ - |
+ if command -v studio >/dev/null 2>&1; then
+ studio build/android
+ elif [ -d "/Applications/Android Studio.app" ]; then
+ open -a "Android Studio" build/android
+ else
+ echo "Android Studio not found. Install it from https://developer.android.com/studio,"
+ echo "then open the build/android directory."
+ exit 1
+ fi
+
+ logs:
+ summary: Stream Android logcat filtered to this app
+ cmds:
+ - '"{{.ADB}}" logcat -v time | grep -E "(Wails|{{.APP_NAME}})" || true'
+
+ logs:all:
+ summary: Stream all Android logcat (verbose)
+ cmds:
+ - '"{{.ADB}}" logcat -v time'
+
+ clean:
+ summary: Clean build artifacts
+ cmds:
+ - rm -rf {{.BIN_DIR}}
+ - rm -rf build/android/app/build
+ - rm -rf build/android/app/src/main/jniLibs/*/libwails.so
+ - rm -rf build/android/.gradle
diff --git a/build/android/app/build.gradle b/build/android/app/build.gradle
new file mode 100644
index 0000000..b9d2170
--- /dev/null
+++ b/build/android/app/build.gradle
@@ -0,0 +1,85 @@
+plugins {
+ id 'com.android.application'
+}
+
+android {
+ namespace 'com.wails.app'
+ compileSdk 35
+
+ buildFeatures {
+ buildConfig = true
+ }
+
+ defaultConfig {
+ // Product id (Play Store / package name). Java namespace stays
+ // com.wails.app — that is the Wails Android bridge package.
+ applicationId "com.schollz.croc-desktop"
+ minSdk 21
+ targetSdk 35
+ versionCode 1
+ versionName "1.0"
+
+ // Configure supported ABIs
+ ndk {
+ abiFilters 'arm64-v8a', 'x86_64'
+ }
+ }
+
+ def keystoreFile = System.getenv("ANDROID_KEYSTORE_FILE")
+ def hasKeystore = keystoreFile != null && !keystoreFile.trim().isEmpty()
+
+ signingConfigs {
+ // A real keystore can be provided via environment variables; without
+ // one, release builds are signed with the debug keystore so they can
+ // be installed for testing (not suitable for Play Store uploads).
+ release {
+ if (hasKeystore) {
+ storeFile file(keystoreFile)
+ storePassword System.getenv("ANDROID_KEYSTORE_PASSWORD")
+ keyAlias System.getenv("ANDROID_KEY_ALIAS")
+ keyPassword System.getenv("ANDROID_KEY_PASSWORD")
+ }
+ }
+ }
+
+ buildTypes {
+ release {
+ minifyEnabled false
+ proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
+ signingConfig hasKeystore ? signingConfigs.release : signingConfigs.debug
+ }
+ debug {
+ debuggable true
+ }
+ }
+
+ compileOptions {
+ sourceCompatibility JavaVersion.VERSION_11
+ targetCompatibility JavaVersion.VERSION_11
+ }
+
+ // Source sets configuration
+ sourceSets {
+ main {
+ // JNI libraries are in jniLibs folder
+ jniLibs.srcDirs = ['src/main/jniLibs']
+ // Assets for the WebView
+ assets.srcDirs = ['src/main/assets']
+ }
+ }
+
+ // Packaging options
+ packagingOptions {
+ // Don't strip Go symbols in debug builds
+ doNotStrip '*/arm64-v8a/libwails.so'
+ doNotStrip '*/x86_64/libwails.so'
+ }
+}
+
+dependencies {
+ implementation 'androidx.appcompat:appcompat:1.6.1'
+ implementation 'androidx.webkit:webkit:1.9.0'
+ implementation 'com.google.android.material:material:1.11.0'
+ implementation 'androidx.biometric:biometric:1.1.0'
+ implementation 'androidx.security:security-crypto:1.1.0-alpha06'
+}
diff --git a/build/android/app/proguard-rules.pro b/build/android/app/proguard-rules.pro
new file mode 100644
index 0000000..8b88c3d
--- /dev/null
+++ b/build/android/app/proguard-rules.pro
@@ -0,0 +1,12 @@
+# Add project specific ProGuard rules here.
+# You can control the set of applied configuration files using the
+# proguardFiles setting in build.gradle.
+
+# Keep native methods
+-keepclasseswithmembernames class * {
+ native ;
+}
+
+# Keep Wails bridge classes
+-keep class com.wails.app.WailsBridge { *; }
+-keep class com.wails.app.WailsJSBridge { *; }
diff --git a/build/android/app/src/main/AndroidManifest.xml b/build/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..03d343a
--- /dev/null
+++ b/build/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,63 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/build/android/app/src/main/java/com/wails/app/MainActivity.java b/build/android/app/src/main/java/com/wails/app/MainActivity.java
new file mode 100644
index 0000000..7b71d3c
--- /dev/null
+++ b/build/android/app/src/main/java/com/wails/app/MainActivity.java
@@ -0,0 +1,821 @@
+package com.wails.app;
+
+import android.annotation.SuppressLint;
+import android.content.BroadcastReceiver;
+import android.content.Context;
+import android.content.Intent;
+import android.content.IntentFilter;
+import android.content.res.Configuration;
+import android.database.Cursor;
+import android.net.ConnectivityManager;
+import android.net.Network;
+import android.net.NetworkCapabilities;
+import android.net.Uri;
+import android.os.BatteryManager;
+import android.os.Build;
+import android.os.Bundle;
+import android.os.PowerManager;
+import android.content.pm.PackageManager;
+import android.graphics.Bitmap;
+import android.graphics.BitmapFactory;
+import android.provider.MediaStore;
+import android.provider.OpenableColumns;
+import android.util.Base64;
+import android.util.Log;
+import android.webkit.WebResourceRequest;
+import android.webkit.WebResourceResponse;
+import android.webkit.WebSettings;
+import android.webkit.WebView;
+import android.webkit.WebViewClient;
+
+import androidx.annotation.Nullable;
+import androidx.appcompat.app.AppCompatActivity;
+import androidx.core.content.FileProvider;
+import androidx.webkit.WebViewAssetLoader;
+
+import org.json.JSONObject;
+
+import java.io.File;
+import java.io.FileOutputStream;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * MainActivity hosts the WebView and manages the Wails application lifecycle.
+ * It uses WebViewAssetLoader to serve assets from the Go library without
+ * requiring a network server.
+ */
+public class MainActivity extends AppCompatActivity {
+ private static final String TAG = "WailsActivity";
+ private static final boolean DEBUG = BuildConfig.DEBUG;
+ private static final String WAILS_SCHEME = "https";
+ private static final String WAILS_HOST = "wails.localhost";
+ private static final int FILE_PICKER_REQUEST = 7001;
+
+ private WebView webView;
+ private WailsBridge bridge;
+ // Battery: system-event receivers are registered only while the activity is
+ // in the foreground (onStart) and torn down in onStop, so background battery/
+ // network/screen broadcasts don't wake the app.
+ private boolean systemReceiversRegistered = false;
+ private WebViewAssetLoader assetLoader;
+
+ // The Go-side dialog ID of the in-flight file picker (-1 when idle)
+ private int pendingFilePickerCallbackID = -1;
+ private static final int PHOTO_CAPTURE_REQUEST = 7002;
+ private static final int VIDEO_CAPTURE_REQUEST = 7003;
+ private static final int CAMERA_PERMISSION_REQUEST = 7010;
+ private File pendingCaptureFile;
+ private boolean pendingCaptureIsVideo;
+
+ // System-event sources (battery/power, screen lock, network). Registered in
+ // onCreate, torn down in onDestroy. Each forwards a "system:*" event to JS
+ // via the bridge.
+ private BroadcastReceiver batteryReceiver;
+ private BroadcastReceiver screenReceiver;
+ private BroadcastReceiver powerSaveReceiver;
+ private ConnectivityManager connectivityManager;
+ private ConnectivityManager.NetworkCallback networkCallback;
+
+ @Override
+ protected void onCreate(Bundle savedInstanceState) {
+ super.onCreate(savedInstanceState);
+ setContentView(R.layout.activity_main);
+
+ // Initialize the native Go library
+ bridge = new WailsBridge(this);
+ bridge.initialize();
+
+ // Set up WebView
+ setupWebView();
+
+ // Load the application
+ loadApplication();
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
+ private void setupWebView() {
+ webView = findViewById(R.id.webview);
+ bridge.setWebView(webView);
+
+ // Configure WebView settings
+ WebSettings settings = webView.getSettings();
+ settings.setJavaScriptEnabled(true);
+ settings.setDomStorageEnabled(true);
+ settings.setDatabaseEnabled(true);
+ settings.setAllowFileAccess(false);
+ settings.setAllowContentAccess(false);
+ settings.setMediaPlaybackRequiresUserGesture(false);
+ settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW);
+
+ // Enable debugging in debug builds
+ if (DEBUG) {
+ WebView.setWebContentsDebuggingEnabled(true);
+ }
+
+ // Set up asset loader for serving local assets
+ assetLoader = new WebViewAssetLoader.Builder()
+ .setDomain(WAILS_HOST)
+ .addPathHandler("/", new WailsPathHandler(bridge))
+ .build();
+
+ // Set up WebView client to intercept requests
+ webView.setWebViewClient(new WebViewClient() {
+ @Nullable
+ @Override
+ public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceRequest request) {
+ // Handle wails.localhost requests
+ if (request.getUrl().getHost() != null &&
+ request.getUrl().getHost().equals(WAILS_HOST)) {
+
+ // For wails API calls (runtime, capabilities, etc.) pass the
+ // full URL including the query string, because
+ // WebViewAssetLoader.PathHandler strips query params
+ String path = request.getUrl().getPath();
+ if (path != null && path.startsWith("/wails/")) {
+ String fullPath = path;
+ String query = request.getUrl().getQuery();
+ if (query != null && !query.isEmpty()) {
+ fullPath = path + "?" + query;
+ }
+ if (DEBUG) Log.d(TAG, "Wails API call: " + fullPath);
+
+ byte[] data = bridge.serveAsset(fullPath, request.getMethod(), "{}");
+ if (data != null && data.length > 0) {
+ java.io.InputStream inputStream = new java.io.ByteArrayInputStream(data);
+ java.util.Map headers = new java.util.HashMap<>();
+ headers.put("Access-Control-Allow-Origin", "*");
+ headers.put("Cache-Control", "no-cache");
+ headers.put("Content-Type", "application/json");
+
+ return new WebResourceResponse(
+ "application/json",
+ "UTF-8",
+ 200,
+ "OK",
+ headers,
+ inputStream
+ );
+ }
+ // Return error response if data is null
+ return new WebResourceResponse(
+ "application/json",
+ "UTF-8",
+ 500,
+ "Internal Error",
+ new java.util.HashMap<>(),
+ new java.io.ByteArrayInputStream("{}".getBytes())
+ );
+ }
+
+ // Stream captured photos/videos from the cache with HTTP Range
+ // support so