Compare commits

..

13 commits

Author SHA1 Message Date
Zach Botterman
51d30325fc
[v2] Notifications API (#4256)
* init v2

* implement macOS and Windows

* minor cleanup

* fix segfault

* linux

* 🐰

* remove windows init

* formatting

* fix win icon

* clean

* clean

* codesign full path

* fix en/decoding and notification types

* changelog & docs

* fix options and channel fix

* update docs

* correct docs

* Update website/docs/reference/runtime/notification.mdx

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* work through rabbit suggestions

* nil checks, cleanups, docs

* locks

* Update v2/internal/frontend/desktop/windows/notifications.go

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

* update js runtime

* update docs

* second pass of comments

* coherent JSON key, icon improv

---------

Co-authored-by: Lea Anthony <lea.anthony@gmail.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-03-15 02:38:32 +00:00
github-actions[bot]
f8595e3052
chore: update sponsors.svg (#5032)
Co-authored-by: leaanthony <1943904+leaanthony@users.noreply.github.com>
2026-03-15 13:07:12 +11:00
github-actions[bot]
4d0abeb37c
chore: update sponsors.svg (#5025)
Co-authored-by: leaanthony <1943904+leaanthony@users.noreply.github.com>
2026-02-28 14:09:40 +11:00
github-actions[bot]
033650d792
chore: update sponsors.svg (#5015)
Co-authored-by: leaanthony <1943904+leaanthony@users.noreply.github.com>
2026-02-26 21:01:45 +11:00
github-actions[bot]
4c49f27edf
chore: update sponsors.svg (#5000)
Co-authored-by: leaanthony <1943904+leaanthony@users.noreply.github.com>
2026-02-22 15:27:44 +11:00
github-actions[bot]
c84578721c
chore: update sponsors.svg (#4999)
Co-authored-by: leaanthony <1943904+leaanthony@users.noreply.github.com>
2026-02-18 21:18:16 +11:00
github-actions[bot]
354fee648e
chore: update sponsors.svg (#4997)
Co-authored-by: leaanthony <1943904+leaanthony@users.noreply.github.com>
2026-02-17 22:39:19 +11:00
github-actions[bot]
da3ce17161
chore: update sponsors.svg (#4993)
Co-authored-by: leaanthony <1943904+leaanthony@users.noreply.github.com>
2026-02-14 18:43:34 +11:00
Lea Anthony
bbd1b33122
Add Claude Code GitHub Workflow (#4988)
* "Claude PR Assistant workflow"

* "Claude Code Review workflow"
2026-02-14 00:55:40 +11:00
github-actions[bot]
ae40ca4ac1
chore: update sponsors.svg (#4980)
Co-authored-by: leaanthony <1943904+leaanthony@users.noreply.github.com>
2026-02-11 21:50:29 +11:00
github-actions[bot]
093aa2d663
chore: update sponsors.svg (#4978)
Co-authored-by: leaanthony <1943904+leaanthony@users.noreply.github.com>
2026-02-09 20:56:49 +11:00
Jay Pipes
e906751c89
update github.com/jaypipes/ghw dependency (#4970)
There's been a ton of improvements in the `ghw` library since the
v0.13.0 release, including the update of certain transitive dependencies
around Windows and Darwin support libraries.

This patch simply brings in those improvements. The `v0.21.3` release of
`ghw` is fully backwards-compatible with `v0.13.0`.

Signed-off-by: Jay Pipes <jaypipes@gmail.com>
2026-02-09 07:49:23 +11:00
Lea Anthony
718fd92f85
fix(v2): prevent wails init in non-empty directory with -d flag (#4955)
* fix(v2): prevent wails init in non-empty directory with -d flag

When using -d to specify a target directory, wails init now checks if
the directory is non-empty and errors if so. This prevents accidental
data loss (e.g., overwriting .git directories).

Fixes #4940

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* test(v2): add tests for init non-empty directory check

Add tests to verify:
- Install fails when target directory is non-empty
- Install succeeds when target directory is empty

Also update changelog with the fix.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* Apply suggestions from code review

Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com>
2026-02-04 21:23:07 +11:00
2902 changed files with 2942 additions and 236176 deletions

View file

@ -0,0 +1,44 @@
name: Claude Code Review
on:
pull_request:
types: [opened, synchronize, ready_for_review, reopened]
# Optional: Only run on specific file changes
# paths:
# - "src/**/*.ts"
# - "src/**/*.tsx"
# - "src/**/*.js"
# - "src/**/*.jsx"
jobs:
claude-review:
# Optional: Filter by PR author
# if: |
# github.event.pull_request.user.login == 'external-contributor' ||
# github.event.pull_request.user.login == 'new-developer' ||
# github.event.pull_request.author_association == 'FIRST_TIME_CONTRIBUTOR'
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
plugin_marketplaces: 'https://github.com/anthropics/claude-code.git'
plugins: 'code-review@claude-code-plugins'
prompt: '/code-review:code-review ${{ github.repository }}/pull/${{ github.event.pull_request.number }}'
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options

50
.github/workflows/claude.yml vendored Normal file
View file

@ -0,0 +1,50 @@
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
issues:
types: [opened, assigned]
pull_request_review:
types: [submitted]
jobs:
claude:
if: |
(github.event_name == 'issue_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review_comment' && contains(github.event.comment.body, '@claude')) ||
(github.event_name == 'pull_request_review' && contains(github.event.review.body, '@claude')) ||
(github.event_name == 'issues' && (contains(github.event.issue.body, '@claude') || contains(github.event.issue.title, '@claude')))
runs-on: ubuntu-latest
permissions:
contents: read
pull-requests: read
issues: read
id-token: write
actions: read # Required for Claude to read CI results on PRs
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 1
- name: Run Claude Code
id: claude
uses: anthropics/claude-code-action@v1
with:
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
# This is an optional setting that allows Claude to read CI results on PRs
additional_permissions: |
actions: read
# Optional: Give a custom prompt to Claude. If this is not specified, Claude will perform the instructions specified in the comment that tagged it.
# prompt: 'Update the pull request description to include a summary of changes.'
# Optional: Add claude_args to customize behavior and configuration
# See https://github.com/anthropics/claude-code-action/blob/main/docs/usage.md
# or https://code.claude.com/docs/en/cli-reference for available options
# claude_args: '--allowed-tools Bash(gh pr:*)'

View file

@ -11,7 +11,6 @@ type Init struct {
InitGit bool `name:"g" description:"Initialise git repository"`
IDE string `name:"ide" description:"Generate IDE project files"`
List bool `name:"l" description:"List templates"`
Force bool `name:"f" description:"Force init in non-empty directory (use with caution)"`
}
func (i *Init) Default() *Init {

View file

@ -105,15 +105,6 @@ func initProject(f *flags.Init) error {
// Try to discover author details from git config
findAuthorDetails(options)
// Safety check: fail if target directory is non-empty
absTargetDir, err := GetAbsoluteTargetDir(f.ProjectDir)
if err != nil {
return fmt.Errorf("failed to resolve target directory path: %w", err)
}
if err := CheckDirectorySafety(absTargetDir, f.Force); err != nil {
return err
}
// Start Time
start := time.Now()

View file

@ -1,51 +0,0 @@
package main
import (
"fmt"
"path/filepath"
"github.com/wailsapp/wails/v2/internal/fs"
)
// GetAbsoluteTargetDir returns the absolute path of the target directory.
// If targetDir is empty, it returns an empty string (no target directory specified).
func GetAbsoluteTargetDir(targetDir string) (string, error) {
if targetDir == "" {
return "", nil
}
return filepath.Abs(targetDir)
}
// CheckDirectorySafety checks if the target directory is safe to initialize a project in.
// It returns an error if the directory exists and is non-empty, unless force is true.
// The absTargetDir should be an absolute path obtained from GetAbsoluteTargetDir.
func CheckDirectorySafety(absTargetDir string, force bool) error {
// If no target directory is specified, the default behavior creates a new directory
// with the project name, so we don't need to check safety
if absTargetDir == "" {
return nil
}
// If directory doesn't exist, it's safe
if !fs.DirExists(absTargetDir) {
return nil
}
// Check if directory is empty
isEmpty, err := fs.DirIsEmpty(absTargetDir)
if err != nil {
return fmt.Errorf("failed to check target directory: %w", err)
}
// If directory is empty, it's safe
if isEmpty {
return nil
}
// Directory is non-empty - fail unless force flag is set
if force {
return nil
}
return fmt.Errorf("target directory '%s' is not empty. Aborting to prevent data loss. Use -f to force init in a non-empty directory", absTargetDir)
}

View file

@ -1,177 +0,0 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestGetAbsoluteTargetDir(t *testing.T) {
tests := []struct {
name string
targetDir string
wantEmpty bool
wantErr bool
}{
{
name: "empty string returns empty",
targetDir: "",
wantEmpty: true,
wantErr: false,
},
{
name: "relative path returns absolute",
targetDir: "relative/path",
wantEmpty: false,
wantErr: false,
},
{
name: "absolute path returns absolute",
targetDir: "/absolute/path",
wantEmpty: false,
wantErr: false,
},
{
name: "current directory returns absolute",
targetDir: ".",
wantEmpty: false,
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
result, err := GetAbsoluteTargetDir(tt.targetDir)
if (err != nil) != tt.wantErr {
t.Errorf("GetAbsoluteTargetDir() error = %v, wantErr %v", err, tt.wantErr)
return
}
if tt.wantEmpty && result != "" {
t.Errorf("GetAbsoluteTargetDir() = %v, want empty string", result)
}
if !tt.wantEmpty {
if result == "" {
t.Error("GetAbsoluteTargetDir() returned empty string, want non-empty")
}
if !filepath.IsAbs(result) {
t.Errorf("GetAbsoluteTargetDir() = %v, want absolute path", result)
}
}
})
}
}
func TestCheckDirectorySafety(t *testing.T) {
// Create a temporary directory for testing
tempDir := t.TempDir()
tests := []struct {
name string
force bool
setup func(t *testing.T) string // returns absolute path to use, may create files
wantErr bool
errMsg string // substring to check in error message
}{
{
name: "empty target dir string - should be safe",
force: false,
setup: func(t *testing.T) string { return "" },
wantErr: false,
},
{
name: "non-existent directory - should be safe",
force: false,
setup: func(t *testing.T) string {
return filepath.Join(tempDir, "nonexistent")
},
wantErr: false,
},
{
name: "empty existing directory - should be safe",
force: false,
setup: func(t *testing.T) string {
dir := filepath.Join(tempDir, "empty_dir")
if err := os.Mkdir(dir, 0755); err != nil {
t.Fatalf("failed to create test directory: %v", err)
}
return dir
},
wantErr: false,
},
{
name: "non-empty directory with force flag - should be safe",
force: true,
setup: func(t *testing.T) string {
dir := filepath.Join(tempDir, "nonempty_force")
if err := os.Mkdir(dir, 0755); err != nil {
t.Fatalf("failed to create test directory: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("content"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
return dir
},
wantErr: false,
},
{
name: "non-empty directory without force - should return error",
force: false,
setup: func(t *testing.T) string {
dir := filepath.Join(tempDir, "nonempty_no_force")
if err := os.Mkdir(dir, 0755); err != nil {
t.Fatalf("failed to create test directory: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, "file.txt"), []byte("content"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
return dir
},
wantErr: true,
errMsg: "Use -f to force",
},
{
name: "non-empty directory with .git folder - should return error",
force: false,
setup: func(t *testing.T) string {
dir := filepath.Join(tempDir, "with_git")
if err := os.Mkdir(dir, 0755); err != nil {
t.Fatalf("failed to create test directory: %v", err)
}
if err := os.Mkdir(filepath.Join(dir, ".git"), 0755); err != nil {
t.Fatalf("failed to create .git directory: %v", err)
}
if err := os.WriteFile(filepath.Join(dir, ".git", "config"), []byte("[core]"), 0644); err != nil {
t.Fatalf("failed to create test file: %v", err)
}
return dir
},
wantErr: true,
errMsg: "not empty",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
absTargetDir := tt.setup(t)
err := CheckDirectorySafety(absTargetDir, tt.force)
// Check error expectation
if (err != nil) != tt.wantErr {
t.Errorf("CheckDirectorySafety() error = %v, wantErr %v", err, tt.wantErr)
return
}
// Check error message contains expected substring
if tt.wantErr && tt.errMsg != "" {
if !strings.Contains(err.Error(), tt.errMsg) {
t.Errorf("CheckDirectorySafety() error = %v, want error containing %q", err, tt.errMsg)
}
}
})
}
}

View file

@ -17,7 +17,7 @@ require (
github.com/google/uuid v1.6.0
github.com/gorilla/websocket v1.5.3
github.com/jackmordaunt/icns v1.0.0
github.com/jaypipes/ghw v0.13.0
github.com/jaypipes/ghw v0.21.3
github.com/labstack/echo/v4 v4.13.3
github.com/labstack/gommon v0.4.2
github.com/leaanthony/clir v1.3.0
@ -51,9 +51,9 @@ require (
atomicgo.dev/keyboard v0.2.9 // indirect
atomicgo.dev/schedule v0.1.0 // indirect
dario.cat/mergo v1.0.0 // indirect
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 // indirect
github.com/Microsoft/go-winio v0.6.1 // indirect
github.com/ProtonMail/go-crypto v1.1.5 // indirect
github.com/StackExchange/wmi v1.2.1 // indirect
github.com/alecthomas/chroma/v2 v2.14.0 // indirect
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/aymerick/douceur v0.2.0 // indirect
@ -72,7 +72,7 @@ require (
github.com/gorilla/css v1.0.1 // indirect
github.com/itchyny/gojq v0.12.13 // indirect
github.com/itchyny/timefmt-go v0.1.5 // indirect
github.com/jaypipes/pcidb v1.0.1 // indirect
github.com/jaypipes/pcidb v1.1.1 // indirect
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 // indirect
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect
github.com/kevinburke/ssh_config v1.2.0 // indirect
@ -82,7 +82,6 @@ require (
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/microcosm-cc/bluemonday v1.0.27 // indirect
github.com/mitchellh/go-homedir v1.1.0 // indirect
github.com/muesli/reflow v0.3.0 // indirect
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
@ -101,6 +100,7 @@ require (
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
github.com/yuin/goldmark v1.7.4 // indirect
github.com/yuin/goldmark-emoji v1.0.3 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/crypto v0.33.0 // indirect
golang.org/x/image v0.12.0 // indirect
golang.org/x/sync v0.11.0 // indirect
@ -108,6 +108,6 @@ require (
golang.org/x/text v0.22.0 // indirect
gopkg.in/warnings.v0 v0.1.2 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
howett.net/plist v1.0.0 // indirect
howett.net/plist v1.0.2-0.20250314012144-ee69052608d9 // indirect
mvdan.cc/sh/v3 v3.7.0 // indirect
)

View file

@ -8,6 +8,8 @@ atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs=
atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU=
dario.cat/mergo v1.0.0 h1:AGCNq9Evsj31mOgNPcLyXc+4PNABt905YmuqPYYpBWk=
dario.cat/mergo v1.0.0/go.mod h1:uNxQE+84aUszobStD9th8a29P2fMDhsBdgRYvZOxGmk=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 h1:N3IGoHHp9pb6mj1cbXbuaSXV/UMKwmbKLf53nQmtqMA=
git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3/go.mod h1:QtOLZGz8olr4qH2vWK0QH0w0O4T9fEIjMuWpKUsH7nc=
github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs=
github.com/MarvinJWendt/testza v0.2.1/go.mod h1:God7bhG8n6uQxwdScay+gjm9/LnO4D3kkcZX4hv9Rp8=
github.com/MarvinJWendt/testza v0.2.8/go.mod h1:nwIcjmr0Zz+Rcwfh3/4UhBp7ePKVhuBExvZqnKYWlII=
@ -24,8 +26,6 @@ github.com/Microsoft/go-winio v0.6.1 h1:9/kr64B9VUZrLm5YYwbGtUJnMgqWVOdUAXu6Migc
github.com/Microsoft/go-winio v0.6.1/go.mod h1:LRdKpFKfdobln8UmuiYcKPot9D2v6svN5+sAH+4kjUM=
github.com/ProtonMail/go-crypto v1.1.5 h1:eoAQfK2dwL+tFSFpr7TbOaPNUbPiJj4fLYwwGE1FQO4=
github.com/ProtonMail/go-crypto v1.1.5/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
github.com/StackExchange/wmi v1.2.1 h1:VIkavFPXSjcnS+O8yTq7NI32k0R5Aj+v39y29VYDOSA=
github.com/StackExchange/wmi v1.2.1/go.mod h1:rcmrprowKIVzvc+NUiLncP2uuArMWLCbu9SBzvHz7e8=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d h1:licZJFw2RwpHMqeKTCYkitsPqHNxTmd4SNR5r94FGM8=
github.com/acarl005/stripansi v0.0.0-20180116102854-5a71ef0e047d/go.mod h1:asat636LX7Bqt5lYEZ27JNDcqxfjdBQuJ/MM4CN/Lzo=
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
@ -88,7 +88,7 @@ github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399 h1:eMj
github.com/go-git/go-git-fixtures/v4 v4.3.2-0.20231010084843-55a94097c399/go.mod h1:1OCfN199q1Jm3HZlxleg+Dw/mwps2Wbk9frAWm+4FII=
github.com/go-git/go-git/v5 v5.13.2 h1:7O7xvsK7K+rZPKW6AQR1YyNhfywkv7B8/FsP3ki6Zv0=
github.com/go-git/go-git/v5 v5.13.2/go.mod h1:hWdW5P4YZRjmpGHwRH2v3zkWcNl6HeXaXQEMGb3NJ9A=
github.com/go-ole/go-ole v1.2.5/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk=
@ -117,10 +117,10 @@ github.com/itchyny/timefmt-go v0.1.5 h1:G0INE2la8S6ru/ZI5JecgyzbbJNs5lG1RcBqa7Jm
github.com/itchyny/timefmt-go v0.1.5/go.mod h1:nEP7L+2YmAbT2kZ2HfSs1d8Xtw9LY8D2stDBckWakZ8=
github.com/jackmordaunt/icns v1.0.0 h1:RYSxplerf/l/DUd09AHtITwckkv/mqjVv4DjYdPmAMQ=
github.com/jackmordaunt/icns v1.0.0/go.mod h1:7TTQVEuGzVVfOPPlLNHJIkzA6CoV7aH1Dv9dW351oOo=
github.com/jaypipes/ghw v0.13.0 h1:log8MXuB8hzTNnSktqpXMHc0c/2k/WgjOMSUtnI1RV4=
github.com/jaypipes/ghw v0.13.0/go.mod h1:In8SsaDqlb1oTyrbmTC14uy+fbBMvp+xdqX51MidlD8=
github.com/jaypipes/pcidb v1.0.1 h1:WB2zh27T3nwg8AE8ei81sNRb9yWBii3JGNJtT7K9Oic=
github.com/jaypipes/pcidb v1.0.1/go.mod h1:6xYUz/yYEyOkIkUt2t2J2folIuZ4Yg6uByCGFXMCeE4=
github.com/jaypipes/ghw v0.21.3 h1:v5mUHM+RN854Vqmk49Uh213jyUA4+8uqaRajlYESsh8=
github.com/jaypipes/ghw v0.21.3/go.mod h1:GPrvwbtPoxYUenr74+nAnWbardIZq600vJDD5HnPsPE=
github.com/jaypipes/pcidb v1.1.1 h1:QmPhpsbmmnCwZmHeYAATxEaoRuiMAJusKYkUncMC0ro=
github.com/jaypipes/pcidb v1.1.1/go.mod h1:x27LT2krrUgjf875KxQXKB0Ha/YXLdZRVmw6hH0G7g8=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A=
github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo=
github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e h1:Q3+PugElBCf4PFpxhErSzU3/PY5sFL5Z6rfv4AbGAck=
@ -178,8 +178,6 @@ github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6T
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwXFM08ygZfk=
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mitchellh/go-homedir v1.1.0 h1:lukF9ziXFxDFPkA1vsr5zpc1XuPDn/wFntq5mG+4E0Y=
github.com/mitchellh/go-homedir v1.1.0/go.mod h1:SfyaCUpYCn1Vlf4IUYiD9fPX4A5wJrkLzIz1N1q0pr0=
github.com/muesli/reflow v0.3.0 h1:IFsN6K9NfGtjeggFP+68I4chLZV2yIKsXJFNZ+eWh6s=
github.com/muesli/reflow v0.3.0/go.mod h1:pbwTDkVPibjO2kyvBQRBxTWEEGDGq0FlB1BIKtnHY/8=
github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a h1:2MaM6YC3mGu54x+RKAA6JiFFHlHDY1UbkxqppT7wYOg=
@ -263,6 +261,8 @@ github.com/yuin/goldmark v1.7.4 h1:BDXOHExt+A7gwPCJgPIIq7ENvceR7we7rOS9TNoLZeg=
github.com/yuin/goldmark v1.7.4/go.mod h1:uzxRWxtg69N339t3louHJ7+O03ezfj6PlliRlaOzY1E=
github.com/yuin/goldmark-emoji v1.0.3 h1:aLRkLHOuBR2czCY4R8olwMjID+tENfhyFDMCRhbIQY4=
github.com/yuin/goldmark-emoji v1.0.3/go.mod h1:tTkZEbwu5wkPmgTcitqddVxY9osFZiavD+r4AzQrh1U=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.0.0-20220622213112-05595931fe9d/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
@ -340,7 +340,6 @@ gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntN
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/warnings.v0 v0.1.2 h1:wFXVbFY8DY5/xOe1ECiWdKCzZlxgshcYVNkBHstARME=
gopkg.in/warnings.v0 v0.1.2/go.mod h1:jksf8JmL6Qr/oQM2OXTHunEvvTAsrWBLb6OOjuVWRNI=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
@ -348,7 +347,7 @@ gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C
gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM=
howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
howett.net/plist v1.0.2-0.20250314012144-ee69052608d9 h1:eeH1AIcPvSc0Z25ThsYF+Xoqbn0CI/YnXVYoTLFdGQw=
howett.net/plist v1.0.2-0.20250314012144-ee69052608d9/go.mod h1:fyFX5Hj5tP1Mpk8obqA9MZgXT416Q5711SDT7dQLTLk=
mvdan.cc/sh/v3 v3.7.0 h1:lSTjdP/1xsddtaKfGg7Myu7DnlHItd3/M2tomOcNNBg=
mvdan.cc/sh/v3 v3.7.0/go.mod h1:K2gwkaesF/D7av7Kxl0HbF5kGOd2ArupNTX3X44+8l8=

View file

@ -69,6 +69,21 @@ void UpdateMenuItem(void* nsmenuitem, int checked);
void RunMainLoop(void);
void ReleaseContext(void *inctx);
/* Notifications */
bool IsNotificationAvailable(void *inctx);
bool CheckBundleIdentifier(void *inctx);
bool EnsureDelegateInitialized(void *inctx);
void RequestNotificationAuthorization(void *inctx, int channelID);
void CheckNotificationAuthorization(void *inctx, int channelID);
void SendNotification(void *inctx, int channelID, const char *identifier, const char *title, const char *subtitle, const char *body, const char *data_json);
void SendNotificationWithActions(void *inctx, int channelID, const char *identifier, const char *title, const char *subtitle, const char *body, const char *categoryId, const char *actions_json);
void RegisterNotificationCategory(void *inctx, int channelID, const char *categoryId, const char *actions_json, bool hasReplyField, const char *replyPlaceholder, const char *replyButtonTitle);
void RemoveNotificationCategory(void *inctx, int channelID, const char *categoryId);
void RemoveAllPendingNotifications(void *inctx);
void RemovePendingNotification(void *inctx, const char *identifier);
void RemoveAllDeliveredNotifications(void *inctx);
void RemoveDeliveredNotification(void *inctx, const char *identifier);
NSString* safeInit(const char* input);
#endif /* Application_h */

View file

@ -367,6 +367,74 @@ void AppendSeparator(void* inMenu) {
}
bool IsNotificationAvailable(void *inctx) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
return [ctx IsNotificationAvailable];
}
bool CheckBundleIdentifier(void *inctx) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
return [ctx CheckBundleIdentifier];
}
bool EnsureDelegateInitialized(void *inctx) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
return [ctx EnsureDelegateInitialized];
}
void RequestNotificationAuthorization(void *inctx, int channelID) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
[ctx RequestNotificationAuthorization:channelID];
}
void CheckNotificationAuthorization(void *inctx, int channelID) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
[ctx CheckNotificationAuthorization:channelID];
}
void SendNotification(void *inctx, int channelID, const char *identifier, const char *title, const char *subtitle, const char *body, const char *data_json) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
[ctx SendNotification:channelID :identifier :title :subtitle :body :data_json];
}
void SendNotificationWithActions(void *inctx, int channelID, const char *identifier, const char *title, const char *subtitle, const char *body, const char *categoryId, const char *actions_json) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
[ctx SendNotificationWithActions:channelID :identifier :title :subtitle :body :categoryId :actions_json];
}
void RegisterNotificationCategory(void *inctx, int channelID, const char *categoryId, const char *actions_json, bool hasReplyField, const char *replyPlaceholder, const char *replyButtonTitle) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
[ctx RegisterNotificationCategory:channelID :categoryId :actions_json :hasReplyField :replyPlaceholder :replyButtonTitle];
}
void RemoveNotificationCategory(void *inctx, int channelID, const char *categoryId) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
[ctx RemoveNotificationCategory:channelID :categoryId];
}
void RemoveAllPendingNotifications(void *inctx) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
[ctx RemoveAllPendingNotifications];
}
void RemovePendingNotification(void *inctx, const char *identifier) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
[ctx RemovePendingNotification:identifier];
}
void RemoveAllDeliveredNotifications(void *inctx) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
[ctx RemoveAllDeliveredNotifications];
}
void RemoveDeliveredNotification(void *inctx, const char *identifier) {
WailsContext *ctx = (__bridge WailsContext*)inctx;
[ctx RemoveDeliveredNotification:identifier];
}
void Run(void *inctx, const char* url) {
WailsContext *ctx = (__bridge WailsContext*) inctx;

View file

@ -92,10 +92,24 @@ struct Preferences {
- (void) ShowApplication;
- (void) Quit;
-(void) MessageDialog :(NSString*)dialogType :(NSString*)title :(NSString*)message :(NSString*)button1 :(NSString*)button2 :(NSString*)button3 :(NSString*)button4 :(NSString*)defaultButton :(NSString*)cancelButton :(void*)iconData :(int)iconDataLength;
- (void) MessageDialog :(NSString*)dialogType :(NSString*)title :(NSString*)message :(NSString*)button1 :(NSString*)button2 :(NSString*)button3 :(NSString*)button4 :(NSString*)defaultButton :(NSString*)cancelButton :(void*)iconData :(int)iconDataLength;
- (void) OpenFileDialog :(NSString*)title :(NSString*)defaultFilename :(NSString*)defaultDirectory :(bool)allowDirectories :(bool)allowFiles :(bool)canCreateDirectories :(bool)treatPackagesAsDirectories :(bool)resolveAliases :(bool)showHiddenFiles :(bool)allowMultipleSelection :(NSString*)filters;
- (void) SaveFileDialog :(NSString*)title :(NSString*)defaultFilename :(NSString*)defaultDirectory :(bool)canCreateDirectories :(bool)treatPackagesAsDirectories :(bool)showHiddenFiles :(NSString*)filters;
- (bool) IsNotificationAvailable;
- (bool) CheckBundleIdentifier;
- (bool) EnsureDelegateInitialized;
- (void) RequestNotificationAuthorization:(int)channelID;
- (void) CheckNotificationAuthorization:(int)channelID;
- (void) SendNotification:(int)channelID :(const char *)identifier :(const char *)title :(const char *)subtitle :(const char *)body :(const char *)dataJSON;
- (void) SendNotificationWithActions:(int)channelID :(const char *)identifier :(const char *)title :(const char *)subtitle :(const char *)body :(const char *)categoryId :(const char *)actionsJSON;
- (void) RegisterNotificationCategory:(int)channelID :(const char *)categoryId :(const char *)actionsJSON :(bool)hasReplyField :(const char *)replyPlaceholder :(const char *)replyButtonTitle;
- (void) RemoveNotificationCategory:(int)channelID :(const char *)categoryId;
- (void) RemoveAllPendingNotifications;
- (void) RemovePendingNotification:(const char *)identifier;
- (void) RemoveAllDeliveredNotifications;
- (void) RemoveDeliveredNotification:(const char *)identifier;
- (void) loadRequest:(NSString*)url;
- (void) ExecJS:(NSString*)script;
- (NSScreen*) getCurrentScreen;

View file

@ -5,6 +5,7 @@
// Created by Lea Anthony on 10/10/21.
//
#include "Application.h"
#import <Foundation/Foundation.h>
#import <WebKit/WebKit.h>
#import "WailsContext.h"
@ -36,6 +37,14 @@ typedef void (^schemeTaskCaller)(id<WKURLSchemeTask>);
@end
// Notifications
#if __MAC_OS_X_VERSION_MAX_ALLOWED >= 101400
#import <UserNotifications/UserNotifications.h>
#endif
extern void captureResult(int channelID, bool success, const char* error);
extern void didReceiveNotificationResponse(const char *jsonPayload, const char* error);
@implementation WailsContext
- (void) SetSize:(int)width :(int)height {
@ -723,6 +732,357 @@ typedef void (^schemeTaskCaller)(id<WKURLSchemeTask>);
}
/***** Notifications ******/
- (bool) IsNotificationAvailable {
if (@available(macOS 10.14, *)) {
return YES;
} else {
return NO;
}
}
- (bool) CheckBundleIdentifier {
NSBundle *main = [NSBundle mainBundle];
if (main.bundleIdentifier == nil) {
return NO;
}
return YES;
}
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
willPresentNotification:(UNNotification *)notification
withCompletionHandler:(void (^)(UNNotificationPresentationOptions options))completionHandler API_AVAILABLE(macos(10.14)) {
UNNotificationPresentationOptions options = UNNotificationPresentationOptionSound;
if (@available(macOS 11.0, *)) {
// These options are only available in macOS 11.0+
options = UNNotificationPresentationOptionList |
UNNotificationPresentationOptionBanner |
UNNotificationPresentationOptionSound;
}
completionHandler(options);
}
- (void)userNotificationCenter:(UNUserNotificationCenter *)center
didReceiveNotificationResponse:(UNNotificationResponse *)response
withCompletionHandler:(void (^)(void))completionHandler API_AVAILABLE(macos(10.14)) {
NSMutableDictionary *payload = [NSMutableDictionary dictionary];
[payload setObject:response.notification.request.identifier forKey:@"id"];
[payload setObject:response.actionIdentifier forKey:@"actionIdentifier"];
[payload setObject:response.notification.request.content.title ?: @"" forKey:@"title"];
[payload setObject:response.notification.request.content.body ?: @"" forKey:@"body"];
if (response.notification.request.content.categoryIdentifier) {
[payload setObject:response.notification.request.content.categoryIdentifier forKey:@"categoryId"];
}
if (response.notification.request.content.subtitle) {
[payload setObject:response.notification.request.content.subtitle forKey:@"subtitle"];
}
if (response.notification.request.content.userInfo) {
[payload setObject:response.notification.request.content.userInfo forKey:@"userInfo"];
}
if ([response isKindOfClass:[UNTextInputNotificationResponse class]]) {
UNTextInputNotificationResponse *textResponse = (UNTextInputNotificationResponse *)response;
[payload setObject:textResponse.userText forKey:@"userText"];
}
NSError *error = nil;
NSData *jsonData = [NSJSONSerialization dataWithJSONObject:payload options:0 error:&error];
if (error) {
NSString *errorMsg = [NSString stringWithFormat:@"Error: %@", [error localizedDescription]];
didReceiveNotificationResponse(NULL, [errorMsg UTF8String]);
} else {
NSString *jsonString = [[NSString alloc] initWithData:jsonData encoding:NSUTF8StringEncoding];
didReceiveNotificationResponse([jsonString UTF8String], NULL);
}
completionHandler();
}
- (bool) EnsureDelegateInitialized {
if (@available(macOS 10.14, *)) {
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
center.delegate = (id<UNUserNotificationCenterDelegate>)self;
return YES;
}
return NO;
}
- (void) RequestNotificationAuthorization :(int)channelID {
if (@available(macOS 10.14, *)) {
if (![self EnsureDelegateInitialized]) {
NSString *errorMsg = @"Notification delegate has been lost. Reinitialize the notification service.";
captureResult(channelID, false, [errorMsg UTF8String]);
return;
}
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
UNAuthorizationOptions options = UNAuthorizationOptionAlert | UNAuthorizationOptionSound | UNAuthorizationOptionBadge;
[center requestAuthorizationWithOptions:options completionHandler:^(BOOL granted, NSError * _Nullable error) {
if (error) {
NSString *errorMsg = [NSString stringWithFormat:@"Error: %@", [error localizedDescription]];
captureResult(channelID, false, [errorMsg UTF8String]);
} else {
captureResult(channelID, granted, NULL);
}
}];
} else {
captureResult(channelID, false, "Notifications not available on macOS versions prior to 10.14");
}
}
- (void) CheckNotificationAuthorization :(int) channelID {
if (@available(macOS 10.14, *)) {
if (![self EnsureDelegateInitialized]) {
NSString *errorMsg = @"Notification delegate has been lost. Reinitialize the notification service.";
captureResult(channelID, false, [errorMsg UTF8String]);
return;
}
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center getNotificationSettingsWithCompletionHandler:^(UNNotificationSettings *settings) {
BOOL isAuthorized = (settings.authorizationStatus == UNAuthorizationStatusAuthorized);
captureResult(channelID, isAuthorized, NULL);
}];
} else {
captureResult(channelID, false, "Notifications not available on macOS versions prior to 10.14");
}
}
- (UNMutableNotificationContent *)createNotificationContent:(const char *)title subtitle:(const char *)subtitle body:(const char *)body dataJSON:(const char *)dataJSON error:(NSError **)contentError API_AVAILABLE(macos(10.14)) {
if (title == NULL) title = "";
if (body == NULL) body = "";
NSString *nsTitle = [NSString stringWithUTF8String:title];
NSString *nsSubtitle = subtitle ? [NSString stringWithUTF8String:subtitle] : @"";
NSString *nsBody = [NSString stringWithUTF8String:body];
UNMutableNotificationContent *content = [[[UNMutableNotificationContent alloc] init] autorelease];
content.title = nsTitle;
if (![nsSubtitle isEqualToString:@""]) {
content.subtitle = nsSubtitle;
}
content.body = nsBody;
content.sound = [UNNotificationSound defaultSound];
// Parse JSON data if provided
if (dataJSON) {
NSString *dataJsonStr = [NSString stringWithUTF8String:dataJSON];
NSData *jsonData = [dataJsonStr dataUsingEncoding:NSUTF8StringEncoding];
NSError *error = nil;
NSDictionary *parsedData = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&error];
if (!error && parsedData) {
content.userInfo = parsedData;
} else if (error) {
if (contentError) *contentError = error;
}
}
return content;
}
- (void) sendNotificationWithRequest:(UNNotificationRequest *)request channelID:(int)channelID API_AVAILABLE(macos(10.14)) {
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center addNotificationRequest:request withCompletionHandler:^(NSError * _Nullable error) {
if (error) {
NSString *errorMsg = [NSString stringWithFormat:@"Error: %@", [error localizedDescription]];
captureResult(channelID, false, [errorMsg UTF8String]);
} else {
captureResult(channelID, true, NULL);
}
}];
}
- (void) SendNotification:(int)channelID :(const char *)identifier :(const char *)title :(const char *)subtitle :(const char *)body :(const char *)dataJSON API_AVAILABLE(macos(10.14)) {
if (![self EnsureDelegateInitialized]) {
NSString *errorMsg = @"Notification delegate has been lost. Reinitialize the notification service.";
captureResult(channelID, false, [errorMsg UTF8String]);
return;
}
NSString *nsIdentifier = [NSString stringWithUTF8String:identifier];
NSError *contentError = nil;
UNMutableNotificationContent *content = [self createNotificationContent:title subtitle:subtitle body:body dataJSON:dataJSON error:&contentError];
if (contentError) {
NSString *errorMsg = [NSString stringWithFormat:@"Error: %@", [contentError localizedDescription]];
captureResult(channelID, false, [errorMsg UTF8String]);
return;
}
UNTimeIntervalNotificationTrigger *trigger = nil;
UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:nsIdentifier content:content trigger:trigger];
[self sendNotificationWithRequest:request channelID:channelID];
}
- (void) SendNotificationWithActions:(int)channelID :(const char *)identifier :(const char *)title :(const char *)subtitle :(const char *)body :(const char *)categoryId :(const char *)dataJSON API_AVAILABLE(macos(10.14)) {
if (![self EnsureDelegateInitialized]) {
NSString *errorMsg = @"Notification delegate has been lost. Reinitialize the notification service.";
captureResult(channelID, false, [errorMsg UTF8String]);
return;
}
NSString *nsIdentifier = [NSString stringWithUTF8String:identifier];
NSString *nsCategoryId = [NSString stringWithUTF8String:categoryId];
NSError *contentError = nil;
UNMutableNotificationContent *content = [self createNotificationContent:title subtitle:subtitle body:body dataJSON:dataJSON error:&contentError];
if (contentError) {
NSString *errorMsg = [NSString stringWithFormat:@"Error: %@", [contentError localizedDescription]];
captureResult(channelID, false, [errorMsg UTF8String]);
return;
}
content.categoryIdentifier = nsCategoryId;
UNTimeIntervalNotificationTrigger *trigger = nil;
UNNotificationRequest *request = [UNNotificationRequest requestWithIdentifier:nsIdentifier content:content trigger:trigger];
[self sendNotificationWithRequest:request channelID:channelID];
}
- (void) RegisterNotificationCategory:(int)channelID :(const char *)categoryId :(const char *)actionsJSON :(bool)hasReplyField :(const char *)replyPlaceholder :(const char *)replyButtonTitle API_AVAILABLE(macos(10.14)) {
if (![self EnsureDelegateInitialized]) {
NSString *errorMsg = @"Notification delegate has been lost. Reinitialize the notification service.";
captureResult(channelID, false, [errorMsg UTF8String]);
return;
}
NSString *nsCategoryId = [NSString stringWithUTF8String:categoryId];
NSString *actionsJsonStr = actionsJSON ? [NSString stringWithUTF8String:actionsJSON] : @"[]";
NSData *jsonData = [actionsJsonStr dataUsingEncoding:NSUTF8StringEncoding];
NSError *error = nil;
NSArray *actionsArray = [NSJSONSerialization JSONObjectWithData:jsonData options:0 error:&error];
if (error) {
NSString *errorMsg = [NSString stringWithFormat:@"Error: %@", [error localizedDescription]];
captureResult(channelID, false, [errorMsg UTF8String]);
return;
}
NSMutableArray *actions = [NSMutableArray array];
for (NSDictionary *actionDict in actionsArray) {
NSString *actionId = actionDict[@"id"];
NSString *actionTitle = actionDict[@"title"];
BOOL destructive = [actionDict[@"destructive"] boolValue];
if (actionId && actionTitle) {
UNNotificationActionOptions options = UNNotificationActionOptionNone;
if (destructive) options |= UNNotificationActionOptionDestructive;
UNNotificationAction *action = [UNNotificationAction actionWithIdentifier:actionId
title:actionTitle
options:options];
[actions addObject:action];
}
}
if (hasReplyField) {
// Defensive NULL checks: if hasReplyField is true, both strings must be non-NULL
if (!replyPlaceholder || !replyButtonTitle) {
NSString *errorMsg = @"hasReplyField is true but replyPlaceholder or replyButtonTitle is NULL";
captureResult(channelID, false, [errorMsg UTF8String]);
return;
}
NSString *placeholder = [NSString stringWithUTF8String:replyPlaceholder];
NSString *buttonTitle = [NSString stringWithUTF8String:replyButtonTitle];
UNTextInputNotificationAction *textAction =
[UNTextInputNotificationAction actionWithIdentifier:@"TEXT_REPLY"
title:buttonTitle
options:UNNotificationActionOptionNone
textInputButtonTitle:buttonTitle
textInputPlaceholder:placeholder];
[actions addObject:textAction];
}
UNNotificationCategory *newCategory = [UNNotificationCategory categoryWithIdentifier:nsCategoryId
actions:actions
intentIdentifiers:@[]
options:UNNotificationCategoryOptionNone];
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center getNotificationCategoriesWithCompletionHandler:^(NSSet<UNNotificationCategory *> *categories) {
NSMutableSet *updatedCategories = [NSMutableSet setWithSet:categories];
// Remove existing category with same identifier if found
UNNotificationCategory *existingCategory = nil;
for (UNNotificationCategory *category in updatedCategories) {
if ([category.identifier isEqualToString:nsCategoryId]) {
existingCategory = category;
break;
}
}
if (existingCategory) {
[updatedCategories removeObject:existingCategory];
}
// Add the new category
[updatedCategories addObject:newCategory];
[center setNotificationCategories:updatedCategories];
captureResult(channelID, true, NULL);
}];
}
- (void) RemoveNotificationCategory:(int)channelID :(const char *)categoryId API_AVAILABLE(macos(10.14)) {
NSString *nsCategoryId = [NSString stringWithUTF8String:categoryId];
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center getNotificationCategoriesWithCompletionHandler:^(NSSet<UNNotificationCategory *> *categories) {
NSMutableSet *updatedCategories = [NSMutableSet setWithSet:categories];
// Find and remove the matching category
UNNotificationCategory *categoryToRemove = nil;
for (UNNotificationCategory *category in updatedCategories) {
if ([category.identifier isEqualToString:nsCategoryId]) {
categoryToRemove = category;
break;
}
}
if (categoryToRemove) {
[updatedCategories removeObject:categoryToRemove];
[center setNotificationCategories:updatedCategories];
captureResult(channelID, true, NULL);
} else {
NSString *errorMsg = [NSString stringWithFormat:@"Category '%@' not found", nsCategoryId];
captureResult(channelID, false, [errorMsg UTF8String]);
}
}];
}
- (void) RemoveAllPendingNotifications API_AVAILABLE(macos(10.14)) {
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center removeAllPendingNotificationRequests];
}
- (void) RemovePendingNotification:(const char *)identifier API_AVAILABLE(macos(10.14)) {
NSString *nsIdentifier = [NSString stringWithUTF8String:identifier];
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center removePendingNotificationRequestsWithIdentifiers:@[nsIdentifier]];
}
- (void) RemoveAllDeliveredNotifications API_AVAILABLE(macos(10.14)) {
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center removeAllDeliveredNotifications];
}
- (void) RemoveDeliveredNotification:(const char *)identifier API_AVAILABLE(macos(10.14)) {
NSString *nsIdentifier = [NSString stringWithUTF8String:identifier];
UNUserNotificationCenter *center = [UNUserNotificationCenter currentNotificationCenter];
[center removeDeliveredNotificationsWithIdentifiers:@[nsIdentifier]];
}
- (void) SetAbout :(NSString*)title :(NSString*)description :(void*)imagedata :(int)datalen {
self.aboutTitle = title;
self.aboutDescription = description;
@ -731,7 +1091,7 @@ typedef void (^schemeTaskCaller)(id<WKURLSchemeTask>);
self.aboutImage = [[NSImage alloc] initWithData:imageData];
}
-(void) About {
- (void) About {
WailsAlert *alert = [WailsAlert new];
[alert setAlertStyle:NSAlertStyleInformational];

View file

@ -1,6 +1,7 @@
//go:build darwin && !ios
//go:build darwin
// +build darwin
package notifications
package darwin
/*
#cgo CFLAGS:-x objective-c
@ -10,25 +11,51 @@ package notifications
#cgo LDFLAGS: -framework UserNotifications
#endif
#import "./notifications_darwin.h"
#import "Application.h"
#import "WailsContext.h"
*/
import "C"
import (
"context"
"encoding/json"
"fmt"
"os"
"sync"
"time"
"unsafe"
"encoding/json"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v2/internal/frontend"
)
type darwinNotifier struct {
// Package-scoped variable only accessible within this file
var (
currentFrontend *Frontend
frontendMutex sync.RWMutex
// Notification channels
channels map[int]chan notificationChannel
channelsLock sync.Mutex
nextChannelID int
notificationResultCallback func(result frontend.NotificationResult)
callbackLock sync.RWMutex
)
const DefaultActionIdentifier = "DEFAULT_ACTION"
const AppleDefaultActionIdentifier = "com.apple.UNNotificationDefaultActionIdentifier"
// setCurrentFrontend sets the current frontend instance
// This is called when RequestNotificationAuthorization or CheckNotificationAuthorization is called
func setCurrentFrontend(f *Frontend) {
frontendMutex.Lock()
defer frontendMutex.Unlock()
currentFrontend = f
}
// getCurrentFrontend gets the current frontend instance
func getCurrentFrontend() *Frontend {
frontendMutex.RLock()
defer frontendMutex.RUnlock()
return currentFrontend
}
type notificationChannel struct {
@ -36,94 +63,77 @@ type notificationChannel struct {
Error error
}
type ChannelHandler interface {
GetChannel(id int) (chan notificationChannel, bool)
}
const AppleDefaultActionIdentifier = "com.apple.UNNotificationDefaultActionIdentifier"
// Creates a new Notifications Service.
// Your app must be packaged and signed for this feature to work.
func New() *NotificationService {
notificationServiceOnce.Do(func() {
impl := &darwinNotifier{
channels: make(map[int]chan notificationChannel),
nextChannelID: 0,
}
NotificationService_ = &NotificationService{
impl: impl,
}
})
return NotificationService_
}
func (dn *darwinNotifier) Startup(ctx context.Context, options application.ServiceOptions) error {
if !isNotificationAvailable() {
func (f *Frontend) InitializeNotifications() error {
if !f.IsNotificationAvailable() {
return fmt.Errorf("notifications are not available on this system")
}
if !checkBundleIdentifier() {
if !f.checkBundleIdentifier() {
return fmt.Errorf("notifications require a valid bundle identifier")
}
if !bool(C.ensureDelegateInitialized()) {
if !bool(C.EnsureDelegateInitialized(f.mainWindow.context)) {
return fmt.Errorf("failed to initialize notification center delegate")
}
channels = make(map[int]chan notificationChannel)
nextChannelID = 0
setCurrentFrontend(f)
return nil
}
func (dn *darwinNotifier) Shutdown() error {
return nil
// CleanupNotifications is a macOS stub that does nothing.
// (Linux-specific cleanup)
func (f *Frontend) CleanupNotifications() {
// No cleanup needed on macOS
}
// isNotificationAvailable checks if notifications are available on the system.
func isNotificationAvailable() bool {
return bool(C.isNotificationAvailable())
func (f *Frontend) IsNotificationAvailable() bool {
return bool(C.IsNotificationAvailable(f.mainWindow.context))
}
func checkBundleIdentifier() bool {
return bool(C.checkBundleIdentifier())
func (f *Frontend) checkBundleIdentifier() bool {
return bool(C.CheckBundleIdentifier(f.mainWindow.context))
}
// RequestNotificationAuthorization requests permission for notifications.
// Default timeout is 3 minutes
func (dn *darwinNotifier) RequestNotificationAuthorization() (bool, error) {
func (f *Frontend) RequestNotificationAuthorization() (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 180*time.Second)
defer cancel()
id, resultCh := dn.registerChannel()
id, resultCh := f.registerChannel()
C.requestNotificationAuthorization(C.int(id))
C.RequestNotificationAuthorization(f.mainWindow.context, C.int(id))
select {
case result := <-resultCh:
close(resultCh)
return result.Success, result.Error
case <-ctx.Done():
dn.cleanupChannel(id)
f.cleanupChannel(id)
return false, fmt.Errorf("notification authorization timed out after 3 minutes: %w", ctx.Err())
}
}
// CheckNotificationAuthorization checks current notification permission status.
func (dn *darwinNotifier) CheckNotificationAuthorization() (bool, error) {
func (f *Frontend) CheckNotificationAuthorization() (bool, error) {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
id, resultCh := dn.registerChannel()
id, resultCh := f.registerChannel()
C.checkNotificationAuthorization(C.int(id))
C.CheckNotificationAuthorization(f.mainWindow.context, C.int(id))
select {
case result := <-resultCh:
close(resultCh)
return result.Success, result.Error
case <-ctx.Done():
dn.cleanupChannel(id)
f.cleanupChannel(id)
return false, fmt.Errorf("notification authorization timed out after 15s: %w", ctx.Err())
}
}
// SendNotification sends a basic notification with a unique identifier, title, subtitle, and body.
func (dn *darwinNotifier) SendNotification(options NotificationOptions) error {
func (f *Frontend) SendNotification(options frontend.NotificationOptions) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@ -146,11 +156,12 @@ func (dn *darwinNotifier) SendNotification(options NotificationOptions) error {
defer C.free(unsafe.Pointer(cDataJSON))
}
id, resultCh := dn.registerChannel()
C.sendNotification(C.int(id), cIdentifier, cTitle, cSubtitle, cBody, cDataJSON)
id, resultCh := f.registerChannel()
C.SendNotification(f.mainWindow.context, C.int(id), cIdentifier, cTitle, cSubtitle, cBody, cDataJSON)
select {
case result := <-resultCh:
close(resultCh)
if !result.Success {
if result.Error != nil {
return result.Error
@ -159,7 +170,7 @@ func (dn *darwinNotifier) SendNotification(options NotificationOptions) error {
}
return nil
case <-ctx.Done():
dn.cleanupChannel(id)
f.cleanupChannel(id)
return fmt.Errorf("sending notification timed out: %w", ctx.Err())
}
}
@ -167,7 +178,7 @@ func (dn *darwinNotifier) SendNotification(options NotificationOptions) error {
// SendNotificationWithActions sends a notification with additional actions and inputs.
// A NotificationCategory must be registered with RegisterNotificationCategory first. The `CategoryID` must match the registered category.
// If a NotificationCategory is not registered a basic notification will be sent.
func (dn *darwinNotifier) SendNotificationWithActions(options NotificationOptions) error {
func (f *Frontend) SendNotificationWithActions(options frontend.NotificationOptions) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@ -192,11 +203,12 @@ func (dn *darwinNotifier) SendNotificationWithActions(options NotificationOption
defer C.free(unsafe.Pointer(cDataJSON))
}
id, resultCh := dn.registerChannel()
C.sendNotificationWithActions(C.int(id), cIdentifier, cTitle, cSubtitle, cBody, cCategoryID, cDataJSON)
id, resultCh := f.registerChannel()
C.SendNotificationWithActions(f.mainWindow.context, C.int(id), cIdentifier, cTitle, cSubtitle, cBody, cCategoryID, cDataJSON)
select {
case result := <-resultCh:
close(resultCh)
if !result.Success {
if result.Error != nil {
return result.Error
@ -205,14 +217,14 @@ func (dn *darwinNotifier) SendNotificationWithActions(options NotificationOption
}
return nil
case <-ctx.Done():
dn.cleanupChannel(id)
f.cleanupChannel(id)
return fmt.Errorf("sending notification timed out: %w", ctx.Err())
}
}
// RegisterNotificationCategory registers a new NotificationCategory to be used with SendNotificationWithActions.
// Registering a category with the same name as a previously registered NotificationCategory will override it.
func (dn *darwinNotifier) RegisterNotificationCategory(category NotificationCategory) error {
func (f *Frontend) RegisterNotificationCategory(category frontend.NotificationCategory) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
@ -234,12 +246,13 @@ func (dn *darwinNotifier) RegisterNotificationCategory(category NotificationCate
defer C.free(unsafe.Pointer(cReplyButtonTitle))
}
id, resultCh := dn.registerChannel()
C.registerNotificationCategory(C.int(id), cCategoryID, cActionsJSON, C.bool(category.HasReplyField),
id, resultCh := f.registerChannel()
C.RegisterNotificationCategory(f.mainWindow.context, C.int(id), cCategoryID, cActionsJSON, C.bool(category.HasReplyField),
cReplyPlaceholder, cReplyButtonTitle)
select {
case result := <-resultCh:
close(resultCh)
if !result.Success {
if result.Error != nil {
return result.Error
@ -248,24 +261,25 @@ func (dn *darwinNotifier) RegisterNotificationCategory(category NotificationCate
}
return nil
case <-ctx.Done():
dn.cleanupChannel(id)
f.cleanupChannel(id)
return fmt.Errorf("category registration timed out: %w", ctx.Err())
}
}
// RemoveNotificationCategory remove a previously registered NotificationCategory.
func (dn *darwinNotifier) RemoveNotificationCategory(categoryId string) error {
func (f *Frontend) RemoveNotificationCategory(categoryId string) error {
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cCategoryID := C.CString(categoryId)
defer C.free(unsafe.Pointer(cCategoryID))
id, resultCh := dn.registerChannel()
C.removeNotificationCategory(C.int(id), cCategoryID)
id, resultCh := f.registerChannel()
C.RemoveNotificationCategory(f.mainWindow.context, C.int(id), cCategoryID)
select {
case result := <-resultCh:
close(resultCh)
if !result.Success {
if result.Error != nil {
return result.Error
@ -274,36 +288,36 @@ func (dn *darwinNotifier) RemoveNotificationCategory(categoryId string) error {
}
return nil
case <-ctx.Done():
dn.cleanupChannel(id)
f.cleanupChannel(id)
return fmt.Errorf("category removal timed out: %w", ctx.Err())
}
}
// RemoveAllPendingNotifications removes all pending notifications.
func (dn *darwinNotifier) RemoveAllPendingNotifications() error {
C.removeAllPendingNotifications()
func (f *Frontend) RemoveAllPendingNotifications() error {
C.RemoveAllPendingNotifications(f.mainWindow.context)
return nil
}
// RemovePendingNotification removes a pending notification matching the unique identifier.
func (dn *darwinNotifier) RemovePendingNotification(identifier string) error {
func (f *Frontend) RemovePendingNotification(identifier string) error {
cIdentifier := C.CString(identifier)
defer C.free(unsafe.Pointer(cIdentifier))
C.removePendingNotification(cIdentifier)
C.RemovePendingNotification(f.mainWindow.context, cIdentifier)
return nil
}
// RemoveAllDeliveredNotifications removes all delivered notifications.
func (dn *darwinNotifier) RemoveAllDeliveredNotifications() error {
C.removeAllDeliveredNotifications()
func (f *Frontend) RemoveAllDeliveredNotifications() error {
C.RemoveAllDeliveredNotifications(f.mainWindow.context)
return nil
}
// RemoveDeliveredNotification removes a delivered notification matching the unique identifier.
func (dn *darwinNotifier) RemoveDeliveredNotification(identifier string) error {
func (f *Frontend) RemoveDeliveredNotification(identifier string) error {
cIdentifier := C.CString(identifier)
defer C.free(unsafe.Pointer(cIdentifier))
C.removeDeliveredNotification(cIdentifier)
C.RemoveDeliveredNotification(f.mainWindow.context, cIdentifier)
return nil
}
@ -314,23 +328,24 @@ func (dn *darwinNotifier) RemoveDeliveredNotification(identifier string) error {
// RemoveAllDeliveredNotifications
// RemoveDeliveredNotification
// (Linux-specific)
func (dn *darwinNotifier) RemoveNotification(identifier string) error {
func (f *Frontend) RemoveNotification(identifier string) error {
return nil
}
func (f *Frontend) OnNotificationResponse(callback func(result frontend.NotificationResult)) {
callbackLock.Lock()
notificationResultCallback = callback
callbackLock.Unlock()
}
//export captureResult
func captureResult(channelID C.int, success C.bool, errorMsg *C.char) {
ns := getNotificationService()
if ns == nil {
f := getCurrentFrontend()
if f == nil {
return
}
handler, ok := ns.impl.(ChannelHandler)
if !ok {
return
}
resultCh, exists := handler.GetChannel(int(channelID))
resultCh, exists := f.GetChannel(int(channelID))
if !exists {
return
}
@ -344,39 +359,32 @@ func captureResult(channelID C.int, success C.bool, errorMsg *C.char) {
Success: bool(success),
Error: err,
}
close(resultCh)
}
//export didReceiveNotificationResponse
func didReceiveNotificationResponse(jsonPayload *C.char, err *C.char) {
result := NotificationResult{}
result := frontend.NotificationResult{}
if err != nil {
errMsg := C.GoString(err)
result.Error = fmt.Errorf("notification response error: %s", errMsg)
if ns := getNotificationService(); ns != nil {
ns.handleNotificationResult(result)
}
handleNotificationResult(result)
return
}
if jsonPayload == nil {
result.Error = fmt.Errorf("received nil JSON payload in notification response")
if ns := getNotificationService(); ns != nil {
ns.handleNotificationResult(result)
}
handleNotificationResult(result)
return
}
payload := C.GoString(jsonPayload)
var response NotificationResponse
var response frontend.NotificationResponse
if err := json.Unmarshal([]byte(payload), &response); err != nil {
result.Error = fmt.Errorf("failed to unmarshal notification response: %w", err)
if ns := getNotificationService(); ns != nil {
ns.handleNotificationResult(result)
}
handleNotificationResult(result)
return
}
@ -385,43 +393,73 @@ func didReceiveNotificationResponse(jsonPayload *C.char, err *C.char) {
}
result.Response = response
if ns := getNotificationService(); ns != nil {
ns.handleNotificationResult(result)
handleNotificationResult(result)
}
func handleNotificationResult(result frontend.NotificationResult) {
callbackLock.Lock()
callback := notificationResultCallback
callbackLock.Unlock()
if callback != nil {
go func() {
defer func() {
if r := recover(); r != nil {
// Log panic but don't crash the app
fmt.Fprintf(os.Stderr, "panic in notification callback: %v\n", r)
}
}()
callback(result)
}()
}
}
// Helper methods
func (dn *darwinNotifier) registerChannel() (int, chan notificationChannel) {
dn.channelsLock.Lock()
defer dn.channelsLock.Unlock()
func (f *Frontend) registerChannel() (int, chan notificationChannel) {
channelsLock.Lock()
defer channelsLock.Unlock()
id := dn.nextChannelID
dn.nextChannelID++
// Initialize channels map if it's nil
if channels == nil {
channels = make(map[int]chan notificationChannel)
nextChannelID = 0
}
id := nextChannelID
nextChannelID++
resultCh := make(chan notificationChannel, 1)
dn.channels[id] = resultCh
channels[id] = resultCh
return id, resultCh
}
func (dn *darwinNotifier) GetChannel(id int) (chan notificationChannel, bool) {
dn.channelsLock.Lock()
defer dn.channelsLock.Unlock()
func (f *Frontend) GetChannel(id int) (chan notificationChannel, bool) {
channelsLock.Lock()
defer channelsLock.Unlock()
ch, exists := dn.channels[id]
if channels == nil {
return nil, false
}
ch, exists := channels[id]
if exists {
delete(dn.channels, id)
delete(channels, id)
}
return ch, exists
}
func (dn *darwinNotifier) cleanupChannel(id int) {
dn.channelsLock.Lock()
defer dn.channelsLock.Unlock()
func (f *Frontend) cleanupChannel(id int) {
channelsLock.Lock()
defer channelsLock.Unlock()
if ch, exists := dn.channels[id]; exists {
delete(dn.channels, id)
if channels == nil {
return
}
if ch, exists := channels[id]; exists {
delete(channels, id)
close(ch)
}
}

View file

@ -1,29 +1,31 @@
//go:build linux
// +build linux
package notifications
package linux
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sync"
"encoding/json"
"github.com/godbus/dbus/v5"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v2/internal/frontend"
)
type linuxNotifier struct {
conn *dbus.Conn
categories map[string]NotificationCategory
categoriesLock sync.RWMutex
notifications map[uint32]*notificationData
notificationsLock sync.RWMutex
appName string
cancel context.CancelFunc
}
var (
conn *dbus.Conn
categories map[string]frontend.NotificationCategory = make(map[string]frontend.NotificationCategory)
categoriesLock sync.RWMutex
notifications map[uint32]*notificationData = make(map[uint32]*notificationData)
notificationsLock sync.RWMutex
notificationResultCallback func(result frontend.NotificationResult)
callbackLock sync.RWMutex
appName string
cancel context.CancelFunc
)
type notificationData struct {
ID string
@ -39,78 +41,75 @@ type notificationData struct {
const (
dbusNotificationInterface = "org.freedesktop.Notifications"
dbusNotificationPath = "/org/freedesktop/Notifications"
DefaultActionIdentifier = "DEFAULT_ACTION"
)
// Creates a new Notifications Service.
func New() *NotificationService {
notificationServiceOnce.Do(func() {
impl := &linuxNotifier{
categories: make(map[string]NotificationCategory),
notifications: make(map[uint32]*notificationData),
}
func (f *Frontend) InitializeNotifications() error {
// Clean up any previous initialization
f.CleanupNotifications()
NotificationService_ = &NotificationService{
impl: impl,
}
})
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get executable: %w", err)
}
appName = filepath.Base(exe)
return NotificationService_
}
// Startup is called when the service is loaded.
func (ln *linuxNotifier) Startup(ctx context.Context, options application.ServiceOptions) error {
ln.appName = application.Get().Config().Name
conn, err := dbus.ConnectSessionBus()
_conn, err := dbus.ConnectSessionBus()
if err != nil {
return fmt.Errorf("failed to connect to session bus: %w", err)
}
ln.conn = conn
conn = _conn
if err := ln.loadCategories(); err != nil {
fmt.Printf("Failed to load notification categories: %v\n", err)
if err := f.loadCategories(); err != nil {
f.logger.Warning("Failed to load notification categories: %v", err)
}
var signalCtx context.Context
signalCtx, ln.cancel = context.WithCancel(context.Background())
signalCtx, cancel = context.WithCancel(context.Background())
if err := ln.setupSignalHandling(signalCtx); err != nil {
if err := f.setupSignalHandling(signalCtx); err != nil {
return fmt.Errorf("failed to set up notification signal handling: %w", err)
}
return nil
}
// Shutdown will save categories and close the D-Bus connection when the service unloads.
func (ln *linuxNotifier) Shutdown() error {
if ln.cancel != nil {
ln.cancel()
// CleanupNotifications cleans up notification resources
func (f *Frontend) CleanupNotifications() {
if cancel != nil {
cancel()
cancel = nil
}
if err := ln.saveCategories(); err != nil {
fmt.Printf("Failed to save notification categories: %v\n", err)
if conn != nil {
conn.Close()
conn = nil
}
}
if ln.conn != nil {
return ln.conn.Close()
}
return nil
func (f *Frontend) IsNotificationAvailable() bool {
return true
}
// RequestNotificationAuthorization is a Linux stub that always returns true, nil.
// (authorization is macOS-specific)
func (ln *linuxNotifier) RequestNotificationAuthorization() (bool, error) {
func (f *Frontend) RequestNotificationAuthorization() (bool, error) {
return true, nil
}
// CheckNotificationAuthorization is a Linux stub that always returns true.
// (authorization is macOS-specific)
func (ln *linuxNotifier) CheckNotificationAuthorization() (bool, error) {
func (f *Frontend) CheckNotificationAuthorization() (bool, error) {
return true, nil
}
// SendNotification sends a basic notification with a unique identifier, title, subtitle, and body.
func (ln *linuxNotifier) SendNotification(options NotificationOptions) error {
func (f *Frontend) SendNotification(options frontend.NotificationOptions) error {
if conn == nil {
return fmt.Errorf("notifications not initialized")
}
hints := map[string]dbus.Variant{}
body := options.Body
@ -135,11 +134,11 @@ func (ln *linuxNotifier) SendNotification(options NotificationOptions) error {
}
// Call the Notify method on the D-Bus interface
obj := ln.conn.Object(dbusNotificationInterface, dbusNotificationPath)
obj := conn.Object(dbusNotificationInterface, dbusNotificationPath)
call := obj.Call(
dbusNotificationInterface+".Notify",
0,
ln.appName,
appName,
uint32(0),
"", // Icon
options.Title,
@ -168,22 +167,26 @@ func (ln *linuxNotifier) SendNotification(options NotificationOptions) error {
ActionMap: actionMap,
}
ln.notificationsLock.Lock()
ln.notifications[dbusID] = notification
ln.notificationsLock.Unlock()
notificationsLock.Lock()
notifications[dbusID] = notification
notificationsLock.Unlock()
return nil
}
// SendNotificationWithActions sends a notification with additional actions.
func (ln *linuxNotifier) SendNotificationWithActions(options NotificationOptions) error {
ln.categoriesLock.RLock()
category, exists := ln.categories[options.CategoryID]
ln.categoriesLock.RUnlock()
func (f *Frontend) SendNotificationWithActions(options frontend.NotificationOptions) error {
if conn == nil {
return fmt.Errorf("notifications not initialized")
}
categoriesLock.RLock()
category, exists := categories[options.CategoryID]
categoriesLock.RUnlock()
if options.CategoryID == "" || !exists {
// Fall back to basic notification
return ln.SendNotification(options)
return f.SendNotification(options)
}
body := options.Body
@ -216,11 +219,11 @@ func (ln *linuxNotifier) SendNotificationWithActions(options NotificationOptions
}
}
obj := ln.conn.Object(dbusNotificationInterface, dbusNotificationPath)
obj := conn.Object(dbusNotificationInterface, dbusNotificationPath)
call := obj.Call(
dbusNotificationInterface+".Notify",
0,
ln.appName,
appName,
uint32(0),
"", // Icon
options.Title,
@ -250,95 +253,106 @@ func (ln *linuxNotifier) SendNotificationWithActions(options NotificationOptions
ActionMap: actionMap,
}
ln.notificationsLock.Lock()
ln.notifications[dbusID] = notification
ln.notificationsLock.Unlock()
notificationsLock.Lock()
notifications[dbusID] = notification
notificationsLock.Unlock()
return nil
}
// RegisterNotificationCategory registers a new NotificationCategory to be used with SendNotificationWithActions.
func (ln *linuxNotifier) RegisterNotificationCategory(category NotificationCategory) error {
ln.categoriesLock.Lock()
ln.categories[category.ID] = category
ln.categoriesLock.Unlock()
func (f *Frontend) RegisterNotificationCategory(category frontend.NotificationCategory) error {
categoriesLock.Lock()
categories[category.ID] = category
categoriesLock.Unlock()
if err := ln.saveCategories(); err != nil {
fmt.Printf("Failed to save notification categories: %v\n", err)
if err := f.saveCategories(); err != nil {
f.logger.Warning("Failed to save notification categories: %v", err)
}
return nil
}
// RemoveNotificationCategory removes a previously registered NotificationCategory.
func (ln *linuxNotifier) RemoveNotificationCategory(categoryId string) error {
ln.categoriesLock.Lock()
delete(ln.categories, categoryId)
ln.categoriesLock.Unlock()
func (f *Frontend) RemoveNotificationCategory(categoryId string) error {
categoriesLock.Lock()
delete(categories, categoryId)
categoriesLock.Unlock()
if err := ln.saveCategories(); err != nil {
fmt.Printf("Failed to save notification categories: %v\n", err)
if err := f.saveCategories(); err != nil {
f.logger.Warning("Failed to save notification categories: %v", err)
}
return nil
}
// RemoveAllPendingNotifications attempts to remove all active notifications.
func (ln *linuxNotifier) RemoveAllPendingNotifications() error {
ln.notificationsLock.Lock()
dbusIDs := make([]uint32, 0, len(ln.notifications))
for id := range ln.notifications {
func (f *Frontend) RemoveAllPendingNotifications() error {
notificationsLock.Lock()
dbusIDs := make([]uint32, 0, len(notifications))
for id := range notifications {
dbusIDs = append(dbusIDs, id)
}
ln.notificationsLock.Unlock()
notificationsLock.Unlock()
for _, id := range dbusIDs {
ln.closeNotification(id)
f.closeNotification(id)
}
return nil
}
// RemovePendingNotification removes a pending notification.
func (ln *linuxNotifier) RemovePendingNotification(identifier string) error {
func (f *Frontend) RemovePendingNotification(identifier string) error {
var dbusID uint32
found := false
ln.notificationsLock.Lock()
for id, notif := range ln.notifications {
notificationsLock.Lock()
for id, notif := range notifications {
if notif.ID == identifier {
dbusID = id
found = true
break
}
}
ln.notificationsLock.Unlock()
notificationsLock.Unlock()
if !found {
return nil
}
return ln.closeNotification(dbusID)
return f.closeNotification(dbusID)
}
// RemoveAllDeliveredNotifications functionally equivalent to RemoveAllPendingNotification on Linux.
func (ln *linuxNotifier) RemoveAllDeliveredNotifications() error {
return ln.RemoveAllPendingNotifications()
func (f *Frontend) RemoveAllDeliveredNotifications() error {
return f.RemoveAllPendingNotifications()
}
// RemoveDeliveredNotification functionally equivalent RemovePendingNotification on Linux.
func (ln *linuxNotifier) RemoveDeliveredNotification(identifier string) error {
return ln.RemovePendingNotification(identifier)
func (f *Frontend) RemoveDeliveredNotification(identifier string) error {
return f.RemovePendingNotification(identifier)
}
// RemoveNotification removes a notification by identifier.
func (ln *linuxNotifier) RemoveNotification(identifier string) error {
return ln.RemovePendingNotification(identifier)
func (f *Frontend) RemoveNotification(identifier string) error {
return f.RemovePendingNotification(identifier)
}
func (f *Frontend) OnNotificationResponse(callback func(result frontend.NotificationResult)) {
callbackLock.Lock()
defer callbackLock.Unlock()
notificationResultCallback = callback
}
// Helper method to close a notification.
func (ln *linuxNotifier) closeNotification(id uint32) error {
obj := ln.conn.Object(dbusNotificationInterface, dbusNotificationPath)
func (f *Frontend) closeNotification(id uint32) error {
if conn == nil {
return fmt.Errorf("notifications not initialized")
}
obj := conn.Object(dbusNotificationInterface, dbusNotificationPath)
call := obj.Call(dbusNotificationInterface+".CloseNotification", 0, id)
if call.Err != nil {
@ -348,13 +362,13 @@ func (ln *linuxNotifier) closeNotification(id uint32) error {
return nil
}
func (ln *linuxNotifier) getConfigDir() (string, error) {
func (f *Frontend) getConfigDir() (string, error) {
configDir, err := os.UserConfigDir()
if err != nil {
return "", fmt.Errorf("failed to get user config directory: %w", err)
}
appConfigDir := filepath.Join(configDir, ln.appName)
appConfigDir := filepath.Join(configDir, appName)
if err := os.MkdirAll(appConfigDir, 0755); err != nil {
return "", fmt.Errorf("failed to create app config directory: %w", err)
}
@ -363,17 +377,17 @@ func (ln *linuxNotifier) getConfigDir() (string, error) {
}
// Save notification categories.
func (ln *linuxNotifier) saveCategories() error {
configDir, err := ln.getConfigDir()
func (f *Frontend) saveCategories() error {
configDir, err := f.getConfigDir()
if err != nil {
return err
}
categoriesFile := filepath.Join(configDir, "notification-categories.json")
ln.categoriesLock.RLock()
categoriesData, err := json.MarshalIndent(ln.categories, "", " ")
ln.categoriesLock.RUnlock()
categoriesLock.RLock()
categoriesData, err := json.MarshalIndent(categories, "", " ")
categoriesLock.RUnlock()
if err != nil {
return fmt.Errorf("failed to marshal notification categories: %w", err)
@ -387,8 +401,8 @@ func (ln *linuxNotifier) saveCategories() error {
}
// Load notification categories.
func (ln *linuxNotifier) loadCategories() error {
configDir, err := ln.getConfigDir()
func (f *Frontend) loadCategories() error {
configDir, err := f.getConfigDir()
if err != nil {
return err
}
@ -404,28 +418,28 @@ func (ln *linuxNotifier) loadCategories() error {
return fmt.Errorf("failed to read notification categories from disk: %w", err)
}
categories := make(map[string]NotificationCategory)
if err := json.Unmarshal(categoriesData, &categories); err != nil {
_categories := make(map[string]frontend.NotificationCategory)
if err := json.Unmarshal(categoriesData, &_categories); err != nil {
return fmt.Errorf("failed to unmarshal notification categories: %w", err)
}
ln.categoriesLock.Lock()
ln.categories = categories
ln.categoriesLock.Unlock()
categoriesLock.Lock()
categories = _categories
categoriesLock.Unlock()
return nil
}
// Setup signal handling for notification actions.
func (ln *linuxNotifier) setupSignalHandling(ctx context.Context) error {
if err := ln.conn.AddMatchSignal(
func (f *Frontend) setupSignalHandling(ctx context.Context) error {
if err := conn.AddMatchSignal(
dbus.WithMatchInterface(dbusNotificationInterface),
dbus.WithMatchMember("ActionInvoked"),
); err != nil {
return err
}
if err := ln.conn.AddMatchSignal(
if err := conn.AddMatchSignal(
dbus.WithMatchInterface(dbusNotificationInterface),
dbus.WithMatchMember("NotificationClosed"),
); err != nil {
@ -433,15 +447,15 @@ func (ln *linuxNotifier) setupSignalHandling(ctx context.Context) error {
}
c := make(chan *dbus.Signal, 10)
ln.conn.Signal(c)
conn.Signal(c)
go ln.handleSignals(ctx, c)
go f.handleSignals(ctx, c)
return nil
}
// Handle incoming D-Bus signals.
func (ln *linuxNotifier) handleSignals(ctx context.Context, c chan *dbus.Signal) {
func (f *Frontend) handleSignals(ctx context.Context, c chan *dbus.Signal) {
for {
select {
case <-ctx.Done():
@ -453,16 +467,16 @@ func (ln *linuxNotifier) handleSignals(ctx context.Context, c chan *dbus.Signal)
switch signal.Name {
case dbusNotificationInterface + ".ActionInvoked":
ln.handleActionInvoked(signal)
f.handleActionInvoked(signal)
case dbusNotificationInterface + ".NotificationClosed":
ln.handleNotificationClosed(signal)
f.handleNotificationClosed(signal)
}
}
}
}
// Handle ActionInvoked signal.
func (ln *linuxNotifier) handleActionInvoked(signal *dbus.Signal) {
func (f *Frontend) handleActionInvoked(signal *dbus.Signal) {
if len(signal.Body) < 2 {
return
}
@ -477,12 +491,12 @@ func (ln *linuxNotifier) handleActionInvoked(signal *dbus.Signal) {
return
}
ln.notificationsLock.Lock()
notification, exists := ln.notifications[dbusID]
notificationsLock.Lock()
notification, exists := notifications[dbusID]
if exists {
delete(ln.notifications, dbusID)
delete(notifications, dbusID)
}
ln.notificationsLock.Unlock()
notificationsLock.Unlock()
if !exists {
return
@ -493,7 +507,7 @@ func (ln *linuxNotifier) handleActionInvoked(signal *dbus.Signal) {
appActionID = actionID
}
response := NotificationResponse{
response := frontend.NotificationResponse{
ID: notification.ID,
ActionIdentifier: appActionID,
Title: notification.Title,
@ -503,12 +517,28 @@ func (ln *linuxNotifier) handleActionInvoked(signal *dbus.Signal) {
UserInfo: notification.Data,
}
result := NotificationResult{
result := frontend.NotificationResult{
Response: response,
}
if ns := getNotificationService(); ns != nil {
ns.handleNotificationResult(result)
handleNotificationResult(result)
}
func handleNotificationResult(result frontend.NotificationResult) {
callbackLock.Lock()
callback := notificationResultCallback
callbackLock.Unlock()
if callback != nil {
go func() {
defer func() {
if r := recover(); r != nil {
// Log panic but don't crash the app
fmt.Fprintf(os.Stderr, "panic in notification callback: %v\n", r)
}
}()
callback(result)
}()
}
}
@ -518,7 +548,7 @@ func (ln *linuxNotifier) handleActionInvoked(signal *dbus.Signal) {
// 2 - dismissed by user (click on X)
// 3 - closed by CloseNotification call
// 4 - undefined/reserved
func (ln *linuxNotifier) handleNotificationClosed(signal *dbus.Signal) {
func (f *Frontend) handleNotificationClosed(signal *dbus.Signal) {
if len(signal.Body) < 2 {
return
}
@ -533,19 +563,19 @@ func (ln *linuxNotifier) handleNotificationClosed(signal *dbus.Signal) {
reason = 0 // Unknown reason
}
ln.notificationsLock.Lock()
notification, exists := ln.notifications[dbusID]
notificationsLock.Lock()
notification, exists := notifications[dbusID]
if exists {
delete(ln.notifications, dbusID)
delete(notifications, dbusID)
}
ln.notificationsLock.Unlock()
notificationsLock.Unlock()
if !exists {
return
}
if reason == 2 {
response := NotificationResponse{
response := frontend.NotificationResponse{
ID: notification.ID,
ActionIdentifier: DefaultActionIdentifier,
Title: notification.Title,
@ -555,12 +585,10 @@ func (ln *linuxNotifier) handleNotificationClosed(signal *dbus.Signal) {
UserInfo: notification.Data,
}
result := NotificationResult{
result := frontend.NotificationResult{
Response: response,
}
if ns := getNotificationService(); ns != nil {
ns.handleNotificationResult(result)
}
handleNotificationResult(result)
}
}

View file

@ -1,34 +1,44 @@
//go:build windows
// +build windows
package notifications
package windows
import (
"context"
"encoding/base64"
"encoding/json"
"log"
"sync"
wintoast "git.sr.ht/~jackmordaunt/go-toast/v2/wintoast"
"github.com/google/uuid"
"github.com/wailsapp/wails/v2/internal/frontend"
"github.com/wailsapp/wails/v2/internal/frontend/desktop/windows/winc"
"github.com/wailsapp/wails/v2/internal/frontend/desktop/windows/winc/w32"
"fmt"
"os"
"path/filepath"
"sync"
_ "unsafe"
"encoding/json"
_ "unsafe" // for go:linkname
"git.sr.ht/~jackmordaunt/go-toast/v2"
wintoast "git.sr.ht/~jackmordaunt/go-toast/v2/wintoast"
"github.com/google/uuid"
"github.com/wailsapp/wails/v3/pkg/application"
"github.com/wailsapp/wails/v3/pkg/w32"
"golang.org/x/sys/windows/registry"
)
type windowsNotifier struct {
categories map[string]NotificationCategory
var (
categories map[string]frontend.NotificationCategory
categoriesLock sync.RWMutex
appName string
appGUID string
iconPath string
iconPath string = ""
exePath string
}
iconOnce sync.Once
iconErr error
notificationResultCallback func(result frontend.NotificationResult)
callbackLock sync.RWMutex
)
const DefaultActionIdentifier = "DEFAULT_ACTION"
const (
ToastRegistryPath = `Software\Classes\AppUserModelId\`
@ -39,74 +49,50 @@ const (
// NotificationPayload combines the action ID and user data into a single structure
type NotificationPayload struct {
Action string `json:"action"`
Options NotificationOptions `json:"payload,omitempty"`
Action string `json:"action"`
Options frontend.NotificationOptions `json:"payload,omitempty"`
}
// Creates a new Notifications Service.
func New() *NotificationService {
notificationServiceOnce.Do(func() {
impl := &windowsNotifier{
categories: make(map[string]NotificationCategory),
}
NotificationService_ = &NotificationService{
impl: impl,
}
})
return NotificationService_
}
//go:linkname registerFactoryInternal git.sr.ht/~jackmordaunt/go-toast/v2/wintoast.registerClassFactory
func registerFactoryInternal(factory *wintoast.IClassFactory) error
// Startup is called when the service is loaded
// Sets an activation callback to emit an event when notifications are interacted with.
func (wn *windowsNotifier) Startup(ctx context.Context, options application.ServiceOptions) error {
wn.categoriesLock.Lock()
defer wn.categoriesLock.Unlock()
app := application.Get()
cfg := app.Config()
wn.appName = cfg.Name
guid, err := wn.getGUID()
if err != nil {
return err
}
wn.appGUID = guid
wn.iconPath = filepath.Join(os.TempDir(), wn.appName+wn.appGUID+".png")
func (f *Frontend) InitializeNotifications() error {
categoriesLock.Lock()
defer categoriesLock.Unlock()
categories = make(map[string]frontend.NotificationCategory)
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("failed to get executable path: %w", err)
return fmt.Errorf("failed to get executable: %w", err)
}
wn.exePath = exe
exePath = exe
appName = filepath.Base(exePath)
appGUID, err = getGUID()
if err != nil {
return err
}
iconPath = filepath.Join(os.TempDir(), appName+appGUID+".png")
// Create the registry key for the toast activator
key, _, err := registry.CreateKey(registry.CURRENT_USER,
`Software\Classes\CLSID\`+wn.appGUID+`\LocalServer32`, registry.ALL_ACCESS)
`Software\Classes\CLSID\`+appGUID+`\LocalServer32`, registry.ALL_ACCESS)
if err != nil {
return fmt.Errorf("failed to create CLSID key: %w", err)
}
defer key.Close()
if err := key.SetStringValue("", fmt.Sprintf("\"%s\" %%1", wn.exePath)); err != nil {
if err := key.SetStringValue("", fmt.Sprintf("\"%s\" %%1", exePath)); err != nil {
return fmt.Errorf("failed to set CLSID server path: %w", err)
}
key.Close()
toast.SetAppData(toast.AppData{
AppID: wn.appName,
GUID: guid,
IconPath: wn.iconPath,
ActivationExe: wn.exePath,
AppID: appName,
GUID: appGUID,
IconPath: iconPath,
ActivationExe: exePath,
})
toast.SetActivationCallback(func(args string, data []toast.UserData) {
result := NotificationResult{}
result := frontend.NotificationResult{}
actionIdentifier, options, err := parseNotificationResponse(args)
@ -114,7 +100,7 @@ func (wn *windowsNotifier) Startup(ctx context.Context, options application.Serv
result.Error = err
} else {
// Subtitle is retained but was not shown with the notification
response := NotificationResponse{
response := frontend.NotificationResponse{
ID: options.ID,
ActionIdentifier: actionIdentifier,
Title: options.Title,
@ -124,51 +110,62 @@ func (wn *windowsNotifier) Startup(ctx context.Context, options application.Serv
UserInfo: options.Data,
}
if userText, found := wn.getUserText(data); found {
if userText, found := getUserText(data); found {
response.UserText = userText
}
result.Response = response
}
if ns := getNotificationService(); ns != nil {
ns.handleNotificationResult(result)
}
handleNotificationResult(result)
})
// Register the class factory for the toast activator
if err := registerFactoryInternal(wintoast.ClassFactory); err != nil {
// Register the COM class factory for toast activation.
// This is required for Windows to activate the app when users interact with notifications.
// The go-toast library's SetAppData and SetActivationCallback handle the callback setup,
// but the COM class factory registration is not exposed via public APIs, so we use
// go:linkname to access the internal registerClassFactory function.
if err := registerToastClassFactory(wintoast.ClassFactory); err != nil {
return fmt.Errorf("CoRegisterClassObject failed: %w", err)
}
return wn.loadCategoriesFromRegistry()
return loadCategoriesFromRegistry()
}
// Shutdown will attempt to save the categories to the registry when the service unloads
func (wn *windowsNotifier) Shutdown() error {
wn.categoriesLock.Lock()
defer wn.categoriesLock.Unlock()
// registerToastClassFactory registers the COM class factory required for Windows toast notification activation.
// This function uses go:linkname to access the unexported registerClassFactory function from go-toast.
// The class factory is necessary for Windows COM activation when users click notification actions.
// Without this registration, notification actions will not activate the application.
//
// This is a workaround until go-toast exports this functionality via a public API.
// See: https://git.sr.ht/~jackmordaunt/go-toast
//
//go:linkname registerToastClassFactory git.sr.ht/~jackmordaunt/go-toast/v2/wintoast.registerClassFactory
func registerToastClassFactory(factory *wintoast.IClassFactory) error
return wn.saveCategoriesToRegistry()
// CleanupNotifications is a Windows stub that does nothing.
// (Linux-specific cleanup)
func (f *Frontend) CleanupNotifications() {
// No cleanup needed on Windows
}
// RequestNotificationAuthorization is a Windows stub that always returns true, nil.
// (user authorization is macOS-specific)
func (wn *windowsNotifier) RequestNotificationAuthorization() (bool, error) {
func (f *Frontend) IsNotificationAvailable() bool {
return true
}
func (f *Frontend) RequestNotificationAuthorization() (bool, error) {
return true, nil
}
// CheckNotificationAuthorization is a Windows stub that always returns true.
// (user authorization is macOS-specific)
func (wn *windowsNotifier) CheckNotificationAuthorization() (bool, error) {
func (f *Frontend) CheckNotificationAuthorization() (bool, error) {
return true, nil
}
// SendNotification sends a basic notification with a name, title, and body. All other options are ignored on Windows.
// (subtitle is only available on macOS and Linux)
func (wn *windowsNotifier) SendNotification(options NotificationOptions) error {
if err := wn.saveIconToDir(); err != nil {
fmt.Printf("Error saving icon: %v\n", err)
func (f *Frontend) SendNotification(options frontend.NotificationOptions) error {
if err := f.saveIconToDir(); err != nil {
f.logger.Warning("Error saving icon: %v", err)
}
n := toast.Notification{
@ -178,7 +175,7 @@ func (wn *windowsNotifier) SendNotification(options NotificationOptions) error {
ActivationArguments: DefaultActionIdentifier,
}
encodedPayload, err := wn.encodePayload(DefaultActionIdentifier, options)
encodedPayload, err := encodePayload(DefaultActionIdentifier, options)
if err != nil {
return fmt.Errorf("failed to encode notification payload: %w", err)
}
@ -191,17 +188,18 @@ func (wn *windowsNotifier) SendNotification(options NotificationOptions) error {
// A NotificationCategory must be registered with RegisterNotificationCategory first. The `CategoryID` must match the registered category.
// If a NotificationCategory is not registered a basic notification will be sent.
// (subtitle is only available on macOS and Linux)
func (wn *windowsNotifier) SendNotificationWithActions(options NotificationOptions) error {
if err := wn.saveIconToDir(); err != nil {
fmt.Printf("Error saving icon: %v\n", err)
func (f *Frontend) SendNotificationWithActions(options frontend.NotificationOptions) error {
if err := f.saveIconToDir(); err != nil {
f.logger.Warning("Error saving icon: %v", err)
}
wn.categoriesLock.RLock()
nCategory, categoryExists := wn.categories[options.CategoryID]
wn.categoriesLock.RUnlock()
categoriesLock.RLock()
nCategory, categoryExists := categories[options.CategoryID]
categoriesLock.RUnlock()
if options.CategoryID == "" || !categoryExists {
fmt.Printf("Category '%s' not found, sending basic notification without actions\n", options.CategoryID)
f.logger.Warning("Category '%s' not found, sending basic notification without actions", options.CategoryID)
return f.SendNotification(options)
}
n := toast.Notification{
@ -231,14 +229,14 @@ func (wn *windowsNotifier) SendNotificationWithActions(options NotificationOptio
})
}
encodedPayload, err := wn.encodePayload(n.ActivationArguments, options)
encodedPayload, err := encodePayload(n.ActivationArguments, options)
if err != nil {
return fmt.Errorf("failed to encode notification payload: %w", err)
}
n.ActivationArguments = encodedPayload
for index := range n.Actions {
encodedPayload, err := wn.encodePayload(n.Actions[index].Arguments, options)
encodedPayload, err := encodePayload(n.Actions[index].Arguments, options)
if err != nil {
return fmt.Errorf("failed to encode notification payload: %w", err)
}
@ -250,117 +248,85 @@ func (wn *windowsNotifier) SendNotificationWithActions(options NotificationOptio
// RegisterNotificationCategory registers a new NotificationCategory to be used with SendNotificationWithActions.
// Registering a category with the same name as a previously registered NotificationCategory will override it.
func (wn *windowsNotifier) RegisterNotificationCategory(category NotificationCategory) error {
wn.categoriesLock.Lock()
defer wn.categoriesLock.Unlock()
func (f *Frontend) RegisterNotificationCategory(category frontend.NotificationCategory) error {
categoriesLock.Lock()
defer categoriesLock.Unlock()
wn.categories[category.ID] = NotificationCategory{
categories[category.ID] = frontend.NotificationCategory{
ID: category.ID,
Actions: category.Actions,
HasReplyField: bool(category.HasReplyField),
HasReplyField: category.HasReplyField,
ReplyPlaceholder: category.ReplyPlaceholder,
ReplyButtonTitle: category.ReplyButtonTitle,
}
return wn.saveCategoriesToRegistry()
return saveCategoriesToRegistry()
}
// RemoveNotificationCategory removes a previously registered NotificationCategory.
func (wn *windowsNotifier) RemoveNotificationCategory(categoryId string) error {
wn.categoriesLock.Lock()
defer wn.categoriesLock.Unlock()
func (f *Frontend) RemoveNotificationCategory(categoryId string) error {
categoriesLock.Lock()
defer categoriesLock.Unlock()
delete(wn.categories, categoryId)
delete(categories, categoryId)
return wn.saveCategoriesToRegistry()
return saveCategoriesToRegistry()
}
// RemoveAllPendingNotifications is a Windows stub that always returns nil.
// (macOS and Linux only)
func (wn *windowsNotifier) RemoveAllPendingNotifications() error {
func (f *Frontend) RemoveAllPendingNotifications() error {
return nil
}
// RemovePendingNotification is a Windows stub that always returns nil.
// (macOS and Linux only)
func (wn *windowsNotifier) RemovePendingNotification(_ string) error {
func (f *Frontend) RemovePendingNotification(_ string) error {
return nil
}
// RemoveAllDeliveredNotifications is a Windows stub that always returns nil.
// (macOS and Linux only)
func (wn *windowsNotifier) RemoveAllDeliveredNotifications() error {
func (f *Frontend) RemoveAllDeliveredNotifications() error {
return nil
}
// RemoveDeliveredNotification is a Windows stub that always returns nil.
// (macOS and Linux only)
func (wn *windowsNotifier) RemoveDeliveredNotification(_ string) error {
func (f *Frontend) RemoveDeliveredNotification(_ string) error {
return nil
}
// RemoveNotification is a Windows stub that always returns nil.
// (Linux-specific)
func (wn *windowsNotifier) RemoveNotification(identifier string) error {
func (f *Frontend) RemoveNotification(identifier string) error {
return nil
}
// encodePayload combines an action ID and user data into a single encoded string
func (wn *windowsNotifier) encodePayload(actionID string, options NotificationOptions) (string, error) {
payload := NotificationPayload{
Action: actionID,
Options: options,
}
func (f *Frontend) OnNotificationResponse(callback func(result frontend.NotificationResult)) {
callbackLock.Lock()
defer callbackLock.Unlock()
jsonData, err := json.Marshal(payload)
if err != nil {
return actionID, err
}
encodedPayload := base64.StdEncoding.EncodeToString(jsonData)
return encodedPayload, nil
notificationResultCallback = callback
}
// decodePayload extracts the action ID and user data from an encoded payload
func decodePayload(encodedString string) (string, NotificationOptions, error) {
jsonData, err := base64.StdEncoding.DecodeString(encodedString)
if err != nil {
return encodedString, NotificationOptions{}, fmt.Errorf("failed to decode base64 payload: %w", err)
}
var payload NotificationPayload
if err := json.Unmarshal(jsonData, &payload); err != nil {
return encodedString, NotificationOptions{}, fmt.Errorf("failed to unmarshal notification payload: %w", err)
}
return payload.Action, payload.Options, nil
func (f *Frontend) saveIconToDir() error {
iconOnce.Do(func() {
hIcon := w32.ExtractIcon(exePath, 0)
if hIcon == 0 {
iconErr = fmt.Errorf("ExtractIcon failed for %s", exePath)
return
}
defer w32.DestroyIcon(hIcon)
iconErr = winc.SaveHIconAsPNG(hIcon, iconPath)
})
return iconErr
}
// parseNotificationResponse updated to use structured payload decoding
func parseNotificationResponse(response string) (action string, options NotificationOptions, err error) {
actionID, options, err := decodePayload(response)
if err != nil {
fmt.Printf("Warning: Failed to decode notification response: %v\n", err)
return response, NotificationOptions{}, err
}
return actionID, options, nil
}
func (wn *windowsNotifier) saveIconToDir() error {
icon, err := application.NewIconFromResource(w32.GetModuleHandle(""), uint16(3))
if err != nil {
return fmt.Errorf("failed to retrieve application icon: %w", err)
}
return w32.SaveHIconAsPNG(icon, wn.iconPath)
}
func (wn *windowsNotifier) saveCategoriesToRegistry() error {
func saveCategoriesToRegistry() error {
// We assume lock is held by caller
registryPath := fmt.Sprintf(NotificationCategoriesRegistryPath, wn.appName)
registryPath := fmt.Sprintf(NotificationCategoriesRegistryPath, appName)
key, _, err := registry.CreateKey(
registry.CURRENT_USER,
@ -372,7 +338,7 @@ func (wn *windowsNotifier) saveCategoriesToRegistry() error {
}
defer key.Close()
data, err := json.Marshal(wn.categories)
data, err := json.Marshal(categories)
if err != nil {
return err
}
@ -380,10 +346,10 @@ func (wn *windowsNotifier) saveCategoriesToRegistry() error {
return key.SetStringValue(NotificationCategoriesRegistryKey, string(data))
}
func (wn *windowsNotifier) loadCategoriesFromRegistry() error {
func loadCategoriesFromRegistry() error {
// We assume lock is held by caller
registryPath := fmt.Sprintf(NotificationCategoriesRegistryPath, wn.appName)
registryPath := fmt.Sprintf(NotificationCategoriesRegistryPath, appName)
key, err := registry.OpenKey(
registry.CURRENT_USER,
@ -408,17 +374,17 @@ func (wn *windowsNotifier) loadCategoriesFromRegistry() error {
return fmt.Errorf("failed to read categories from registry: %w", err)
}
categories := make(map[string]NotificationCategory)
if err := json.Unmarshal([]byte(data), &categories); err != nil {
_categories := make(map[string]frontend.NotificationCategory)
if err := json.Unmarshal([]byte(data), &_categories); err != nil {
return fmt.Errorf("failed to parse notification categories from registry: %w", err)
}
wn.categories = categories
categories = _categories
return nil
}
func (wn *windowsNotifier) getUserText(data []toast.UserData) (string, bool) {
func getUserText(data []toast.UserData) (string, bool) {
for _, d := range data {
if d.Key == "userText" {
return d.Value, true
@ -427,8 +393,71 @@ func (wn *windowsNotifier) getUserText(data []toast.UserData) (string, bool) {
return "", false
}
func (wn *windowsNotifier) getGUID() (string, error) {
keyPath := ToastRegistryPath + wn.appName
// encodePayload combines an action ID and user data into a single encoded string
func encodePayload(actionID string, options frontend.NotificationOptions) (string, error) {
payload := NotificationPayload{
Action: actionID,
Options: options,
}
jsonData, err := json.Marshal(payload)
if err != nil {
return actionID, err
}
encodedPayload := base64.StdEncoding.EncodeToString(jsonData)
return encodedPayload, nil
}
// decodePayload extracts the action ID and user data from an encoded payload
func decodePayload(encodedString string) (string, frontend.NotificationOptions, error) {
jsonData, err := base64.StdEncoding.DecodeString(encodedString)
if err != nil {
return encodedString, frontend.NotificationOptions{}, fmt.Errorf("failed to decode base64 payload: %w", err)
}
var payload NotificationPayload
if err := json.Unmarshal(jsonData, &payload); err != nil {
return encodedString, frontend.NotificationOptions{}, fmt.Errorf("failed to unmarshal notification payload: %w", err)
}
return payload.Action, payload.Options, nil
}
// parseNotificationResponse updated to use structured payload decoding
func parseNotificationResponse(response string) (action string, options frontend.NotificationOptions, err error) {
actionID, options, err := decodePayload(response)
if err != nil {
log.Printf("Warning: Failed to decode notification response: %v", err)
return response, frontend.NotificationOptions{}, err
}
return actionID, options, nil
}
func handleNotificationResult(result frontend.NotificationResult) {
callbackLock.RLock()
callback := notificationResultCallback
callbackLock.RUnlock()
if callback != nil {
go func() {
defer func() {
if r := recover(); r != nil {
// Log panic but don't crash the app
fmt.Fprintf(os.Stderr, "panic in notification callback: %v\n", r)
}
}()
callback(result)
}()
}
}
// Helper functions
func getGUID() (string, error) {
keyPath := ToastRegistryPath + appName
k, err := registry.OpenKey(registry.CURRENT_USER, keyPath, registry.QUERY_VALUE)
if err == nil {
@ -439,7 +468,7 @@ func (wn *windowsNotifier) getGUID() (string, error) {
}
}
guid := wn.generateGUID()
guid := generateGUID()
k, _, err = registry.CreateKey(registry.CURRENT_USER, keyPath, registry.WRITE)
if err != nil {
@ -454,7 +483,7 @@ func (wn *windowsNotifier) getGUID() (string, error) {
return guid, nil
}
func (wn *windowsNotifier) generateGUID() string {
func generateGUID() string {
guid := uuid.New()
return fmt.Sprintf("{%s}", guid.String())
}

View file

@ -10,11 +10,86 @@ package winc
import (
"errors"
"fmt"
"image"
"image/png"
"os"
"syscall"
"unsafe"
"github.com/wailsapp/wails/v2/internal/frontend/desktop/windows/winc/w32"
)
var (
user32 = syscall.NewLazyDLL("user32.dll")
gdi32 = syscall.NewLazyDLL("gdi32.dll")
procGetIconInfo = user32.NewProc("GetIconInfo")
procDeleteObject = gdi32.NewProc("DeleteObject")
procGetObject = gdi32.NewProc("GetObjectW")
procGetDIBits = gdi32.NewProc("GetDIBits")
procCreateCompatibleDC = gdi32.NewProc("CreateCompatibleDC")
procSelectObject = gdi32.NewProc("SelectObject")
procDeleteDC = gdi32.NewProc("DeleteDC")
)
func init() {
// Validate DLL loads at initialization time to surface missing APIs early
if err := user32.Load(); err != nil {
panic(fmt.Sprintf("failed to load user32.dll: %v", err))
}
if err := gdi32.Load(); err != nil {
panic(fmt.Sprintf("failed to load gdi32.dll: %v", err))
}
}
// ICONINFO mirrors the Win32 ICONINFO struct
type ICONINFO struct {
FIcon int32
XHotspot uint32
YHotspot uint32
HbmMask uintptr
HbmColor uintptr
}
// http://msdn.microsoft.com/en-us/library/windows/desktop/dd183376.aspx
type BITMAPINFOHEADER struct {
BiSize uint32
BiWidth int32
BiHeight int32
BiPlanes uint16
BiBitCount uint16
BiCompression uint32
BiSizeImage uint32
BiXPelsPerMeter int32
BiYPelsPerMeter int32
BiClrUsed uint32
BiClrImportant uint32
}
// http://msdn.microsoft.com/en-us/library/windows/desktop/dd162938.aspx
type RGBQUAD struct {
RgbBlue byte
RgbGreen byte
RgbRed byte
RgbReserved byte
}
// http://msdn.microsoft.com/en-us/library/windows/desktop/dd183375.aspx
type BITMAPINFO struct {
BmiHeader BITMAPINFOHEADER
BmiColors *RGBQUAD
}
// http://msdn.microsoft.com/en-us/library/windows/desktop/dd183371.aspx
type BITMAP struct {
BmType int32
BmWidth int32
BmHeight int32
BmWidthBytes int32
BmPlanes uint16
BmBitsPixel uint16
BmBits unsafe.Pointer
}
type Icon struct {
handle w32.HICON
}
@ -46,6 +121,95 @@ func ExtractIcon(fileName string, index int) (*Icon, error) {
return ico, err
}
func SaveHIconAsPNG(hIcon w32.HICON, filePath string) error {
// Get icon info
var iconInfo ICONINFO
ret, _, err := procGetIconInfo.Call(
uintptr(hIcon),
uintptr(unsafe.Pointer(&iconInfo)),
)
if ret == 0 {
return err
}
defer procDeleteObject.Call(uintptr(iconInfo.HbmMask))
defer procDeleteObject.Call(uintptr(iconInfo.HbmColor))
// Get bitmap info
var bmp BITMAP
ret, _, err = procGetObject.Call(
uintptr(iconInfo.HbmColor),
unsafe.Sizeof(bmp),
uintptr(unsafe.Pointer(&bmp)),
)
if ret == 0 {
return err
}
// Get screen DC for GetDIBits (bitmap must not be selected into a DC)
screenDC := w32.GetDC(0)
if screenDC == 0 {
return fmt.Errorf("failed to get screen DC")
}
defer w32.ReleaseDC(0, screenDC)
// Prepare bitmap info header
var bi BITMAPINFO
bi.BmiHeader.BiSize = uint32(unsafe.Sizeof(bi.BmiHeader))
bi.BmiHeader.BiWidth = bmp.BmWidth
bi.BmiHeader.BiHeight = bmp.BmHeight
bi.BmiHeader.BiPlanes = 1
bi.BmiHeader.BiBitCount = 32
bi.BmiHeader.BiCompression = w32.BI_RGB
// Allocate memory for bitmap bits
width, height := int(bmp.BmWidth), int(bmp.BmHeight)
bufferSize := width * height * 4
bits := make([]byte, bufferSize)
// Get bitmap bits using screen DC (bitmap must not be selected into any DC)
ret, _, err = procGetDIBits.Call(
uintptr(screenDC),
uintptr(iconInfo.HbmColor),
0,
uintptr(bmp.BmHeight),
uintptr(unsafe.Pointer(&bits[0])),
uintptr(unsafe.Pointer(&bi)),
w32.DIB_RGB_COLORS,
)
if ret == 0 {
return fmt.Errorf("failed to get bitmap bits: %w", err)
}
// Create Go image
img := image.NewRGBA(image.Rect(0, 0, width, height))
// Convert DIB to RGBA
for y := 0; y < height; y++ {
for x := 0; x < width; x++ {
// DIB is bottom-up, so we need to invert Y
dibIndex := ((height-1-y)*width + x) * 4
// RGBA image is top-down
imgIndex := (y*width + x) * 4
// BGRA to RGBA
img.Pix[imgIndex] = bits[dibIndex+2] // R
img.Pix[imgIndex+1] = bits[dibIndex+1] // G
img.Pix[imgIndex+2] = bits[dibIndex] // B
img.Pix[imgIndex+3] = bits[dibIndex+3] // A
}
}
// Create output file
outFile, err := os.Create(filePath)
if err != nil {
return err
}
defer outFile.Close()
// Encode and save the image
return png.Encode(outFile, img)
}
func (ic *Icon) Destroy() bool {
return w32.DestroyIcon(ic.handle)
}

View file

@ -61,6 +61,102 @@ func (d *Dispatcher) processSystemCall(payload callMessage, sender frontend.Fron
return false, err
}
return true, nil
case "InitializeNotifications":
err := sender.InitializeNotifications()
return nil, err
case "CleanupNotifications":
sender.CleanupNotifications()
return nil, nil
case "IsNotificationAvailable":
return sender.IsNotificationAvailable(), nil
case "RequestNotificationAuthorization":
authorized, err := sender.RequestNotificationAuthorization()
if err != nil {
return nil, err
}
return authorized, nil
case "CheckNotificationAuthorization":
authorized, err := sender.CheckNotificationAuthorization()
if err != nil {
return nil, err
}
return authorized, nil
case "SendNotification":
if len(payload.Args) < 1 {
return nil, errors.New("empty argument, cannot send notification")
}
var options frontend.NotificationOptions
if err := json.Unmarshal(payload.Args[0], &options); err != nil {
return nil, err
}
err := sender.SendNotification(options)
return nil, err
case "SendNotificationWithActions":
if len(payload.Args) < 1 {
return nil, errors.New("empty argument, cannot send notification")
}
var options frontend.NotificationOptions
if err := json.Unmarshal(payload.Args[0], &options); err != nil {
return nil, err
}
err := sender.SendNotificationWithActions(options)
return nil, err
case "RegisterNotificationCategory":
if len(payload.Args) < 1 {
return nil, errors.New("empty argument, cannot register category")
}
var category frontend.NotificationCategory
if err := json.Unmarshal(payload.Args[0], &category); err != nil {
return nil, err
}
err := sender.RegisterNotificationCategory(category)
return nil, err
case "RemoveNotificationCategory":
if len(payload.Args) < 1 {
return nil, errors.New("empty argument, cannot remove category")
}
var categoryId string
if err := json.Unmarshal(payload.Args[0], &categoryId); err != nil {
return nil, err
}
err := sender.RemoveNotificationCategory(categoryId)
return nil, err
case "RemoveAllPendingNotifications":
err := sender.RemoveAllPendingNotifications()
return nil, err
case "RemovePendingNotification":
if len(payload.Args) < 1 {
return nil, errors.New("empty argument, cannot remove notification")
}
var identifier string
if err := json.Unmarshal(payload.Args[0], &identifier); err != nil {
return nil, err
}
err := sender.RemovePendingNotification(identifier)
return nil, err
case "RemoveAllDeliveredNotifications":
err := sender.RemoveAllDeliveredNotifications()
return nil, err
case "RemoveDeliveredNotification":
if len(payload.Args) < 1 {
return nil, errors.New("empty argument, cannot remove notification")
}
var identifier string
if err := json.Unmarshal(payload.Args[0], &identifier); err != nil {
return nil, err
}
err := sender.RemoveDeliveredNotification(identifier)
return nil, err
case "RemoveNotification":
if len(payload.Args) < 1 {
return nil, errors.New("empty argument, cannot remove notification")
}
var identifier string
if err := json.Unmarshal(payload.Args[0], &identifier); err != nil {
return nil, err
}
err := sender.RemoveNotification(identifier)
return nil, err
default:
return nil, fmt.Errorf("unknown systemcall message: %s", payload.Name)
}

View file

@ -76,6 +76,51 @@ type MessageDialogOptions struct {
Icon []byte
}
// NotificationOptions contains configuration for a notification.
type NotificationOptions struct {
ID string `json:"id"`
Title string `json:"title"`
Subtitle string `json:"subtitle,omitempty"` // (macOS and Linux only)
Body string `json:"body,omitempty"`
CategoryID string `json:"categoryId,omitempty"`
Data map[string]interface{} `json:"data,omitempty"`
}
// NotificationAction represents an action button for a notification.
type NotificationAction struct {
ID string `json:"id,omitempty"`
Title string `json:"title,omitempty"`
Destructive bool `json:"destructive,omitempty"` // (macOS-specific)
}
// NotificationCategory groups actions for notifications.
type NotificationCategory struct {
ID string `json:"id,omitempty"`
Actions []NotificationAction `json:"actions,omitempty"`
HasReplyField bool `json:"hasReplyField,omitempty"`
ReplyPlaceholder string `json:"replyPlaceholder,omitempty"`
ReplyButtonTitle string `json:"replyButtonTitle,omitempty"`
}
// NotificationResponse represents the response sent by interacting with a notification.
type NotificationResponse struct {
ID string `json:"id,omitempty"`
ActionIdentifier string `json:"actionIdentifier,omitempty"`
CategoryID string `json:"categoryId,omitempty"` // Consistent with NotificationOptions
Title string `json:"title,omitempty"`
Subtitle string `json:"subtitle,omitempty"` // (macOS and Linux only)
Body string `json:"body,omitempty"`
UserText string `json:"userText,omitempty"`
UserInfo map[string]interface{} `json:"userInfo,omitempty"`
}
// NotificationResult represents the result of a notification response,
// returning the response or any errors that occurred.
type NotificationResult struct {
Response NotificationResponse
Error error
}
type Frontend interface {
Run(ctx context.Context) error
RunMainLoop()
@ -139,4 +184,21 @@ type Frontend interface {
// Clipboard
ClipboardGetText() (string, error)
ClipboardSetText(text string) error
// Notifications
InitializeNotifications() error
CleanupNotifications()
IsNotificationAvailable() bool
RequestNotificationAuthorization() (bool, error)
CheckNotificationAuthorization() (bool, error)
OnNotificationResponse(callback func(result NotificationResult))
SendNotification(options NotificationOptions) error
SendNotificationWithActions(options NotificationOptions) error
RegisterNotificationCategory(category NotificationCategory) error
RemoveNotificationCategory(categoryId string) error
RemoveAllPendingNotifications() error
RemovePendingNotification(identifier string) error
RemoveAllDeliveredNotifications() error
RemoveDeliveredNotification(identifier string) error
RemoveNotification(identifier string) error
}

View file

@ -27,6 +27,7 @@ import * as Browser from "./browser";
import * as Clipboard from "./clipboard";
import * as DragAndDrop from "./draganddrop";
import * as ContextMenu from "./contextmenu";
import * as Notifications from "./notifications";
export function Quit() {
window.WailsInvoke('Q');
@ -52,6 +53,7 @@ window.runtime = {
...Screen,
...Clipboard,
...DragAndDrop,
...Notifications,
EventsOn,
EventsOnce,
EventsOnMultiple,

View file

@ -0,0 +1,200 @@
/*
_ __ _ __
| | / /___ _(_) /____
| | /| / / __ `/ / / ___/
| |/ |/ / /_/ / / (__ )
|__/|__/\__,_/_/_/____/
The electron alternative for Go
(c) Lea Anthony 2019-present
*/
/* jshint esversion: 9 */
import {Call} from "./calls";
/**
* Initialize the notification service for the application.
* This must be called before sending any notifications.
* On macOS, this also ensures the notification delegate is properly initialized.
*
* @export
* @return {Promise<void>}
*/
export function InitializeNotifications() {
return Call(":wails:InitializeNotifications");
}
/**
* Clean up notification resources and release any held connections.
* This should be called when shutting down the application to properly release resources
* (primarily needed on Linux to close D-Bus connections).
*
* @export
* @return {Promise<void>}
*/
export function CleanupNotifications() {
return Call(":wails:CleanupNotifications");
}
/**
* Check if notifications are available on the current platform.
*
* @export
* @return {Promise<boolean>} True if notifications are available, false otherwise
*/
export function IsNotificationAvailable() {
return Call(":wails:IsNotificationAvailable");
}
/**
* Request notification authorization from the user.
* On macOS, this prompts the user to allow notifications.
* On other platforms, this always returns true.
*
* @export
* @return {Promise<boolean>} True if authorization was granted, false otherwise
*/
export function RequestNotificationAuthorization() {
return Call(":wails:RequestNotificationAuthorization");
}
/**
* Check the current notification authorization status.
* On macOS, this checks if the app has notification permissions.
* On other platforms, this always returns true.
*
* @export
* @return {Promise<boolean>} True if authorized, false otherwise
*/
export function CheckNotificationAuthorization() {
return Call(":wails:CheckNotificationAuthorization");
}
/**
* Send a basic notification with the given options.
* The notification will display with the provided title, subtitle (if supported), and body text.
*
* @export
* @param {Object} options - Notification options
* @param {string} options.id - Unique identifier for the notification
* @param {string} options.title - Notification title
* @param {string} [options.subtitle] - Notification subtitle (macOS and Linux only)
* @param {string} [options.body] - Notification body text
* @param {string} [options.categoryId] - Category ID for action buttons (requires SendNotificationWithActions)
* @param {Object<string, any>} [options.data] - Additional user data to attach to the notification
* @return {Promise<void>}
*/
export function SendNotification(options) {
return Call(":wails:SendNotification", [options]);
}
/**
* Send a notification with action buttons.
* A NotificationCategory must be registered first using RegisterNotificationCategory.
* The options.categoryId must match a previously registered category ID.
* If the category is not found, a basic notification will be sent instead.
*
* @export
* @param {Object} options - Notification options
* @param {string} options.id - Unique identifier for the notification
* @param {string} options.title - Notification title
* @param {string} [options.subtitle] - Notification subtitle (macOS and Linux only)
* @param {string} [options.body] - Notification body text
* @param {string} options.categoryId - Category ID that matches a registered category
* @param {Object<string, any>} [options.data] - Additional user data to attach to the notification
* @return {Promise<void>}
*/
export function SendNotificationWithActions(options) {
return Call(":wails:SendNotificationWithActions", [options]);
}
/**
* Register a notification category that can be used with SendNotificationWithActions.
* Categories define the action buttons and optional reply fields that will appear on notifications.
* Registering a category with the same ID as a previously registered category will override it.
*
* @export
* @param {Object} category - Notification category definition
* @param {string} category.id - Unique identifier for the category
* @param {Array<Object>} [category.actions] - Array of action buttons
* @param {string} category.actions[].id - Unique identifier for the action
* @param {string} category.actions[].title - Display title for the action button
* @param {boolean} [category.actions[].destructive] - Whether the action is destructive (macOS-specific)
* @param {boolean} [category.hasReplyField] - Whether to include a text input field for replies
* @param {string} [category.replyPlaceholder] - Placeholder text for the reply field (required if hasReplyField is true)
* @param {string} [category.replyButtonTitle] - Title for the reply button (required if hasReplyField is true)
* @return {Promise<void>}
*/
export function RegisterNotificationCategory(category) {
return Call(":wails:RegisterNotificationCategory", [category]);
}
/**
* Remove a previously registered notification category.
*
* @export
* @param {string} categoryId - The ID of the category to remove
* @return {Promise<void>}
*/
export function RemoveNotificationCategory(categoryId) {
return Call(":wails:RemoveNotificationCategory", [categoryId]);
}
/**
* Remove all pending notifications from the notification center.
* On Windows, this is a no-op as the platform manages notification lifecycle automatically.
*
* @export
* @return {Promise<void>}
*/
export function RemoveAllPendingNotifications() {
return Call(":wails:RemoveAllPendingNotifications");
}
/**
* Remove a specific pending notification by its identifier.
* On Windows, this is a no-op as the platform manages notification lifecycle automatically.
*
* @export
* @param {string} identifier - The ID of the notification to remove
* @return {Promise<void>}
*/
export function RemovePendingNotification(identifier) {
return Call(":wails:RemovePendingNotification", [identifier]);
}
/**
* Remove all delivered notifications from the notification center.
* On Windows, this is a no-op as the platform manages notification lifecycle automatically.
*
* @export
* @return {Promise<void>}
*/
export function RemoveAllDeliveredNotifications() {
return Call(":wails:RemoveAllDeliveredNotifications");
}
/**
* Remove a specific delivered notification by its identifier.
* On Windows, this is a no-op as the platform manages notification lifecycle automatically.
*
* @export
* @param {string} identifier - The ID of the notification to remove
* @return {Promise<void>}
*/
export function RemoveDeliveredNotification(identifier) {
return Call(":wails:RemoveDeliveredNotification", [identifier]);
}
/**
* Remove a notification by its identifier.
* This is a convenience function that works across platforms.
* On macOS, use the more specific RemovePendingNotification or RemoveDeliveredNotification functions.
*
* @export
* @param {string} identifier - The ID of the notification to remove
* @return {Promise<void>}
*/
export function RemoveNotification(identifier) {
return Call(":wails:RemoveNotification", [identifier]);
}

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -246,4 +246,85 @@ export function OnFileDropOff() :void
export function CanResolveFilePaths(): boolean;
// Resolves file paths for an array of files
export function ResolveFilePaths(files: File[]): void
export function ResolveFilePaths(files: File[]): void
// Notification types
export interface NotificationOptions {
id: string;
title: string;
subtitle?: string; // macOS and Linux only
body?: string;
categoryId?: string;
data?: { [key: string]: any };
}
export interface NotificationAction {
id?: string;
title?: string;
destructive?: boolean; // macOS-specific
}
export interface NotificationCategory {
id?: string;
actions?: NotificationAction[];
hasReplyField?: boolean;
replyPlaceholder?: string;
replyButtonTitle?: string;
}
// [InitializeNotifications](https://wails.io/docs/reference/runtime/notification#initializenotifications)
// Initializes the notification service for the application.
// This must be called before sending any notifications.
export function InitializeNotifications(): Promise<void>;
// [CleanupNotifications](https://wails.io/docs/reference/runtime/notification#cleanupnotifications)
// Cleans up notification resources and releases any held connections.
export function CleanupNotifications(): Promise<void>;
// [IsNotificationAvailable](https://wails.io/docs/reference/runtime/notification#isnotificationavailable)
// Checks if notifications are available on the current platform.
export function IsNotificationAvailable(): Promise<boolean>;
// [RequestNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#requestnotificationauthorization)
// Requests notification authorization from the user (macOS only).
export function RequestNotificationAuthorization(): Promise<boolean>;
// [CheckNotificationAuthorization](https://wails.io/docs/reference/runtime/notification#checknotificationauthorization)
// Checks the current notification authorization status (macOS only).
export function CheckNotificationAuthorization(): Promise<boolean>;
// [SendNotification](https://wails.io/docs/reference/runtime/notification#sendnotification)
// Sends a basic notification with the given options.
export function SendNotification(options: NotificationOptions): Promise<void>;
// [SendNotificationWithActions](https://wails.io/docs/reference/runtime/notification#sendnotificationwithactions)
// Sends a notification with action buttons. Requires a registered category.
export function SendNotificationWithActions(options: NotificationOptions): Promise<void>;
// [RegisterNotificationCategory](https://wails.io/docs/reference/runtime/notification#registernotificationcategory)
// Registers a notification category that can be used with SendNotificationWithActions.
export function RegisterNotificationCategory(category: NotificationCategory): Promise<void>;
// [RemoveNotificationCategory](https://wails.io/docs/reference/runtime/notification#removenotificationcategory)
// Removes a previously registered notification category.
export function RemoveNotificationCategory(categoryId: string): Promise<void>;
// [RemoveAllPendingNotifications](https://wails.io/docs/reference/runtime/notification#removeallpendingnotifications)
// Removes all pending notifications from the notification center.
export function RemoveAllPendingNotifications(): Promise<void>;
// [RemovePendingNotification](https://wails.io/docs/reference/runtime/notification#removependingnotification)
// Removes a specific pending notification by its identifier.
export function RemovePendingNotification(identifier: string): Promise<void>;
// [RemoveAllDeliveredNotifications](https://wails.io/docs/reference/runtime/notification#removealldeliverednotifications)
// Removes all delivered notifications from the notification center.
export function RemoveAllDeliveredNotifications(): Promise<void>;
// [RemoveDeliveredNotification](https://wails.io/docs/reference/runtime/notification#removedeliverednotification)
// Removes a specific delivered notification by its identifier.
export function RemoveDeliveredNotification(identifier: string): Promise<void>;
// [RemoveNotification](https://wails.io/docs/reference/runtime/notification#removenotification)
// Removes a notification by its identifier (cross-platform convenience function).
export function RemoveNotification(identifier: string): Promise<void>;

View file

@ -239,4 +239,60 @@ export function CanResolveFilePaths() {
export function ResolveFilePaths(files) {
return window.runtime.ResolveFilePaths(files);
}
export function InitializeNotifications() {
return window.runtime.InitializeNotifications();
}
export function CleanupNotifications() {
return window.runtime.CleanupNotifications();
}
export function IsNotificationAvailable() {
return window.runtime.IsNotificationAvailable();
}
export function RequestNotificationAuthorization() {
return window.runtime.RequestNotificationAuthorization();
}
export function CheckNotificationAuthorization() {
return window.runtime.CheckNotificationAuthorization();
}
export function SendNotification(options) {
return window.runtime.SendNotification(options);
}
export function SendNotificationWithActions(options) {
return window.runtime.SendNotificationWithActions(options);
}
export function RegisterNotificationCategory(category) {
return window.runtime.RegisterNotificationCategory(category);
}
export function RemoveNotificationCategory(categoryId) {
return window.runtime.RemoveNotificationCategory(categoryId);
}
export function RemoveAllPendingNotifications() {
return window.runtime.RemoveAllPendingNotifications();
}
export function RemovePendingNotification(identifier) {
return window.runtime.RemovePendingNotification(identifier);
}
export function RemoveAllDeliveredNotifications() {
return window.runtime.RemoveAllDeliveredNotifications();
}
export function RemoveDeliveredNotification(identifier) {
return window.runtime.RemoveDeliveredNotification(identifier);
}
export function RemoveNotification(identifier) {
return window.runtime.RemoveNotification(identifier);
}

View file

@ -3,6 +3,7 @@ package build
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
@ -357,6 +358,16 @@ func execBuildApplication(builder Builder, options *Options) (string, error) {
pterm.Println("Done.")
}
if runtime.GOOS == "darwin" && options.Platform == "darwin" {
// On macOS, self-sign the .app bundle so notifications work
printBulletPoint("Self-signing application: ")
cmd := exec.Command("/usr/bin/codesign", "--force", "--deep", "--sign", "-", options.CompiledBinary)
if out, err := cmd.CombinedOutput(); err != nil {
return "", fmt.Errorf("codesign failed: %v %s", err, out)
}
pterm.Println("Done.")
}
if options.Platform == "windows" {
const nativeWebView2Loader = "native_webview2loader"

View file

@ -0,0 +1,136 @@
package runtime
import (
"context"
"github.com/wailsapp/wails/v2/internal/frontend"
)
// NotificationOptions contains configuration for a notification.
type NotificationOptions = frontend.NotificationOptions
// NotificationAction represents an action button for a notification.
type NotificationAction = frontend.NotificationAction
// NotificationCategory groups actions for notifications.
type NotificationCategory = frontend.NotificationCategory
// NotificationResponse represents the response sent by interacting with a notification.
type NotificationResponse = frontend.NotificationResponse
// NotificationResult represents the result of a notification response,
// returning the response or any errors that occurred.
type NotificationResult = frontend.NotificationResult
// InitializeNotifications initializes the notification service for the application.
// This must be called before sending any notifications. On macOS, this also ensures
// the notification delegate is properly initialized.
func InitializeNotifications(ctx context.Context) error {
fe := getFrontend(ctx)
return fe.InitializeNotifications()
}
// CleanupNotifications cleans up notification resources and releases any held connections.
// This should be called when shutting down the application to properly release resources
// (primarily needed on Linux to close D-Bus connections).
func CleanupNotifications(ctx context.Context) {
fe := getFrontend(ctx)
fe.CleanupNotifications()
}
// IsNotificationAvailable checks if notifications are available on the current platform.
func IsNotificationAvailable(ctx context.Context) bool {
fe := getFrontend(ctx)
return fe.IsNotificationAvailable()
}
// RequestNotificationAuthorization requests notification authorization from the user.
// On macOS, this prompts the user to allow notifications. On other platforms, this
// always returns true. Returns true if authorization was granted, false otherwise.
func RequestNotificationAuthorization(ctx context.Context) (bool, error) {
fe := getFrontend(ctx)
return fe.RequestNotificationAuthorization()
}
// CheckNotificationAuthorization checks the current notification authorization status.
// On macOS, this checks if the app has notification permissions. On other platforms,
// this always returns true.
func CheckNotificationAuthorization(ctx context.Context) (bool, error) {
fe := getFrontend(ctx)
return fe.CheckNotificationAuthorization()
}
// SendNotification sends a basic notification with the given options.
// The notification will display with the provided title, subtitle (if supported),
// and body text.
func SendNotification(ctx context.Context, options NotificationOptions) error {
fe := getFrontend(ctx)
return fe.SendNotification(options)
}
// SendNotificationWithActions sends a notification with action buttons.
// A NotificationCategory must be registered first using RegisterNotificationCategory.
// The options.CategoryID must match a previously registered category ID.
// If the category is not found, a basic notification will be sent instead.
func SendNotificationWithActions(ctx context.Context, options NotificationOptions) error {
fe := getFrontend(ctx)
return fe.SendNotificationWithActions(options)
}
// RegisterNotificationCategory registers a notification category that can be used
// with SendNotificationWithActions. Categories define the action buttons and optional
// reply fields that will appear on notifications.
func RegisterNotificationCategory(ctx context.Context, category NotificationCategory) error {
fe := getFrontend(ctx)
return fe.RegisterNotificationCategory(category)
}
// RemoveNotificationCategory removes a previously registered notification category.
func RemoveNotificationCategory(ctx context.Context, categoryId string) error {
fe := getFrontend(ctx)
return fe.RemoveNotificationCategory(categoryId)
}
// RemoveAllPendingNotifications removes all pending notifications from the notification center.
// On Windows, this is a no-op as the platform manages notification lifecycle automatically.
func RemoveAllPendingNotifications(ctx context.Context) error {
fe := getFrontend(ctx)
return fe.RemoveAllPendingNotifications()
}
// RemovePendingNotification removes a specific pending notification by its identifier.
// On Windows, this is a no-op as the platform manages notification lifecycle automatically.
func RemovePendingNotification(ctx context.Context, identifier string) error {
fe := getFrontend(ctx)
return fe.RemovePendingNotification(identifier)
}
// RemoveAllDeliveredNotifications removes all delivered notifications from the notification center.
// On Windows, this is a no-op as the platform manages notification lifecycle automatically.
func RemoveAllDeliveredNotifications(ctx context.Context) error {
fe := getFrontend(ctx)
return fe.RemoveAllDeliveredNotifications()
}
// RemoveDeliveredNotification removes a specific delivered notification by its identifier.
// On Windows, this is a no-op as the platform manages notification lifecycle automatically.
func RemoveDeliveredNotification(ctx context.Context, identifier string) error {
fe := getFrontend(ctx)
return fe.RemoveDeliveredNotification(identifier)
}
// RemoveNotification removes a notification by its identifier.
// This is a convenience function that works across platforms. On macOS, use the
// more specific RemovePendingNotification or RemoveDeliveredNotification functions.
func RemoveNotification(ctx context.Context, identifier string) error {
fe := getFrontend(ctx)
return fe.RemoveNotification(identifier)
}
// OnNotificationResponse registers a callback function that will be invoked when
// a user interacts with a notification (e.g., clicks an action button or the notification itself).
// The callback receives a NotificationResult containing the response details or any errors.
func OnNotificationResponse(ctx context.Context, callback func(result NotificationResult)) {
fe := getFrontend(ctx)
fe.OnNotificationResponse(callback)
}

View file

@ -186,7 +186,16 @@ func Install(options *Options) (bool, *Template, error) {
return false, nil, err
}
options.TargetDir = targetDir
if !fs.DirExists(options.TargetDir) {
if fs.DirExists(options.TargetDir) {
// Check if directory is non-empty
entries, err := os.ReadDir(options.TargetDir)
if err != nil {
return false, nil, err
}
if len(entries) > 0 {
return false, nil, fmt.Errorf("cannot initialise project in non-empty directory: %s", options.TargetDir)
}
} else {
err := fs.Mkdir(options.TargetDir)
if err != nil {
return false, nil, err

View file

@ -52,3 +52,48 @@ func TestInstall(t *testing.T) {
is2.NoErr(err)
}
func TestInstallFailsInNonEmptyDirectory(t *testing.T) {
is2 := is.New(t)
// Create a temp directory with a file in it
tempDir, err := os.MkdirTemp("", "wails-test-nonempty-*")
is2.NoErr(err)
defer func() {
_ = os.RemoveAll(tempDir)
}()
// Create a file in the directory to make it non-empty
err = os.WriteFile(filepath.Join(tempDir, "existing-file.txt"), []byte("test"), 0644)
is2.NoErr(err)
options := &Options{
ProjectName: "test",
TemplateName: "vanilla",
TargetDir: tempDir,
}
_, _, err = Install(options)
is2.True(err != nil) // Should fail
is2.True(err.Error() == "cannot initialise project in non-empty directory: "+tempDir)
}
func TestInstallSucceedsInEmptyDirectory(t *testing.T) {
is2 := is.New(t)
// Create an empty temp directory
tempDir, err := os.MkdirTemp("", "wails-test-empty-*")
is2.NoErr(err)
defer func() {
_ = os.RemoveAll(tempDir)
}()
options := &Options{
ProjectName: "test",
TemplateName: "vanilla",
TargetDir: tempDir,
}
_, _, err = Install(options)
is2.NoErr(err) // Should succeed in empty directory
}

12
v3/.gitignore vendored
View file

@ -1,12 +0,0 @@
examples/kitchensink/kitchensink
cmd/wails3/wails
/examples/systray-menu/systray
/examples/window/window
/examples/dialogs/dialogs
/examples/menu/menu
/examples/clipboard/clipboard
/examples/plain/plain
/cmd/wails3/ui/.task/
!internal/commands/webview2/MicrosoftEdgeWebview2Setup.exe
internal/commands/appimage_testfiles/appimage_testfiles
testiosapp/

View file

@ -1 +0,0 @@
website

View file

@ -1,6 +0,0 @@
overrides:
- files:
- "**/*.md"
options:
printWidth: 80
proseWrap: always

File diff suppressed because it is too large Load diff

View file

@ -1,419 +0,0 @@
# Wails v3 iOS Architecture
## Executive Summary
This document provides a comprehensive technical architecture for iOS support in Wails v3. The implementation enables Go applications to run natively on iOS with a WKWebView frontend, maintaining the Wails philosophy of using web technologies for UI while leveraging Go for business logic.
## Table of Contents
1. [Architecture Overview](#architecture-overview)
2. [Core Components](#core-components)
3. [Layer Architecture](#layer-architecture)
4. [Implementation Details](#implementation-details)
5. [Battery Optimization](#battery-optimization)
6. [Build System](#build-system)
7. [Security Considerations](#security-considerations)
8. [API Reference](#api-reference)
## Architecture Overview
### Design Principles
1. **Battery Efficiency First**: All architectural decisions prioritize battery life
2. **No Network Ports**: Asset serving happens in-process via native APIs
3. **Minimal WebView Instances**: Maximum 2 concurrent WebViews (1 primary, 1 for transitions)
4. **Native Integration**: Deep iOS integration using Objective-C runtime
5. **Wails v3 Compatibility**: Maintain API compatibility with existing Wails v3 applications
### High-Level Architecture
```
┌─────────────────────────────────────────────────────────────┐
│ iOS Application │
├─────────────────────────────────────────────────────────────┤
│ UIKit Framework │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ WailsViewController │ │
│ │ ┌───────────────────────────────────────────────┐ │ │
│ │ │ WKWebView Instance │ │ │
│ │ │ ┌─────────────────────────────────────────┐ │ │ │
│ │ │ │ Web Application (HTML/JS) │ │ │ │
│ │ │ └─────────────────────────────────────────┘ │ │ │
│ │ └───────────────────────────────────────────────┘ │ │
│ └─────────────────────────────────────────────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ Bridge Layer (CGO) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │URL Handler │ │JS Bridge │ │Message Handler│ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
├─────────────────────────────────────────────────────────────┤
│ Go Runtime │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Wails Application │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │
│ │ │App Logic │ │Services │ │Asset Server │ │ │
│ │ └──────────┘ └──────────┘ └──────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
```
## Core Components
### 1. Platform Layer (`application_ios.go`)
**Purpose**: Go interface for iOS platform operations
**Key Functions**:
- `platformRun()`: Initialize and run the iOS application
- `platformQuit()`: Gracefully shutdown the application
- `isDarkMode()`: Detect iOS dark mode state
- `ExecuteJavaScript(windowID uint, js string)`: Execute JS in WebView
**Exported Go Functions (Called from Objective-C)**:
- `ServeAssetRequest(windowID C.uint, urlStr *C.char, callbackID C.uint)`
- `HandleJSMessage(windowID C.uint, message *C.char)`
### 2. Native iOS Layer (`application_ios.m`)
**Components**:
#### WailsSchemeHandler
```objc
@interface WailsSchemeHandler : NSObject <WKURLSchemeHandler>
```
- Implements `WKURLSchemeHandler` protocol
- Intercepts `wails://` URL requests
- Bridges to Go for asset serving
- Manages pending requests with callback IDs
**Methods**:
- `startURLSchemeTask:`: Intercept request, call Go handler
- `stopURLSchemeTask:`: Cancel pending request
- `completeRequest:withData:mimeType:`: Complete request with data from Go
#### WailsMessageHandler
```objc
@interface WailsMessageHandler : NSObject <WKScriptMessageHandler>
```
- Implements JavaScript to Go communication
- Handles `window.webkit.messageHandlers.external.postMessage()`
- Serializes messages to JSON for Go processing
**Methods**:
- `userContentController:didReceiveScriptMessage:`: Process JS messages
#### WailsViewController
```objc
@interface WailsViewController : UIViewController
```
- Main view controller containing WKWebView
- Manages WebView lifecycle
- Handles JavaScript execution requests
**Properties**:
- `webView`: WKWebView instance
- `schemeHandler`: Custom URL scheme handler
- `messageHandler`: JS message handler
- `windowID`: Unique window identifier
**Methods**:
- `viewDidLoad`: Initialize WebView with configuration
- `executeJavaScript:`: Run JS code in WebView
### 3. Bridge Layer (CGO)
**C Interface Functions**:
```c
void ios_app_init(void); // Initialize iOS app
void ios_app_run(void); // Run main loop
void ios_app_quit(void); // Quit application
bool ios_is_dark_mode(void); // Check dark mode
unsigned int ios_create_webview(void); // Create WebView
void ios_execute_javascript(unsigned int windowID, const char* js);
void ios_complete_request(unsigned int callbackID, const char* data, const char* mimeType);
```
## Layer Architecture
### Layer 1: Presentation Layer (WebView)
**Responsibilities**:
- Render HTML/CSS/JavaScript UI
- Handle user interactions
- Communicate with native layer
**Key Features**:
- WKWebView for modern web standards
- Hardware-accelerated rendering
- Efficient memory management
### Layer 2: Communication Layer
**Request Interception**:
```
WebView Request → WKURLSchemeHandler → Go ServeAssetRequest → AssetServer → Response
```
**JavaScript Bridge**:
```
JS postMessage → WKScriptMessageHandler → Go HandleJSMessage → Process → ExecuteJavaScript
```
### Layer 3: Application Layer (Go)
**Components**:
- Application lifecycle management
- Service binding and method calls
- Asset serving from embedded fs.FS
- Business logic execution
### Layer 4: Platform Integration Layer
**iOS-Specific Features**:
- Dark mode detection
- System appearance integration
- iOS-specific optimizations
## Implementation Details
### Request Handling Flow
1. **WebView makes request** to `wails://localhost/path`
2. **WKURLSchemeHandler intercepts** request
3. **Creates callback ID** and stores `WKURLSchemeTask`
4. **Calls Go function** `ServeAssetRequest` with URL and callback ID
5. **Go processes request** through AssetServer
6. **Go calls** `ios_complete_request` with response data
7. **Objective-C completes** the `WKURLSchemeTask` with response
### JavaScript Execution Flow
1. **Go calls** `ios_execute_javascript` with JS code
2. **Bridge dispatches** to main thread
3. **WKWebView evaluates** JavaScript
4. **Completion handler** logs any errors
### Message Passing Flow
1. **JavaScript calls** `window.webkit.messageHandlers.wails.postMessage(data)`
2. **WKScriptMessageHandler receives** message
3. **Serializes to JSON** and passes to Go
4. **Go processes** message in `HandleJSMessage`
5. **Go can respond** via `ExecuteJavaScript`
## Battery Optimization
### WebView Configuration
```objc
// Disable unnecessary features
config.suppressesIncrementalRendering = NO;
config.allowsInlineMediaPlayback = YES;
config.mediaTypesRequiringUserActionForPlayback = WKAudiovisualMediaTypeNone;
```
### Memory Management
1. **Single WebView Instance**: Reuse instead of creating new instances
2. **Automatic Reference Counting**: Use ARC for Objective-C objects
3. **Lazy Loading**: Initialize components only when needed
4. **Resource Cleanup**: Properly release resources when done
### Request Optimization
1. **In-Process Serving**: No network overhead
2. **Direct Memory Transfer**: Pass data directly without serialization
3. **Efficient Caching**: Leverage WKWebView's built-in cache
4. **Minimal Wake Locks**: No background network activity
## Build System
### Build Tags
```go
//go:build ios
```
### CGO Configuration
```go
#cgo CFLAGS: -x objective-c -fobjc-arc
#cgo LDFLAGS: -framework Foundation -framework UIKit -framework WebKit
```
### Build Script (`build_ios.sh`)
**Steps**:
1. Check dependencies (go, xcodebuild, xcrun)
2. Set up iOS cross-compilation environment
3. Build Go binary with iOS tags
4. Create app bundle structure
5. Generate Info.plist
6. Sign for simulator
7. Create launch script
**Environment Variables**:
```bash
export CGO_ENABLED=1
export GOOS=ios
export GOARCH=arm64
export SDK_PATH=$(xcrun --sdk iphonesimulator --show-sdk-path)
```
### Simulator Deployment
```bash
xcrun simctl install "$DEVICE_ID" "WailsIOSDemo.app"
xcrun simctl launch "$DEVICE_ID" "com.wails.iosdemo"
```
## Security Considerations
### URL Scheme Security
1. **Custom Scheme**: Use `wails://` to avoid conflicts
2. **Origin Validation**: Only serve to authorized WebViews
3. **No External Access**: Scheme handler only responds to app's WebView
### JavaScript Execution
1. **Input Validation**: Sanitize JS code before execution
2. **Sandboxed Execution**: WKWebView provides isolation
3. **No eval()**: Avoid dynamic code evaluation
### Data Protection
1. **In-Memory Only**: No temporary files on disk
2. **ATS Compliance**: App Transport Security enabled
3. **Secure Communication**: All data stays within app process
## API Reference
### Go API
#### Application Functions
```go
// Create new iOS application
app := application.New(application.Options{
Name: "App Name",
Description: "App Description",
})
// Run the application
app.Run()
// Execute JavaScript
app.ExecuteJavaScript(windowID, "console.log('Hello')")
```
#### Service Binding
```go
type MyService struct{}
func (s *MyService) Greet(name string) string {
return fmt.Sprintf("Hello, %s!", name)
}
app := application.New(application.Options{
Services: []application.Service{
application.NewService(&MyService{}),
},
})
```
### JavaScript API
#### Send Message to Go
```javascript
window.webkit.messageHandlers.wails.postMessage({
type: 'methodCall',
service: 'MyService',
method: 'Greet',
args: ['World']
});
```
#### Receive from Go
```javascript
window.wailsCallback = function(data) {
console.log('Received:', data);
};
```
### Objective-C Bridge API
#### From Go to Objective-C
```c
// Execute JavaScript
ios_execute_javascript(windowID, "alert('Hello')");
// Complete asset request
ios_complete_request(callbackID, htmlData, "text/html");
```
#### From Objective-C to Go
```c
// Serve asset request
ServeAssetRequest(windowID, urlString, callbackID);
// Handle JavaScript message
HandleJSMessage(windowID, jsonMessage);
```
## Performance Metrics
### Target Metrics
- **WebView Creation**: < 100ms
- **Asset Request**: < 10ms for cached, < 50ms for first load
- **JS Execution**: < 5ms for simple scripts
- **Message Passing**: < 2ms round trip
- **Memory Usage**: < 50MB baseline
- **Battery Impact**: < 2% per hour active use
### Monitoring
1. **Xcode Instruments**: CPU, Memory, Energy profiling
2. **WebView Inspector**: JavaScript performance
3. **Go Profiling**: pprof for Go code analysis
## Future Enhancements
### Phase 1: Core Stability
- [ ] Production-ready error handling
- [ ] Comprehensive test suite
- [ ] Performance optimization
### Phase 2: Feature Parity
- [ ] Multiple window support
- [ ] System tray integration
- [ ] Native menu implementation
### Phase 3: iOS-Specific Features
- [ ] Widget extension support
- [ ] App Clip support
- [ ] ShareSheet integration
- [ ] Siri Shortcuts
### Phase 4: Advanced Features
- [ ] Background task support
- [ ] Push notifications
- [ ] CloudKit integration
- [ ] Apple Watch companion app
## Conclusion
This architecture provides a solid foundation for iOS support in Wails v3. The design prioritizes battery efficiency, native performance, and seamless integration with the existing Wails ecosystem. The proof of concept demonstrates all four required capabilities:
1. ✅ **WebView Creation**: Native WKWebView with optimized configuration
2. ✅ **Request Interception**: Custom scheme handler without network ports
3. ✅ **JavaScript Execution**: Bidirectional communication bridge
4. ✅ **iOS Simulator Support**: Complete build and deployment pipeline
The architecture is designed to scale from this proof of concept to a full production implementation while maintaining the simplicity and elegance that Wails developers expect.

View file

@ -1,100 +0,0 @@
# iOS Features TODO (Prioritized)
This document lists potential iOS features and platform options to enhance the Wails v3 iOS runtime. Items are ordered by importance for typical app development workflows.
## Top Priority (Implement first)
1) Input accessory bar control
- Status: Implemented as `IOSOptions.DisableInputAccessoryView` (default false = shown). Native toggle + WKWebView subclass.
2) Scrolling and bounce behavior
- Options:
- `DisableScroll` (default true in current runtime to preserve no-scroll template behavior)
- `DisableBounce` (default true in current runtime)
- `HideScrollIndicators` (default true in current runtime)
- Purpose: control elastic bounce, page scrolling, and indicators.
3) Web Inspector / Debug
- Options:
- `DisableInspectable` (default false; inspector enabled by default in dev)
- Purpose: enable/disable WKWebView inspector.
4) Back/forward navigation gestures
- Options:
- `AllowsBackForwardNavigationGestures` (default false)
- Purpose: enable iOS edge-swipe navigation.
5) Link previews
- Options:
- `DisableLinkPreview` (default false)
- Purpose: allow long-press link previews.
6) Media autoplay and inline playback
- Options:
- `DisableInlineMediaPlayback` (default false)
- `RequireUserActionForMediaPlayback` (default false)
- Purpose: control media playback UX.
7) User agent customization
- Options:
- `UserAgent` (string)
- `ApplicationNameForUserAgent` (string; default "wails.io")
- Purpose: customize UA / identify app.
8) Keyboard behavior
- Options:
- Already: `DisableInputAccessoryView`
- Future: `KeyboardDismissMode` (none | onDrag | interactive)
- Purpose: refine keyboard UX.
9) Safe-area and content inset behavior
- Options (future):
- `ContentInsetAdjustment` (automatic | never | always)
- `UseSafeArea` (bool)
- Purpose: fine-tune layout under notch/home indicator.
10) Data detectors (future feasibility)
- Options: `DataDetectorTypes []string` (phoneNumber, link, address)
- Note: Not all are directly available on WKWebView; feasibility TBD.
## Medium Priority
11) Pull-to-refresh (custom)
12) File picker / photo access bridges
13) Haptics feedback helpers
14) Clipboard read/write helpers (partially present)
15) Share sheet / activity view bridges
16) Background audio / PiP controls
17) App lifecycle event hooks (background/foreground)
18) Permissions prompts helpers (camera, mic, photos)
19) Open in external browser vs in-app policy
20) Cookie / storage policy helpers
## Low Priority
21) Theme/dynamic color helpers bridging to CSS vars
22) Orientation lock helpers per window
23) Status bar style control from Go
24) Network reachability events bridge
25) Push notifications
---
# Implementation Plan (Top 10)
Implement the following immediately:
- DisableScroll, DisableBounce, HideScrollIndicators
- AllowsBackForwardNavigationGestures
- DisableLinkPreview
- DisableInlineMediaPlayback
- RequireUserActionForMediaPlayback
- DisableInspectable
- UserAgent
- ApplicationNameForUserAgent
Approach:
- Extend `IOSOptions` in `pkg/application/application_options.go` with these fields.
- Add native globals + C setters in `pkg/application/application_ios.h/.m`.
- Apply options in `pkg/application/webview_window_ios.m` during WKWebView configuration and on the scrollView.
- Wire from Go in `pkg/application/application_ios.go`.
- Maintain current template behavior as defaults (no scroll/bounce/indicators) to avoid regressions in existing tests.

View file

@ -1,53 +0,0 @@
# iOS Runtime Feature Plan
This document outlines proposed iOS-only runtime features for Wails v3, the initial milestones, and method shapes exposed to the frontend runtime as `IOS.*`.
## Goals
- Provide a first-class iOS runtime namespace: `IOS`.
- Expose UX-critical features with a small, well-defined, promise-based API.
- Follow the existing runtime pattern: JS -> /wails/runtime -> Go -> ObjC.
## Object: IOS
- Object ID: 11 (reserved in runtime objectNames)
## Milestone 1 (MVP)
- Haptics
- `IOS.Haptics.Impact(style: "light"|"medium"|"heavy"|"soft"|"rigid"): Promise<void>`
- Device
- `IOS.Device.Info(): Promise<{ model: string; systemName: string; systemVersion: string; isSimulator: boolean }>`
## Milestone 2
- Permissions
- `IOS.Permissions.Request("camera"|"microphone"|"photos"|"notifications"): Promise<"granted"|"denied"|"limited">`
- `IOS.Permissions.Status(kind): Promise<"granted"|"denied"|"limited"|"restricted"|"not_determined">`
- Camera
- `IOS.Camera.PickPhoto(options?): Promise<{ uri: string }>`
- `IOS.Camera.PickVideo(options?): Promise<{ uri: string, duration?: number }>`
- Photos
- `IOS.Photos.SaveImage(dataURL|blob, options?): Promise<void>`
- `IOS.Photos.SaveVideo(fileURI, options?): Promise<void>`
## Milestone 3
- Share
- `IOS.Share.Sheet({ text?, url?, imageDataURL? }): Promise<void>`
- Files
- `IOS.Files.Pick({ types?, multiple? }): Promise<Array<{ uri: string, name: string, size?: number }>>`
- Biometric
- `IOS.Biometric.CanAuthenticate(): Promise<boolean>`
- `IOS.Biometric.Authenticate(reason: string): Promise<boolean>`
- Notifications
- `IOS.Notifications.RequestPermission(): Promise<boolean>`
- `IOS.Notifications.Schedule(localNotification): Promise<string /* id */>`
## Notes
- All APIs should be safe no-ops on other platforms (reject with a meaningful error) or be tree-shaken by frontend bundlers.
- UI-affecting APIs must ensure main-thread execution in ObjC.
- File/Photo APIs will use security-scoped bookmarks where relevant.
## Implementation Status
- [x] Define plan (this document)
- [ ] JS runtime: add IOS object ID + IOS module exports
- [ ] Go: message dispatcher for IOS object
- [ ] iOS: Haptics.Impact(style) native bridge
- [ ] JS->Go->ObjC wiring for Haptics
- [ ] Device.Info() basic implementation

View file

@ -1,9 +0,0 @@
# v3 Alpha
Thanks for wanting to help out with testing/developing Wails v3! This guide will help you get started.
## Getting Started
All the instructions for getting started are in the v3 documentation directory: `mkdocs-website`.
Please read the README.md file in that directory for more information.

View file

@ -1,452 +0,0 @@
# Cross-Platform Testing Guide for Wails v3
This document describes the comprehensive cross-platform testing system for Wails v3 examples, supporting Mac, Linux, and Windows compilation.
## Overview
The testing system ensures all Wails v3 examples build successfully across all supported platforms:
- **macOS (Darwin)** - Native compilation
- **Windows** - Cross-compilation from any platform
- **Linux** - Multi-architecture Docker compilation (ARM64 + x86_64)
## Test Directory Structure
The testing infrastructure is organized in a dedicated test directory:
```bash
v3/
├── test/
│ └── docker/
│ ├── Dockerfile.linux-arm64 # ARM64 native compilation
│ └── Dockerfile.linux-x86_64 # x86_64 native compilation
├── Taskfile.yaml # Build task definitions
└── TESTING.md # This documentation
```
**Benefits of the organized structure:**
- **Separation of Concerns**: Testing files are isolated from application code
- **Clear Organization**: All Docker-related files in one location
- **Easier Maintenance**: Centralized testing infrastructure
- **Better Git Management**: Clean separation for .gitignore patterns
## Available Commands
### 🚀 Complete Cross-Platform Testing
```bash
# Build all examples for ALL platforms (macOS + Windows + Linux)
task test:examples:all
```
**Total: 129 builds** (43 examples × 3 platforms) + CLI code testing
### All Examples (No DIR Parameter Needed)
```bash
# Current platform only (all 43 examples + CLI code)
task test:examples
# All examples for specific Linux architectures
task test:examples:linux:docker # Auto-detect architecture
task test:examples:linux:docker:arm64 # ARM64 native
task test:examples:linux:docker:x86_64 # x86_64 native
# CLI code testing only
task test:cli
```
### Single Example Builds (Requires DIR=example)
```bash
# macOS/Darwin single example
task test:example:darwin DIR=badge
# Windows cross-compilation single example
task test:example:windows DIR=badge
# Linux native builds (on Linux systems)
task test:example:linux DIR=badge
# Linux Docker builds (multi-architecture)
task test:example:linux:docker DIR=badge # Auto-detect architecture
task test:example:linux:docker:arm64 DIR=badge # ARM64 native
task test:example:linux:docker:x86_64 DIR=badge # x86_64 native
```
## Build Artifacts
All builds generate platform-specific binaries with clear naming:
- **macOS**: `testbuild-{example}-darwin`
- **Windows**: `testbuild-{example}-windows.exe`
- **Linux**: `testbuild-{example}-linux`
- **Linux ARM64**: `testbuild-{example}-linux-arm64` (Docker)
- **Linux x86_64**: `testbuild-{example}-linux-x86_64` (Docker)
Example outputs:
```text
examples/badge/testbuild-badge-darwin
examples/badge/testbuild-badge-windows.exe
examples/badge/testbuild-badge-linux-arm64
examples/badge/testbuild-badge-linux-x86_64
```
## Validation Status
### ✅ **Production Ready (v3.0.0-alpha)**
- **Total Examples**: 43 examples fully tested
- **macOS**: ✅ All examples compile successfully (100%)
- **Windows**: ✅ All examples cross-compile successfully (100%)
- **Linux**: ✅ Multi-architecture Docker compilation (ARM64 + x86_64)
- **Build System**: Comprehensive Taskfile.yaml integration
- **Git Integration**: Complete .gitignore patterns for build artifacts
- **Total Build Capacity**: 129 cross-platform builds per test cycle
## Supported Examples
The system builds all 43 Wails v3 examples:
- badge, badge-custom, binding, build
- cancel-async, cancel-chaining, clipboard, contextmenus
- dev, dialogs, dialogs-basic, drag-n-drop
- environment, events, events-bug, file-association
- frameless, gin-example, gin-routing, gin-service
- hide-window, html-dnd-api, ignore-mouse, keybindings
- menu, notifications, panic-handling, plain
- raw-message, screen, services, show-macos-toolbar
- single-instance, systray-basic, systray-custom, systray-menu
- video, window, window-api, window-call
- window-menu, wml
**Recently Added (v3.0.0-alpha):**
- dev, events-bug, gin-example, gin-routing, gin-service
- html-dnd-api, notifications
## Platform Requirements
### macOS (Darwin)
- Go 1.23+
- Xcode Command Line Tools
- No additional dependencies required
**Environment Variables:**
```bash
CGO_LDFLAGS="-framework UniformTypeIdentifiers -mmacosx-version-min=10.13"
CGO_CFLAGS="-mmacosx-version-min=10.13"
```
### Windows (Cross-compilation)
- Go 1.23+
- No additional dependencies for cross-compilation
**Environment Variables:**
```bash
GOOS=windows
GOARCH=amd64
```
### Linux (Docker) - ✅ Multi-Architecture Support
Uses Ubuntu 24.04 base image with full GTK development environment:
**Current Status:** Complete multi-architecture Docker compilation system
- ✅ ARM64 native compilation (Ubuntu 24.04)
- ✅ x86_64 native compilation (Ubuntu 24.04)
- ✅ Automatic architecture detection
- ✅ All dependencies install correctly (GTK + WebKit)
- ✅ Go 1.24 environment configured for each architecture
- ✅ Native compilation eliminates cross-compilation CGO issues
**Architecture Support:**
- **ARM64**: Native compilation using `Dockerfile.linux-arm64`
- **x86_64**: Native compilation using `Dockerfile.linux-x86_64` with `--platform=linux/amd64`
- **Auto-detect**: Taskfile automatically selects appropriate architecture
**Core Dependencies:**
- `build-essential` - GCC compiler toolchain (architecture-specific)
- `pkg-config` - Package configuration tool
- `libgtk-3-dev` - GTK+ 3.x development files
- `libwebkit2gtk-4.1-dev` - WebKit2GTK development files
- `git` - Version control (for go mod operations)
- `ca-certificates` - HTTPS support
**Docker Images:**
- `wails-v3-linux-arm64` - Ubuntu 24.04 ARM64 native compilation (built from `test/docker/Dockerfile.linux-arm64`)
- `wails-v3-linux-x86_64` - Ubuntu 24.04 x86_64 native compilation (built from `test/docker/Dockerfile.linux-x86_64`)
- `wails-v3-linux-fixed` - Legacy unified image (deprecated)
## Docker Configuration
### Multi-Architecture Build System
#### ARM64 Native Build Environment (`test/docker/Dockerfile.linux-arm64`)
```dockerfile
FROM ubuntu:24.04
# ARM64 native compilation environment
# Go 1.24.0 ARM64 binary (go1.24.0.linux-arm64.tar.gz)
# Native GCC toolchain for ARM64
# All GTK/WebKit dependencies for ARM64
# Build script: /build/build-linux-arm64.sh
# Output: testbuild-{example}-linux-arm64
```
#### x86_64 Native Build Environment (`test/docker/Dockerfile.linux-x86_64`)
```dockerfile
FROM --platform=linux/amd64 ubuntu:24.04
# x86_64 native compilation environment
# Go 1.24.0 x86_64 binary (go1.24.0.linux-amd64.tar.gz)
# Native GCC toolchain for x86_64
# All GTK/WebKit dependencies for x86_64
# Build script: /build/build-linux-x86_64.sh
# Output: testbuild-{example}-linux-x86_64
```
### Available Docker Tasks
#### Architecture-Specific Tasks
```bash
# ARM64 builds
task test:example:linux:docker:arm64 DIR=badge
task test:examples:linux:docker:arm64
# x86_64 builds
task test:example:linux:docker:x86_64 DIR=badge
task test:examples:linux:docker:x86_64
```
#### Auto-Detection Tasks (Recommended)
```bash
# Single example (auto-detects host architecture)
task test:example:linux:docker DIR=badge
# All examples (auto-detects host architecture)
task test:examples:linux:docker
```
## Implementation Details
### Key Fixes Applied in v3.0.0-alpha
#### 1. **Complete Example Coverage**
- **Before**: 35 examples tested
- **After**: 43 examples tested (100% coverage)
- **Added**: dev, events-bug, gin-example, gin-routing, gin-service, html-dnd-api, notifications
#### 2. **Go Module Resolution**
- **Issue**: Inconsistent replace directives across examples
- **Fix**: Standardized all examples to use `replace github.com/wailsapp/wails/v3 => ../..`
- **Examples Fixed**: gin-example, gin-routing, notifications
#### 3. **Frontend Asset Embedding**
- **Issue**: Some examples referenced missing `frontend/dist` directories
- **Fix**: Updated embed paths from `//go:embed all:frontend/dist` to `//go:embed all:frontend`
- **Examples Fixed**: file-association, notifications
#### 4. **Manager API Migration**
- **Issue**: Windows badge service using deprecated API
- **Fix**: Updated `app.CurrentWindow()``app.Windows.Current()`
- **Files Fixed**: pkg/services/badge/badge_windows.go
#### 5. **File Association Example**
- **Issue**: Undefined window variable
- **Fix**: Added proper window assignment from `app.Windows.NewWithOptions()`
- **Files Fixed**: examples/file-association/main.go
### Build Performance
- **macOS**: ~2-3 minutes for all 43 examples
- **Windows Cross-Compile**: ~2-3 minutes for all 43 examples
- **Linux Docker**: ~5-10 minutes for all 43 examples (includes image build)
- **Total Build Time**: ~10-15 minutes for complete cross-platform validation (129 builds)
## Usage Examples
### Single Example Testing (Requires DIR Parameter)
```bash
# Test the badge example on all platforms
task test:example:darwin DIR=badge # macOS native
task test:example:windows DIR=badge # Windows cross-compile
task test:example:linux:docker DIR=badge # Linux Docker (auto-detect arch)
```
### All Examples Testing (No DIR Parameter)
```bash
# Test everything - all 43 examples, all platforms
task test:examples:all
# This runs:
# 1. All Darwin builds (43 examples)
# 2. All Windows cross-compilation (43 examples)
# 3. All Linux Docker builds (43 examples, auto-architecture)
# Platform-specific all examples
task test:examples # Current platform (43 examples)
task test:examples:linux:docker:arm64 # ARM64 builds (43 examples)
task test:examples:linux:docker:x86_64 # x86_64 builds (43 examples)
```
### Continuous Integration
```bash
# For CI/CD pipelines
task test:examples:all # Complete cross-platform (129 builds)
task test:examples # Current platform only (43 builds)
```
## Build Process Details
### macOS Builds
1. Sets macOS-specific CGO flags for compatibility
2. Runs `go mod tidy` in each example directory
3. Compiles with `go build -o testbuild-{example}-darwin`
4. Links against UniformTypeIdentifiers framework
### Windows Cross-Compilation
1. Sets `GOOS=windows GOARCH=amd64` environment
2. Runs `go mod tidy` in each example directory
3. Cross-compiles with `go build -o testbuild-{example}-windows.exe`
4. No CGO dependencies required (uses Windows APIs)
### Linux Docker Builds
1. **Auto-Detection**: Detects host architecture (ARM64 or x86_64)
2. **Image Selection**: Uses appropriate Ubuntu 24.04 image for target architecture
3. **Native Compilation**: Eliminates cross-compilation CGO issues
4. **Environment Setup**: Full GTK/WebKit development environment
5. **Build Process**: Runs `go mod tidy && go build` with native toolchain
6. **Output**: Architecture-specific binaries (`-linux-arm64` or `-linux-x86_64`)
## Troubleshooting
### Common Issues (All Resolved in v3.0.0-alpha)
#### **Go Module Resolution Errors**
```bash
Error: replacement directory ../wails/v3 does not exist
```
**Solution**: All examples now use standardized `replace github.com/wailsapp/wails/v3 => ../..`
#### **Frontend Asset Embedding Errors**
```bash
Error: pattern frontend/dist: no matching files found
```
**Solution**: Updated to `//go:embed all:frontend` for examples without dist directories
#### **Manager API Errors**
```bash
Error: app.CurrentWindow undefined
```
**Solution**: Updated to use new manager pattern `app.Windows.Current()`
#### **Build Warnings**
Some examples may show compatibility warnings (e.g., notifications using macOS 10.14+ APIs with 10.13 target). These are non-blocking warnings that can be addressed separately.
### Performance Optimization
#### **Parallel Builds**
```bash
# The task system automatically runs builds in parallel where possible
task v3:test:examples:all # Optimized for maximum throughput
```
#### **Selective Testing**
```bash
# Test specific examples to debug issues
task v3:test:example:darwin DIR=badge
task v3:test:example:windows DIR=contextmenus
```
### Performance Tips
**Parallel Builds:**
```bash
# Build multiple examples simultaneously
task v3:test:example:darwin DIR=badge &
task v3:test:example:darwin DIR=binding &
task v3:test:example:darwin DIR=build &
wait
```
**Docker Image Caching:**
```bash
# Pre-build Docker images
docker build -f Dockerfile.linux -t wails-v3-linux-builder .
docker build -f Dockerfile.linux-simple -t wails-v3-linux-simple .
```
## Integration with Git
### Ignored Files
All build artifacts are automatically ignored via `.gitignore`:
```gitignore
/v3/examples/*/testbuild-*
```
### Clean Build Environment
```bash
# Remove all test build artifacts
find v3/examples -name "testbuild-*" -delete
```
## Validation Results
### Current Status (as of implementation):
- ✅ **macOS**: All 43 examples compile successfully
- ✅ **Windows**: All 43 examples cross-compile successfully
- ✅ **Linux**: Multi-architecture Docker system fully functional
### Build Time Estimates:
- **macOS**: ~2-3 minutes for all examples
- **Windows**: ~2-3 minutes for all examples (cross-compile)
- **Linux Docker**: ~5-10 minutes for all examples (includes image build and compilation)
- **Complete Cross-Platform**: ~10-15 minutes for 129 total builds
## Future Enhancements
### Planned Improvements:
1. **Automated Testing**: Add runtime testing in addition to compilation
2. **Multi-Architecture**: Support ARM64 builds for Apple Silicon and Windows ARM
3. **Build Caching**: Implement Go build cache for faster repeated builds
4. **Parallel Docker**: Multi-stage Docker builds for faster Linux compilation
5. **Platform Matrix**: GitHub Actions integration for automated CI/CD
### Platform Extensions:
- **FreeBSD**: Add BSD build support
- **Android/iOS**: Mobile platform compilation (when supported)
- **WebAssembly**: WASM target compilation
## Changelog
### v3.0.0-alpha (2025-06-20)
#### 🎯 Complete Cross-Platform Testing System
#### **✨ New Features**
- **Complete Example Coverage**: All 43 examples now tested (was 35)
- **Cross-Platform Validation**: Mac + Windows builds for all examples
- **Standardized Build Artifacts**: Consistent platform-specific naming
- **Enhanced Git Integration**: Complete .gitignore patterns for build artifacts
#### **🐛 Major Fixes**
- **Go Module Resolution**: Standardized replace directives across all examples
- **Frontend Asset Embedding**: Fixed missing frontend/dist directory references
- **Manager API Migration**: Updated deprecated Windows badge service calls
- **File Association**: Fixed undefined window variable
- **Build Completeness**: Added 8 missing examples to test suite
#### **🔧 Infrastructure Improvements**
- **Taskfile Integration**: Comprehensive cross-platform build tasks
- **Performance Optimization**: Parallel builds where possible
- **Error Handling**: Clear build failure reporting and debugging
- **Documentation**: Complete testing guide with troubleshooting
#### **📊 Validation Results**
- **macOS**: ✅ 43/43 examples compile successfully
- **Windows**: ✅ 43/43 examples cross-compile successfully
- **Build Time**: ~5-6 minutes for complete cross-platform validation
- **Reliability**: 100% success rate with proper error handling
## Support
For issues with cross-platform builds:
1. Check platform-specific requirements above
2. Review the troubleshooting section for resolved issues
3. Verify Go 1.24+ is installed
4. Check build logs for specific error messages
5. Use selective testing to isolate problems
## References
- [Wails v3 Documentation](https://wails.io/docs/)
- [Go Cross Compilation](https://golang.org/doc/install/cross)
- [GTK Development Libraries](https://www.gtk.org/docs/installations/linux)
- [Task Runner Documentation](https://taskfile.dev/)

View file

@ -1,401 +0,0 @@
# https://taskfile.dev
version: "3"
includes:
generator:
taskfile: ./internal/generator
dir: ./internal/generator
runtime:
taskfile: ./internal/runtime
dir: ./internal/runtime
website:
taskfile: ./website
dir: ./website
optional: true
docs:
taskfile: ../docs
dir: ../docs
optional: true
tasks:
recreate-template-dir:
dir: internal/templates
internal: true
silent: true
cmds:
- rm -rf {{.TEMPLATE_DIR}}
- mkdir -p {{.TEMPLATE_DIR}}
install:
dir: cmd/wails3
silent: true
cmds:
- go install
- echo "Installed wails CLI"
release:
summary: Release a new version of Wails. Call with `task v3:release -- <version>`
dir: tasks/release
cmds:
- go run release.go {{.CLI_ARGS}}
taskfile:upgrade:
cmds:
- go get -u github.com/go-task/task/v3
reinstall-cli:
dir: cmd/wails3
internal: true
silent: true
cmds:
- go install
- echo "Reinstalled wails CLI"
generate:events:
dir: tasks/events
cmds:
- go run generate.go
- go fmt ../../pkg/events/events.go
precommit:
cmds:
- go test ./...
- task: format
# - task: docs:update:api
test:example:darwin:
dir: 'examples/{{.DIR}}'
platforms:
- darwin
cmds:
- echo "Building example {{.DIR}} for Darwin"
- go mod tidy
- go build -o "testbuild-{{.DIR}}-darwin{{exeExt}}"
env:
CGO_LDFLAGS: -framework UniformTypeIdentifiers -mmacosx-version-min=10.13
CGO_CFLAGS: -mmacosx-version-min=10.13
test:example:windows:
dir: 'examples/{{.DIR}}'
platforms:
- windows
cmds:
- echo "Building example {{.DIR}} for Windows"
- go mod tidy
- go build -o "testbuild-{{.DIR}}-windows.exe"
env:
GOOS: windows
GOARCH: amd64
test:example:linux:
dir: 'examples/{{.DIR}}'
platforms:
- linux
cmds:
- echo "Building example {{.DIR}} for Linux"
- go mod tidy
- go build -o "testbuild-{{.DIR}}-linux"
test:example:linux:docker:arm64:
summary: Build a single example for Linux ARM64 using Docker (Ubuntu 24.04)
cmds:
- echo "Building example {{.DIR}} for Linux ARM64 using Docker"
- docker build --pull -f test/docker/Dockerfile.linux-arm64 -t wails-v3-linux-arm64 .
- docker run --rm wails-v3-linux-arm64 /build/build-linux-arm64.sh {{.DIR}}
test:example:linux:docker:x86_64:
summary: Build a single example for Linux x86_64 using Docker (Ubuntu 24.04)
cmds:
- echo "Building example {{.DIR}} for Linux x86_64 using Docker"
- docker build --pull -f test/docker/Dockerfile.linux-x86_64 -t wails-v3-linux-x86_64 .
- docker run --rm wails-v3-linux-x86_64 /build/build-linux-x86_64.sh {{.DIR}}
test:examples:linux:docker:arm64:
summary: Build all examples for Linux ARM64 using Docker (Ubuntu 24.04)
cmds:
- echo "Building Docker image for Linux ARM64 compilation..."
- docker build --pull -f test/docker/Dockerfile.linux-arm64 -t wails-v3-linux-arm64 .
- echo "Running Linux ARM64 compilation in Docker container..."
- docker run --rm wails-v3-linux-arm64
test:examples:linux:docker:x86_64:
summary: Build all examples for Linux x86_64 using Docker (Ubuntu 24.04)
cmds:
- echo "Building Docker image for Linux x86_64 compilation..."
- docker build --pull -f test/docker/Dockerfile.linux-x86_64 -t wails-v3-linux-x86_64 .
- echo "Running Linux x86_64 compilation in Docker container..."
- docker run --rm wails-v3-linux-x86_64
test:example:linux:docker:
summary: Build a single example for Linux using Docker (auto-detect architecture)
cmds:
- echo "Auto-detecting architecture for Linux Docker build..."
- |
if [ "$(uname -m)" = "arm64" ] || [ "$(uname -m)" = "aarch64" ]; then
echo "Detected ARM64, using ARM64 Docker image"
task test:example:linux:docker:arm64 DIR={{.DIR}}
else
echo "Detected x86_64, using x86_64 Docker image"
task test:example:linux:docker:x86_64 DIR={{.DIR}}
fi
test:examples:linux:docker:
summary: Build all examples for Linux using Docker (auto-detect architecture)
cmds:
- echo "Auto-detecting architecture for Linux Docker build..."
- |
if [ "$(uname -m)" = "arm64" ] || [ "$(uname -m)" = "aarch64" ]; then
echo "Detected ARM64, using ARM64 Docker image"
task test:examples:linux:docker:arm64
else
echo "Detected x86_64, using x86_64 Docker image"
task test:examples:linux:docker:x86_64
fi
test:examples:all:
summary: Builds all examples for all platforms (Mac + Windows + Linux via Docker)
vars:
EXAMPLEDIRS: |
badge
badge-custom
binding
build
cancel-async
cancel-chaining
clipboard
contextmenus
dev
dialogs
dialogs-basic
drag-n-drop
environment
events
events-bug
file-association
frameless
gin-example
gin-routing
gin-service
hide-window
ignore-mouse
keybindings
liquid-glass
menu
notifications
panic-handling
plain
raw-message
screen
services
show-macos-toolbar
single-instance
systray-basic
systray-custom
systray-menu
video
window
window-api
window-call
window-menu
wml
cmds:
- echo "Building all examples for all platforms..."
- echo "=== Building for Darwin ==="
- for: { var: EXAMPLEDIRS }
task: test:example:darwin
vars:
DIR: "{{.ITEM}}"
- echo "=== Building for Windows (cross-compile) ==="
- for: { var: EXAMPLEDIRS }
task: test:example:windows
vars:
DIR: "{{.ITEM}}"
- echo "=== Building for Linux (Docker) ==="
- task: test:examples:linux:docker
- echo "=== Testing CLI Code ==="
- task: test:cli
- echo "=== Cleaning Up Test Binaries ==="
- task: clean:test:binaries
test:cli:
summary: Test CLI-related code compilation
cmds:
- echo "Testing CLI appimage testfiles compilation..."
- cd internal/commands/appimage_testfiles && go mod tidy && go build
- echo "✅ CLI appimage testfiles compile successfully"
test:cli:all:
summary: Test all CLI components and critical test files
cmds:
- echo "Testing CLI appimage testfiles..."
- cd internal/commands/appimage_testfiles && go mod tidy && go build
- echo "Testing window visibility test..."
- cd tests/window-visibility-test && go mod tidy && go build
- echo "Testing service implementations..."
- cd pkg/services/badge && go build
- echo "✅ All CLI components compile successfully"
test:generator:
summary: Test code generator test cases compilation
cmds:
- echo "Testing generator test cases (sample)..."
- cd internal/generator/testcases/function_single && go mod tidy && go build
- cd internal/generator/testcases/complex_method && go mod tidy && go build
- cd internal/generator/testcases/struct_literal_single && go mod tidy && go build
- echo "✅ Generator test cases compile successfully"
test:templates:
summary: Test template generation for core templates
cmds:
- echo "Testing template generation (core templates)..."
- task: install
- echo "Testing lit template generation..."
- rm -rf ./test-template-lit && wails3 init -n test-template-lit -t lit
- mkdir -p ./test-template-lit/frontend/dist && touch ./test-template-lit/frontend/dist/.keep
- cd ./test-template-lit && go mod tidy && go build
- rm -rf ./test-template-lit
- echo "Testing react template generation..."
- rm -rf ./test-template-react && wails3 init -n test-template-react -t react
- mkdir -p ./test-template-react/frontend/dist && touch ./test-template-react/frontend/dist/.keep
- cd ./test-template-react && go mod tidy && go build
- rm -rf ./test-template-react
- echo "✅ Template generation tests completed successfully"
test:infrastructure:
summary: Test critical infrastructure components
cmds:
- echo "=== Testing CLI Components ==="
- task: test:cli:all
- echo "=== Testing Generator ==="
- task: test:generator
- echo "=== Testing Templates ==="
- task: test:templates
- echo "=== Testing pkg/application ==="
- cd pkg/application && go test -c -o /dev/null ./...
- echo "=== Cleaning Up Test Binaries ==="
- task: clean:test:binaries
- echo "✅ All infrastructure components test successfully"
test:examples:
summary: Builds the examples for current platform only
vars:
EXAMPLEDIRS: |
badge
badge-custom
binding
build
cancel-async
cancel-chaining
clipboard
contextmenus
dev
dialogs
dialogs-basic
drag-n-drop
environment
events
events-bug
file-association
frameless
gin-example
gin-routing
gin-service
hide-window
ignore-mouse
keybindings
liquid-glass
menu
notifications
panic-handling
plain
raw-message
screen
services
show-macos-toolbar
single-instance
systray-basic
systray-custom
systray-menu
video
window
window-api
window-call
window-menu
wml
cmds:
- echo "Testing examples compilation..."
- for: { var: EXAMPLEDIRS }
task: test:example:darwin
vars:
DIR: "{{.ITEM}}"
platforms: [darwin]
- for: { var: EXAMPLEDIRS }
task: test:example:linux
vars:
DIR: "{{.ITEM}}"
platforms: [linux]
- for: { var: EXAMPLEDIRS }
task: test:example:windows
vars:
DIR: "{{.ITEM}}"
platforms: [windows]
- echo "Testing CLI code..."
- task: test:cli
- echo "=== Cleaning Up Test Binaries ==="
- task: clean:test:binaries
clean:test:binaries:
summary: Clean up all test-generated binary files and directories (cross-platform)
cmds:
- echo "🧹 Cleaning up test binaries..."
- go run tasks/cleanup/cleanup.go
- echo "✅ Test binaries cleaned up"
test:all:
summary: Run all tests including examples, infrastructure, and Go unit tests
cmds:
- echo "=== Running Go Unit Tests ==="
- go test ./...
- echo "=== Testing Examples (Current Platform) ==="
- task: test:examples
- echo "=== Testing Infrastructure Components ==="
- task: test:infrastructure
- echo "=== Cleaning Up Test Binaries ==="
- task: clean:test:binaries
- echo "✅ All tests completed successfully"
build:server:
summary: Build an application in server mode (no GUI, HTTP server only)
desc: |
Builds a Wails application in server mode using the -tags server build tag.
Server mode enables running the application as a pure HTTP server without
native GUI dependencies.
Usage: task build:server DIR=examples/server
dir: '{{.DIR | default "."}}'
cmds:
- echo "Building {{.DIR | default `.`}} in server mode..."
- go build -tags server -o '{{.OUTPUT | default "server-app"}}' .
- echo "Server mode build complete"
test:example:server:
summary: Build and test the server mode example
dir: 'examples/server'
cmds:
- echo "Building server example with -tags server..."
- go mod tidy
- go build -tags server -o "testbuild-server"
- echo "✅ Server example builds successfully"
- rm -f testbuild-server
test:server:
summary: Run server mode unit tests
dir: 'pkg/application'
cmds:
- echo "Running server mode tests..."
- go test -tags server -v -run TestServerMode .
- echo "✅ Server mode tests passed"

View file

@ -1,62 +0,0 @@
# Unreleased Changes
<!--
This file is used to collect changelog entries for the next v3-alpha release.
Add your changes under the appropriate sections below.
Guidelines:
- Follow the "Keep a Changelog" format (https://keepachangelog.com/)
- Write clear, concise descriptions of changes
- Include the impact on users when relevant
- Use present tense ("Add feature" not "Added feature")
- Reference issue/PR numbers when applicable
This file is automatically processed by the nightly release workflow.
After processing, the content will be moved to the main changelog and this file will be reset.
-->
## Added
<!-- New features, capabilities, or enhancements -->
- Add `UseApplicationMenu` option to `WebviewWindowOptions` allowing windows on Windows/Linux to inherit the application menu set via `app.Menu.Set()` by @leaanthony
## Changed
<!-- Changes in existing functionality -->
- Move `EnabledFeatures`, `DisabledFeatures`, and `AdditionalBrowserArgs` from per-window options to application-level `Options.Windows` (#4559) by @leaanthony
## Fixed
<!-- Bug fixes -->
- Fix potential panic when setting empty icon or bitmap on Linux (#4923) by @ddmoney420
- Fix ErrorDialog crash when called from service binding on macOS (#3631) by @leaanthony
- Make menus to be displayed on Windows OS in `v3\examples\dialogs` by @ndianabasi
- Fix race condition causing TypeError during page reload (#4872) by @ddmoney420
- Fix incorrect output from binding generator tests by removing global state in the `Collector.IsVoidAlias()` method (#4941) by @fbbdev
## Deprecated
<!-- Soon-to-be removed features -->
## Removed
<!-- Features removed in this release -->
- **BREAKING**: Remove `EnabledFeatures`, `DisabledFeatures`, and `AdditionalLaunchArgs` from per-window `WindowsWindow` options. Use application-level `Options.Windows.EnabledFeatures`, `Options.Windows.DisabledFeatures`, and `Options.Windows.AdditionalBrowserArgs` instead. These flags apply globally to the shared WebView2 environment (#4559) by @leaanthony
## Security
<!-- Security-related changes -->
---
### Example Entries:
**Added:**
- Add support for custom window icons in application options
- Add new `SetWindowIcon()` method to runtime API (#1234)
**Changed:**
- Update minimum Go version requirement to 1.21
- Improve error messages for invalid configuration files
**Fixed:**
- Fix memory leak in event system during window close operations (#5678)
- Fix crash when using context menus on Linux with Wayland
**Security:**
- Update dependencies to address CVE-2024-12345 in third-party library

View file

@ -1,233 +0,0 @@
#!/bin/bash
# Wails v3 iOS Build Script
# This script builds a Wails application for iOS Simulator
set -e
# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo -e "${GREEN}Wails v3 iOS Build Script${NC}"
echo "==============================="
# Check for required tools
check_command() {
if ! command -v $1 &> /dev/null; then
echo -e "${RED}Error: $1 is not installed${NC}"
exit 1
fi
}
echo "Checking dependencies..."
check_command go
check_command xcodebuild
check_command xcrun
# Configuration
APP_NAME="${APP_NAME:-WailsIOSDemo}"
BUNDLE_ID="${BUNDLE_ID:-com.wails.iosdemo}"
BUILD_DIR="build/ios"
SIMULATOR_SDK="iphonesimulator"
MIN_IOS_VERSION="13.0"
# Clean build directory
echo "Cleaning build directory..."
rm -rf $BUILD_DIR
mkdir -p $BUILD_DIR
# Create the iOS app structure
echo "Creating iOS app structure..."
APP_DIR="$BUILD_DIR/$APP_NAME.app"
mkdir -p "$APP_DIR"
# Create Info.plist
echo "Creating Info.plist..."
cat > "$BUILD_DIR/Info.plist" << EOF
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>en</string>
<key>CFBundleExecutable</key>
<string>$APP_NAME</string>
<key>CFBundleIdentifier</key>
<string>$BUNDLE_ID</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$APP_NAME</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>MinimumOSVersion</key>
<string>$MIN_IOS_VERSION</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<false/>
</dict>
</dict>
</plist>
EOF
cp "$BUILD_DIR/Info.plist" "$APP_DIR/"
# Build the Go application for iOS Simulator
echo -e "${YELLOW}Building Go application for iOS Simulator...${NC}"
# Set up environment for iOS cross-compilation
export CGO_ENABLED=1
export GOOS=ios
export GOARCH=arm64
export SDK_PATH=$(xcrun --sdk $SIMULATOR_SDK --show-sdk-path)
export CGO_CFLAGS="-isysroot $SDK_PATH -mios-simulator-version-min=$MIN_IOS_VERSION -arch arm64 -fembed-bitcode"
export CGO_LDFLAGS="-isysroot $SDK_PATH -mios-simulator-version-min=$MIN_IOS_VERSION -arch arm64"
# Find clang for the simulator
export CC=$(xcrun --sdk $SIMULATOR_SDK --find clang)
export CXX=$(xcrun --sdk $SIMULATOR_SDK --find clang++)
echo "SDK Path: $SDK_PATH"
echo "CC: $CC"
# Build the demo app using the example
echo "Building demo application..."
# Create a simplified main.go that uses local packages
cat > "$BUILD_DIR/main.go" << 'EOF'
//go:build ios
package main
import (
"fmt"
"log"
)
// Since we're building a proof of concept, we'll create a minimal app
// that demonstrates the iOS integration
func main() {
fmt.Println("Wails iOS Demo Starting...")
// For the PoC, we'll import the iOS platform code directly
// In production, this would use the full Wails v3 application package
log.Println("iOS application would start here")
// The actual iOS app initialization happens in the Objective-C layer
// This is just a placeholder for the build process
}
EOF
# Try to build the binary
cd "$BUILD_DIR"
echo "Attempting to build iOS binary..."
# For now, let's create a simple test binary to verify the build toolchain
go build -tags ios -o "$APP_NAME" main.go 2>&1 || {
echo -e "${YELLOW}Note: Full iOS build requires gomobile or additional setup${NC}"
echo "Creating placeholder binary for demonstration..."
# Create a placeholder executable
cat > "$APP_NAME.c" << 'EOF'
#include <stdio.h>
int main() {
printf("Wails iOS Demo Placeholder\n");
return 0;
}
EOF
$CC -isysroot $SDK_PATH -arch arm64 -mios-simulator-version-min=$MIN_IOS_VERSION \
-o "$APP_NAME" "$APP_NAME.c"
}
# Sign the app for simulator (no actual certificate needed)
echo "Preparing app for simulator..."
codesign --force --sign - "$APP_NAME" 2>/dev/null || true
mv "$APP_NAME" "$APP_DIR/"
# Create a simple launch script
echo "Creating launch script..."
cd - > /dev/null
cat > "$BUILD_DIR/run_simulator.sh" << 'EOF'
#!/bin/bash
echo "iOS Simulator Launch Script"
echo "============================"
# Check if Simulator is available
if ! command -v open &> /dev/null; then
echo "Error: Cannot open Simulator"
exit 1
fi
# Open Xcode Simulator
echo "Opening iOS Simulator..."
open -a Simulator 2>/dev/null || {
echo "Error: Could not open Simulator. Make sure Xcode is installed."
exit 1
}
echo ""
echo "Simulator should now be opening..."
echo ""
echo "Note: This is a proof of concept demonstrating:"
echo " 1. ✅ WebView creation (application_ios.m)"
echo " 2. ✅ Request interception via WKURLSchemeHandler"
echo " 3. ✅ JavaScript execution bridge"
echo " 4. ✅ iOS Simulator build support"
echo ""
echo "The full implementation would require:"
echo " - gomobile for proper Go/iOS integration"
echo " - Proper Xcode project generation"
echo " - Full CGO bindings compilation"
echo ""
echo "See IOS_ARCHITECTURE.md for complete technical details."
EOF
chmod +x "$BUILD_DIR/run_simulator.sh"
echo -e "${GREEN}Build complete!${NC}"
echo ""
echo "Build artifacts created in: $BUILD_DIR"
echo ""
echo "To open the iOS Simulator:"
echo " cd $BUILD_DIR && ./run_simulator.sh"
echo ""
echo "The proof of concept demonstrates:"
echo " 1. ✅ WebView creation code (pkg/application/application_ios.m)"
echo " 2. ✅ Request interception (WKURLSchemeHandler implementation)"
echo " 3. ✅ JavaScript execution (bidirectional bridge)"
echo " 4. ✅ iOS build configuration and simulator support"
echo ""
echo "Full implementation requires gomobile integration."
echo "See IOS_ARCHITECTURE.md for complete technical documentation."

View file

@ -1,83 +0,0 @@
# The Wails CLI
The Wails CLI is a command line tool that allows you to create, build and run Wails applications.
There are a number of commands related to tooling, such as icon generation and asset bundling.
## Commands
### task
The `task` command is for running tasks defined in `Taskfile.yml`. It is a wrapper around [Task](https://taskfile.dev).
### generate
The `generate` command is used to generate resources and assets for your Wails project.
It can be used to generate many things including:
- application icons,
- resource files for Windows applications
- Info.plist files for macOS deployments
#### icon
The `icon` command generates icons for your project.
| Flag | Type | Description | Default |
|--------------------|--------|------------------------------------------------------|----------------------|
| `-example` | bool | Generates example icon file (appicon.png) | |
| `-input` | string | The input image file | |
| `-sizes` | string | The sizes to generate in .ico file (comma separated) | "256,128,64,48,32,16" |
| `-windowsFilename` | string | The output filename for the Windows icon | icon.ico |
| `-macFilename` | string | The output filename for the Mac icon bundle | icons.icns |
```bash
wails3 generate icon -input myicon.png -sizes "32,64,128" -windowsFilename myicon.ico -macFilename myicon.icns
```
This will generate icons for mac and windows and save them in the current directory as `myicon.ico`
and `myicons.icns`.
#### syso
The `syso` command generates a Windows resource file (aka `.syso`).
```bash
wails3 generate syso <options>
```
| Flag | Type | Description | Default |
|-------------|--------|--------------------------------------------|------------------|
| `-example` | bool | Generates example manifest & info files | |
| `-manifest` | string | The manifest file | |
| `-info` | string | The info.json file | |
| `-icon` | string | The icon file | |
| `-out` | string | The output filename for the syso file | `wails.exe.syso` |
| `-arch` | string | The target architecture (amd64,arm64,386) | `runtime.GOOS` |
If `-example` is provided, the command will generate example manifest and info files
in the current directory and exit.
If `-manifest` is provided, the command will use the provided manifest file to generate
the syso file.
If `-info` is provided, the command will use the provided info.json file to set the version
information in the syso file.
NOTE: We use [winres](https://github.com/tc-hib/winres) to generate the syso file. Please
refer to the winres documentation for more information.
NOTE: Whilst the tool will work for 32-bit Windows, it is not supported. Please use 64-bit.
#### defaults
```bash
wails3 generate defaults
```
This will generate all the default assets and resources in the current directory.
#### bindings
```bash
wails3 generate bindings
```
Generates bindings and models for your bound Go methods and structs.

View file

@ -1,174 +0,0 @@
package main
import (
"os"
"runtime/debug"
"github.com/pkg/browser"
"github.com/pterm/pterm"
"github.com/samber/lo"
"github.com/leaanthony/clir"
"github.com/wailsapp/wails/v3/internal/commands"
"github.com/wailsapp/wails/v3/internal/flags"
"github.com/wailsapp/wails/v3/internal/term"
)
func init() {
buildInfo, ok := debug.ReadBuildInfo()
if !ok {
return
}
commands.BuildSettings = lo.Associate(buildInfo.Settings, func(setting debug.BuildSetting) (string, string) {
return setting.Key, setting.Value
})
// Iterate over the Deps and add them to the build settings using a prefix of "mod."
for _, dep := range buildInfo.Deps {
commands.BuildSettings["mod."+dep.Path] = dep.Version
}
}
func main() {
app := clir.NewCli("wails", "The Wails3 CLI", "v3")
app.NewSubCommand("docs", "Open the docs").Action(openDocs)
app.NewSubCommandFunction("init", "Initialise a new project", commands.Init)
build := app.NewSubCommand("build", "Build the project")
var buildFlags flags.Build
build.AddFlags(&buildFlags)
build.Action(func() error {
return commands.Build(&buildFlags, build.OtherArgs())
})
app.NewSubCommandFunction("dev", "Run in Dev mode", commands.Dev)
pkg := app.NewSubCommand("package", "Package application")
var pkgFlags flags.Package
pkg.AddFlags(&pkgFlags)
pkg.Action(func() error {
return commands.Package(&pkgFlags, pkg.OtherArgs())
})
app.NewSubCommandFunction("doctor", "System status report", commands.Doctor)
app.NewSubCommandFunction("releasenotes", "Show release notes", commands.ReleaseNotes)
task := app.NewSubCommand("task", "Run and list tasks")
var taskFlags commands.RunTaskOptions
task.AddFlags(&taskFlags)
task.Action(func() error {
return commands.RunTask(&taskFlags, task.OtherArgs())
})
task.LongDescription("\nUsage: wails3 task [taskname] [flags]\n\nTasks are defined in the `Taskfile.yaml` file. See https://taskfile.dev for more information.")
generate := app.NewSubCommand("generate", "Generation tools")
generate.NewSubCommandFunction("build-assets", "Generate build assets", commands.GenerateBuildAssets)
generate.NewSubCommandFunction("icons", "Generate icons", commands.GenerateIcons)
generate.NewSubCommandFunction("syso", "Generate Windows .syso file", commands.GenerateSyso)
generate.NewSubCommandFunction("runtime", "Generate the pre-built version of the runtime", commands.GenerateRuntime)
generate.NewSubCommandFunction("webview2bootstrapper", "Generate WebView2 bootstrapper", commands.GenerateWebView2Bootstrapper)
generate.NewSubCommandFunction("template", "Generate a new template", commands.GenerateTemplate)
update := app.NewSubCommand("update", "Update tools")
update.NewSubCommandFunction("build-assets", "Updates the build assets using the given config file", commands.UpdateBuildAssets)
update.NewSubCommandFunction("cli", "Updates the Wails CLI", commands.UpdateCLI)
bindgen := generate.NewSubCommand("bindings", "Generate bindings + models")
var bindgenFlags flags.GenerateBindingsOptions
bindgen.AddFlags(&bindgenFlags)
bindgen.Action(func() error {
return commands.GenerateBindings(&bindgenFlags, bindgen.OtherArgs())
})
bindgen.LongDescription("\nUsage: wails3 generate bindings [flags] [patterns...]\n\nPatterns match packages to scan for bound types.\nPattern format is analogous to that of the Go build tool,\ne.g. './...' matches packages in the current directory and all descendants.\nIf no pattern is given, the tool will fall back to the current directory.")
generate.NewSubCommandFunction("constants", "Generate JS constants from Go", commands.GenerateConstants)
generate.NewSubCommandFunction(".desktop", "Generate .desktop file", commands.GenerateDotDesktop)
generate.NewSubCommandFunction("appimage", "Generate Linux AppImage", commands.GenerateAppImage)
plugin := app.NewSubCommand("service", "Service tools")
plugin.NewSubCommandFunction("init", "Initialise a new service", commands.ServiceInit)
tool := app.NewSubCommand("tool", "Various tools")
tool.NewSubCommandFunction("checkport", "Checks if a port is open. Useful for testing if vite is running.", commands.ToolCheckPort)
tool.NewSubCommandFunction("watcher", "Watches files and runs a command when they change", commands.Watcher)
tool.NewSubCommandFunction("cp", "Copy files", commands.Cp)
tool.NewSubCommandFunction("buildinfo", "Show Build Info", commands.BuildInfo)
tool.NewSubCommandFunction("package", "Generate Linux packages (deb, rpm, archlinux)", commands.ToolPackage)
tool.NewSubCommandFunction("version", "Bump semantic version", commands.ToolVersion)
tool.NewSubCommandFunction("lipo", "Create macOS universal binary from multiple architectures", commands.ToolLipo)
// Low-level sign tool (used by Taskfiles)
toolSign := tool.NewSubCommand("sign", "Sign a binary or package directly")
var toolSignFlags flags.Sign
toolSign.AddFlags(&toolSignFlags)
toolSign.Action(func() error {
return commands.Sign(&toolSignFlags)
})
// Setup commands
setup := app.NewSubCommand("setup", "Project setup wizards")
setupSigning := setup.NewSubCommand("signing", "Configure code signing")
var setupSigningFlags flags.SigningSetup
setupSigning.AddFlags(&setupSigningFlags)
setupSigning.Action(func() error {
return commands.SigningSetup(&setupSigningFlags)
})
setupEntitlements := setup.NewSubCommand("entitlements", "Configure macOS entitlements")
var setupEntitlementsFlags flags.EntitlementsSetup
setupEntitlements.AddFlags(&setupEntitlementsFlags)
setupEntitlements.Action(func() error {
return commands.EntitlementsSetup(&setupEntitlementsFlags)
})
// Sign command (wrapper that calls platform-specific tasks)
sign := app.NewSubCommand("sign", "Sign binaries and packages for current or specified platform")
var signWrapperFlags flags.SignWrapper
sign.AddFlags(&signWrapperFlags)
sign.Action(func() error {
return commands.SignWrapper(&signWrapperFlags, sign.OtherArgs())
})
// iOS tools
ios := app.NewSubCommand("ios", "iOS tooling")
ios.NewSubCommandFunction("overlay:gen", "Generate Go overlay for iOS bridge shim", commands.IOSOverlayGen)
ios.NewSubCommandFunction("xcode:gen", "Generate Xcode project in output directory", commands.IOSXcodeGen)
app.NewSubCommandFunction("version", "Print the version", commands.Version)
app.NewSubCommand("sponsor", "Sponsor the project").Action(openSponsor)
defer printFooter()
err := app.Run()
if err != nil {
pterm.Error.Println(err)
os.Exit(1)
}
}
func printFooter() {
if !commands.DisableFooter {
docsLink := term.Hyperlink("https://v3.wails.io/getting-started/your-first-app/", "wails3 docs")
pterm.Println(pterm.LightGreen("\nNeed documentation? Run: ") + pterm.LightBlue(docsLink))
// Check if we're in a teminal
printer := pterm.PrefixPrinter{
MessageStyle: pterm.NewStyle(pterm.FgLightGreen),
Prefix: pterm.Prefix{
Style: pterm.NewStyle(pterm.FgRed, pterm.BgLightWhite),
Text: "♥ ",
},
}
linkText := term.Hyperlink("https://github.com/sponsors/leaanthony", "wails3 sponsor")
printer.Println("If Wails is useful to you or your company, please consider sponsoring the project: " + pterm.LightBlue(linkText))
}
}
func openDocs() error {
commands.DisableFooter = true
return browser.OpenURL("https://v3.wails.io/getting-started/your-first-app/")
}
func openSponsor() error {
commands.DisableFooter = true
return browser.OpenURL("https://github.com/sponsors/leaanthony")
}

View file

@ -1,17 +0,0 @@
# v3
*NOTE*: The examples in this directory may or may not compile / run at any given time during alpha development.
## Running the examples
cd v3/examples/<example>
go mod tidy
go run .
## Compiling the examples
cd v3/examples/<example>
go mod tidy
go build
./<example>

View file

@ -1,24 +0,0 @@
# Build outputs
bin/
*.apk
*.aab
# Android build artifacts
build/android/.gradle/
build/android/app/build/
build/android/local.properties
# JNI libraries (generated during build)
build/android/app/src/main/jniLibs/*/libwails.so
# IDE
.idea/
*.iml
# OS
.DS_Store
Thumbs.db
# Frontend build
frontend/dist/
frontend/node_modules/

View file

@ -1 +0,0 @@
a40fe27d90a25e84deeed985e4075cfa

View file

@ -1 +0,0 @@
82dedd4f821c351be61d8e1dbb6eefa

View file

@ -1 +0,0 @@
7bfce68482b8f82eb3495774fb52ddca

View file

@ -1 +0,0 @@
aef25acb8df5f0f69361a3df9b49b2e

View file

@ -1,34 +0,0 @@
version: '3'
includes:
common: ./build/Taskfile.yml
windows: ./build/windows/Taskfile.yml
darwin: ./build/darwin/Taskfile.yml
linux: ./build/linux/Taskfile.yml
android: ./build/android/Taskfile.yml
vars:
APP_NAME: "android"
BIN_DIR: "bin"
VITE_PORT: '{{.WAILS_VITE_PORT | default 9245}}'
tasks:
build:
summary: Builds the application
cmds:
- task: "{{OS}}:build"
package:
summary: Packages a production build of the application
cmds:
- task: "{{OS}}:package"
run:
summary: Runs the application
cmds:
- task: "{{OS}}:run"
dev:
summary: Runs the application in development mode
cmds:
- wails3 dev -config ./build/config.yml -port {{.VITE_PORT}}

View file

@ -1,174 +0,0 @@
version: '3'
tasks:
go:mod:tidy:
summary: Runs `go mod tidy`
internal: true
cmds:
- go mod tidy
install:frontend:deps:
summary: Install frontend dependencies
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
build:frontend:
label: build:frontend (PRODUCTION={{.PRODUCTION}})
summary: Build the frontend project
dir: frontend
sources:
- "**/*"
generates:
- dist/**/*
deps:
- task: install:frontend:deps
- task: generate:bindings
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
cmds:
- npm run {{.BUILD_COMMAND}} -q
env:
PRODUCTION: '{{.PRODUCTION | default "false"}}'
vars:
BUILD_COMMAND: '{{if eq .PRODUCTION "true"}}build{{else}}build:dev{{end}}'
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
# Fetch Puppertino full.css and LICENSE from GitHub main branch
curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/dist/css/full.css -o frontend/public/puppertino/puppertino.css
curl -fsSL https://raw.githubusercontent.com/codedgar/Puppertino/main/LICENSE -o frontend/public/puppertino/LICENSE
echo "Puppertino CSS updated at frontend/public/puppertino/puppertino.css"
# 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 " <link rel=\"stylesheet\" href=\"/puppertino/puppertino.css\"/>"; 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:
label: generate:bindings (BUILD_FLAGS={{.BUILD_FLAGS}})
summary: Generates bindings for the frontend
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
generate:icons:
summary: Generates Windows `.ico` and Mac `.icns` files from an image
dir: build
sources:
- "appicon.png"
generates:
- "darwin/icons.icns"
- "windows/icon.ico"
cmds:
- wails3 generate icons -input appicon.png -macfilename darwin/icons.icns -windowsfilename windows/icon.ico
dev:frontend:
summary: Runs the frontend in development mode
dir: frontend
deps:
- task: install:frontend:deps
cmds:
- npm 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 .
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/<YourProject>.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}}"

View file

@ -1,237 +0,0 @@
version: '3'
includes:
common: ../Taskfile.yml
vars:
APP_ID: '{{.APP_ID | default "com.wails.app"}}'
MIN_SDK: '21'
TARGET_SDK: '34'
NDK_VERSION: 'r26d'
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 build of the application for Android
deps:
- task: common:go:mod:tidy
- task: generate:android:bindings
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
PRODUCTION:
ref: .PRODUCTION
- task: common:generate:icons
cmds:
- echo "Building Android app {{.APP_NAME}}..."
- task: compile:go:shared
vars:
ARCH: '{{.ARCH | default "arm64"}}'
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:
- |
NDK_ROOT="${ANDROID_NDK_HOME:-$ANDROID_HOME/ndk/{{.NDK_VERSION}}}"
if [ ! -d "$NDK_ROOT" ]; then
echo "Error: Android NDK not found at $NDK_ROOT"
echo "Please set ANDROID_NDK_HOME or install NDK {{.NDK_VERSION}} via Android Studio"
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 {{.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 an APK
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: assemble:apk
package:fat:
summary: Packages a production build for all architectures (fat APK)
cmds:
- task: compile:go:all-archs
- task: assemble:apk
assemble:apk:
summary: Assembles the APK using Gradle
cmds:
- |
cd build/android
./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
cmds:
- |
cd build/android
./gradlew assembleRelease
cp app/build/outputs/apk/release/app-release-unsigned.apk ../../{{.BIN_DIR}}/{{.APP_NAME}}-release.apk
echo "Release APK created: {{.BIN_DIR}}/{{.APP_NAME}}-release.apk"
generate:android:bindings:
internal: true
summary: Generates bindings for Android
sources:
- "**/*.go"
- go.mod
- go.sum
generates:
- frontend/bindings/**/*
cmds:
- wails3 generate bindings -f '{{.BUILD_FLAGS}}' -clean=true
env:
GOOS: android
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default "arm64"}}'
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 | head -1)
if [ -z "$AVD_NAME" ]; then
echo "No Android Virtual Devices found."
echo "Create one using: Android Studio > Tools > Device Manager"
exit 1
fi
echo "Starting emulator: $AVD_NAME"
emulator -avd "$AVD_NAME" -no-snapshot-load &
# Wait for emulator to boot (max 60 seconds)
echo "Waiting for emulator to boot..."
adb wait-for-device
for i in {1..60}; 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: command -v adb
msg: "adb not found. Please install Android SDK and add platform-tools to PATH"
- sh: command -v emulator
msg: "emulator not found. Please install Android SDK and add emulator to PATH"
deploy-emulator:
summary: Deploy to Android Emulator
deps: [package]
cmds:
- adb uninstall {{.APP_ID}} 2>/dev/null || true
- adb install {{.BIN_DIR}}/{{.APP_NAME}}.apk
- adb shell am start -n {{.APP_ID}}/.MainActivity
run:
summary: Run the application in Android Emulator
deps:
- task: ensure-emulator
- task: build
vars:
ARCH: x86_64
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}}/.MainActivity
logs:
summary: Stream Android logcat filtered to this app
cmds:
- adb logcat -v time | grep -E "(Wails|{{.APP_NAME}})"
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

View file

@ -1,63 +0,0 @@
plugins {
id 'com.android.application'
}
android {
namespace 'com.wails.app'
compileSdk 34
buildFeatures {
buildConfig = true
}
defaultConfig {
applicationId "com.wails.app"
minSdk 21
targetSdk 34
versionCode 1
versionName "1.0"
// Configure supported ABIs
ndk {
abiFilters 'arm64-v8a', 'x86_64'
}
}
buildTypes {
release {
minifyEnabled false
proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro'
}
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'
}

View file

@ -1,12 +0,0 @@
# 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 <methods>;
}
# Keep Wails bridge classes
-keep class com.wails.app.WailsBridge { *; }
-keep class com.wails.app.WailsJSBridge { *; }

View file

@ -1,30 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<!-- Internet permission for WebView -->
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.WailsApp"
android:usesCleartextTraffic="true"
tools:targetApi="31">
<activity
android:name=".MainActivity"
android:exported="true"
android:configChanges="orientation|screenSize|keyboardHidden"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View file

@ -1,198 +0,0 @@
package com.wails.app;
import android.annotation.SuppressLint;
import android.os.Bundle;
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.webkit.WebViewAssetLoader;
import com.wails.app.BuildConfig;
/**
* 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 String WAILS_SCHEME = "https";
private static final String WAILS_HOST = "wails.localhost";
private WebView webView;
private WailsBridge bridge;
private WebViewAssetLoader assetLoader;
@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);
// 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 (BuildConfig.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) {
String url = request.getUrl().toString();
Log.d(TAG, "Intercepting request: " + url);
// Handle wails.localhost requests
if (request.getUrl().getHost() != null &&
request.getUrl().getHost().equals(WAILS_HOST)) {
// For wails API calls (runtime, capabilities, etc.), we need to pass the full URL
// including query string because WebViewAssetLoader.PathHandler strips query params
String path = request.getUrl().getPath();
if (path != null && path.startsWith("/wails/")) {
// Get full path with query string for runtime calls
String fullPath = path;
String query = request.getUrl().getQuery();
if (query != null && !query.isEmpty()) {
fullPath = path + "?" + query;
}
Log.d(TAG, "Wails API call detected, full path: " + fullPath);
// Call bridge directly with full path
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<String, String> 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())
);
}
// For regular assets, use the asset loader
return assetLoader.shouldInterceptRequest(request.getUrl());
}
return super.shouldInterceptRequest(view, request);
}
@Override
public void onPageFinished(WebView view, String url) {
super.onPageFinished(view, url);
Log.d(TAG, "Page loaded: " + url);
// Inject Wails runtime
bridge.injectRuntime(webView, url);
}
});
// Add JavaScript interface for Go communication
webView.addJavascriptInterface(new WailsJSBridge(bridge, webView), "wails");
}
private void loadApplication() {
// Load the main page from the asset server
String url = WAILS_SCHEME + "://" + WAILS_HOST + "/";
Log.d(TAG, "Loading URL: " + url);
webView.loadUrl(url);
}
/**
* Execute JavaScript in the WebView from the Go side
*/
public void executeJavaScript(final String js) {
runOnUiThread(() -> {
if (webView != null) {
webView.evaluateJavascript(js, null);
}
});
}
@Override
protected void onResume() {
super.onResume();
if (bridge != null) {
bridge.onResume();
}
}
@Override
protected void onPause() {
super.onPause();
if (bridge != null) {
bridge.onPause();
}
}
@Override
protected void onDestroy() {
super.onDestroy();
if (bridge != null) {
bridge.shutdown();
}
if (webView != null) {
webView.destroy();
}
}
@Override
public void onBackPressed() {
if (webView != null && webView.canGoBack()) {
webView.goBack();
} else {
super.onBackPressed();
}
}
}

View file

@ -1,214 +0,0 @@
package com.wails.app;
import android.content.Context;
import android.util.Log;
import android.webkit.WebView;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicInteger;
/**
* WailsBridge manages the connection between the Java/Android side and the Go native library.
* It handles:
* - Loading and initializing the native Go library
* - Serving asset requests from Go
* - Passing messages between JavaScript and Go
* - Managing callbacks for async operations
*/
public class WailsBridge {
private static final String TAG = "WailsBridge";
static {
// Load the native Go library
System.loadLibrary("wails");
}
private final Context context;
private final AtomicInteger callbackIdGenerator = new AtomicInteger(0);
private final ConcurrentHashMap<Integer, AssetCallback> pendingAssetCallbacks = new ConcurrentHashMap<>();
private final ConcurrentHashMap<Integer, MessageCallback> pendingMessageCallbacks = new ConcurrentHashMap<>();
private WebView webView;
private volatile boolean initialized = false;
// Native methods - implemented in Go
private static native void nativeInit(WailsBridge bridge);
private static native void nativeShutdown();
private static native void nativeOnResume();
private static native void nativeOnPause();
private static native void nativeOnPageFinished(String url);
private static native byte[] nativeServeAsset(String path, String method, String headers);
private static native String nativeHandleMessage(String message);
private static native String nativeGetAssetMimeType(String path);
public WailsBridge(Context context) {
this.context = context;
}
/**
* Initialize the native Go library
*/
public void initialize() {
if (initialized) {
return;
}
Log.i(TAG, "Initializing Wails bridge...");
try {
nativeInit(this);
initialized = true;
Log.i(TAG, "Wails bridge initialized successfully");
} catch (Exception e) {
Log.e(TAG, "Failed to initialize Wails bridge", e);
}
}
/**
* Shutdown the native Go library
*/
public void shutdown() {
if (!initialized) {
return;
}
Log.i(TAG, "Shutting down Wails bridge...");
try {
nativeShutdown();
initialized = false;
} catch (Exception e) {
Log.e(TAG, "Error during shutdown", e);
}
}
/**
* Called when the activity resumes
*/
public void onResume() {
if (initialized) {
nativeOnResume();
}
}
/**
* Called when the activity pauses
*/
public void onPause() {
if (initialized) {
nativeOnPause();
}
}
/**
* Serve an asset from the Go asset server
* @param path The URL path requested
* @param method The HTTP method
* @param headers The request headers as JSON
* @return The asset data, or null if not found
*/
public byte[] serveAsset(String path, String method, String headers) {
if (!initialized) {
Log.w(TAG, "Bridge not initialized, cannot serve asset: " + path);
return null;
}
Log.d(TAG, "Serving asset: " + path);
try {
return nativeServeAsset(path, method, headers);
} catch (Exception e) {
Log.e(TAG, "Error serving asset: " + path, e);
return null;
}
}
/**
* Get the MIME type for an asset
* @param path The asset path
* @return The MIME type string
*/
public String getAssetMimeType(String path) {
if (!initialized) {
return "application/octet-stream";
}
try {
String mimeType = nativeGetAssetMimeType(path);
return mimeType != null ? mimeType : "application/octet-stream";
} catch (Exception e) {
Log.e(TAG, "Error getting MIME type for: " + path, e);
return "application/octet-stream";
}
}
/**
* Handle a message from JavaScript
* @param message The message from JavaScript (JSON)
* @return The response to send back to JavaScript (JSON)
*/
public String handleMessage(String message) {
if (!initialized) {
Log.w(TAG, "Bridge not initialized, cannot handle message");
return "{\"error\":\"Bridge not initialized\"}";
}
Log.d(TAG, "Handling message from JS: " + message);
try {
return nativeHandleMessage(message);
} catch (Exception e) {
Log.e(TAG, "Error handling message", e);
return "{\"error\":\"" + e.getMessage() + "\"}";
}
}
/**
* Inject the Wails runtime JavaScript into the WebView.
* Called when the page finishes loading.
* @param webView The WebView to inject into
* @param url The URL that finished loading
*/
public void injectRuntime(WebView webView, String url) {
this.webView = webView;
// Notify Go side that page has finished loading so it can inject the runtime
Log.d(TAG, "Page finished loading: " + url + ", notifying Go side");
if (initialized) {
nativeOnPageFinished(url);
}
}
/**
* Execute JavaScript in the WebView (called from Go side)
* @param js The JavaScript code to execute
*/
public void executeJavaScript(String js) {
if (webView != null) {
webView.post(() -> webView.evaluateJavascript(js, null));
}
}
/**
* Called from Go when an event needs to be emitted to JavaScript
* @param eventName The event name
* @param eventData The event data (JSON)
*/
public void emitEvent(String eventName, String eventData) {
String js = String.format("window.wails && window.wails._emit('%s', %s);",
escapeJsString(eventName), eventData);
executeJavaScript(js);
}
private String escapeJsString(String str) {
return str.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\n", "\\n")
.replace("\r", "\\r");
}
// Callback interfaces
public interface AssetCallback {
void onAssetReady(byte[] data, String mimeType);
void onAssetError(String error);
}
public interface MessageCallback {
void onResponse(String response);
void onError(String error);
}
}

View file

@ -1,142 +0,0 @@
package com.wails.app;
import android.util.Log;
import android.webkit.JavascriptInterface;
import android.webkit.WebView;
import com.wails.app.BuildConfig;
/**
* WailsJSBridge provides the JavaScript interface that allows the web frontend
* to communicate with the Go backend. This is exposed to JavaScript as the
* `window.wails` object.
*
* Similar to iOS's WKScriptMessageHandler but using Android's addJavascriptInterface.
*/
public class WailsJSBridge {
private static final String TAG = "WailsJSBridge";
private final WailsBridge bridge;
private final WebView webView;
public WailsJSBridge(WailsBridge bridge, WebView webView) {
this.bridge = bridge;
this.webView = webView;
}
/**
* Send a message to Go and return the response synchronously.
* Called from JavaScript: wails.invoke(message)
*
* @param message The message to send (JSON string)
* @return The response from Go (JSON string)
*/
@JavascriptInterface
public String invoke(String message) {
Log.d(TAG, "Invoke called: " + message);
return bridge.handleMessage(message);
}
/**
* Send a message to Go asynchronously.
* The response will be sent back via a callback.
* Called from JavaScript: wails.invokeAsync(callbackId, message)
*
* @param callbackId The callback ID to use for the response
* @param message The message to send (JSON string)
*/
@JavascriptInterface
public void invokeAsync(final String callbackId, final String message) {
Log.d(TAG, "InvokeAsync called: " + message);
// Handle in background thread to not block JavaScript
new Thread(() -> {
try {
String response = bridge.handleMessage(message);
sendCallback(callbackId, response, null);
} catch (Exception e) {
Log.e(TAG, "Error in async invoke", e);
sendCallback(callbackId, null, e.getMessage());
}
}).start();
}
/**
* Log a message from JavaScript to Android's logcat
* Called from JavaScript: wails.log(level, message)
*
* @param level The log level (debug, info, warn, error)
* @param message The message to log
*/
@JavascriptInterface
public void log(String level, String message) {
switch (level.toLowerCase()) {
case "debug":
Log.d(TAG + "/JS", message);
break;
case "info":
Log.i(TAG + "/JS", message);
break;
case "warn":
Log.w(TAG + "/JS", message);
break;
case "error":
Log.e(TAG + "/JS", message);
break;
default:
Log.v(TAG + "/JS", message);
break;
}
}
/**
* Get the platform name
* Called from JavaScript: wails.platform()
*
* @return "android"
*/
@JavascriptInterface
public String platform() {
return "android";
}
/**
* Check if we're running in debug mode
* Called from JavaScript: wails.isDebug()
*
* @return true if debug build, false otherwise
*/
@JavascriptInterface
public boolean isDebug() {
return BuildConfig.DEBUG;
}
/**
* Send a callback response to JavaScript
*/
private void sendCallback(String callbackId, String result, String error) {
final String js;
if (error != null) {
js = String.format(
"window.wails && window.wails._callback('%s', null, '%s');",
escapeJsString(callbackId),
escapeJsString(error)
);
} else {
js = String.format(
"window.wails && window.wails._callback('%s', %s, null);",
escapeJsString(callbackId),
result != null ? result : "null"
);
}
webView.post(() -> webView.evaluateJavascript(js, null));
}
private String escapeJsString(String str) {
if (str == null) return "";
return str.replace("\\", "\\\\")
.replace("'", "\\'")
.replace("\n", "\\n")
.replace("\r", "\\r");
}
}

View file

@ -1,118 +0,0 @@
package com.wails.app;
import android.net.Uri;
import android.util.Log;
import android.webkit.WebResourceResponse;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.webkit.WebViewAssetLoader;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.util.HashMap;
import java.util.Map;
/**
* WailsPathHandler implements WebViewAssetLoader.PathHandler to serve assets
* from the Go asset server. This allows the WebView to load assets without
* using a network server, similar to iOS's WKURLSchemeHandler.
*/
public class WailsPathHandler implements WebViewAssetLoader.PathHandler {
private static final String TAG = "WailsPathHandler";
private final WailsBridge bridge;
public WailsPathHandler(WailsBridge bridge) {
this.bridge = bridge;
}
@Nullable
@Override
public WebResourceResponse handle(@NonNull String path) {
Log.d(TAG, "Handling path: " + path);
// Normalize path
if (path.isEmpty() || path.equals("/")) {
path = "/index.html";
}
// Get asset from Go
byte[] data = bridge.serveAsset(path, "GET", "{}");
if (data == null || data.length == 0) {
Log.w(TAG, "Asset not found: " + path);
return null; // Return null to let WebView handle 404
}
// Determine MIME type
String mimeType = bridge.getAssetMimeType(path);
Log.d(TAG, "Serving " + path + " with type " + mimeType + " (" + data.length + " bytes)");
// Create response
InputStream inputStream = new ByteArrayInputStream(data);
Map<String, String> headers = new HashMap<>();
headers.put("Access-Control-Allow-Origin", "*");
headers.put("Cache-Control", "no-cache");
return new WebResourceResponse(
mimeType,
"UTF-8",
200,
"OK",
headers,
inputStream
);
}
/**
* Determine MIME type from file extension
*/
private String getMimeType(String path) {
String lowerPath = path.toLowerCase();
if (lowerPath.endsWith(".html") || lowerPath.endsWith(".htm")) {
return "text/html";
} else if (lowerPath.endsWith(".js") || lowerPath.endsWith(".mjs")) {
return "application/javascript";
} else if (lowerPath.endsWith(".css")) {
return "text/css";
} else if (lowerPath.endsWith(".json")) {
return "application/json";
} else if (lowerPath.endsWith(".png")) {
return "image/png";
} else if (lowerPath.endsWith(".jpg") || lowerPath.endsWith(".jpeg")) {
return "image/jpeg";
} else if (lowerPath.endsWith(".gif")) {
return "image/gif";
} else if (lowerPath.endsWith(".svg")) {
return "image/svg+xml";
} else if (lowerPath.endsWith(".ico")) {
return "image/x-icon";
} else if (lowerPath.endsWith(".woff")) {
return "font/woff";
} else if (lowerPath.endsWith(".woff2")) {
return "font/woff2";
} else if (lowerPath.endsWith(".ttf")) {
return "font/ttf";
} else if (lowerPath.endsWith(".eot")) {
return "application/vnd.ms-fontobject";
} else if (lowerPath.endsWith(".xml")) {
return "application/xml";
} else if (lowerPath.endsWith(".txt")) {
return "text/plain";
} else if (lowerPath.endsWith(".wasm")) {
return "application/wasm";
} else if (lowerPath.endsWith(".mp3")) {
return "audio/mpeg";
} else if (lowerPath.endsWith(".mp4")) {
return "video/mp4";
} else if (lowerPath.endsWith(".webm")) {
return "video/webm";
} else if (lowerPath.endsWith(".webp")) {
return "image/webp";
}
return "application/octet-stream";
}
}

View file

@ -1,12 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/main_container"
android:layout_width="match_parent"
android:layout_height="match_parent">
<WebView
android:id="@+id/webview"
android:layout_width="match_parent"
android:layout_height="match_parent" />
</FrameLayout>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7 KiB

View file

@ -1,8 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="wails_blue">#3574D4</color>
<color name="wails_blue_dark">#2C5FB8</color>
<color name="wails_background">#1B2636</color>
<color name="white">#FFFFFFFF</color>
<color name="black">#FF000000</color>
</resources>

View file

@ -1,4 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Wails App</string>
</resources>

View file

@ -1,14 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="Theme.WailsApp" parent="Theme.MaterialComponents.DayNight.NoActionBar">
<!-- Primary brand color. -->
<item name="colorPrimary">@color/wails_blue</item>
<item name="colorPrimaryVariant">@color/wails_blue_dark</item>
<item name="colorOnPrimary">@android:color/white</item>
<!-- Status bar color. -->
<item name="android:statusBarColor">@color/wails_background</item>
<item name="android:navigationBarColor">@color/wails_background</item>
<!-- Window background -->
<item name="android:windowBackground">@color/wails_background</item>
</style>
</resources>

View file

@ -1,4 +0,0 @@
// Top-level build file where you can add configuration options common to all sub-projects/modules.
plugins {
id 'com.android.application' version '8.7.3' apply false
}

File diff suppressed because one or more lines are too long

View file

@ -1,26 +0,0 @@
# Project-wide Gradle settings.
# IDE (e.g. Android Studio) users:
# Gradle settings configured through the IDE *will override*
# any settings specified in this file.
# For more details on how to configure your build environment visit
# http://www.gradle.org/docs/current/userguide/build_environment.html
# Specifies the JVM arguments used for the daemon process.
# The setting is particularly useful for tweaking memory settings.
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
# When configured, Gradle will run in incubating parallel mode.
# This option should only be used with decoupled projects. For more details, visit
# https://developer.android.com/build/optimize-your-build#parallel
# org.gradle.parallel=true
# AndroidX package structure to make it clearer which packages are bundled with the
# Android operating system, and which are packaged with your app's APK
# https://developer.android.com/topic/libraries/support-library/androidx-rn
android.useAndroidX=true
# Enables namespacing of each library's R class so that its R class includes only the
# resources declared in the library itself and none from the library's dependencies,
# thereby reducing the size of the R class for that library
android.nonTransitiveRClass=true

View file

@ -1,7 +0,0 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

View file

@ -1,248 +0,0 @@
#!/bin/sh
#
# Copyright © 2015 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
# SPDX-License-Identifier: Apache-2.0
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

View file

@ -1,93 +0,0 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@rem SPDX-License-Identifier: Apache-2.0
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

View file

@ -1,151 +0,0 @@
package main
import (
"fmt"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
)
func main() {
fmt.Println("Checking Android development dependencies...")
fmt.Println()
errors := []string{}
// Check Go
if !checkCommand("go", "version") {
errors = append(errors, "Go is not installed. Install from https://go.dev/dl/")
} else {
fmt.Println("✓ Go is installed")
}
// Check ANDROID_HOME
androidHome := os.Getenv("ANDROID_HOME")
if androidHome == "" {
androidHome = os.Getenv("ANDROID_SDK_ROOT")
}
if androidHome == "" {
// Try common default locations
home, _ := os.UserHomeDir()
possiblePaths := []string{
filepath.Join(home, "Android", "Sdk"),
filepath.Join(home, "Library", "Android", "sdk"),
"/usr/local/share/android-sdk",
}
for _, p := range possiblePaths {
if _, err := os.Stat(p); err == nil {
androidHome = p
break
}
}
}
if androidHome == "" {
errors = append(errors, "ANDROID_HOME not set. Install Android Studio and set ANDROID_HOME environment variable")
} else {
fmt.Printf("✓ ANDROID_HOME: %s\n", androidHome)
}
// Check adb
if !checkCommand("adb", "version") {
if androidHome != "" {
platformTools := filepath.Join(androidHome, "platform-tools")
errors = append(errors, fmt.Sprintf("adb not found. Add %s to PATH", platformTools))
} else {
errors = append(errors, "adb not found. Install Android SDK Platform-Tools")
}
} else {
fmt.Println("✓ adb is installed")
}
// Check emulator
if !checkCommand("emulator", "-list-avds") {
if androidHome != "" {
emulatorPath := filepath.Join(androidHome, "emulator")
errors = append(errors, fmt.Sprintf("emulator not found. Add %s to PATH", emulatorPath))
} else {
errors = append(errors, "emulator not found. Install Android Emulator via SDK Manager")
}
} else {
fmt.Println("✓ Android Emulator is installed")
}
// Check NDK
ndkHome := os.Getenv("ANDROID_NDK_HOME")
if ndkHome == "" && androidHome != "" {
// Look for NDK in default location
ndkDir := filepath.Join(androidHome, "ndk")
if entries, err := os.ReadDir(ndkDir); err == nil {
for _, entry := range entries {
if entry.IsDir() {
ndkHome = filepath.Join(ndkDir, entry.Name())
break
}
}
}
}
if ndkHome == "" {
errors = append(errors, "Android NDK not found. Install NDK via Android Studio > SDK Manager > SDK Tools > NDK (Side by side)")
} else {
fmt.Printf("✓ Android NDK: %s\n", ndkHome)
}
// Check Java
if !checkCommand("java", "-version") {
errors = append(errors, "Java not found. Install JDK 11+ (OpenJDK recommended)")
} else {
fmt.Println("✓ Java is installed")
}
// Check for AVD (Android Virtual Device)
if checkCommand("emulator", "-list-avds") {
cmd := exec.Command("emulator", "-list-avds")
output, err := cmd.Output()
if err == nil && len(strings.TrimSpace(string(output))) > 0 {
avds := strings.Split(strings.TrimSpace(string(output)), "\n")
fmt.Printf("✓ Found %d Android Virtual Device(s)\n", len(avds))
} else {
fmt.Println("⚠ No Android Virtual Devices found. Create one via Android Studio > Tools > Device Manager")
}
}
fmt.Println()
if len(errors) > 0 {
fmt.Println("❌ Missing dependencies:")
for _, err := range errors {
fmt.Printf(" - %s\n", err)
}
fmt.Println()
fmt.Println("Setup instructions:")
fmt.Println("1. Install Android Studio: https://developer.android.com/studio")
fmt.Println("2. Open SDK Manager and install:")
fmt.Println(" - Android SDK Platform (API 34)")
fmt.Println(" - Android SDK Build-Tools")
fmt.Println(" - Android SDK Platform-Tools")
fmt.Println(" - Android Emulator")
fmt.Println(" - NDK (Side by side)")
fmt.Println("3. Set environment variables:")
if runtime.GOOS == "darwin" {
fmt.Println(" export ANDROID_HOME=$HOME/Library/Android/sdk")
} else {
fmt.Println(" export ANDROID_HOME=$HOME/Android/Sdk")
}
fmt.Println(" export PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/emulator")
fmt.Println("4. Create an AVD via Android Studio > Tools > Device Manager")
os.Exit(1)
}
fmt.Println("✓ All Android development dependencies are installed!")
}
func checkCommand(name string, args ...string) bool {
cmd := exec.Command(name, args...)
cmd.Stdout = nil
cmd.Stderr = nil
return cmd.Run() == nil
}

View file

@ -1,18 +0,0 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "WailsApp"
include ':app'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 130 KiB

View file

@ -1,75 +0,0 @@
# This file contains the configuration for this project.
# When you update `info` or `fileAssociations`, run `wails3 task common:update:build-assets` to update the assets.
# Note that this will overwrite any changes you have made to the assets.
version: '3'
# This information is used to generate the build assets.
info:
companyName: "My Company" # The name of the company
productName: "My Product" # The name of the application
productIdentifier: "com.mycompany.myproduct" # The unique product identifier
description: "A program that does X" # The application description
copyright: "(c) 2025, My Company" # Copyright text
comments: "Some Product Comments" # Comments
version: "0.0.1" # The application version
# Android build configuration (uncomment to customise Android project generation)
# Note: Keys under `android` OVERRIDE values under `info` when set.
# android:
# # The Android application ID used in the generated project (applicationId)
# applicationId: "com.mycompany.myproduct"
# # The display name shown under the app icon
# displayName: "My Product"
# # The app version code (integer, must increment for each release)
# versionCode: 1
# # The app version name (displayed to users)
# versionName: "0.0.1"
# # Minimum SDK version (API level)
# minSdkVersion: 21
# # Target SDK version (API level)
# targetSdkVersion: 34
# # The company/organisation name for templates and project settings
# company: "My Company"
# Dev mode configuration
dev_mode:
root_path: .
log_level: warn
debounce: 1000
ignore:
dir:
- .git
- node_modules
- frontend
- bin
file:
- .DS_Store
- .gitignore
- .gitkeep
watched_extension:
- "*.go"
git_ignore: true
executes:
- cmd: wails3 task common:install:frontend:deps
type: once
- cmd: wails3 task common:dev:frontend
type: background
- cmd: go mod tidy
type: blocking
- cmd: wails3 task build
type: blocking
- cmd: wails3 task run
type: primary
# File Associations
# More information at: https://v3.wails.io/noit/done/yet
fileAssociations:
# - ext: wails
# name: Wails
# description: Wails Application File
# iconName: wailsFileIcon
# role: Editor
# Other data
other:
- name: My Other Data

View file

@ -1,32 +0,0 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>My Product</string>
<key>CFBundleExecutable</key>
<string>ios</string>
<key>CFBundleIdentifier</key>
<string>com.wails.ios</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>CFBundleGetInfoString</key>
<string>This is a comment</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>LSMinimumSystemVersion</key>
<string>10.15.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>© now, My Company</string>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsLocalNetworking</key>
<true/>
</dict>
</dict>
</plist>

View file

@ -1,27 +0,0 @@
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleName</key>
<string>My Product</string>
<key>CFBundleExecutable</key>
<string>ios</string>
<key>CFBundleIdentifier</key>
<string>com.wails.ios</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>CFBundleGetInfoString</key>
<string>This is a comment</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundleIconFile</key>
<string>icons</string>
<key>LSMinimumSystemVersion</key>
<string>10.15.0</string>
<key>NSHighResolutionCapable</key>
<string>true</string>
<key>NSHumanReadableCopyright</key>
<string>© now, My Company</string>
</dict>
</plist>

View file

@ -1,81 +0,0 @@
version: '3'
includes:
common: ../Taskfile.yml
tasks:
build:
summary: Creates a production build of the application
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
PRODUCTION:
ref: .PRODUCTION
- task: common:generate:icons
cmds:
- go build {{.BUILD_FLAGS}} -o {{.OUTPUT}}
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
DEFAULT_OUTPUT: '{{.BIN_DIR}}/{{.APP_NAME}}'
OUTPUT: '{{ .OUTPUT | default .DEFAULT_OUTPUT }}'
env:
GOOS: darwin
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
CGO_CFLAGS: "-mmacosx-version-min=10.15"
CGO_LDFLAGS: "-mmacosx-version-min=10.15"
MACOSX_DEPLOYMENT_TARGET: "10.15"
PRODUCTION: '{{.PRODUCTION | default "false"}}'
build:universal:
summary: Builds darwin universal binary (arm64 + amd64)
deps:
- task: build
vars:
ARCH: amd64
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-amd64"
- task: build
vars:
ARCH: arm64
OUTPUT: "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
cmds:
- lipo -create -output "{{.BIN_DIR}}/{{.APP_NAME}}" "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
- rm "{{.BIN_DIR}}/{{.APP_NAME}}-amd64" "{{.BIN_DIR}}/{{.APP_NAME}}-arm64"
package:
summary: Packages a production build of the application into a `.app` bundle
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: create:app:bundle
package:universal:
summary: Packages darwin universal binary (arm64 + amd64)
deps:
- task: build:universal
cmds:
- task: create:app:bundle
create:app:bundle:
summary: Creates an `.app` bundle
cmds:
- mkdir -p {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/{MacOS,Resources}
- cp build/darwin/icons.icns {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/Resources
- cp {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents/MacOS
- cp build/darwin/Info.plist {{.BIN_DIR}}/{{.APP_NAME}}.app/Contents
- codesign --force --deep --sign - {{.BIN_DIR}}/{{.APP_NAME}}.app
run:
cmds:
- mkdir -p {{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/{MacOS,Resources}
- cp build/darwin/icons.icns {{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Resources
- cp {{.BIN_DIR}}/{{.APP_NAME}} {{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS
- cp build/darwin/Info.dev.plist {{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/Info.plist
- codesign --force --deep --sign - {{.BIN_DIR}}/{{.APP_NAME}}.dev.app
- '{{.BIN_DIR}}/{{.APP_NAME}}.dev.app/Contents/MacOS/{{.APP_NAME}}'

View file

@ -1,119 +0,0 @@
version: '3'
includes:
common: ../Taskfile.yml
tasks:
build:
summary: Builds the application for Linux
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
PRODUCTION:
ref: .PRODUCTION
- task: common:generate:icons
cmds:
- go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
env:
GOOS: linux
CGO_ENABLED: 1
GOARCH: '{{.ARCH | default ARCH}}'
PRODUCTION: '{{.PRODUCTION | default "false"}}'
package:
summary: Packages a production build of the application for Linux
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: create:appimage
- task: create:deb
- task: create:rpm
- task: create:aur
create:appimage:
summary: Creates an AppImage
dir: build/linux/appimage
deps:
- task: build
vars:
PRODUCTION: "true"
- task: generate:dotdesktop
cmds:
- cp {{.APP_BINARY}} {{.APP_NAME}}
- cp ../../appicon.png appicon.png
- wails3 generate appimage -binary {{.APP_NAME}} -icon {{.ICON}} -desktopfile {{.DESKTOP_FILE}} -outputdir {{.OUTPUT_DIR}} -builddir {{.ROOT_DIR}}/build/linux/appimage/build
vars:
APP_NAME: '{{.APP_NAME}}'
APP_BINARY: '../../../bin/{{.APP_NAME}}'
ICON: '../../appicon.png'
DESKTOP_FILE: '../{{.APP_NAME}}.desktop'
OUTPUT_DIR: '../../../bin'
create:deb:
summary: Creates a deb package
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: generate:dotdesktop
- task: generate:deb
create:rpm:
summary: Creates a rpm package
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: generate:dotdesktop
- task: generate:rpm
create:aur:
summary: Creates a arch linux packager package
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- task: generate:dotdesktop
- task: generate:aur
generate:deb:
summary: Creates a deb package
cmds:
- wails3 tool package -name {{.APP_NAME}} -format deb -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
generate:rpm:
summary: Creates a rpm package
cmds:
- wails3 tool package -name {{.APP_NAME}} -format rpm -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
generate:aur:
summary: Creates a arch linux packager package
cmds:
- wails3 tool package -name {{.APP_NAME}} -format archlinux -config ./build/linux/nfpm/nfpm.yaml -out {{.ROOT_DIR}}/bin
generate:dotdesktop:
summary: Generates a `.desktop` file
dir: build
cmds:
- mkdir -p {{.ROOT_DIR}}/build/linux/appimage
- wails3 generate .desktop -name "{{.APP_NAME}}" -exec "{{.EXEC}}" -icon "{{.ICON}}" -outputfile {{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop -categories "{{.CATEGORIES}}"
vars:
APP_NAME: '{{.APP_NAME}}'
EXEC: '{{.APP_NAME}}'
ICON: '{{.APP_NAME}}'
CATEGORIES: 'Development;'
OUTPUTFILE: '{{.ROOT_DIR}}/build/linux/{{.APP_NAME}}.desktop'
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}'

View file

@ -1,34 +0,0 @@
#!/usr/bin/env bash
# Copyright (c) 2018-Present Lea Anthony
# SPDX-License-Identifier: MIT
# Fail script on any error
set -euxo pipefail
# Define variables
APP_DIR="${APP_NAME}.AppDir"
# Create AppDir structure
mkdir -p "${APP_DIR}/usr/bin"
cp -r "${APP_BINARY}" "${APP_DIR}/usr/bin/"
cp "${ICON_PATH}" "${APP_DIR}/"
cp "${DESKTOP_FILE}" "${APP_DIR}/"
if [[ $(uname -m) == *x86_64* ]]; then
# Download linuxdeploy and make it executable
wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage
chmod +x linuxdeploy-x86_64.AppImage
# Run linuxdeploy to bundle the application
./linuxdeploy-x86_64.AppImage --appdir "${APP_DIR}" --output appimage
else
# Download linuxdeploy and make it executable (arm64)
wget -q -4 -N https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-aarch64.AppImage
chmod +x linuxdeploy-aarch64.AppImage
# Run linuxdeploy to bundle the application (arm64)
./linuxdeploy-aarch64.AppImage --appdir "${APP_DIR}" --output appimage
fi
# Rename the generated AppImage
mv "${APP_NAME}*.AppImage" "${APP_NAME}.AppImage"

View file

@ -1,11 +0,0 @@
[Desktop Entry]
Version=1.0
Name=My Product
Comment=My Product Description
# The Exec line includes %u to pass the URL to the application
Exec=/usr/local/bin/ios %u
Terminal=false
Type=Application
Icon=ios
Categories=Utility;
StartupWMClass=ios

View file

@ -1,67 +0,0 @@
# Feel free to remove those if you don't want/need to use them.
# Make sure to check the documentation at https://nfpm.goreleaser.com
#
# The lines below are called `modelines`. See `:help modeline`
name: "ios"
arch: ${GOARCH}
platform: "linux"
version: "0.1.0"
section: "default"
priority: "extra"
maintainer: ${GIT_COMMITTER_NAME} <${GIT_COMMITTER_EMAIL}>
description: "My Product Description"
vendor: "My Company"
homepage: "https://wails.io"
license: "MIT"
release: "1"
contents:
- src: "./bin/ios"
dst: "/usr/local/bin/ios"
- src: "./build/appicon.png"
dst: "/usr/share/icons/hicolor/128x128/apps/ios.png"
- src: "./build/linux/ios.desktop"
dst: "/usr/share/applications/ios.desktop"
# Default dependencies for Debian 12/Ubuntu 22.04+ with WebKit 4.1
depends:
- libgtk-3-0
- libwebkit2gtk-4.1-0
# Distribution-specific overrides for different package formats and WebKit versions
overrides:
# RPM packages for RHEL/CentOS/AlmaLinux/Rocky Linux (WebKit 4.0)
rpm:
depends:
- gtk3
- webkit2gtk4.1
# Arch Linux packages (WebKit 4.1)
archlinux:
depends:
- gtk3
- webkit2gtk-4.1
# scripts section to ensure desktop database is updated after install
scripts:
postinstall: "./build/linux/nfpm/scripts/postinstall.sh"
# You can also add preremove, postremove if needed
# preremove: "./build/linux/nfpm/scripts/preremove.sh"
# postremove: "./build/linux/nfpm/scripts/postremove.sh"
# replaces:
# - foobar
# provides:
# - bar
# depends:
# - gtk3
# - libwebkit2gtk
# recommends:
# - whatever
# suggests:
# - something-else
# conflicts:
# - not-foo
# - not-bar
# changelog: "changelog.yaml"

View file

@ -1,21 +0,0 @@
#!/bin/sh
# Update desktop database for .desktop file changes
# This makes the application appear in application menus and registers its capabilities.
if command -v update-desktop-database >/dev/null 2>&1; then
echo "Updating desktop database..."
update-desktop-database -q /usr/share/applications
else
echo "Warning: update-desktop-database command not found. Desktop file may not be immediately recognized." >&2
fi
# Update MIME database for custom URL schemes (x-scheme-handler)
# This ensures the system knows how to handle your custom protocols.
if command -v update-mime-database >/dev/null 2>&1; then
echo "Updating MIME database..."
update-mime-database -n /usr/share/mime
else
echo "Warning: update-mime-database command not found. Custom URL schemes may not be immediately recognized." >&2
fi
exit 0

View file

@ -1 +0,0 @@
#!/bin/bash

View file

@ -1 +0,0 @@
#!/bin/bash

View file

@ -1 +0,0 @@
#!/bin/bash

View file

@ -1,98 +0,0 @@
version: '3'
includes:
common: ../Taskfile.yml
tasks:
build:
summary: Builds the application for Windows
deps:
- task: common:go:mod:tidy
- task: common:build:frontend
vars:
BUILD_FLAGS:
ref: .BUILD_FLAGS
PRODUCTION:
ref: .PRODUCTION
- task: common:generate:icons
cmds:
- task: generate:syso
- go build {{.BUILD_FLAGS}} -o {{.BIN_DIR}}/{{.APP_NAME}}.exe
- cmd: powershell Remove-item *.syso
platforms: [windows]
- cmd: rm -f *.syso
platforms: [linux, darwin]
vars:
BUILD_FLAGS: '{{if eq .PRODUCTION "true"}}-tags production -trimpath -buildvcs=false -ldflags="-w -s -H windowsgui"{{else}}-buildvcs=false -gcflags=all="-l"{{end}}'
env:
GOOS: windows
CGO_ENABLED: 0
GOARCH: '{{.ARCH | default ARCH}}'
PRODUCTION: '{{.PRODUCTION | default "false"}}'
package:
summary: Packages a production build of the application
cmds:
- |-
if [ "{{.FORMAT | default "nsis"}}" = "msix" ]; then
task: create:msix:package
else
task: create:nsis:installer
fi
vars:
FORMAT: '{{.FORMAT | default "nsis"}}'
generate:syso:
summary: Generates Windows `.syso` file
dir: build
cmds:
- wails3 generate syso -arch {{.ARCH}} -icon windows/icon.ico -manifest windows/wails.exe.manifest -info windows/info.json -out ../wails_windows_{{.ARCH}}.syso
vars:
ARCH: '{{.ARCH | default ARCH}}'
create:nsis:installer:
summary: Creates an NSIS installer
dir: build/windows/nsis
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
# Create the Microsoft WebView2 bootstrapper if it doesn't exist
- wails3 generate webview2bootstrapper -dir "{{.ROOT_DIR}}/build/windows/nsis"
- makensis -DARG_WAILS_{{.ARG_FLAG}}_BINARY="{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" project.nsi
vars:
ARCH: '{{.ARCH | default ARCH}}'
ARG_FLAG: '{{if eq .ARCH "amd64"}}AMD64{{else}}ARM64{{end}}'
create:msix:package:
summary: Creates an MSIX package
deps:
- task: build
vars:
PRODUCTION: "true"
cmds:
- |-
wails3 tool msix \
--config "{{.ROOT_DIR}}/wails.json" \
--name "{{.APP_NAME}}" \
--executable "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}.exe" \
--arch "{{.ARCH}}" \
--out "{{.ROOT_DIR}}/{{.BIN_DIR}}/{{.APP_NAME}}-{{.ARCH}}.msix" \
{{if .CERT_PATH}}--cert "{{.CERT_PATH}}"{{end}} \
{{if .PUBLISHER}}--publisher "{{.PUBLISHER}}"{{end}} \
{{if .USE_MSIX_TOOL}}--use-msix-tool{{else}}--use-makeappx{{end}}
vars:
ARCH: '{{.ARCH | default ARCH}}'
CERT_PATH: '{{.CERT_PATH | default ""}}'
PUBLISHER: '{{.PUBLISHER | default ""}}'
USE_MSIX_TOOL: '{{.USE_MSIX_TOOL | default "false"}}'
install:msix:tools:
summary: Installs tools required for MSIX packaging
cmds:
- wails3 tool msix-install-tools
run:
cmds:
- '{{.BIN_DIR}}/{{.APP_NAME}}.exe'

Binary file not shown.

Before

Width:  |  Height:  |  Size: 21 KiB

View file

@ -1,15 +0,0 @@
{
"fixed": {
"file_version": "0.1.0"
},
"info": {
"0000": {
"ProductVersion": "0.1.0",
"CompanyName": "My Company",
"FileDescription": "My Product Description",
"LegalCopyright": "© now, My Company",
"ProductName": "My Product",
"Comments": "This is a comment"
}
}
}

Some files were not shown because too many files have changed in this diff Show more