diff --git a/v2/cmd/wails/generate.go b/v2/cmd/wails/generate.go index 15a6b33d8..2c022d5be 100644 --- a/v2/cmd/wails/generate.go +++ b/v2/cmd/wails/generate.go @@ -48,11 +48,12 @@ func generateModule(f *flags.GenerateModule) error { } _, err = bindings.GenerateBindings(bindings.Options{ - Compiler: f.Compiler, - Tags: buildTags, - TsPrefix: projectConfig.Bindings.TsGeneration.Prefix, - TsSuffix: projectConfig.Bindings.TsGeneration.Suffix, - TsOutputType: projectConfig.Bindings.TsGeneration.OutputType, + Compiler: f.Compiler, + Tags: buildTags, + TsPrefix: projectConfig.Bindings.TsGeneration.Prefix, + TsSuffix: projectConfig.Bindings.TsGeneration.Suffix, + TsOutputType: projectConfig.Bindings.TsGeneration.OutputType, + UseNullableSlices: projectConfig.Bindings.TsGeneration.UseNullableSlices, }) if err != nil { return err diff --git a/v2/internal/app/app_bindings.go b/v2/internal/app/app_bindings.go index be031819c..ab6e56e0a 100644 --- a/v2/internal/app/app_bindings.go +++ b/v2/internal/app/app_bindings.go @@ -32,6 +32,7 @@ func (a *App) Run() error { var tsPrefixFlag *string var tsPostfixFlag *string var tsOutputTypeFlag *string + var useNullableSlicesFlag *bool tsPrefix := os.Getenv("tsprefix") if tsPrefix == "" { @@ -48,6 +49,12 @@ func (a *App) Run() error { tsOutputTypeFlag = bindingFlags.String("tsoutputtype", "", "Output type for generated typescript entities (classes|interfaces)") } + useNullableSlicesEnv := os.Getenv("usenullableslices") + useNullableSlices := useNullableSlicesEnv == "true" + if useNullableSlicesEnv == "" { + useNullableSlicesFlag = bindingFlags.Bool("usenullableslices", false, "Generate nullable slice types (Type[] | null)") + } + _ = bindingFlags.Parse(os.Args[1:]) if tsPrefixFlag != nil { tsPrefix = *tsPrefixFlag @@ -58,12 +65,16 @@ func (a *App) Run() error { if tsOutputTypeFlag != nil { tsOutputType = *tsOutputTypeFlag } + if useNullableSlicesFlag != nil { + useNullableSlices = *useNullableSlicesFlag + } appBindings := binding.NewBindings(a.logger, a.options.Bind, bindingExemptions, IsObfuscated(), a.options.EnumBind) appBindings.SetTsPrefix(tsPrefix) appBindings.SetTsSuffix(tsSuffix) appBindings.SetOutputType(tsOutputType) + appBindings.SetUseNullableSlices(useNullableSlices) err := generateBindings(appBindings) if err != nil { diff --git a/v2/internal/binding/binding.go b/v2/internal/binding/binding.go index b7bf07ae0..126b4d5b4 100644 --- a/v2/internal/binding/binding.go +++ b/v2/internal/binding/binding.go @@ -25,10 +25,11 @@ type Bindings struct { structsToGenerateTS map[string]map[string]interface{} enumsToGenerateTS map[string]map[string]interface{} - tsPrefix string - tsSuffix string - tsInterface bool - obfuscate bool + tsPrefix string + tsSuffix string + tsInterface bool + obfuscate bool + useNullableSlices bool } // NewBindings returns a new Bindings object @@ -101,6 +102,7 @@ func (b *Bindings) GenerateModels() ([]byte, error) { w.WithPrefix(b.tsPrefix) w.WithSuffix(b.tsSuffix) w.WithInterface(b.tsInterface) + w.WithUseNullableSlices(b.useNullableSlices) w.Namespace = packageName w.WithBackupDir("") w.KnownStructs = allStructNames @@ -161,6 +163,7 @@ func (b *Bindings) GenerateModels() ([]byte, error) { w.WithPrefix(b.tsPrefix) w.WithSuffix(b.tsSuffix) w.WithInterface(b.tsInterface) + w.WithUseNullableSlices(b.useNullableSlices) w.Namespace = packageName w.WithBackupDir("") @@ -328,6 +331,11 @@ func (b *Bindings) SetOutputType(outputType string) *Bindings { return b } +func (b *Bindings) SetUseNullableSlices(v bool) *Bindings { + b.useNullableSlices = v + return b +} + func (b *Bindings) getAllStructNames() *slicer.StringSlicer { var result slicer.StringSlicer for packageName, structsToGenerate := range b.structsToGenerateTS { diff --git a/v2/internal/binding/generate.go b/v2/internal/binding/generate.go index 77edc983d..d57149053 100644 --- a/v2/internal/binding/generate.go +++ b/v2/internal/binding/generate.go @@ -92,7 +92,7 @@ func (b *Bindings) GenerateGoBindings(baseDir string) error { for count, input := range methodDetails.Inputs { arg := fmt.Sprintf("arg%d", count+1) entityName := entityFullReturnType(input.TypeName, b.tsPrefix, b.tsSuffix, &importNamespaces) - args.Add(arg + ":" + goTypeToTypescriptType(entityName, &importNamespaces)) + args.Add(arg + ":" + b.goTypeToTypescriptType(entityName, &importNamespaces)) } tsBody.WriteString(args.Join(",") + "):") // now build Typescript return types @@ -108,11 +108,11 @@ func (b *Bindings) GenerateGoBindings(baseDir string) error { returnType = "Promise" } else { outputTypeName := entityFullReturnType(methodDetails.Outputs[0].TypeName, b.tsPrefix, b.tsSuffix, &importNamespaces) - firstType := goTypeToTypescriptType(outputTypeName, &importNamespaces) + firstType := b.goTypeToTypescriptType(outputTypeName, &importNamespaces) returnType = "Promise<" + firstType if methodDetails.OutputCount() == 2 && methodDetails.Outputs[1].TypeName != "error" { outputTypeName = entityFullReturnType(methodDetails.Outputs[1].TypeName, b.tsPrefix, b.tsSuffix, &importNamespaces) - secondType := goTypeToTypescriptType(outputTypeName, &importNamespaces) + secondType := b.goTypeToTypescriptType(outputTypeName, &importNamespaces) returnType += "|" + secondType } returnType += ">" @@ -175,7 +175,7 @@ var ( jsVariableUnsafeChars = regexp.MustCompile(`[^A-Za-z0-9_]`) ) -func arrayifyValue(valueArray string, valueType string) string { +func arrayifyValue(valueArray string, valueType string, useNullableSlices bool) string { valueType = strings.ReplaceAll(valueType, "*", "") gidx := strings.IndexRune(valueType, '[') if gidx > 0 { // its a generic type @@ -187,23 +187,20 @@ func arrayifyValue(valueArray string, valueType string) string { return valueType } - return "Array<" + valueType + ">" + result := "Array<" + valueType + ">" + if useNullableSlices { + result += " | null" + } + return result } -func goTypeToJSDocType(input string, importNamespaces *slicer.StringSlicer) string { +func (b *Bindings) goTypeToJSDocType(input string, importNamespaces *slicer.StringSlicer) string { matches := mapRegex.FindStringSubmatch(input) keyPackage := matches[keyPackageIndex] keyType := matches[keyTypeIndex] valueArray := matches[valueArrayIndex] valuePackage := matches[valuePackageIndex] valueType := matches[valueTypeIndex] - // fmt.Printf("input=%s, keyPackage=%s, keyType=%s, valueArray=%s, valuePackage=%s, valueType=%s\n", - // input, - // keyPackage, - // keyType, - // valueArray, - // valuePackage, - // valueType) // byte array is special case if valueArray == "[]" && valueType == "byte" { @@ -222,20 +219,20 @@ func goTypeToJSDocType(input string, importNamespaces *slicer.StringSlicer) stri key := fullyQualifiedName(keyPackage, keyType) var value string if strings.HasPrefix(valueType, "map") { - value = goTypeToJSDocType(valueType, importNamespaces) + value = b.goTypeToJSDocType(valueType, importNamespaces) } else { value = fullyQualifiedName(valuePackage, valueType) } if len(key) > 0 { - return fmt.Sprintf("Record<%s, %s>", key, arrayifyValue(valueArray, value)) + return fmt.Sprintf("Record<%s, %s>", key, arrayifyValue(valueArray, value, b.useNullableSlices)) } - return arrayifyValue(valueArray, value) + return arrayifyValue(valueArray, value, b.useNullableSlices) } -func goTypeToTypescriptType(input string, importNamespaces *slicer.StringSlicer) string { - return goTypeToJSDocType(input, importNamespaces) +func (b *Bindings) goTypeToTypescriptType(input string, importNamespaces *slicer.StringSlicer) string { + return b.goTypeToJSDocType(input, importNamespaces) } func entityFullReturnType(input, prefix, suffix string, importNamespaces *slicer.StringSlicer) string { diff --git a/v2/internal/binding/generate_test.go b/v2/internal/binding/generate_test.go index 26d7c70df..5ceb7d0cd 100644 --- a/v2/internal/binding/generate_test.go +++ b/v2/internal/binding/generate_test.go @@ -139,10 +139,44 @@ func Test_goTypeToJSDocType(t *testing.T) { want: "main.ListData_net_http_Request_", }, } + b := &Bindings{} var importNamespaces slicer.StringSlicer for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - if got := goTypeToJSDocType(tt.input, &importNamespaces); got != tt.want { + if got := b.goTypeToJSDocType(tt.input, &importNamespaces); got != tt.want { + t.Errorf("goTypeToJSDocType() = %v, want %v", got, tt.want) + } + }) + } +} + +func Test_goTypeToJSDocType_nullable(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "[]int nullable", + input: "[]int", + want: "Array | null", + }, + { + name: "[]bool nullable", + input: "[]bool", + want: "Array | null", + }, + { + name: "[]byte still string", + input: "[]byte", + want: "string", + }, + } + b := &Bindings{useNullableSlices: true} + var importNamespaces slicer.StringSlicer + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := b.goTypeToJSDocType(tt.input, &importNamespaces); got != tt.want { t.Errorf("goTypeToJSDocType() = %v, want %v", got, tt.want) } }) diff --git a/v2/internal/project/project.go b/v2/internal/project/project.go index 2df99bdfa..9c96ffafb 100644 --- a/v2/internal/project/project.go +++ b/v2/internal/project/project.go @@ -251,9 +251,10 @@ type Bindings struct { } type TsGeneration struct { - Prefix string `json:"prefix"` - Suffix string `json:"suffix"` - OutputType string `json:"outputType"` + Prefix string `json:"prefix"` + Suffix string `json:"suffix"` + OutputType string `json:"outputType"` + UseNullableSlices bool `json:"useNullableSlices"` } // Parse the given JSON data into a Project struct diff --git a/v2/internal/typescriptify/typescriptify.go b/v2/internal/typescriptify/typescriptify.go index e732c5976..a5c37314f 100644 --- a/v2/internal/typescriptify/typescriptify.go +++ b/v2/internal/typescriptify/typescriptify.go @@ -116,9 +116,10 @@ type TypeScriptify struct { // throwaway, used when converting alreadyConverted map[string]bool - Namespace string - KnownStructs *slicer.StringSlicer - KnownEnums *slicer.StringSlicer + Namespace string + KnownStructs *slicer.StringSlicer + KnownEnums *slicer.StringSlicer + UseNullableSlices bool } func New() *TypeScriptify { @@ -253,6 +254,11 @@ func (t *TypeScriptify) WithSuffix(s string) *TypeScriptify { return t } +func (t *TypeScriptify) WithUseNullableSlices(v bool) *TypeScriptify { + t.UseNullableSlices = v + return t +} + func (t *TypeScriptify) Add(obj interface{}) *TypeScriptify { switch ty := obj.(type) { case StructType: @@ -658,11 +664,12 @@ func (t *TypeScriptify) convertType(depth int, typeOf reflect.Type, customCode m result = "export " + result } builder := typeScriptClassBuilder{ - types: t.kinds, - indent: t.Indent, - prefix: t.Prefix, - suffix: t.Suffix, - namespace: t.Namespace, + types: t.kinds, + indent: t.Indent, + prefix: t.Prefix, + suffix: t.Suffix, + namespace: t.Namespace, + useNullableSlices: t.UseNullableSlices, } for _, field := range fields { @@ -846,6 +853,7 @@ type typeScriptClassBuilder struct { constructorBody []string prefix, suffix string namespace string + useNullableSlices bool } func (t *typeScriptClassBuilder) AddSimpleArrayField(fieldName string, field reflect.StructField, arrayDepth int, opts TypeOptions) error { @@ -863,7 +871,11 @@ func (t *typeScriptClassBuilder) AddSimpleArrayField(fieldName string, field ref t.addInitializerFieldLine(strippedFieldName, fmt.Sprintf("source[\"%s\"]", strippedFieldName)) return nil } else if len(typeScriptType) > 0 { - t.addField(fieldName, fmt.Sprint(typeScriptType, strings.Repeat("[]", arrayDepth)), false) + nullableSuffix := "" + if t.useNullableSlices { + nullableSuffix = " | null" + } + t.addField(fieldName, fmt.Sprint(typeScriptType, strings.Repeat("[]", arrayDepth), nullableSuffix), false) t.addInitializerFieldLine(strippedFieldName, fmt.Sprintf("source[\"%s\"]", strippedFieldName)) return nil } @@ -936,7 +948,11 @@ func (t *typeScriptClassBuilder) AddArrayOfStructsField(fieldName string, field fieldType = field.Type.Elem().String() } strippedFieldName := strings.ReplaceAll(fieldName, "?", "") - t.addField(fieldName, fmt.Sprint(t.prefix+fieldType+t.suffix, strings.Repeat("[]", arrayDepth)), false) + nullableSuffix := "" + if t.useNullableSlices { + nullableSuffix = " | null" + } + t.addField(fieldName, fmt.Sprint(t.prefix+fieldType+t.suffix, strings.Repeat("[]", arrayDepth), nullableSuffix), false) t.addInitializerFieldLine(strippedFieldName, fmt.Sprintf("this.convertValues(source[\"%s\"], %s)", strippedFieldName, t.prefix+fieldType+t.suffix)) } diff --git a/v2/pkg/commands/bindings/bindings.go b/v2/pkg/commands/bindings/bindings.go index 82ce0d58f..c7cf8ec28 100644 --- a/v2/pkg/commands/bindings/bindings.go +++ b/v2/pkg/commands/bindings/bindings.go @@ -15,14 +15,15 @@ import ( // Options for generating bindings type Options struct { - Filename string - Tags []string - ProjectDirectory string - Compiler string - GoModTidy bool - TsPrefix string - TsSuffix string - TsOutputType string + Filename string + Tags []string + ProjectDirectory string + Compiler string + GoModTidy bool + TsPrefix string + TsSuffix string + TsOutputType string + UseNullableSlices bool } // GenerateBindings generates bindings for the Wails project in the given ProjectDirectory. @@ -83,6 +84,9 @@ func GenerateBindings(options Options) (string, error) { env = shell.SetEnv(env, "tsprefix", options.TsPrefix) env = shell.SetEnv(env, "tssuffix", options.TsSuffix) env = shell.SetEnv(env, "tsoutputtype", options.TsOutputType) + if options.UseNullableSlices { + env = shell.SetEnv(env, "usenullableslices", "true") + } stdout, stderr, err = shell.RunCommandWithEnv(env, workingDirectory, filename) if err != nil { diff --git a/v2/pkg/commands/build/build.go b/v2/pkg/commands/build/build.go index 7263f63ae..8ed134cbe 100644 --- a/v2/pkg/commands/build/build.go +++ b/v2/pkg/commands/build/build.go @@ -231,12 +231,13 @@ func GenerateBindings(buildOptions *Options) error { // Generate Bindings output, err := bindings.GenerateBindings(bindings.Options{ - Compiler: buildOptions.Compiler, - Tags: buildOptions.UserTags, - GoModTidy: !buildOptions.SkipModTidy, - TsPrefix: buildOptions.ProjectData.Bindings.TsGeneration.Prefix, - TsSuffix: buildOptions.ProjectData.Bindings.TsGeneration.Suffix, - TsOutputType: buildOptions.ProjectData.Bindings.TsGeneration.OutputType, + Compiler: buildOptions.Compiler, + Tags: buildOptions.UserTags, + GoModTidy: !buildOptions.SkipModTidy, + TsPrefix: buildOptions.ProjectData.Bindings.TsGeneration.Prefix, + TsSuffix: buildOptions.ProjectData.Bindings.TsGeneration.Suffix, + TsOutputType: buildOptions.ProjectData.Bindings.TsGeneration.OutputType, + UseNullableSlices: buildOptions.ProjectData.Bindings.TsGeneration.UseNullableSlices, }) if err != nil { return err diff --git a/website/src/pages/changelog.mdx b/website/src/pages/changelog.mdx index 50208109b..3e5849e02 100644 --- a/website/src/pages/changelog.mdx +++ b/website/src/pages/changelog.mdx @@ -34,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- Added `nullableSlices` opt-in to allow nullable array type generation in [#4920](https://github.com/wailsapp/wails/pull/4920) - Add origin verification for bindings by @APshenkin in [PR](https://github.com/wailsapp/wails/pull/4480) - Configure Vite timeout by @leaanthony in [PR](https://github.com/wailsapp/wails/pull/4374) - Added `ContentProtection` option to allow hiding the application window from screen sharing software [#4241](https://github.com/wailsapp/wails/pull/4241) by [@Taiterbase](https://github.com/Taiterbase)