github.com/AlpineAIO/wails/v2@v2.0.0-beta.32.0.20240505041856-1047a8fa5fef/pkg/commands/build/base.go (about)

     1  package build
     2  
     3  import (
     4  	"bytes"
     5  	"fmt"
     6  	"os"
     7  	"os/exec"
     8  	"path/filepath"
     9  	"runtime"
    10  	"strconv"
    11  	"strings"
    12  
    13  	"github.com/pterm/pterm"
    14  
    15  	"github.com/AlpineAIO/wails/v2/internal/system"
    16  
    17  	"github.com/AlpineAIO/wails/v2/internal/frontend/runtime/wrapper"
    18  	"github.com/leaanthony/gosod"
    19  
    20  	"github.com/pkg/errors"
    21  
    22  	"github.com/AlpineAIO/wails/v2/internal/fs"
    23  	"github.com/AlpineAIO/wails/v2/internal/project"
    24  	"github.com/AlpineAIO/wails/v2/internal/shell"
    25  	"github.com/AlpineAIO/wails/v2/pkg/clilogger"
    26  	"github.com/leaanthony/slicer"
    27  )
    28  
    29  const (
    30  	VERBOSE int = 2
    31  )
    32  
    33  // BaseBuilder is the common builder struct
    34  type BaseBuilder struct {
    35  	filesToDelete slicer.StringSlicer
    36  	projectData   *project.Project
    37  	options       *Options
    38  }
    39  
    40  // NewBaseBuilder creates a new BaseBuilder
    41  func NewBaseBuilder(options *Options) *BaseBuilder {
    42  	result := &BaseBuilder{
    43  		options: options,
    44  	}
    45  	return result
    46  }
    47  
    48  // SetProjectData sets the project data for this builder
    49  func (b *BaseBuilder) SetProjectData(projectData *project.Project) {
    50  	b.projectData = projectData
    51  }
    52  
    53  func (b *BaseBuilder) addFileToDelete(filename string) {
    54  	if !b.options.KeepAssets {
    55  		b.filesToDelete.Add(filename)
    56  	}
    57  }
    58  
    59  func (b *BaseBuilder) fileExists(path string) bool {
    60  	// if file doesn't exist, ignore
    61  	_, err := os.Stat(path)
    62  	if err != nil {
    63  		return !os.IsNotExist(err)
    64  	}
    65  	return true
    66  }
    67  
    68  func (b *BaseBuilder) convertFileToIntegerString(filename string) (string, error) {
    69  	rawData, err := os.ReadFile(filename)
    70  	if err != nil {
    71  		return "", err
    72  	}
    73  	return b.convertByteSliceToIntegerString(rawData), nil
    74  }
    75  
    76  func (b *BaseBuilder) convertByteSliceToIntegerString(data []byte) string {
    77  	// Create string builder
    78  	var result strings.Builder
    79  
    80  	if len(data) > 0 {
    81  
    82  		// Loop over all but 1 bytes
    83  		for i := 0; i < len(data)-1; i++ {
    84  			result.WriteString(fmt.Sprintf("%v,", data[i]))
    85  		}
    86  
    87  		result.WriteString(strconv.FormatUint(uint64(data[len(data)-1]), 10))
    88  	}
    89  
    90  	return result.String()
    91  }
    92  
    93  // CleanUp does post-build housekeeping
    94  func (b *BaseBuilder) CleanUp() {
    95  	// Delete all the files
    96  	b.filesToDelete.Each(func(filename string) {
    97  		// if file doesn't exist, ignore
    98  		if !b.fileExists(filename) {
    99  			return
   100  		}
   101  
   102  		// Delete file. We ignore errors because these files will be overwritten
   103  		// by the next build anyway.
   104  		_ = os.Remove(filename)
   105  	})
   106  }
   107  
   108  func commandPrettifier(args []string) string {
   109  	// If we have a single argument, just return it
   110  	if len(args) == 1 {
   111  		return args[0]
   112  	}
   113  	// If an argument contains a space, quote it
   114  	for i, arg := range args {
   115  		if strings.Contains(arg, " ") {
   116  			args[i] = fmt.Sprintf("\"%s\"", arg)
   117  		}
   118  	}
   119  	return strings.Join(args, " ")
   120  }
   121  
   122  func (b *BaseBuilder) OutputFilename(options *Options) string {
   123  	outputFile := options.OutputFile
   124  	if outputFile == "" {
   125  		target := strings.TrimSuffix(b.projectData.OutputFilename, ".exe")
   126  		if b.projectData.OutputType != "desktop" {
   127  			target += "-" + b.projectData.OutputType
   128  		}
   129  		// If we aren't using the standard compiler, add it to the filename
   130  		if options.Compiler != "go" {
   131  			// Parse the `go version` output. EG: `go version go1.16 windows/amd64`
   132  			stdout, _, err := shell.RunCommand(".", options.Compiler, "version")
   133  			if err != nil {
   134  				return ""
   135  			}
   136  			versionSplit := strings.Split(stdout, " ")
   137  			if len(versionSplit) == 4 {
   138  				target += "-" + versionSplit[2]
   139  			}
   140  		}
   141  		switch b.options.Platform {
   142  		case "windows":
   143  			outputFile = target + ".exe"
   144  		case "darwin", "linux":
   145  			if b.options.Arch == "" {
   146  				b.options.Arch = runtime.GOARCH
   147  			}
   148  			outputFile = fmt.Sprintf("%s-%s-%s", target, b.options.Platform, b.options.Arch)
   149  		}
   150  
   151  	}
   152  	return outputFile
   153  }
   154  
   155  // CompileProject compiles the project
   156  func (b *BaseBuilder) CompileProject(options *Options) error {
   157  	// Check if the runtime wrapper exists
   158  	err := generateRuntimeWrapper(options)
   159  	if err != nil {
   160  		return err
   161  	}
   162  
   163  	verbose := options.Verbosity == VERBOSE
   164  	// Run go mod tidy first
   165  	if !options.SkipModTidy {
   166  		cmd := exec.Command(options.Compiler, "mod", "tidy")
   167  		cmd.Stderr = os.Stderr
   168  		if verbose {
   169  			println("")
   170  			cmd.Stdout = os.Stdout
   171  		}
   172  		err = cmd.Run()
   173  		if err != nil {
   174  			return err
   175  		}
   176  	}
   177  
   178  	commands := slicer.String()
   179  
   180  	compiler := options.Compiler
   181  	if options.Obfuscated {
   182  		if !shell.CommandExists("garble") {
   183  			return fmt.Errorf("the 'garble' command was not found. Please install it with `go install mvdan.cc/garble@latest`")
   184  		} else {
   185  			compiler = "garble"
   186  			if options.GarbleArgs != "" {
   187  				commands.AddSlice(strings.Split(options.GarbleArgs, " "))
   188  			}
   189  			options.UserTags = append(options.UserTags, "obfuscated")
   190  		}
   191  	}
   192  
   193  	// Default go build command
   194  	commands.Add("build")
   195  
   196  	// Add better debugging flags
   197  	if options.Mode == Dev || options.Mode == Debug {
   198  		commands.Add("-gcflags")
   199  		commands.Add("all=-N -l")
   200  	}
   201  
   202  	if options.ForceBuild {
   203  		commands.Add("-a")
   204  	}
   205  
   206  	if options.TrimPath {
   207  		commands.Add("-trimpath")
   208  	}
   209  
   210  	if options.RaceDetector {
   211  		commands.Add("-race")
   212  	}
   213  
   214  	var tags slicer.StringSlicer
   215  	tags.Add(options.OutputType)
   216  	tags.AddSlice(options.UserTags)
   217  
   218  	// Add webview2 strategy if we have it
   219  	if options.WebView2Strategy != "" {
   220  		tags.Add(options.WebView2Strategy)
   221  	}
   222  
   223  	if options.Mode == Production || options.Mode == Debug {
   224  		tags.Add("production")
   225  	}
   226  	// This mode allows you to debug a production build (not dev build)
   227  	if options.Mode == Debug {
   228  		tags.Add("debug")
   229  	}
   230  
   231  	// This options allows you to enable devtools in production build (not dev build as it's always enabled there)
   232  	if options.Devtools {
   233  		tags.Add("devtools")
   234  	}
   235  
   236  	if options.Obfuscated {
   237  		tags.Add("obfuscated")
   238  	}
   239  
   240  	tags.Deduplicate()
   241  
   242  	// Add the output type build tag
   243  	commands.Add("-tags")
   244  	commands.Add(tags.Join(","))
   245  
   246  	// LDFlags
   247  	ldflags := slicer.String()
   248  	if options.LDFlags != "" {
   249  		ldflags.Add(options.LDFlags)
   250  	}
   251  
   252  	if options.Mode == Production {
   253  		ldflags.Add("-w", "-s")
   254  		if options.Platform == "windows" && !options.WindowsConsole {
   255  			ldflags.Add("-H windowsgui")
   256  		}
   257  	}
   258  
   259  	ldflags.Deduplicate()
   260  
   261  	if ldflags.Length() > 0 {
   262  		commands.Add("-ldflags")
   263  		commands.Add(ldflags.Join(" "))
   264  	}
   265  
   266  	// Get application build directory
   267  	appDir := options.BinDirectory
   268  	if options.CleanBinDirectory {
   269  		err = cleanBinDirectory(options)
   270  		if err != nil {
   271  			return err
   272  		}
   273  	}
   274  
   275  	// Set up output filename
   276  	outputFile := b.OutputFilename(options)
   277  	compiledBinary := filepath.Join(appDir, outputFile)
   278  	commands.Add("-o")
   279  	commands.Add(compiledBinary)
   280  
   281  	options.CompiledBinary = compiledBinary
   282  
   283  	// Build the application
   284  	cmd := exec.Command(compiler, commands.AsSlice()...)
   285  	cmd.Stderr = os.Stderr
   286  	if verbose {
   287  		pterm.Info.Println("Build command:", compiler, commandPrettifier(commands.AsSlice()))
   288  		cmd.Stdout = os.Stdout
   289  	}
   290  	// Set the directory
   291  	cmd.Dir = b.projectData.Path
   292  
   293  	// Add CGO flags
   294  	// TODO: Remove this as we don't generate headers any more
   295  	// We use the project/build dir as a temporary place for our generated c headers
   296  	buildBaseDir, err := fs.RelativeToCwd("build")
   297  	if err != nil {
   298  		return err
   299  	}
   300  
   301  	cmd.Env = os.Environ() // inherit env
   302  
   303  	if options.Platform != "windows" {
   304  		// Use shell.UpsertEnv so we don't overwrite user's CGO_CFLAGS
   305  		cmd.Env = shell.UpsertEnv(cmd.Env, "CGO_CFLAGS", func(v string) string {
   306  			if options.Platform == "darwin" {
   307  				if v != "" {
   308  					v += " "
   309  				}
   310  				v += "-mmacosx-version-min=10.13"
   311  			}
   312  			return v
   313  		})
   314  		// Use shell.UpsertEnv so we don't overwrite user's CGO_CXXFLAGS
   315  		cmd.Env = shell.UpsertEnv(cmd.Env, "CGO_CXXFLAGS", func(v string) string {
   316  			if v != "" {
   317  				v += " "
   318  			}
   319  			v += "-I" + buildBaseDir
   320  			return v
   321  		})
   322  
   323  		cmd.Env = shell.UpsertEnv(cmd.Env, "CGO_ENABLED", func(v string) string {
   324  			return "1"
   325  		})
   326  		if options.Platform == "darwin" {
   327  			// Determine version so we can link to newer frameworks
   328  			// Why doesn't CGO have this option?!?!
   329  			info, err := system.GetInfo()
   330  			if err != nil {
   331  				return err
   332  			}
   333  			versionSplit := strings.Split(info.OS.Version, ".")
   334  			majorVersion, err := strconv.Atoi(versionSplit[0])
   335  			if err != nil {
   336  				return err
   337  			}
   338  			addUTIFramework := majorVersion >= 11
   339  			// Set the minimum Mac SDK to 10.13
   340  			cmd.Env = shell.UpsertEnv(cmd.Env, "CGO_LDFLAGS", func(v string) string {
   341  				if v != "" {
   342  					v += " "
   343  				}
   344  				if addUTIFramework {
   345  					v += "-framework UniformTypeIdentifiers "
   346  				}
   347  				v += "-mmacosx-version-min=10.13"
   348  
   349  				return v
   350  			})
   351  		}
   352  	}
   353  
   354  	cmd.Env = shell.UpsertEnv(cmd.Env, "GOOS", func(v string) string {
   355  		return options.Platform
   356  	})
   357  
   358  	cmd.Env = shell.UpsertEnv(cmd.Env, "GOARCH", func(v string) string {
   359  		return options.Arch
   360  	})
   361  
   362  	if verbose {
   363  		printBulletPoint("Environment:", strings.Join(cmd.Env, " "))
   364  	}
   365  
   366  	// Run command
   367  	err = cmd.Run()
   368  	cmd.Stderr = os.Stderr
   369  
   370  	// Format error if we have one
   371  	if err != nil {
   372  		if options.Platform == "darwin" {
   373  			output, _ := cmd.CombinedOutput()
   374  			stdErr := string(output)
   375  			if strings.Contains(err.Error(), "ld: framework not found UniformTypeIdentifiers") ||
   376  				strings.Contains(stdErr, "ld: framework not found UniformTypeIdentifiers") {
   377  				pterm.Warning.Println(`
   378  NOTE: It would appear that you do not have the latest Xcode cli tools installed.
   379  Please reinstall by doing the following:
   380    1. Remove the current installation located at "xcode-select -p", EG: sudo rm -rf /Library/Developer/CommandLineTools
   381    2. Install latest Xcode tools: xcode-select --install`)
   382  			}
   383  		}
   384  		return err
   385  	}
   386  
   387  	if !options.Compress {
   388  		return nil
   389  	}
   390  
   391  	printBulletPoint("Compressing application: ")
   392  
   393  	// Do we have upx installed?
   394  	if !shell.CommandExists("upx") {
   395  		pterm.Warning.Println("Warning: Cannot compress binary: upx not found")
   396  		return nil
   397  	}
   398  
   399  	args := []string{"--best", "--no-color", "--no-progress", options.CompiledBinary}
   400  
   401  	if options.CompressFlags != "" {
   402  		args = strings.Split(options.CompressFlags, " ")
   403  		args = append(args, options.CompiledBinary)
   404  	}
   405  
   406  	if verbose {
   407  		pterm.Info.Println("upx", strings.Join(args, " "))
   408  	}
   409  
   410  	output, err := exec.Command("upx", args...).Output()
   411  	if err != nil {
   412  		return errors.Wrap(err, "Error during compression:")
   413  	}
   414  	pterm.Println("Done.")
   415  	if verbose {
   416  		pterm.Info.Println(string(output))
   417  	}
   418  
   419  	return nil
   420  }
   421  
   422  func generateRuntimeWrapper(options *Options) error {
   423  	if options.WailsJSDir == "" {
   424  		cwd, err := os.Getwd()
   425  		if err != nil {
   426  			return err
   427  		}
   428  		options.WailsJSDir = filepath.Join(cwd, "frontend")
   429  	}
   430  	wrapperDir := filepath.Join(options.WailsJSDir, "wailsjs", "runtime")
   431  	_ = os.RemoveAll(wrapperDir)
   432  	extractor := gosod.New(wrapper.RuntimeWrapper)
   433  	err := extractor.Extract(wrapperDir, nil)
   434  	if err != nil {
   435  		return err
   436  	}
   437  
   438  	return nil
   439  }
   440  
   441  // NpmInstall runs "npm install" in the given directory
   442  func (b *BaseBuilder) NpmInstall(sourceDir string, verbose bool) error {
   443  	return b.NpmInstallUsingCommand(sourceDir, "npm install", verbose)
   444  }
   445  
   446  // NpmInstallUsingCommand runs the given install command in the specified npm project directory
   447  func (b *BaseBuilder) NpmInstallUsingCommand(sourceDir string, installCommand string, verbose bool) error {
   448  	packageJSON := filepath.Join(sourceDir, "package.json")
   449  
   450  	// Check package.json exists
   451  	if !fs.FileExists(packageJSON) {
   452  		// No package.json, no install
   453  		return nil
   454  	}
   455  
   456  	install := false
   457  
   458  	// Get the MD5 sum of package.json
   459  	packageJSONMD5 := fs.MustMD5File(packageJSON)
   460  
   461  	// Check whether we need to npm install
   462  	packageChecksumFile := filepath.Join(sourceDir, "package.json.md5")
   463  	if fs.FileExists(packageChecksumFile) {
   464  		// Compare checksums
   465  		storedChecksum := fs.MustLoadString(packageChecksumFile)
   466  		if storedChecksum != packageJSONMD5 {
   467  			fs.MustWriteString(packageChecksumFile, packageJSONMD5)
   468  			install = true
   469  		}
   470  	} else {
   471  		install = true
   472  		fs.MustWriteString(packageChecksumFile, packageJSONMD5)
   473  	}
   474  
   475  	// Install if node_modules doesn't exist
   476  	nodeModulesDir := filepath.Join(sourceDir, "node_modules")
   477  	if !fs.DirExists(nodeModulesDir) {
   478  		install = true
   479  	}
   480  
   481  	// check if forced install
   482  	if b.options.ForceBuild {
   483  		install = true
   484  	}
   485  
   486  	// Shortcut installation
   487  	if !install {
   488  		if verbose {
   489  			pterm.Println("Skipping npm install")
   490  		}
   491  		return nil
   492  	}
   493  
   494  	// Split up the InstallCommand and execute it
   495  	cmd := strings.Split(installCommand, " ")
   496  	stdout, stderr, err := shell.RunCommand(sourceDir, cmd[0], cmd[1:]...)
   497  	if verbose || err != nil {
   498  		for _, l := range strings.Split(stdout, "\n") {
   499  			pterm.Printf("    %s\n", l)
   500  		}
   501  		for _, l := range strings.Split(stderr, "\n") {
   502  			pterm.Printf("    %s\n", l)
   503  		}
   504  	}
   505  
   506  	return err
   507  }
   508  
   509  // NpmRun executes the npm target in the provided directory
   510  func (b *BaseBuilder) NpmRun(projectDir, buildTarget string, verbose bool) error {
   511  	stdout, stderr, err := shell.RunCommand(projectDir, "npm", "run", buildTarget)
   512  	if verbose || err != nil {
   513  		for _, l := range strings.Split(stdout, "\n") {
   514  			pterm.Printf("    %s\n", l)
   515  		}
   516  		for _, l := range strings.Split(stderr, "\n") {
   517  			pterm.Printf("    %s\n", l)
   518  		}
   519  	}
   520  	return err
   521  }
   522  
   523  // NpmRunWithEnvironment executes the npm target in the provided directory, with the given environment variables
   524  func (b *BaseBuilder) NpmRunWithEnvironment(projectDir, buildTarget string, verbose bool, envvars []string) error {
   525  	cmd := shell.CreateCommand(projectDir, "npm", "run", buildTarget)
   526  	cmd.Env = append(os.Environ(), envvars...)
   527  	var stdo, stde bytes.Buffer
   528  	cmd.Stdout = &stdo
   529  	cmd.Stderr = &stde
   530  	err := cmd.Run()
   531  	if verbose || err != nil {
   532  		for _, l := range strings.Split(stdo.String(), "\n") {
   533  			pterm.Printf("    %s\n", l)
   534  		}
   535  		for _, l := range strings.Split(stde.String(), "\n") {
   536  			pterm.Printf("    %s\n", l)
   537  		}
   538  	}
   539  	return err
   540  }
   541  
   542  // BuildFrontend executes the `npm build` command for the frontend directory
   543  func (b *BaseBuilder) BuildFrontend(outputLogger *clilogger.CLILogger) error {
   544  	verbose := b.options.Verbosity == VERBOSE
   545  
   546  	frontendDir := b.projectData.GetFrontendDir()
   547  	if !fs.DirExists(frontendDir) {
   548  		return fmt.Errorf("frontend directory '%s' does not exist", frontendDir)
   549  	}
   550  
   551  	// Check there is an 'InstallCommand' provided in wails.json
   552  	installCommand := b.projectData.InstallCommand
   553  	if b.projectData.OutputType == "dev" {
   554  		installCommand = b.projectData.GetDevInstallerCommand()
   555  	}
   556  	if installCommand == "" {
   557  		// No - don't install
   558  		printBulletPoint("No Install command. Skipping.")
   559  		pterm.Println("")
   560  	} else {
   561  		// Do install if needed
   562  		printBulletPoint("Installing frontend dependencies: ")
   563  		if verbose {
   564  			pterm.Println("")
   565  			pterm.Info.Println("Install command: '" + installCommand + "'")
   566  		}
   567  		if err := b.NpmInstallUsingCommand(frontendDir, installCommand, verbose); err != nil {
   568  			return err
   569  		}
   570  		outputLogger.Println("Done.")
   571  	}
   572  
   573  	// Check if there is a build command
   574  	buildCommand := b.projectData.BuildCommand
   575  	if b.projectData.OutputType == "dev" {
   576  		buildCommand = b.projectData.GetDevBuildCommand()
   577  	}
   578  	if buildCommand == "" {
   579  		printBulletPoint("No Build command. Skipping.")
   580  		pterm.Println("")
   581  		// No - ignore
   582  		return nil
   583  	}
   584  
   585  	printBulletPoint("Compiling frontend: ")
   586  	cmd := strings.Split(buildCommand, " ")
   587  	if verbose {
   588  		pterm.Println("")
   589  		pterm.Info.Println("Build command: '" + buildCommand + "'")
   590  	}
   591  	stdout, stderr, err := shell.RunCommand(frontendDir, cmd[0], cmd[1:]...)
   592  	if verbose || err != nil {
   593  		for _, l := range strings.Split(stdout, "\n") {
   594  			pterm.Printf("    %s\n", l)
   595  		}
   596  		for _, l := range strings.Split(stderr, "\n") {
   597  			pterm.Printf("    %s\n", l)
   598  		}
   599  	}
   600  	if err != nil {
   601  		return err
   602  	}
   603  
   604  	pterm.Println("Done.")
   605  	return nil
   606  }