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 @@

- croc logo + croc logo

croc-desktop

- 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.

- CI - Release - Platforms - Wails v2 + 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)

- Send view with transfer code and QR - Receive view with inline file preview + Download +   + CI +   + Desktop platforms +   + Mobile platforms +   + Wails v3

-Unlike wrappers that spawn the croc CLI, croc-desktop links croc's Go packages -**in-process** (via a fork adding a hooks layer). That gives it things a -subprocess can't have: real accept/overwrite dialogs, structured progress -events, relay hosting, and accurate transfer state. +

+ 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* +

+ Send screen with code and QR +   + Receive screen with file preview +

- History tab - Logs tab with level filter + History of past transfers +   + Live logs

-## 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