5
0
mirror of https://github.com/wailsapp/wails.git synced 2025-05-02 07:21:32 +08:00

Fix miscellaneous bindings and typescript export bugs (#3978)

* Do not attempt to export fields that cannot be json-encoded

* update changelog w/ PR

* also skip UnsafePointers

* WIP to allow conversion from Go generic types to typescript

* support for non-primitive generics also :)

* fix generic types in parameters / return args

* fixes a namespacing bug when mapping to pointer to struct

* fixing invalid knownstructs

* found a place it mattered, pushing the star replacement to the generate side

* descend as much as necessary to find structs

caught these examples in http.Request.TLS:

PeerCertificates []*x509.Certificate
VerifiedChains [][]*x509.Certificate

* accidently reverted other fix

* switch syntax for typescript record outputs

prior syntax is primarily useful for naming keys
so not useful here, and this syntax avoids square
brackets in output which greatly simplifies
generation for Go generics

* better handle edge cases for nested arrays and slices

* lots o tests

* update changelog

---------

Co-authored-by: Lea Anthony <lea.anthony@gmail.com>
This commit is contained in:
Jeremy Jay 2025-01-13 04:14:54 -05:00 committed by GitHub
parent d9b99a990d
commit c4fdfd6415
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 450 additions and 50 deletions

View File

