diff --git a/.github/workflows/build-and-test-v3.yml b/.github/workflows/build-and-test-v3.yml index d08c95baa..c75b7c3d6 100644 --- a/.github/workflows/build-and-test-v3.yml +++ b/.github/workflows/build-and-test-v3.yml @@ -159,7 +159,7 @@ jobs: task test:examples echo "Example compilation tests (GTK3) completed successfully" - - name: Build Examples (GTK4 experimental) + - name: Build Examples (GTK4) if: matrix.os == 'ubuntu-latest' working-directory: v3 run: | @@ -189,11 +189,10 @@ jobs: go test -v ./... ' - - name: Run tests (ubuntu) - GTK4 experimental + - name: Run tests (ubuntu) - GTK4 if: matrix.os == 'ubuntu-latest' working-directory: v3 - # Skip all service tests that hang in CI due to GTK4 display requirements - # The services tests require a fully functional GTK4 display which xvfb cannot provide + # Skip service tests that hang in CI due to GTK4 display requirements run: > xvfb-run --auto-servernum sh -c ' @@ -210,7 +209,8 @@ jobs: if: always() needs: [test_js, test_go, test_templates] runs-on: ubuntu-latest - permissions: {} + permissions: + actions: write steps: - uses: geekyeggo/delete-artifact@v5 with: @@ -304,8 +304,7 @@ jobs: cd .. wails3 build - # Note: GTK4 template builds are not tested here as wails build doesn't - # support -tags flag yet. GTK4 compilation is verified by Go tests. + # GTK4 template builds are covered by the Go example compilation tests above. build_results: if: ${{ always() }} diff --git a/docs/astro.config.mjs b/docs/astro.config.mjs index 352989727..03133e2f5 100644 --- a/docs/astro.config.mjs +++ b/docs/astro.config.mjs @@ -138,6 +138,8 @@ export default defineConfig({ items: [ { label: "Method Binding", link: "/features/bindings/methods" }, { label: "Services", link: "/features/bindings/services" }, + { label: "Data Models", link: "/features/bindings/models" }, + { label: "Enums", link: "/features/bindings/enums" }, { label: "Advanced Binding", link: "/features/bindings/advanced" }, { label: "Best Practices", link: "/features/bindings/best-practices" }, ], diff --git a/docs/src/content/docs/changelog.mdx b/docs/src/content/docs/changelog.mdx index a44e73055..3c30c24e1 100644 --- a/docs/src/content/docs/changelog.mdx +++ b/docs/src/content/docs/changelog.mdx @@ -30,6 +30,55 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 */ ## [Unreleased] +## v3.0.0-alpha.73 - 2026-02-27 + +## Fixed +- Fix frameless window cannot be minimized on darwin (#4294) + +## v3.0.0-alpha.72 - 2026-02-16 + +## Fixed +- Fix 20-30 minute hangs during `wails3 build` and `wails3 dev` by excluding `node_modules/` from go-task's up-to-date checking. Previously, the `sources: "**/*"` glob caused go-task to enumerate and checksum every file in `node_modules/` (50-100k+ files with heavy dependencies like MUI), especially slow on Windows/NTFS (#4939) + +## v3.0.0-alpha.71 - 2026-02-10 + +## Added +- Bumped ghw version for better Apple device support by @leaanthony (#4977) +- Add `GetBadge` method to the dock service + +## Fixed +- Fix GTK4 build failure caused by C `Screen` typedef colliding with X11 Xlib.h (#4957) +- Fix dock badge methods consistency on macOS + +## v3.0.0-alpha.70 - 2026-02-09 + +## Added +- Add `-tags` flag to `wails3 build` command for passing custom Go build tags (e.g., `wails3 build -tags gtk4`) (#4957) +- Add documentation for automatic enum generation in binding generator, including dedicated Enums page and sidebar navigation (#4972) + +## Fixed +- Fix `InvisibleTitleBarHeight` being applied to all macOS windows instead of only frameless or transparent title bar windows (#4960) +- Fix window shaking/jitter when resizing from top corners with `InvisibleTitleBarHeight` enabled, by skipping drag initiation near window edges (#4960) +- Fix generation of mapped types with enum keys in JS/TS bindings (#4437) by @fbbdev + +## v3.0.0-alpha.69 - 2026-02-08 + +## Added +- Add `-tags` flag to `wails3 build` command for passing custom Go build tags (e.g., `wails3 build -tags gtk4`) (#4957) + +## Fixed +- Fix file drag-and-drop on Windows not working at non-100% display scaling +- Fix HTML5 internal drag-and-drop being broken when file drop was enabled on Windows +- Fix file drop coordinates being in wrong pixel space on Windows (physical vs CSS pixels) +- Fix file drag-and-drop on Linux not working reliably with hover effects +- Fix HTML5 internal drag-and-drop being broken when file drop was enabled on Linux +- Fix window show/hide on Linux/GTK4 sometimes restoring to minimized state by using `gtk_window_present()` (#4957) +- Fix window position get/set on Linux/GTK4 always returning 0,0 by adding X11-conditional support via `XTranslateCoordinates`/`XMoveWindow` (#4957) +- Fix max window size not being enforced on Linux/GTK4 by adding signal-based size clamping to replace removed `gtk_window_set_geometry_hints` (#4957) +- Fix DPI scaling on Linux/GTK4 by implementing proper PhysicalBounds calculation and fractional scaling support via `gdk_monitor_get_scale` (GTK 4.14+) +- Fix menu items duplicating when creating new windows on Linux/GTK4 +- Fix generation of mapped types with enum keys in JS/TS bindings (#4437) by @fbbdev + ## v3.0.0-alpha.68 - 2026-02-07 ## Added diff --git a/docs/src/content/docs/features/bindings/enums.mdx b/docs/src/content/docs/features/bindings/enums.mdx new file mode 100644 index 000000000..1e389273a --- /dev/null +++ b/docs/src/content/docs/features/bindings/enums.mdx @@ -0,0 +1,529 @@ +--- +title: Enums +description: Automatic enum generation from Go constants +sidebar: + order: 4 +--- + +import { Card, CardGrid } from "@astrojs/starlight/components"; + +## Enum Bindings + +The Wails v3 binding generator **automatically detects Go constant types and generates TypeScript enums or JavaScript const objects**. No registration, no configuration — just define your types and constants in Go, and the generator handles the rest. + +:::note +Unlike Wails v2, there is **no need to call `EnumBind`** or register enums manually. The generator discovers them automatically from your source code. +::: + +## Quick Start + +**Define a named type with constants in Go:** + +```go +type Status string + +const ( + StatusActive Status = "active" + StatusPending Status = "pending" + StatusClosed Status = "closed" +) +``` + +**Use the type in a struct or service method:** + +```go +type Ticket struct { + ID int `json:"id"` + Title string `json:"title"` + Status Status `json:"status"` +} +``` + +**Generate bindings:** + +```bash +wails3 generate bindings +``` + +The generator output will report enum counts alongside models: + +``` +3 Enums, 5 Models +``` + +**Use in your frontend:** + +```javascript +import { Ticket, Status } from './bindings/myapp/models' + +const ticket = new Ticket({ + id: 1, + title: "Bug report", + status: Status.StatusActive +}) +``` + +**That's it!** The enum type is enforced in both Go and JavaScript/TypeScript. + +## Defining Enums + +An enum in Wails is a **named type** with an underlying basic type, combined with **const declarations** of that type. + +### String Enums + +```go +// Title is a title +type Title string + +const ( + // Mister is a title + Mister Title = "Mr" + Miss Title = "Miss" + Ms Title = "Ms" + Mrs Title = "Mrs" + Dr Title = "Dr" +) +``` + +**Generated TypeScript:** + +```typescript +/** + * Title is a title + */ +export enum Title { + /** + * The Go zero value for the underlying type of the enum. + */ + $zero = "", + + /** + * Mister is a title + */ + Mister = "Mr", + Miss = "Miss", + Ms = "Ms", + Mrs = "Mrs", + Dr = "Dr", +} +``` + +**Generated JavaScript:** + +```javascript +/** + * Title is a title + * @readonly + * @enum {string} + */ +export const Title = { + /** + * The Go zero value for the underlying type of the enum. + */ + $zero: "", + + /** + * Mister is a title + */ + Mister: "Mr", + Miss: "Miss", + Ms: "Ms", + Mrs: "Mrs", + Dr: "Dr", +}; +``` + +### Integer Enums + +```go +type Priority int + +const ( + PriorityLow Priority = 0 + PriorityMedium Priority = 1 + PriorityHigh Priority = 2 +) +``` + +**Generated TypeScript:** + +```typescript +export enum Priority { + /** + * The Go zero value for the underlying type of the enum. + */ + $zero = 0, + + PriorityLow = 0, + PriorityMedium = 1, + PriorityHigh = 2, +} +``` + +### Type Alias Enums + +Go type aliases (`=`) also work, but generate a slightly different output — a type definition plus a const object, rather than a native TypeScript `enum`: + +```go +// Age is an integer with some predefined values +type Age = int + +const ( + NewBorn Age = 0 + Teenager Age = 12 + YoungAdult Age = 18 + + // Oh no, some grey hair! + MiddleAged Age = 50 + Mathusalem Age = 1000 // Unbelievable! +) +``` + +**Generated TypeScript:** + +```typescript +/** + * Age is an integer with some predefined values + */ +export type Age = number; + +/** + * Predefined constants for type Age. + * @namespace + */ +export const Age = { + NewBorn: 0, + Teenager: 12, + YoungAdult: 18, + + /** + * Oh no, some grey hair! + */ + MiddleAged: 50, + + /** + * Unbelievable! + */ + Mathusalem: 1000, +}; +``` + +**Generated JavaScript:** + +```javascript +/** + * Age is an integer with some predefined values + * @typedef {number} Age + */ + +/** + * Predefined constants for type Age. + * @namespace + */ +export const Age = { + NewBorn: 0, + Teenager: 12, + YoungAdult: 18, + + /** + * Oh no, some grey hair! + */ + MiddleAged: 50, + + /** + * Unbelievable! + */ + Mathusalem: 1000, +}; +``` + +:::tip +**Named types** (`type Title string`) generate native TypeScript `enum` declarations with a `$zero` member. +**Type aliases** (`type Age = int`) generate a `type` + `const` namespace pair without `$zero`. +::: + +## The `$zero` Value + +Every named-type enum includes a special `$zero` member representing the **Go zero value** for the underlying type: + +| Underlying Type | `$zero` Value | +|----------------|---------------| +| `string` | `""` | +| `int`, `int8`, `int16`, `int32`, `int64` | `0` | +| `uint`, `uint8`, `uint16`, `uint32`, `uint64` | `0` | +| `float32`, `float64` | `0` | +| `bool` | `false` | + +When a struct field uses an enum type and no value is provided, the constructor defaults to `$zero`: + +```typescript +export class Person { + "Title": Title; + + constructor($$source: Partial = {}) { + if (!("Title" in $$source)) { + this["Title"] = Title.$zero; // defaults to "" + } + Object.assign(this, $$source); + } +} +``` + +This ensures type-safe initialisation when generating classes — enum fields are never `undefined`. When generating TypeScript interfaces (using `-i`), there is no constructor and fields may be absent as usual. + +## Using Enums in Structs + +When a struct field has an enum type, the generated code **preserves that type** rather than falling back to the primitive: + +```go +type Person struct { + Title Title + Name string + Age Age +} +``` + +**Generated TypeScript:** + +```typescript +export class Person { + "Title": Title; + "Name": string; + "Age": Age; + + constructor($$source: Partial = {}) { + if (!("Title" in $$source)) { + this["Title"] = Title.$zero; + } + if (!("Name" in $$source)) { + this["Name"] = ""; + } + if (!("Age" in $$source)) { + this["Age"] = 0; + } + + Object.assign(this, $$source); + } +} +``` + +The `Title` field is typed as `Title`, not `string`. This gives your IDE full autocompletion and type checking on enum values. + +## Enums from Imported Packages + +Enums defined in separate packages are fully supported. They are generated into the corresponding package directory: + +```go +// services/types.go +package services + +type Title string + +const ( + Mister Title = "Mr" + Miss Title = "Miss" + Ms Title = "Ms" +) +``` + +```go +// main.go +package main + +import "myapp/services" + +func (*GreetService) Greet(name string, title services.Title) string { + return "Hello " + string(title) + " " + name +} +``` + +The `Title` enum is generated in the `services` models file, and import paths are resolved automatically: + +```typescript +// bindings/myapp/services/models.ts +export enum Title { + $zero = "", + Mister = "Mr", + Miss = "Miss", + Ms = "Ms", +} +``` + +## Enum Methods + +You can add methods to your enum types in Go. These don't affect binding generation but provide useful server-side functionality: + +```go +type Title string + +func (t Title) String() string { + return string(t) +} + +const ( + Mister Title = "Mr" + Miss Title = "Miss" +) +``` + +The generated enum is identical whether or not Go methods exist on the type. + +## Comments and Documentation + +The generator preserves Go comments as JSDoc in the generated output: + +- **Type comments** become the enum's doc comment +- **Const group comments** become section separators +- **Individual const comments** become member doc comments +- **Inline comments** are preserved where possible + +This means your IDE will show documentation for enum values on hover. + +## Supported Underlying Types + +The binding generator supports enums with the following Go underlying types: + +| Go Type | Works as Enum | +|---------|:---:| +| `string` | Yes | +| `int`, `int8`, `int16`, `int32`, `int64` | Yes | +| `uint`, `uint8`, `uint16`, `uint32`, `uint64` | Yes | +| `float32`, `float64` | Yes | +| `byte` (`uint8`) | Yes | +| `rune` (`int32`) | Yes | +| `bool` | Yes | +| `complex64`, `complex128` | No | + +## Limitations + +The following are **not** supported for enum generation: + +- **Generic types** — Type parameters prevent constant detection +- **Types with custom `json.Marshaler` or `encoding.TextMarshaler`** — Custom serialisation means the generated values may not match runtime behaviour, so the generator skips these +- **Constants whose values cannot be statically evaluated or represented** — Constants must have known, representable values in their underlying type. Standard `iota` patterns work fine since the compiler resolves them to concrete values +- **Complex number types** — `complex64` and `complex128` cannot be enum underlying types + +## Complete Example + +**Go:** + +```go +package main + +import ( + "github.com/wailsapp/wails/v3/pkg/application" +) + +// BackgroundType defines the type of background +type BackgroundType string + +const ( + BackgroundSolid BackgroundType = "solid" + BackgroundGradient BackgroundType = "gradient" + BackgroundImage BackgroundType = "image" +) + +type BackgroundConfig struct { + Type BackgroundType `json:"type"` + Value string `json:"value"` +} + +type ThemeService struct{} + +func (*ThemeService) GetBackground() BackgroundConfig { + return BackgroundConfig{ + Type: BackgroundSolid, + Value: "#ffffff", + } +} + +func (*ThemeService) SetBackground(config BackgroundConfig) error { + // Apply background + return nil +} + +func main() { + app := application.New(application.Options{ + Services: []application.Service{ + application.NewService(&ThemeService{}), + }, + }) + app.Window.New() + app.Run() +} +``` + +**Frontend (TypeScript):** + +```typescript +import { GetBackground, SetBackground } from './bindings/myapp/themeservice' +import { BackgroundConfig, BackgroundType } from './bindings/myapp/models' + +// Get current background +const bg = await GetBackground() + +// Check the type using enum values +if (bg.type === BackgroundType.BackgroundSolid) { + console.log("Solid background:", bg.value) +} + +// Set a new background +await SetBackground(new BackgroundConfig({ + type: BackgroundType.BackgroundGradient, + value: "linear-gradient(to right, #000, #fff)" +})) +``` + +**Frontend (JavaScript):** + +```javascript +import { GetBackground, SetBackground } from './bindings/myapp/themeservice' +import { BackgroundConfig, BackgroundType } from './bindings/myapp/models' + +// Use enum values for type-safe comparisons +const bg = await GetBackground() + +switch (bg.type) { + case BackgroundType.BackgroundSolid: + applySolid(bg.value) + break + case BackgroundType.BackgroundGradient: + applyGradient(bg.value) + break + case BackgroundType.BackgroundImage: + applyImage(bg.value) + break +} +``` + +## Next Steps + + + + Structs, type mapping, and model generation. + + [Learn More →](/features/bindings/models) + + + + Bind Go methods to the frontend. + + [Learn More →](/features/bindings/methods) + + + + Directives, code injection, and custom IDs. + + [Learn More →](/features/bindings/advanced) + + + + Binding design patterns. + + [Learn More →](/features/bindings/best-practices) + + + +--- + +**Questions?** Ask in [Discord](https://discord.gg/JDdSxwjhGf) or check the [binding examples](https://github.com/wailsapp/wails/tree/v3-alpha/v3/examples/binding). diff --git a/docs/src/content/docs/features/bindings/models.mdx b/docs/src/content/docs/features/bindings/models.mdx index f393eb694..5df312ea7 100644 --- a/docs/src/content/docs/features/bindings/models.mdx +++ b/docs/src/content/docs/features/bindings/models.mdx @@ -480,6 +480,8 @@ if (user.nickname) { ### Enums +The binding generator automatically detects Go named types with constants and generates TypeScript enums or JavaScript const objects — including a `$zero` member for Go's zero value and full JSDoc preservation. + ```go type UserRole string @@ -488,26 +490,16 @@ const ( RoleUser UserRole = "user" RoleGuest UserRole = "guest" ) - -type User struct { - ID int `json:"id"` - Name string `json:"name"` - Role UserRole `json:"role"` -} ``` -**Generated:** +**Generated TypeScript:** -```javascript -export const UserRole = { - Admin: "admin", - User: "user", - Guest: "guest" -} - -export class User { - /** @type {string} */ - role = UserRole.User +```typescript +export enum UserRole { + $zero = "", + RoleAdmin = "admin", + RoleUser = "user", + RoleGuest = "guest", } ``` @@ -518,10 +510,12 @@ import { User, UserRole } from './bindings/myapp/models' const admin = new User({ name: "Admin", - role: UserRole.Admin + role: UserRole.RoleAdmin }) ``` +For comprehensive coverage of string enums, integer enums, type aliases, imported package enums, and limitations, see the dedicated **[Enums](/features/bindings/enums)** page. + ### Validation ```javascript diff --git a/docs/src/content/docs/features/platform/dock.mdx b/docs/src/content/docs/features/platform/dock.mdx index 2638f7bf2..3cc5d9a92 100644 --- a/docs/src/content/docs/features/platform/dock.mdx +++ b/docs/src/content/docs/features/platform/dock.mdx @@ -131,6 +131,11 @@ Remove the badge from the application icon: dockService.RemoveBadge() ``` +### Getting the set badge +```go +dockService.GetBadge() +``` + ## Platform Considerations @@ -220,6 +225,7 @@ dockService.RemoveBadge() | `SetBadge(label string) error` | Sets a badge with the specified label | | `SetCustomBadge(label string, options BadgeOptions) error` | Sets a badge with the specified label and custom styling options (Windows only) | | `RemoveBadge() error` | Removes the badge from the application icon | +| `GetBadge() *string` | Gets the current badge | ### Structs and Types diff --git a/docs/src/content/docs/features/windows/frameless.mdx b/docs/src/content/docs/features/windows/frameless.mdx index c13cb9d4d..361cced18 100644 --- a/docs/src/content/docs/features/windows/frameless.mdx +++ b/docs/src/content/docs/features/windows/frameless.mdx @@ -394,7 +394,7 @@ body { ``` **Invisible title bar:** - Allows dragging whilst hiding the title bar: + Allows dragging whilst hiding the title bar. This only takes effect when the window is frameless or uses `AppearsTransparent`: ```go Mac: application.MacOptions{ InvisibleTitleBarHeight: 40, diff --git a/docs/src/content/docs/features/windows/multiple.mdx b/docs/src/content/docs/features/windows/multiple.mdx index 41d5a18fe..60694abc9 100644 --- a/docs/src/content/docs/features/windows/multiple.mdx +++ b/docs/src/content/docs/features/windows/multiple.mdx @@ -288,7 +288,7 @@ func CreateToolPalette(app *application.Application) *application.WebviewWindow } ``` -### Pattern 4: Modal dialogs +### Pattern 4: Modal dialogs (macOS only) Child windows that block parent: @@ -303,14 +303,7 @@ func ShowModaldialog(parent *application.WebviewWindow, title string) { Resizable: false, }) - // Disable parent (platform-specific) - parent.SetEnabled(false) - - // Re-enable parent on close - dialog.OnDestroy(func() { - parent.SetEnabled(true) - parent.SetFocus() - }) + parent.AttachModal(dialog) } ``` @@ -359,51 +352,38 @@ func (e *EditorApp) TogglePreview() { ```go childWindow := app.Window.NewWithOptions(application.WebviewWindowOptions{ Title: "Child Window", - Parent: parentWindow, }) + +parentWindow.AttachModal(childWindow) ``` **Behaviour:** -- Child closes when parent closes -- Child stays above parent (on some platforms) -- Child minimises with parent (on some platforms) +- Child stays above parent +- Child moves with parent +- Child blocks interaction to parent **Platform support:** -| Feature | macOS | Windows | Linux | -|---------|-------|---------|-------| -| Auto-close | ✅ | ✅ | ⚠️ Varies | -| Stay above | ✅ | ⚠️ Partial | ⚠️ Varies | -| Minimise together | ✅ | ❌ | ⚠️ Varies | +| macOS | Windows | Linux | +|-------|---------|-------| +| ✅ | ❌ | ❌ | ### Modal Behaviour Create modal-like behaviour: ```go -func ShowModal(parent *application.WebviewWindow) { - modal := app.Window.NewWithOptions(application.WebviewWindowOptions{ - Title: "Modal dialog", +func ShowModaldialog(parent *application.WebviewWindow, title string) { + dialog := app.Window.NewWithOptions(application.WebviewWindowOptions{ + Title: title, Width: 400, Height: 200, - Parent: parent, - AlwaysOnTop: true, }) - // Disable parent interaction - parent.SetEnabled(false) - - // Re-enable on close - modal.OnClose(func() bool { - parent.SetEnabled(true) - parent.SetFocus() - return true - }) + parent.AttachModal(dialog) } ``` -**Note:** True modal behaviour (blocking) varies by platform. - ## Window Lifecycle Management ### Creation Callbacks diff --git a/docs/src/content/docs/features/windows/options.mdx b/docs/src/content/docs/features/windows/options.mdx index 730e62045..9b38d5e2b 100644 --- a/docs/src/content/docs/features/windows/options.mdx +++ b/docs/src/content/docs/features/windows/options.mdx @@ -792,7 +792,8 @@ Mac: application.MacWindow{ **InvisibleTitleBarHeight** (`int`) - Height of invisible title bar area (for dragging) -- Useful when title bar is hidden +- Only takes effect when the native title bar drag area is hidden — i.e. when the window is frameless (`Frameless: true`) or uses a transparent title bar (`AppearsTransparent: true`) +- Has no effect on standard windows with a visible title bar **WindowLevel** (`MacWindowLevel`) - `MacWindowLevelNormal` - Standard window level (default) diff --git a/docs/src/content/docs/reference/window.mdx b/docs/src/content/docs/reference/window.mdx index 346a326e6..c0fc9c686 100644 --- a/docs/src/content/docs/reference/window.mdx +++ b/docs/src/content/docs/reference/window.mdx @@ -597,12 +597,27 @@ if err != nil { } ``` -**Platform support:** -- **macOS**: Full support -- **Windows**: Full support -- **Linux**: Full support +### AttachModal() -**Note:** This triggers the native OS print dialog, allowing the user to select printer settings and print the current window content. +Attaches a second Window as a sheet modal. + +```go +func (w *Window) AttachModal(modalWindow Window) +``` + +**Parameters:** +- `modalWindow` - The window to attach as a modal + +**Platform support:** +- **macOS**: Full support (presents as a sheet) +- **Windows**: No support +- **Linux**: No support + +**Example:** +```go +modalWindow := app.Window.New() +window.AttachModal(modalWindow) +``` ## Platform-Specific Options diff --git a/v3/Taskfile.yaml b/v3/Taskfile.yaml index 171853e14..81603c409 100644 --- a/v3/Taskfile.yaml +++ b/v3/Taskfile.yaml @@ -92,14 +92,14 @@ tasks: GOARCH: amd64 test:example:linux: - summary: Build example for Linux with GTK3 (default) + summary: Build example for Linux (pass BUILD_TAGS env var to add -tags, e.g. BUILD_TAGS=gtk4) dir: 'examples/{{.DIR}}' platforms: - linux cmds: - - echo "Building example {{.DIR}} for Linux (GTK3)" + - echo "Building example {{.DIR}} for Linux (${BUILD_TAGS:-GTK3})" - go mod tidy - - go build -o "testbuild-{{.DIR}}-linux" + - go build ${BUILD_TAGS:+-tags $BUILD_TAGS} -o "testbuild-{{.DIR}}-linux" test:example:linux:gtk4: summary: Build example for Linux with GTK4 (experimental, opt-in via -tags gtk4) @@ -365,12 +365,7 @@ tasks: vars: DIR: "{{.ITEM}}" platforms: [linux] - # GTK4 tests are run separately via Docker tasks since CI doesn't have GTK4 deps - # - for: { var: EXAMPLEDIRS } - # task: test:example:linux:gtk4 - # vars: - # DIR: "{{.ITEM}}" - # platforms: [linux] + # GTK4 example builds are handled in CI via: BUILD_TAGS=gtk4 task test:examples - for: { var: EXAMPLEDIRS } task: test:example:windows vars: diff --git a/v3/UNRELEASED_CHANGELOG.md b/v3/UNRELEASED_CHANGELOG.md index 92ef10a2e..4377950b5 100644 --- a/v3/UNRELEASED_CHANGELOG.md +++ b/v3/UNRELEASED_CHANGELOG.md @@ -17,24 +17,13 @@ After processing, the content will be moved to the main changelog and this file ## Added -- Add `-tags` flag to `wails3 build` command for passing custom Go build tags (e.g., `wails3 build -tags gtk4`) (#4957) +- Add support for modal sheets (macOS) ## Changed ## Fixed -- Fix file drag-and-drop on Windows not working at non-100% display scaling -- Fix HTML5 internal drag-and-drop being broken when file drop was enabled on Windows -- Fix file drop coordinates being in wrong pixel space on Windows (physical vs CSS pixels) -- Fix file drag-and-drop on Linux not working reliably with hover effects -- Fix HTML5 internal drag-and-drop being broken when file drop was enabled on Linux -- Fix window show/hide on Linux/GTK4 sometimes restoring to minimized state by using `gtk_window_present()` (#4957) -- Fix window position get/set on Linux/GTK4 always returning 0,0 by adding X11-conditional support via `XTranslateCoordinates`/`XMoveWindow` (#4957) -- Fix max window size not being enforced on Linux/GTK4 by adding signal-based size clamping to replace removed `gtk_window_set_geometry_hints` (#4957) -- Fix DPI scaling on Linux/GTK4 by implementing proper PhysicalBounds calculation and fractional scaling support via `gdk_monitor_get_scale` (GTK 4.14+) -- Fix menu items duplicating when creating new windows on Linux/GTK4 -- Fix generation of mapped types with enum keys in JS/TS bindings (#4437) by @fbbdev ## Deprecated @@ -47,7 +36,6 @@ After processing, the content will be moved to the main changelog and this file - Restrict GITHUB_TOKEN permissions in workflow files to follow principle of least privilege - Fix path traversal vulnerability in screen example asset middleware - Fix command injection vulnerability in setup wizard dependency installation endpoint -- Fix command injection vulnerability in setup wizard dependency installation endpoint - Update rollup to 3.29.5 to fix XSS vulnerability (CVE-2024-47068) --- diff --git a/v3/examples/android/build/Taskfile.yml b/v3/examples/android/build/Taskfile.yml index 209793bfd..e0a74df87 100644 --- a/v3/examples/android/build/Taskfile.yml +++ b/v3/examples/android/build/Taskfile.yml @@ -27,6 +27,7 @@ tasks: dir: frontend sources: - "**/*" + - exclude: node_modules/**/* generates: - dist/**/* deps: diff --git a/v3/examples/badge-custom/build/Taskfile.yml b/v3/examples/badge-custom/build/Taskfile.yml index 5f3517efc..f0aab9b9c 100644 --- a/v3/examples/badge-custom/build/Taskfile.yml +++ b/v3/examples/badge-custom/build/Taskfile.yml @@ -27,6 +27,7 @@ tasks: dir: frontend sources: - "**/*" + - exclude: node_modules/**/* generates: - dist/**/* deps: diff --git a/v3/examples/badge/build/Taskfile.yml b/v3/examples/badge/build/Taskfile.yml index 5f3517efc..f0aab9b9c 100644 --- a/v3/examples/badge/build/Taskfile.yml +++ b/v3/examples/badge/build/Taskfile.yml @@ -27,6 +27,7 @@ tasks: dir: frontend sources: - "**/*" + - exclude: node_modules/**/* generates: - dist/**/* deps: diff --git a/v3/examples/custom-protocol-example/build/Taskfile.yml b/v3/examples/custom-protocol-example/build/Taskfile.yml index ba497b5b6..f3475d2cf 100644 --- a/v3/examples/custom-protocol-example/build/Taskfile.yml +++ b/v3/examples/custom-protocol-example/build/Taskfile.yml @@ -26,6 +26,7 @@ tasks: dir: frontend sources: - "**/*" + - exclude: node_modules/**/* generates: - dist/**/* deps: diff --git a/v3/examples/dock/build/Taskfile.yml b/v3/examples/dock/build/Taskfile.yml index 5f3517efc..f0aab9b9c 100644 --- a/v3/examples/dock/build/Taskfile.yml +++ b/v3/examples/dock/build/Taskfile.yml @@ -27,6 +27,7 @@ tasks: dir: frontend sources: - "**/*" + - exclude: node_modules/**/* generates: - dist/**/* deps: diff --git a/v3/examples/file-association/build/Taskfile.common.yml b/v3/examples/file-association/build/Taskfile.common.yml index 650c8ea83..540c8a991 100644 --- a/v3/examples/file-association/build/Taskfile.common.yml +++ b/v3/examples/file-association/build/Taskfile.common.yml @@ -30,6 +30,7 @@ tasks: dir: frontend sources: - "**/*" + - exclude: node_modules/**/* generates: - dist/* deps: diff --git a/v3/examples/ios/build/Taskfile.yml b/v3/examples/ios/build/Taskfile.yml index 209793bfd..e0a74df87 100644 --- a/v3/examples/ios/build/Taskfile.yml +++ b/v3/examples/ios/build/Taskfile.yml @@ -27,6 +27,7 @@ tasks: dir: frontend sources: - "**/*" + - exclude: node_modules/**/* generates: - dist/**/* deps: diff --git a/v3/examples/notifications/build/Taskfile.yml b/v3/examples/notifications/build/Taskfile.yml index 5f3517efc..f0aab9b9c 100644 --- a/v3/examples/notifications/build/Taskfile.yml +++ b/v3/examples/notifications/build/Taskfile.yml @@ -27,6 +27,7 @@ tasks: dir: frontend sources: - "**/*" + - exclude: node_modules/**/* generates: - dist/**/* deps: diff --git a/v3/examples/screen/main.go b/v3/examples/screen/main.go index 900bb25f9..4891f6a86 100644 --- a/v3/examples/screen/main.go +++ b/v3/examples/screen/main.go @@ -6,6 +6,7 @@ import ( "log/slog" "net/http" "os" + "path" "path/filepath" "runtime" "strings" @@ -55,22 +56,19 @@ func main() { return } - // Clean the requested URL path and make it relative, to prevent directory traversal - cleanPath := filepath.Clean(r.URL.Path) + // Clean the requested URL path using path.Clean (HTTP paths always use forward slashes). + cleanPath := path.Clean("/" + r.URL.Path) - // Normalize to use forward slashes for leading-separator handling. - normalized := strings.ReplaceAll(cleanPath, "\\", "/") - - // Strip all leading slashes so the path is always treated as relative. - normalized = strings.TrimLeft(normalized, "/") - - // On Windows, also reject drive-letter or UNC-style absolute paths outright. - if strings.HasPrefix(normalized, ":") || strings.HasPrefix(normalized, "\\") { + // Reject Windows drive-letter (e.g. "C:/...") or UNC-style absolute paths. + if len(cleanPath) >= 2 && cleanPath[1] == ':' { next.ServeHTTP(w, r) return } - relativePath := normalized + // Treat the request path as relative by stripping the leading forward slash. + relativePath := strings.TrimPrefix(cleanPath, "/") + // Convert to OS-specific path separators for filesystem operations. + relativePath = filepath.FromSlash(relativePath) // Resolve the requested path against the absolute assets directory. resolvedPath, err := filepath.Abs(filepath.Join(assetsDirAbs, relativePath)) diff --git a/v3/go.mod b/v3/go.mod index c5aa9a11e..ba777a8f1 100644 --- a/v3/go.mod +++ b/v3/go.mod @@ -6,8 +6,11 @@ require ( git.sr.ht/~jackmordaunt/go-toast/v2 v2.0.3 github.com/Masterminds/semver v1.5.0 github.com/adrg/xdg v0.5.3 + github.com/atotto/clipboard v0.1.4 github.com/atterpac/refresh v0.8.6 github.com/bep/debounce v1.2.1 + github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 + github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/glamour v0.10.0 github.com/charmbracelet/huh v0.8.0 github.com/coder/websocket v1.8.14 @@ -22,7 +25,7 @@ require ( github.com/goreleaser/nfpm/v2 v2.44.1 github.com/gorilla/websocket v1.5.3 github.com/jackmordaunt/icns/v2 v2.2.7 - github.com/jaypipes/ghw v0.21.2 + github.com/jaypipes/ghw v0.21.3 github.com/konoui/lipo v0.10.0 github.com/leaanthony/clir v1.7.0 github.com/leaanthony/go-ansi-parser v1.6.1 @@ -52,10 +55,7 @@ require ( require ( al.essio.dev/pkg/shellescape v1.6.0 // indirect atomicgo.dev/schedule v0.1.0 // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/catppuccin/go v0.3.0 // indirect - github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.1 // indirect github.com/charmbracelet/x/cellbuf v0.0.14 // indirect github.com/charmbracelet/x/exp/slice v0.0.0-20260122224438-b01af16209d9 // indirect @@ -93,7 +93,7 @@ require ( github.com/blakesmith/ar v0.0.0-20190502131153-809d4375e1fb // indirect github.com/cavaliergopher/cpio v1.0.1 // indirect github.com/chainguard-dev/git-urls v1.0.2 // indirect - github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 // indirect + github.com/charmbracelet/lipgloss v1.1.1-0.20250404203927-76690c660834 github.com/charmbracelet/x/ansi v0.11.4 // indirect github.com/cloudflare/circl v1.6.3 // indirect github.com/containerd/console v1.0.5 // indirect diff --git a/v3/go.sum b/v3/go.sum index dd0b0ae81..122a5fd73 100644 --- a/v3/go.sum +++ b/v3/go.sum @@ -218,8 +218,8 @@ github.com/huandu/xstrings v1.5.0 h1:2ag3IFq9ZDANvthTwTiqSSZLjDc+BedvHPAp5tJy2TI github.com/huandu/xstrings v1.5.0/go.mod h1:y5/lhBue+AyNmUVz9RLU9xbLR0o4KIIExikq4ovT0aE= github.com/jackmordaunt/icns/v2 v2.2.7 h1:K/RbfvuzjmjVY5y4g+XENRs8ZZatwz4YnLHypa2KwQg= github.com/jackmordaunt/icns/v2 v2.2.7/go.mod h1:ovoTxGguSuoUGKMk5Nn3R7L7BgMQkylsO+bblBuI22A= -github.com/jaypipes/ghw v0.21.2 h1:woW0lqNMPbYk59sur6thOVM8YFP9Hxxr8PM+JtpUrNU= -github.com/jaypipes/ghw v0.21.2/go.mod h1:GPrvwbtPoxYUenr74+nAnWbardIZq600vJDD5HnPsPE= +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= diff --git a/v3/internal/commands/build_assets/Taskfile.tmpl.yml b/v3/internal/commands/build_assets/Taskfile.tmpl.yml index 07592bff8..7837ab350 100644 --- a/v3/internal/commands/build_assets/Taskfile.tmpl.yml +++ b/v3/internal/commands/build_assets/Taskfile.tmpl.yml @@ -27,6 +27,7 @@ tasks: dir: frontend sources: - "**/*" + - exclude: node_modules/**/* generates: - dist/**/* deps: diff --git a/v3/internal/version/version.txt b/v3/internal/version/version.txt index 776e58d05..c4b06870c 100644 --- a/v3/internal/version/version.txt +++ b/v3/internal/version/version.txt @@ -1 +1 @@ -v3.0.0-alpha.68 \ No newline at end of file +v3.0.0-alpha.73 \ No newline at end of file diff --git a/v3/pkg/application/linux_cgo_gtk4.h b/v3/pkg/application/linux_cgo_gtk4.h index 72bf6408d..81126e98a 100644 --- a/v3/pkg/application/linux_cgo_gtk4.h +++ b/v3/pkg/application/linux_cgo_gtk4.h @@ -29,22 +29,6 @@ typedef struct WindowEvent { uint event; } WindowEvent; -typedef struct WailsScreen { - const char* id; - const char* name; - int p_width; - int p_height; - int x; - int y; - int w_width; - int w_height; - int w_x; - int w_y; - float scaleFactor; - double rotation; - bool isPrimary; -} WailsScreen; - typedef struct MenuItemData { guint id; GSimpleAction *action; diff --git a/v3/pkg/application/webview_window.go b/v3/pkg/application/webview_window.go index 6cfa8b821..91909e83e 100644 --- a/v3/pkg/application/webview_window.go +++ b/v3/pkg/application/webview_window.go @@ -113,6 +113,7 @@ type ( setMenu(menu *Menu) snapAssist() setContentProtection(enabled bool) + attachModal(modalWindow *WebviewWindow) } ) @@ -1284,6 +1285,22 @@ func (w *WebviewWindow) NativeWindow() unsafe.Pointer { return w.impl.nativeWindow() } +// AttachModal attaches a modal window to this window, presenting it as a sheet on macOS. +func (w *WebviewWindow) AttachModal(modalWindow Window) { + if w.impl == nil || w.isDestroyed() { + return + } + + modalWebviewWindow, ok := modalWindow.(*WebviewWindow) + if !ok || modalWebviewWindow == nil { + return + } + + InvokeSync(func() { + w.impl.attachModal(modalWebviewWindow) + }) +} + // shouldUnconditionallyClose returns whether the window should close unconditionally func (w *WebviewWindow) shouldUnconditionallyClose() bool { return atomic.LoadUint32(&w.unconditionallyClose) != 0 diff --git a/v3/pkg/application/webview_window_android.go b/v3/pkg/application/webview_window_android.go index ed41a5add..6e139f52c 100644 --- a/v3/pkg/application/webview_window_android.go +++ b/v3/pkg/application/webview_window_android.go @@ -296,6 +296,10 @@ func (w *androidWebviewWindow) nativeWindow() unsafe.Pointer { return nil } +func (w *androidWebviewWindow) attachModal(modalWindow *WebviewWindow) { + // Modal windows are not supported on Android +} + func (w *androidWebviewWindow) on(eventID uint) { // Android event handling } diff --git a/v3/pkg/application/webview_window_darwin.go b/v3/pkg/application/webview_window_darwin.go index 4a53d7337..14fef21da 100644 --- a/v3/pkg/application/webview_window_darwin.go +++ b/v3/pkg/application/webview_window_darwin.go @@ -27,7 +27,7 @@ extern void registerListener(unsigned int event); void* windowNew(unsigned int id, int width, int height, bool fraudulentWebsiteWarningEnabled, bool frameless, bool enableDragAndDrop, struct WebviewPreferences preferences) { NSWindowStyleMask styleMask = NSWindowStyleMaskTitled | NSWindowStyleMaskClosable | NSWindowStyleMaskMiniaturizable | NSWindowStyleMaskResizable; if (frameless) { - styleMask = NSWindowStyleMaskBorderless | NSWindowStyleMaskResizable; + styleMask = NSWindowStyleMaskBorderless | NSWindowStyleMaskResizable | NSWindowStyleMaskMiniaturizable; } WebviewWindow* window = [[WebviewWindow alloc] initWithContentRect:NSMakeRect(0, 0, width-1, height-1) styleMask:styleMask @@ -331,6 +331,24 @@ void windowZoomOut(void* nsWindow) { } } +// createModalWindow presents a modal window as a sheet attached to the parent window +void createModalWindow(void* parentWindowPtr, void* modalWindowPtr) { + if (parentWindowPtr == NULL || modalWindowPtr == NULL) { + return; + } + + NSWindow* parentWindow = (NSWindow*)parentWindowPtr; + NSWindow* modalWindow = (NSWindow*)modalWindowPtr; + + // Present the modal window as a sheet attached to the parent window + // Must be dispatched to the main thread for UI thread safety + dispatch_async(dispatch_get_main_queue(), ^{ + [parentWindow beginSheet:modalWindow completionHandler:^(NSModalResponse returnCode) { + // Sheet was dismissed - window will be released automatically + }]; + }); +} + // set the window position relative to the screen void windowSetRelativePosition(void* nsWindow, int x, int y) { WebviewWindow* window = (WebviewWindow*)nsWindow; @@ -1334,7 +1352,10 @@ func (w *macosWebviewWindow) run() { C.windowSetAppearanceTypeByName(w.nsWindow, C.CString(string(macOptions.Appearance))) } - if macOptions.InvisibleTitleBarHeight != 0 { + // Only apply invisible title bar when the native drag area is hidden + // (frameless window or transparent/hidden title bar presets like HiddenInset) + if macOptions.InvisibleTitleBarHeight != 0 && + (w.parent.options.Frameless || titleBarOptions.AppearsTransparent) { C.windowSetInvisibleTitleBar(w.nsWindow, C.uint(macOptions.InvisibleTitleBarHeight)) } @@ -1548,6 +1569,17 @@ func (w *macosWebviewWindow) setContentProtection(enabled bool) { C.setContentProtection(w.nsWindow, C.bool(enabled)) } +func (w *macosWebviewWindow) attachModal(modalWindow *WebviewWindow) { + if modalWindow == nil || modalWindow.impl == nil || modalWindow.isDestroyed() { + return + } + modalNativeWindow := modalWindow.impl.nativeWindow() + if modalNativeWindow == nil { + return + } + C.createModalWindow(w.nsWindow, modalNativeWindow) +} + func (w *macosWebviewWindow) cut() { } diff --git a/v3/pkg/application/webview_window_darwin.m b/v3/pkg/application/webview_window_darwin.m index 4e2f1cabc..48130a562 100644 --- a/v3/pkg/application/webview_window_darwin.m +++ b/v3/pkg/application/webview_window_darwin.m @@ -345,6 +345,15 @@ typedef NS_ENUM(NSInteger, MacLiquidGlassStyle) { NSPoint location = [event locationInWindow]; NSRect frame = [window frame]; if( location.y > frame.size.height - self.invisibleTitleBarHeight ) { + // Skip drag if the click is near a window edge (resize zone). + // This prevents conflict between dragging and native top-corner resizing, + // which causes window content to shake/jitter (#4960). + CGFloat resizeThreshold = 5.0; + BOOL nearLeftEdge = location.x < resizeThreshold; + BOOL nearRightEdge = location.x > frame.size.width - resizeThreshold; + if( nearLeftEdge || nearRightEdge ) { + return; + } [window performWindowDragWithEvent:event]; return; } diff --git a/v3/pkg/application/webview_window_ios.go b/v3/pkg/application/webview_window_ios.go index bc3087f2c..481788c87 100644 --- a/v3/pkg/application/webview_window_ios.go +++ b/v3/pkg/application/webview_window_ios.go @@ -330,6 +330,10 @@ func (w *iosWebviewWindow) nativeWindow() unsafe.Pointer { return w.nativeHandle } +func (w *iosWebviewWindow) attachModal(modalWindow *WebviewWindow) { + // Modal windows are not supported on iOS +} + func (w *iosWebviewWindow) on(eventID uint) { // iOS event handling } diff --git a/v3/pkg/application/webview_window_linux.go b/v3/pkg/application/webview_window_linux.go index 9a5eb4131..4eddadbed 100644 --- a/v3/pkg/application/webview_window_linux.go +++ b/v3/pkg/application/webview_window_linux.go @@ -410,6 +410,10 @@ func (w *linuxWebviewWindow) nativeWindow() unsafe.Pointer { return unsafe.Pointer(w.window) } +func (w *linuxWebviewWindow) attachModal(modalWindow *WebviewWindow) { + // Modal windows are not supported on Linux +} + func (w *linuxWebviewWindow) print() error { w.execJS("window.print();") return nil diff --git a/v3/pkg/application/webview_window_windows.go b/v3/pkg/application/webview_window_windows.go index 16ba08f0b..434723fb4 100644 --- a/v3/pkg/application/webview_window_windows.go +++ b/v3/pkg/application/webview_window_windows.go @@ -78,6 +78,9 @@ type windowsWebviewWindow struct { // menubarTheme is the theme for the menubar menubarTheme *w32.MenuBarTheme + + // Modal window tracking + parentHWND w32.HWND // Parent window HWND when this window is a modal } func (w *windowsWebviewWindow) setMenu(menu *Menu) { @@ -233,6 +236,37 @@ func (w *windowsWebviewWindow) startDrag() error { return nil } +func (w *windowsWebviewWindow) attachModal(modalWindow *WebviewWindow) { + if modalWindow == nil || modalWindow.impl == nil || modalWindow.isDestroyed() { + return + } + + // Get the modal window's Windows implementation + modalWindowsImpl, ok := modalWindow.impl.(*windowsWebviewWindow) + if !ok { + return + } + + parentHWND := w.hwnd + modalHWND := modalWindowsImpl.hwnd + + // Set parent-child relationship using GWLP_HWNDPARENT + // This ensures the modal stays above parent and moves with it + w32.SetWindowLongPtr(modalHWND, w32.GWLP_HWNDPARENT, uintptr(parentHWND)) + + // Track the parent HWND in the modal window for cleanup + modalWindowsImpl.parentHWND = parentHWND + + // Disable the parent window to block interaction (Microsoft's recommended approach) + // This follows Windows modal dialog best practices + w32.EnableWindow(parentHWND, false) + + // Ensure modal window is shown and brought to front + w32.ShowWindow(modalHWND, w32.SW_SHOW) + w32.SetForegroundWindow(modalHWND) + w32.BringWindowToTop(modalHWND) +} + func (w *windowsWebviewWindow) nativeWindow() unsafe.Pointer { return unsafe.Pointer(w.hwnd) } @@ -725,6 +759,12 @@ func (w *windowsWebviewWindow) setRelativePosition(x int, y int) { } func (w *windowsWebviewWindow) destroy() { + // Re-enable parent window if this was a modal window + if w.parentHWND != 0 { + w32.EnableWindow(w.parentHWND, true) + w.parentHWND = 0 + } + w.parent.markAsDestroyed() // destroy the window w32.DestroyWindow(w.hwnd) @@ -1420,6 +1460,12 @@ func (w *windowsWebviewWindow) WndProc(msg uint32, wparam, lparam uintptr) uintp } defer func() { + // Re-enable parent window if this was a modal window + if w.parentHWND != 0 { + w32.EnableWindow(w.parentHWND, true) + w.parentHWND = 0 + } + windowsApp := globalApplication.impl.(*windowsApp) windowsApp.unregisterWindow(w) diff --git a/v3/pkg/application/window.go b/v3/pkg/application/window.go index 3f4949b16..ec810b115 100644 --- a/v3/pkg/application/window.go +++ b/v3/pkg/application/window.go @@ -91,6 +91,7 @@ type Window interface { Flash(enabled bool) Print() error RegisterHook(eventType events.WindowEventType, callback func(event *WindowEvent)) func() + AttachModal(modalWindow Window) shouldUnconditionallyClose() bool // Editing methods diff --git a/v3/pkg/services/dock/badge_ios.go b/v3/pkg/services/dock/badge_ios.go index 62dda0cb7..136771839 100644 --- a/v3/pkg/services/dock/badge_ios.go +++ b/v3/pkg/services/dock/badge_ios.go @@ -8,7 +8,8 @@ import ( "github.com/wailsapp/wails/v3/pkg/application" ) -type iosDock struct{} +type iosDock struct { +} // New creates a new Dock Service. // On iOS, this returns a stub implementation. @@ -61,4 +62,10 @@ func (d *iosDock) SetCustomBadge(label string, options BadgeOptions) error { func (d *iosDock) RemoveBadge() error { // iOS badge removal would go here via native bridge return nil -} \ No newline at end of file +} + +// GetBadge retrieves the badge from the iOS app icon. +func (d *iosDock) GetBadge() *string { + // iOS badge retrieval would go here via native bridge + return nil +} diff --git a/v3/pkg/services/dock/dock.go b/v3/pkg/services/dock/dock.go index eb11f59ca..3f9890c67 100644 --- a/v3/pkg/services/dock/dock.go +++ b/v3/pkg/services/dock/dock.go @@ -20,6 +20,7 @@ type platformDock interface { SetBadge(label string) error SetCustomBadge(label string, options BadgeOptions) error RemoveBadge() error + GetBadge() *string } // Service represents the dock service @@ -75,3 +76,8 @@ func (d *DockService) SetCustomBadge(label string, options BadgeOptions) error { func (d *DockService) RemoveBadge() error { return d.impl.RemoveBadge() } + +// GetBadge returns the badge label on the application icon. +func (d *DockService) GetBadge() *string { + return d.impl.GetBadge() +} diff --git a/v3/pkg/services/dock/dock_darwin.go b/v3/pkg/services/dock/dock_darwin.go index c42ae6b0c..361e3ef8a 100644 --- a/v3/pkg/services/dock/dock_darwin.go +++ b/v3/pkg/services/dock/dock_darwin.go @@ -8,42 +8,59 @@ package dock #import void hideDockIcon() { - dispatch_async(dispatch_get_main_queue(), ^{ + dispatch_sync(dispatch_get_main_queue(), ^{ [NSApp setActivationPolicy:NSApplicationActivationPolicyAccessory]; }); } void showDockIcon() { - dispatch_async(dispatch_get_main_queue(), ^{ + dispatch_sync(dispatch_get_main_queue(), ^{ [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; }); } -static void setBadge(const char *label) { - dispatch_async(dispatch_get_main_queue(), ^{ +bool setBadge(const char *label) { + __block bool success = false; + dispatch_sync(dispatch_get_main_queue(), ^{ + // Ensure the app is in Regular activation policy (dock icon visible) + NSApplicationActivationPolicy currentPolicy = [NSApp activationPolicy]; + if (currentPolicy != NSApplicationActivationPolicyRegular) { + success = false; + return; + } + NSString *nsLabel = nil; if (label != NULL) { nsLabel = [NSString stringWithUTF8String:label]; } [[NSApp dockTile] setBadgeLabel:nsLabel]; [[NSApp dockTile] display]; + success = true; }); + return success; } */ import "C" import ( "context" + "fmt" + "sync" "unsafe" "github.com/wailsapp/wails/v3/pkg/application" ) -type darwinDock struct{} +type darwinDock struct { + mu sync.RWMutex + Badge *string +} // Creates a new Dock Service. func New() *DockService { return &DockService{ - impl: &darwinDock{}, + impl: &darwinDock{ + Badge: nil, + }, } } @@ -67,22 +84,42 @@ func (d *darwinDock) HideAppIcon() { } // ShowAppIcon shows the app icon in the macOS Dock. +// Note: After showing the dock icon, you may need to call SetBadge again +// to reapply any previously set badge, as changing activation policies clears the badge. func (d *darwinDock) ShowAppIcon() { C.showDockIcon() } -// SetBadge sets the badge label on the application icon. -func (d *darwinDock) SetBadge(label string) error { - // Always pick a label (use “●” if empty), then allocate + free exactly once. - value := label - if value == "" { - value = "●" // Default badge character - } - cLabel := C.CString(value) - defer C.free(unsafe.Pointer(cLabel)) +// setBadge handles the C call and updates the internal badge state with locking. +func (d *darwinDock) setBadge(label *string) error { + var cLabel *C.char + if label != nil { + cLabel = C.CString(*label) + defer C.free(unsafe.Pointer(cLabel)) + } - C.setBadge(cLabel) - return nil + success := C.setBadge(cLabel) + if !success { + return fmt.Errorf("failed to set badge") + } + + d.mu.Lock() + d.Badge = label + d.mu.Unlock() + + return nil +} + +// SetBadge sets the badge label on the application icon. +// Available default badge labels: +// Single space " " empty badge +// Empty string "" dot "●" indeterminate badge +func (d *darwinDock) SetBadge(label string) error { + // Always pick a label (use "●" if empty), then allocate + free exactly once. + if label == "" { + label = "●" // Default badge character + } + return d.setBadge(&label) } // SetCustomBadge is not supported on macOS, SetBadge is called instead. @@ -92,6 +129,12 @@ func (d *darwinDock) SetCustomBadge(label string, options BadgeOptions) error { // RemoveBadge removes the badge label from the application icon. func (d *darwinDock) RemoveBadge() error { - C.setBadge(nil) - return nil + return d.setBadge(nil) +} + +// GetBadge returns the badge label on the application icon. +func (d *darwinDock) GetBadge() *string { + d.mu.RLock() + defer d.mu.RUnlock() + return d.Badge } diff --git a/v3/pkg/services/dock/dock_linux.go b/v3/pkg/services/dock/dock_linux.go index af0311c67..633e6b005 100644 --- a/v3/pkg/services/dock/dock_linux.go +++ b/v3/pkg/services/dock/dock_linux.go @@ -68,3 +68,8 @@ func (l *linuxDock) RemoveBadge() error { // No-op: Linux doesn't have standardized badge support return nil } + +func (l *linuxDock) GetBadge() *string { + // No-op: Linux doesn't have standardized badge support + return nil +} \ No newline at end of file diff --git a/v3/pkg/services/dock/dock_windows.go b/v3/pkg/services/dock/dock_windows.go index 26f40d259..0a0c45efe 100644 --- a/v3/pkg/services/dock/dock_windows.go +++ b/v3/pkg/services/dock/dock_windows.go @@ -24,6 +24,7 @@ type windowsDock struct { badgeSize int fontManager *FontManager badgeOptions BadgeOptions + badge *string } var defaultOptions = BadgeOptions{ @@ -48,6 +49,7 @@ func NewWithOptions(options BadgeOptions) *DockService { return &DockService{ impl: &windowsDock{ badgeOptions: options, + badge: nil, }, } } @@ -121,6 +123,7 @@ func (w *windowsDock) SetBadge(label string) error { } defer w32.DestroyIcon(hicon) + w.badge = &label return w.taskbar.SetOverlayIcon(hwnd, hicon, nil) }) } @@ -182,6 +185,7 @@ func (w *windowsDock) SetCustomBadge(label string, options BadgeOptions) error { } defer w32.DestroyIcon(hicon) + w.badge = &label return w.taskbar.SetOverlayIcon(hwnd, hicon, nil) }) } @@ -209,6 +213,7 @@ func (w *windowsDock) RemoveBadge() error { } hwnd := uintptr(nativeWindow) + w.badge = nil return w.taskbar.SetOverlayIcon(hwnd, 0, nil) }) } @@ -394,3 +399,7 @@ func (w *windowsDock) createBadge() { w.badgeImg = img } + +func (w *windowsDock) GetBadge() *string { + return w.badge +} \ No newline at end of file