@ -262,22 +262,19 @@ func (b *Bindings) AddStructToGenerateTS(packageName string, structName string,
// Iterate this struct and add any struct field references
structType := reflect.TypeOf(s)
if hasElements(structType) {
for hasElements(structType) {
structType = structType.Elem()
}
for i := 0; i < structType.NumField(); i++ {
field := structType.Field(i)
if field.Anonymous {
if field.Anonymous || !field.IsExported() {
continue
}
kind := field.Type.Kind()
if kind == reflect.Struct {
if !field.IsExported() {
continue
}
fqname := field.Type.String()
sNameSplit := strings.Split(fqname, ".")
sNameSplit := strings.SplitN(fqname, ".", 2)
if len(sNameSplit) < 2 {
continue
}
@ -288,25 +285,27 @@ func (b *Bindings) AddStructToGenerateTS(packageName string, structName string,
s := reflect.Indirect(a).Interface()
b.AddStructToGenerateTS(pName, sName, s)
}
} else if hasElements(field.Type) && field.Type.Elem().Kind() == reflect.Struct {
if !field.IsExported() {
continue
} else {
fType := field.Type
for hasElements(fType) {
fType = fType.Elem()
}
fqname := field.Type.Elem().String()
sNameSplit := strings.Split(fqname, ".")
if fType.Kind() == reflect.Struct {
fqname := fType.String()
sNameSplit := strings.SplitN(fqname, ".", 2)
if len(sNameSplit) < 2 {
continue
}
sName := sNameSplit[1]
pName := getPackageName(fqname)
typ := field.Type.Elem()
a := reflect.New(typ)
if b.hasExportedJSONFields(typ) {
a := reflect.New(fType)
if b.hasExportedJSONFields(fType) {
s := reflect.Indirect(a).Interface()
b.AddStructToGenerateTS(pName, sName, s)
}
}
}
}
}
func (b *Bindings) SetTsPrefix(prefix string) *Bindings {

View File

@ -0,0 +1,126 @@
package binding_test
// Issues 2303, 3442, 3709
type DeepMessage struct {
Msg string
}
type DeepElements struct {
Single []int
Double [][]string
FourDouble [4][]float64
DoubleFour [][4]int64
Triple [][][]int
SingleMap map[string]int
SliceMap map[string][]int
DoubleSliceMap map[string][][]int
ArrayMap map[string][4]int
DoubleArrayMap1 map[string][4][]int
DoubleArrayMap2 map[string][][4]int
DoubleArrayMap3 map[string][4][4]int
OneStructs []*DeepMessage
TwoStructs [3][]*DeepMessage
ThreeStructs [][][]DeepMessage
MapStructs map[string][]*DeepMessage
MapTwoStructs map[string][4][]DeepMessage
MapThreeStructs map[string][][7][]*DeepMessage
}
func (x DeepElements) Get() DeepElements {
return x
}
var DeepElementsTest = BindingTest{
name: "DeepElements",
structs: []interface{}{
&DeepElements{},
},
exemptions: nil,
shouldError: false,
want: `
export namespace binding_test {
export class DeepMessage {
Msg: string;
static createFrom(source: any = {}) {
return new DeepMessage(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.Msg = source["Msg"];
}
}
export class DeepElements {
Single: number[];
Double: string[][];
FourDouble: number[][];
DoubleFour: number[][];
Triple: number[][][];
SingleMap: Record<string, number>;
SliceMap: Record<string, number[]>;
DoubleSliceMap: Record<string, number[][]>;
ArrayMap: Record<string, number[]>;
DoubleArrayMap1: Record<string, number[][]>;
DoubleArrayMap2: Record<string, number[][]>;
DoubleArrayMap3: Record<string, number[][]>;
OneStructs: DeepMessage[];
TwoStructs: DeepMessage[][];
ThreeStructs: DeepMessage[][][];
MapStructs: Record<string, DeepMessage[]>;
MapTwoStructs: Record<string, DeepMessage[][]>;
MapThreeStructs: Record<string, DeepMessage[][][]>;
static createFrom(source: any = {}) {
return new DeepElements(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.Single = source["Single"];
this.Double = source["Double"];
this.FourDouble = source["FourDouble"];
this.DoubleFour = source["DoubleFour"];
this.Triple = source["Triple"];
this.SingleMap = source["SingleMap"];
this.SliceMap = source["SliceMap"];
this.DoubleSliceMap = source["DoubleSliceMap"];
this.ArrayMap = source["ArrayMap"];
this.DoubleArrayMap1 = source["DoubleArrayMap1"];
this.DoubleArrayMap2 = source["DoubleArrayMap2"];
this.DoubleArrayMap3 = source["DoubleArrayMap3"];
this.OneStructs = this.convertValues(source["OneStructs"], DeepMessage);
this.TwoStructs = this.convertValues(source["TwoStructs"], DeepMessage);
this.ThreeStructs = this.convertValues(source["ThreeStructs"], DeepMessage);
this.MapStructs = this.convertValues(source["MapStructs"], DeepMessage[], true);
this.MapTwoStructs = this.convertValues(source["MapTwoStructs"], DeepMessage[][], true);
this.MapThreeStructs = this.convertValues(source["MapThreeStructs"], DeepMessage[][][], true);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
`,
}

View File

@ -0,0 +1,154 @@
package binding_test
import "github.com/wailsapp/wails/v2/internal/binding/binding_test/binding_test_import/float_package"
// Issues 3900, 3371, 2323 (no TS generics though)
type ListData[T interface{}] struct {
Total int64 `json:"Total"`
TotalPage int64 `json:"TotalPage"`
PageNum int `json:"PageNum"`
List []T `json:"List,omitempty"`
}
func (x ListData[T]) Get() ListData[T] {
return x
}
var Generics1Test = BindingTest{
name: "Generics1",
structs: []interface{}{
&ListData[string]{},
},
exemptions: nil,
shouldError: false,
want: `
export namespace binding_test {
export class ListData_string_ {
Total: number;
TotalPage: number;
PageNum: number;
List?: string[];
static createFrom(source: any = {}) {
return new ListData_string_(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.Total = source["Total"];
this.TotalPage = source["TotalPage"];
this.PageNum = source["PageNum"];
this.List = source["List"];
}
}
}
`,
}
var Generics2Test = BindingTest{
name: "Generics2",
structs: []interface{}{
&ListData[float_package.SomeStruct]{},
&ListData[*float_package.SomeStruct]{},
},
exemptions: nil,
shouldError: false,
want: `
export namespace binding_test {
export class ListData__github_com_wailsapp_wails_v2_internal_binding_binding_test_binding_test_import_float_package_SomeStruct_ {
Total: number;
TotalPage: number;
PageNum: number;
List?: float_package.SomeStruct[];
static createFrom(source: any = {}) {
return new ListData__github_com_wailsapp_wails_v2_internal_binding_binding_test_binding_test_import_float_package_SomeStruct_(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.Total = source["Total"];
this.TotalPage = source["TotalPage"];
this.PageNum = source["PageNum"];
this.List = this.convertValues(source["List"], float_package.SomeStruct);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
export class ListData_github_com_wailsapp_wails_v2_internal_binding_binding_test_binding_test_import_float_package_SomeStruct_ {
Total: number;
TotalPage: number;
PageNum: number;
List?: float_package.SomeStruct[];
static createFrom(source: any = {}) {
return new ListData_github_com_wailsapp_wails_v2_internal_binding_binding_test_binding_test_import_float_package_SomeStruct_(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.Total = source["Total"];
this.TotalPage = source["TotalPage"];
this.PageNum = source["PageNum"];
this.List = this.convertValues(source["List"], float_package.SomeStruct);
}
convertValues(a: any, classs: any, asMap: boolean = false): any {
if (!a) {
return a;
}
if (a.slice && a.map) {
return (a as any[]).map(elem => this.convertValues(elem, classs));
} else if ("object" === typeof a) {
if (asMap) {
for (const key of Object.keys(a)) {
a[key] = new classs(a[key]);
}
return a;
}
return new classs(a);
}
return a;
}
}
}
export namespace float_package {
export class SomeStruct {
string: string;
static createFrom(source: any = {}) {
return new SomeStruct(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.string = source["string"];
}
}
}
`,
}

View File

@ -0,0 +1,47 @@
package binding_test
import (
"unsafe"
)
// Issues 3755, 3809
type Ignored struct {
Valid bool
Total func() int `json:"Total"`
UnsafeP unsafe.Pointer
Complex64 complex64 `json:"Complex"`
Complex128 complex128
StringChan chan string
}
func (x Ignored) Get() Ignored {
return x
}
var IgnoredTest = BindingTest{
name: "Ignored",
structs: []interface{}{
&Ignored{},
},
exemptions: nil,
shouldError: false,
want: `
export namespace binding_test {
export class Ignored {
Valid: boolean;
static createFrom(source: any = {}) {
return new Ignored(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.Valid = source["Valid"];
}
}
}
`,
}

View File

@ -50,7 +50,7 @@ export namespace binding_test {
export namespace binding_test_import {
export class AMapWrapper {
AMap: {[key: string]: binding_test_nestedimport.A};
AMap: Record<string, binding_test_nestedimport.A>;
static createFrom(source: any = {}) {
return new AMapWrapper(source);
}

View File

@ -18,7 +18,7 @@ var NonStringMapKeyTest = BindingTest{
want: `
export namespace binding_test {
export class NonStringMapKey {
numberMap: {[key: number]: any};
numberMap: Record<number, any>;
static createFrom(source: any = {}) {
return new NonStringMapKey(source);
}

View File

@ -51,6 +51,10 @@ func TestBindings_GenerateModels(t *testing.T) {
SpecialCharacterFieldTest,
WithoutFieldsTest,
NoFieldTagsTest,
Generics1Test,
Generics2Test,
IgnoredTest,
DeepElementsTest,
}
testLogger := &logger.Logger{}

View File

@ -15,11 +15,11 @@ const expectedTypeAliasBindings = `// Cynhyrchwyd y ffeil hon yn awtomatig. PEID
import {binding_test} from '../models';
import {int_package} from '../models';
export function Map():Promise<{[key: string]: string}>;
export function Map():Promise<Record<string, string>>;
export function MapAlias():Promise<binding_test.MapAlias>;
export function MapWithImportedStructValue():Promise<{[key: string]: int_package.SomeStruct}>;
export function MapWithImportedStructValue():Promise<Record<string, int_package.SomeStruct>>;
export function Slice():Promise<Array<string>>;

View File

@ -171,7 +171,18 @@ func fullyQualifiedName(packageName string, typeName string) string {
}
}
var (
jsVariableUnsafeChars = regexp.MustCompile(`[^A-Za-z0-9_]`)
)
func arrayifyValue(valueArray string, valueType string) string {
valueType = strings.ReplaceAll(valueType, "*", "")
gidx := strings.IndexRune(valueType, '[')
if gidx > 0 { // its a generic type
rem := strings.SplitN(valueType, "[", 2)
valueType = rem[0] + "_" + jsVariableUnsafeChars.ReplaceAllLiteralString(rem[1], "_")
}
if len(valueArray) == 0 {
return valueType
}
@ -217,7 +228,7 @@ func goTypeToJSDocType(input string, importNamespaces *slicer.StringSlicer) stri
}
if len(key) > 0 {
return fmt.Sprintf("{[key: %s]: %s}", key, arrayifyValue(valueArray, value))
return fmt.Sprintf("Record<%s, %s>", key, arrayifyValue(valueArray, value))
}
return arrayifyValue(valueArray, value)

View File

@ -116,18 +116,28 @@ func Test_goTypeToJSDocType(t *testing.T) {
{
name: "map",
input: "map[string]float64",
want: "{[key: string]: number}",
want: "Record<string, number>",
},
{
name: "map",
input: "map[string]map[string]float64",
want: "{[key: string]: {[key: string]: number}}",
want: "Record<string, Record<string, number>>",
},
{
name: "types",
input: "main.SomeType",
want: "main.SomeType",
},
{
name: "primitive_generic",
input: "main.ListData[string]",
want: "main.ListData_string_",
},
{
name: "stdlib_generic",
input: "main.ListData[*net/http.Request]",
want: "main.ListData_net_http_Request_",
},
}
var importNamespaces slicer.StringSlicer
for _, tt := range tests {

View File

@ -166,7 +166,7 @@ func getPackageName(in string) string {
}
func getSplitReturn(in string) (string, string) {
result := strings.Split(in, ".")
result := strings.SplitN(in, ".", 2)
return result[0], result[1]
}

View File

@ -40,6 +40,20 @@ const (
jsVariableNameRegex = `^([A-Z]|[a-z]|\$|_)([A-Z]|[a-z]|[0-9]|\$|_)*$`
)
var (
jsVariableUnsafeChars = regexp.MustCompile(`[^A-Za-z0-9_]`)
)
func nameTypeOf(typeOf reflect.Type) string {
tname := typeOf.Name()
gidx := strings.IndexRune(tname, '[')
if gidx > 0 { // its a generic type
rem := strings.SplitN(tname, "[", 2)
tname = rem[0] + "_" + jsVariableUnsafeChars.ReplaceAllLiteralString(rem[1], "_")
}
return tname
}
// TypeOptions overrides options set by `ts_*` tags.
type TypeOptions struct {
TSType string
@ -261,15 +275,32 @@ func (t *TypeScriptify) AddType(typeOf reflect.Type) *TypeScriptify {
func (t *typeScriptClassBuilder) AddMapField(fieldName string, field reflect.StructField) {
keyType := field.Type.Key()
valueType := field.Type.Elem()
valueTypeName := valueType.Name()
valueTypeName := nameTypeOf(valueType)
valueTypeSuffix := ""
if valueType.Kind() == reflect.Ptr {
valueType = valueType.Elem()
valueTypeName = nameTypeOf(valueType)
}
if valueType.Kind() == reflect.Array || valueType.Kind() == reflect.Slice {
arrayDepth := 1
for valueType.Elem().Kind() == reflect.Array || valueType.Elem().Kind() == reflect.Slice {
valueType = valueType.Elem()
arrayDepth++
}
valueType = valueType.Elem()
valueTypeName = nameTypeOf(valueType)
valueTypeSuffix = strings.Repeat("[]", arrayDepth)
}
if valueType.Kind() == reflect.Ptr {
valueType = valueType.Elem()
valueTypeName = nameTypeOf(valueType)
}
if name, ok := t.types[valueType.Kind()]; ok {
valueTypeName = name
}
if valueType.Kind() == reflect.Array || valueType.Kind() == reflect.Slice {
valueTypeName = valueType.Elem().Name() + "[]"
}
if valueType.Kind() == reflect.Ptr {
valueTypeName = valueType.Elem().Name()
if valueType.Kind() == reflect.Map {
// TODO: support nested maps
valueTypeName = "any" // valueType.Elem().Name()
}
if valueType.Kind() == reflect.Struct && differentNamespaces(t.namespace, valueType) {
valueTypeName = valueType.String()
@ -294,11 +325,13 @@ func (t *typeScriptClassBuilder) AddMapField(fieldName string, field reflect.Str
fieldName = fmt.Sprintf(`"%s"?`, strippedFieldName)
}
}
t.fields = append(t.fields, fmt.Sprintf("%s%s: {[key: %s]: %s};", t.indent, fieldName, keyTypeStr, valueTypeName))
t.fields = append(t.fields, fmt.Sprintf("%s%s: Record<%s, %s>;", t.indent, fieldName, keyTypeStr, valueTypeName+valueTypeSuffix))
if valueType.Kind() == reflect.Struct {
t.constructorBody = append(t.constructorBody, fmt.Sprintf("%s%sthis%s = this.convertValues(source[\"%s\"], %s, true);", t.indent, t.indent, dotField, strippedFieldName, t.prefix+valueTypeName+t.suffix))
t.constructorBody = append(t.constructorBody, fmt.Sprintf("%s%sthis%s = this.convertValues(source[\"%s\"], %s, true);",
t.indent, t.indent, dotField, strippedFieldName, t.prefix+valueTypeName+valueTypeSuffix+t.suffix))
} else {
t.constructorBody = append(t.constructorBody, fmt.Sprintf("%s%sthis%s = source[\"%s\"];", t.indent, t.indent, dotField, strippedFieldName))
t.constructorBody = append(t.constructorBody, fmt.Sprintf("%s%sthis%s = source[\"%s\"];",
t.indent, t.indent, dotField, strippedFieldName))
}
}
@ -501,7 +534,7 @@ func (t *TypeScriptify) convertEnum(depth int, typeOf reflect.Type, elements []e
}
t.alreadyConverted[typeOf.String()] = true
entityName := t.Prefix + typeOf.Name() + t.Suffix
entityName := t.Prefix + nameTypeOf(typeOf) + t.Suffix
result := "enum " + entityName + " {\n"
for _, val := range elements {
@ -607,7 +640,7 @@ func (t *TypeScriptify) convertType(depth int, typeOf reflect.Type, customCode m
t.alreadyConverted[typeOf.String()] = true
entityName := t.Prefix + typeOf.Name() + t.Suffix
entityName := t.Prefix + nameTypeOf(typeOf) + t.Suffix
if typeClashWithReservedKeyword(entityName) {
warnAboutTypesClash(entityName)
@ -667,8 +700,10 @@ func (t *TypeScriptify) convertType(depth int, typeOf reflect.Type, customCode m
}
isKnownType := t.KnownStructs.Contains(getStructFQN(field.Type.String()))
if !isKnownType {
println("KnownStructs:", t.KnownStructs.Join("\t"))
println(getStructFQN(field.Type.String()))
println("Not found:", getStructFQN(field.Type.String()))
}
builder.AddStructField(jsonFieldName, field, !isKnownType)
} else if field.Type.Kind() == reflect.Map {
t.logf(depth, "- map field %s.%s", typeOf.Name(), field.Name)
@ -714,11 +749,15 @@ func (t *TypeScriptify) convertType(depth int, typeOf reflect.Type, customCode m
}
arrayDepth := 1
for field.Type.Elem().Kind() == reflect.Slice { // Slice of slices:
for field.Type.Elem().Kind() == reflect.Slice || field.Type.Elem().Kind() == reflect.Array { // Slice of slices:
field.Type = field.Type.Elem()
arrayDepth++
}
if field.Type.Elem().Kind() == reflect.Ptr { // extract ptr type
field.Type = field.Type.Elem()
}
if field.Type.Elem().Kind() == reflect.Struct { // Slice of structs:
t.logf(depth, "- struct slice %s.%s (%s)", typeOf.Name(), field.Name, field.Type.String())
typeScriptChunk, err := t.convertType(depth+1, field.Type.Elem(), customCode)
@ -808,8 +847,12 @@ type typeScriptClassBuilder struct {
}
func (t *typeScriptClassBuilder) AddSimpleArrayField(fieldName string, field reflect.StructField, arrayDepth int, opts TypeOptions) error {
fieldType, kind := field.Type.Elem().Name(), field.Type.Elem().Kind()
typeScriptType := t.types[kind]
fieldType := nameTypeOf(field.Type.Elem())
kind := field.Type.Elem().Kind()
typeScriptType, ok := t.types[kind]
if !ok {
typeScriptType = "any"
}
if len(fieldName) > 0 {
strippedFieldName := strings.ReplaceAll(fieldName, "?", "")
@ -828,9 +871,14 @@ func (t *typeScriptClassBuilder) AddSimpleArrayField(fieldName string, field ref
}
func (t *typeScriptClassBuilder) AddSimpleField(fieldName string, field reflect.StructField, opts TypeOptions) error {
fieldType, kind := field.Type.Name(), field.Type.Kind()
fieldType := nameTypeOf(field.Type)
kind := field.Type.Kind()
typeScriptType, ok := t.types[kind]
if !ok {
typeScriptType = "any"
}
typeScriptType := t.types[kind]
if len(opts.TSType) > 0 {
typeScriptType = opts.TSType
}
@ -852,7 +900,7 @@ func (t *typeScriptClassBuilder) AddSimpleField(fieldName string, field reflect.
}
func (t *typeScriptClassBuilder) AddEnumField(fieldName string, field reflect.StructField) {
fieldType := field.Type.Name()
fieldType := nameTypeOf(field.Type)
t.addField(fieldName, t.prefix+fieldType+t.suffix, false)
strippedFieldName := strings.ReplaceAll(fieldName, "?", "")
t.addInitializerFieldLine(strippedFieldName, fmt.Sprintf("source[\"%s\"]", strippedFieldName))
@ -862,7 +910,7 @@ func (t *typeScriptClassBuilder) AddStructField(fieldName string, field reflect.
strippedFieldName := strings.ReplaceAll(fieldName, "?", "")
classname := "null"
namespace := strings.Split(field.Type.String(), ".")[0]
fqname := t.prefix + field.Type.Name() + t.suffix
fqname := t.prefix + nameTypeOf(field.Type) + t.suffix
if namespace != t.namespace {
fqname = namespace + "." + fqname
}
@ -881,7 +929,7 @@ func (t *typeScriptClassBuilder) AddStructField(fieldName string, field reflect.
}
func (t *typeScriptClassBuilder) AddArrayOfStructsField(fieldName string, field reflect.StructField, arrayDepth int) {
fieldType := field.Type.Elem().Name()
fieldType := nameTypeOf(field.Type.Elem())
if differentNamespaces(t.namespace, field.Type.Elem()) {
fieldType = field.Type.Elem().String()
}

View File

@ -30,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- chore: fix some comments in [PR](https://github.com/wailsapp/wails/pull/3932) by @lvyaoting
- [windows] Fixed frameless window flickering when minimizing/restoring by preventing unnecessary redraws [#3951](https://github.com/wailsapp/wails/issues/3951)
- Fixed failed models.ts build due to non-json-encodable Go types [PR](https://github.com/wailsapp/wails/pull/3975) by [@pbnjay](https://github.com/pbnjay)
- Fixed more binding and typescript export bugs [PR](https://github.com/wailsapp/wails/pull/3978) by [@pbnjay](https://github.com/pbnjay)
### Changed
- Allow to specify macos-min-version externally. Implemented by @APshenkin in [PR](https://github.com/wailsapp/wails/pull/3756)