diff --git a/assets/app/main_desktop.dart b/assets/app/main_desktop.dart new file mode 100644 index 00000000..f99674b3 --- /dev/null +++ b/assets/app/main_desktop.dart @@ -0,0 +1,10 @@ +import 'package:flutter/foundation.dart' + show debugDefaultTargetPlatformOverride; +import 'package:flutter/material.dart'; + +import './main.dart'; + +void main() { + debugDefaultTargetPlatformOverride = TargetPlatform.fuchsia; + runApp(MyApp()); +} diff --git a/assets/plugin/README.md.tmpl b/assets/plugin/README.md.tmpl new file mode 100644 index 00000000..b467c9a0 --- /dev/null +++ b/assets/plugin/README.md.tmpl @@ -0,0 +1,17 @@ +# {{.pluginName}} + +This Go package implements the host-side of the Flutter [{{.pluginName}}](https://{{.urlVSCRepo}}) plugin. + +## Usage + +Import as: + +```go +import {{.pluginName}} "{{.urlVSCRepo}}/go" +``` + +Then add the following option to your go-flutter [application options](https://github.com/go-flutter-desktop/go-flutter/wiki/Plugin-info): + +```go +flutter.AddPlugin(&{{.pluginName}}.{{.structName}}{}), +``` diff --git a/assets/plugin/import.go.tmpl.tmpl b/assets/plugin/import.go.tmpl.tmpl new file mode 100644 index 00000000..a4c1e34a --- /dev/null +++ b/assets/plugin/import.go.tmpl.tmpl @@ -0,0 +1,13 @@ +package main + +// DO NOT EDIT, this file is generated by hover at compile-time for the {{.pluginName}} plugin. + +import ( + flutter "github.com/go-flutter-desktop/go-flutter" + {{.pluginName}} "{{.urlVSCRepo}}/go" +) + +func init() { + // Only the init function can be tweaked by plugin maker. + options = append(options, flutter.AddPlugin(&{{.pluginName}}.{{.structName}}{})) +} diff --git a/assets/plugin/plugin.go.tmpl b/assets/plugin/plugin.go.tmpl new file mode 100644 index 00000000..45d5aa18 --- /dev/null +++ b/assets/plugin/plugin.go.tmpl @@ -0,0 +1,24 @@ +package {{.pluginName}} + +import ( + flutter "github.com/go-flutter-desktop/go-flutter" + "github.com/go-flutter-desktop/go-flutter/plugin" +) + +const channelName = "{{.pluginName}}" + +// {{.structName}} implements flutter.Plugin and handles method. +type {{.structName}} struct{} + +var _ flutter.Plugin = &{{.structName}}{} // compile-time type check + +// InitPlugin initializes the plugin. +func (p *{{.structName}}) InitPlugin(messenger plugin.BinaryMessenger) error { + channel := plugin.NewMethodChannel(messenger, channelName, plugin.StandardMethodCodec{}) + channel.HandleFunc("getPlatformVersion", p.handlePlatformVersion) + return nil +} + +func (p *{{.structName}}) handlePlatformVersion(arguments interface{}) (reply interface{}, err error) { + return "go-flutter " + flutter.PlatformVersion, nil +} diff --git a/cmd/build.go b/cmd/build.go index 2e0ad36e..eec4cfab 100644 --- a/cmd/build.go +++ b/cmd/build.go @@ -11,6 +11,7 @@ import ( "strings" "github.com/go-flutter-desktop/hover/internal/enginecache" + "github.com/go-flutter-desktop/hover/internal/fileutils" "github.com/go-flutter-desktop/hover/internal/log" "github.com/go-flutter-desktop/hover/internal/versioncheck" "github.com/hashicorp/go-version" @@ -118,6 +119,31 @@ var buildWindowsCmd = &cobra.Command{ }, } +// checkForMainDesktop checks and adds the lib/main_desktop.dart dart entry +// point if needed +func checkForMainDesktop() { + if buildTarget != "lib/main_desktop.dart" { + return + } + _, err := os.Stat("lib/main_desktop.dart") + if os.IsNotExist(err) { + log.Warnf("Target file \"lib/main_desktop.dart\" not found.") + log.Warnf("Let hover add the \"lib/main_desktop.dart\" file? ") + if askForConfirmation() { + fileutils.CopyAsset("app/main_desktop.dart", filepath.Join("lib", "main_desktop.dart"), assetsBox) + log.Infof("Target file \"lib/main_desktop.dart\" has been created.") + log.Infof(" Depending on your project, you might want to tweak it.") + return + } + log.Printf("You can define a custom traget by using the %s flag.", log.Au().Magenta("--target")) + os.Exit(1) + } + if err != nil { + log.Errorf("Failed to stat lib/main_desktop.dart: %v\n", err) + os.Exit(1) + } +} + func outputDirectoryPath(targetOS string) string { outputDirectoryPath, err := filepath.Abs(filepath.Join(buildPath, "build", "outputs", targetOS)) if err != nil { @@ -253,6 +279,7 @@ func dockerBuild(projectName string, targetOS string, vmArguments []string) { } func build(projectName string, targetOS string, vmArguments []string) { + checkForMainDesktop() crossCompile = targetOS != runtime.GOOS buildDocker = crossCompile || buildDocker @@ -301,6 +328,14 @@ func build(projectName string, targetOS string, vmArguments []string) { trackWidgetCreation = "--track-widget-creation" } + // must be run before `flutter build bundle` + // because `build bundle` will update the file timestamp + runPluginGet, err := shouldRunPluginGet() + if err != nil { + log.Errorf("Failed to check if plugin get should be run: %v.\n", err) + os.Exit(1) + } + cmdFlutterBuild := exec.Command(flutterBin, "build", "bundle", "--asset-dir", filepath.Join(outputDirectoryPath(targetOS), "flutter_assets"), "--target", buildTarget, @@ -318,6 +353,16 @@ func build(projectName string, targetOS string, vmArguments []string) { } } + if runPluginGet { + log.Printf("listing available plugins:") + if hoverPluginGet(true) { + log.Infof(fmt.Sprintf("run `%s`? ", log.Au().Magenta("hover plugins get"))) + if askForConfirmation() { + hoverPluginGet(false) + } + } + } + var engineFile string switch targetOS { case "darwin": diff --git a/cmd/common.go b/cmd/common.go index b36e1b1c..5f8ebe22 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -3,13 +3,19 @@ package cmd import ( "bufio" "encoding/xml" + "fmt" "io/ioutil" "os" "os/exec" "path/filepath" + "regexp" + "runtime" "strings" + "time" "github.com/go-flutter-desktop/hover/internal/log" + homedir "github.com/mitchellh/go-homedir" + "github.com/pkg/errors" "gopkg.in/yaml.v2" ) @@ -17,8 +23,11 @@ var ( goBin string flutterBin string dockerBin string + gitBin string ) +// initBinaries is used to ensure go and flutter exec are found in the +// user's path func initBinaries() { var err error goAvailable := false @@ -44,51 +53,63 @@ func initBinaries() { log.Errorf("Failed to lookup 'flutter' executable. Please install flutter.\nhttps://flutter.dev/docs/get-started/install") os.Exit(1) } + gitBin, err = exec.LookPath("git") + if err != nil { + log.Warnf("Failed to lookup 'git' executable.") + } } -// PubSpec basic model pubspec +// PubSpec contains the parsed contents of pubspec.yaml type PubSpec struct { Name string Description string Version string Author string Dependencies map[string]interface{} + Flutter map[string]interface{} } var pubspec = PubSpec{} +// getPubSpec returns the working directory pubspec.yaml as a PubSpec func getPubSpec() PubSpec { - { - if pubspec.Name == "" { - file, err := os.Open("pubspec.yaml") - if err != nil { - if os.IsNotExist(err) { - log.Errorf("Error: No pubspec.yaml file found.") - goto Fail - } - log.Errorf("Failed to open pubspec.yaml: %v", err) - os.Exit(1) - } - defer file.Close() - - err = yaml.NewDecoder(file).Decode(&pubspec) - if err != nil { - log.Errorf("Failed to decode pubspec.yaml: %v", err) - goto Fail - } - if _, exists := pubspec.Dependencies["flutter"]; !exists { - log.Errorf("Missing 'flutter' in pubspec.yaml dependencies list.") - goto Fail - } + if pubspec.Name == "" { + pub, err := readPubSpecFile("pubspec.yaml") + if err != nil { + log.Errorf("%v", err) + log.Errorf("This command should be run from the root of your Flutter project.") + os.Exit(1) } + pubspec = *pub + } + return pubspec +} - return pubspec +// readPubSpecFile reads a .yaml file at a path and return a correspond +// PubSpec struct +func readPubSpecFile(pubSpecPath string) (*PubSpec, error) { + file, err := os.Open(pubSpecPath) + if err != nil { + if os.IsNotExist(err) { + return nil, errors.Wrap(err, "Error: No pubspec.yaml file found") + } + return nil, errors.Wrap(err, "Failed to open pubspec.yaml") } + defer file.Close() -Fail: - log.Errorf("This command should be run from the root of your Flutter project.") - os.Exit(1) - return PubSpec{} + var pub PubSpec + err = yaml.NewDecoder(file).Decode(&pub) + if err != nil { + return nil, errors.Wrap(err, "Failed to decode pubspec.yaml") + } + // avoid checking for the flutter dependencies for out of ws directories + if pubSpecPath != "pubspec.yaml" { + return &pub, nil + } + if _, exists := pub.Dependencies["flutter"]; !exists { + return nil, errors.New(fmt.Sprintf("Missing `flutter` in %s dependencies list", pubSpecPath)) + } + return &pub, nil } // assertInFlutterProject asserts this command is executed in a flutter project @@ -96,6 +117,14 @@ func assertInFlutterProject() { getPubSpec() } +// assertInFlutterPluginProject asserts this command is executed in a flutter plugin project +func assertInFlutterPluginProject() { + if _, ok := getPubSpec().Flutter["plugin"]; !ok { + log.Errorf("The directory doesn't appear to contain a plugin package.\nTo create a new plugin, first run `%s`, then run `%s`.", log.Au().Magenta("flutter create --template=plugin"), log.Au().Magenta("hover init-plugin")) + os.Exit(1) + } +} + func assertHoverInitialized() { _, err := os.Stat(buildPath) if os.IsNotExist(err) { @@ -111,6 +140,7 @@ func assertHoverInitialized() { } } +// hoverMigration migrates from old hover buildPath directory to the new one ("desktop" -> "go") func hoverMigration() bool { oldBuildPath := "desktop" file, err := os.Open(filepath.Join(oldBuildPath, "go.mod")) @@ -138,7 +168,7 @@ func hoverMigration() bool { // askForConfirmation asks the user for confirmation. func askForConfirmation() bool { - log.Printf("[y/N]: ") + fmt.Print(log.Au().Bold(log.Au().Cyan("hover: ")).String() + "[y/N]? ") in := bufio.NewReader(os.Stdin) s, err := in.ReadString('\n') if err != nil { @@ -197,3 +227,90 @@ func androidOrganizationName() string { } return orgName } + +var camelcaseRegex = regexp.MustCompile("(^[A-Za-z])|_([A-Za-z])") + +// toCamelCase take a snake_case string and converts it to camelcase +func toCamelCase(str string) string { + return camelcaseRegex.ReplaceAllStringFunc(str, func(s string) string { + return strings.ToUpper(strings.Replace(s, "_", "", -1)) + }) +} + +// initializeGoModule uses the golang binary to initialize the go module +func initializeGoModule(projectPath string) { + wd, err := os.Getwd() + if err != nil { + log.Errorf("Failed to get working dir: %v\n", err) + os.Exit(1) + } + + cmdGoModInit := exec.Command(goBin, "mod", "init", projectPath+"/"+buildPath) + cmdGoModInit.Dir = filepath.Join(wd, buildPath) + cmdGoModInit.Env = append(os.Environ(), + "GO111MODULE=on", + ) + cmdGoModInit.Stderr = os.Stderr + cmdGoModInit.Stdout = os.Stdout + err = cmdGoModInit.Run() + if err != nil { + log.Errorf("Go mod init failed: %v\n", err) + os.Exit(1) + } + + cmdGoModTidy := exec.Command(goBin, "mod", "tidy") + cmdGoModTidy.Dir = filepath.Join(wd, buildPath) + log.Infof("You can add the '%s' directory to git.", cmdGoModTidy.Dir) + cmdGoModTidy.Env = append(os.Environ(), + "GO111MODULE=on", + ) + cmdGoModTidy.Stderr = os.Stderr + cmdGoModTidy.Stdout = os.Stdout + err = cmdGoModTidy.Run() + if err != nil { + log.Errorf("Go mod tidy failed: %v\n", err) + os.Exit(1) + } +} + +// findPubcachePath returns the absolute path for the pub-cache or an error. +func findPubcachePath() (string, error) { + var path string + switch runtime.GOOS { + case "darwin", "linux": + home, err := homedir.Dir() + if err != nil { + return "", errors.Wrap(err, "failed to resolve user home dir") + } + path = filepath.Join(home, ".pub-cache") + case "windows": + path = filepath.Join(os.Getenv("APPDATA"), "Pub", "Cache") + } + return path, nil +} + +// shouldRunPluginGet checks if the pubspec.yaml file is older than the +// .packages file, if it is the case, prompt the user for a hover plugin get. +func shouldRunPluginGet() (bool, error) { + file1Info, err := os.Stat("pubspec.yaml") + if err != nil { + return false, err + } + + file2Info, err := os.Stat(".packages") + if err != nil { + if os.IsNotExist(err) { + return true, nil + } + return false, err + } + modTime1 := file1Info.ModTime() + modTime2 := file2Info.ModTime() + + diff := modTime1.Sub(modTime2) + + if diff > (time.Duration(0) * time.Second) { + return true, nil + } + return false, nil +} diff --git a/cmd/create-plugin.go b/cmd/create-plugin.go deleted file mode 100644 index e9ccaa40..00000000 --- a/cmd/create-plugin.go +++ /dev/null @@ -1,14 +0,0 @@ -package cmd - -// func init() { -// rootCmd.AddCommand(createPluginCmd) -// } - -// var createPluginCmd = &cobra.Command{ -// Use: "create-plugin", -// Short: "Create a new plugin for use with go-flutter applications", -// Run: func(cmd *cobra.Command, args []string) { -// fmt.Println("Not implemented") -// os.Exit(1) -// }, -// } diff --git a/cmd/init-plugin.go b/cmd/init-plugin.go new file mode 100644 index 00000000..28b3049c --- /dev/null +++ b/cmd/init-plugin.go @@ -0,0 +1,54 @@ +package cmd + +import ( + "errors" + "os" + "path/filepath" + + "github.com/go-flutter-desktop/hover/internal/fileutils" + "github.com/go-flutter-desktop/hover/internal/log" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(createPluginCmd) +} + +var createPluginCmd = &cobra.Command{ + Use: "init-plugin", + Short: "Initialize a go-flutter plugin in a existing flutter platform plugin", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) != 1 { + return errors.New("requires one argument, the VCS repository path. e.g.: github.com/my-organization/" + getPubSpec().Name + "\n" + + "This path will be used by Golang to fetch the plugin, make sure it correspond to the code repository of the plugin!") + } + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + assertInFlutterPluginProject() + + vcsPath := args[0] + + err := os.Mkdir(buildPath, 0775) + if err != nil { + if os.IsExist(err) { + log.Errorf("A file or directory named `" + buildPath + "` already exists. Cannot continue init-plugin.") + os.Exit(1) + } + log.Errorf("Failed to create '%s' directory: %v", buildPath, err) + os.Exit(1) + } + + templateData := map[string]string{ + "pluginName": getPubSpec().Name, + "structName": toCamelCase(getPubSpec().Name + "Plugin"), + "urlVSCRepo": vcsPath, + } + + fileutils.CopyTemplate("plugin/plugin.go.tmpl", filepath.Join(buildPath, "plugin.go"), assetsBox, templateData) + fileutils.CopyTemplate("plugin/README.md.tmpl", filepath.Join(buildPath, "README.md"), assetsBox, templateData) + fileutils.CopyTemplate("plugin/import.go.tmpl.tmpl", filepath.Join(buildPath, "import.go.tmpl"), assetsBox, templateData) + + initializeGoModule(vcsPath) + }, +} diff --git a/cmd/init.go b/cmd/init.go index 64674ba5..ce3b1a78 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -2,11 +2,10 @@ package cmd import ( "errors" - "io" "os" - "os/exec" "path/filepath" + "github.com/go-flutter-desktop/hover/internal/fileutils" "github.com/go-flutter-desktop/hover/internal/log" "github.com/spf13/cobra" ) @@ -40,6 +39,8 @@ var initCmd = &cobra.Command{ log.Errorf("A file or directory named '%s' already exists. Cannot continue init.", buildPath) os.Exit(1) } + log.Errorf("Failed to create '%s' directory: %v", buildPath, err) + os.Exit(1) } desktopCmdPath := filepath.Join(buildPath, "cmd") @@ -56,62 +57,13 @@ var initCmd = &cobra.Command{ os.Exit(1) } - copyAsset("app/main.go", filepath.Join(desktopCmdPath, "main.go")) - copyAsset("app/options.go", filepath.Join(desktopCmdPath, "options.go")) - copyAsset("app/icon.png", filepath.Join(desktopAssetsPath, "icon.png")) - copyAsset("app/gitignore", filepath.Join(buildPath, ".gitignore")) - - wd, err := os.Getwd() - if err != nil { - log.Errorf("Failed to get working dir: %v", err) - os.Exit(1) - } - - cmdGoModInit := exec.Command(goBin, "mod", "init", projectPath+"/"+buildPath) - cmdGoModInit.Dir = filepath.Join(wd, buildPath) - cmdGoModInit.Env = append(os.Environ(), - "GO111MODULE=on", - ) - cmdGoModInit.Stderr = os.Stderr - cmdGoModInit.Stdout = os.Stdout - err = cmdGoModInit.Run() - if err != nil { - log.Errorf("Go mod init failed: %v", err) - os.Exit(1) - } + fileutils.CopyAsset("app/main.go", filepath.Join(desktopCmdPath, "main.go"), assetsBox) + fileutils.CopyAsset("app/options.go", filepath.Join(desktopCmdPath, "options.go"), assetsBox) + fileutils.CopyAsset("app/icon.png", filepath.Join(desktopAssetsPath, "icon.png"), assetsBox) + fileutils.CopyAsset("app/gitignore", filepath.Join(buildPath, ".gitignore"), assetsBox) - cmdGoModTidy := exec.Command(goBin, "mod", "tidy") - cmdGoModTidy.Dir = filepath.Join(wd, buildPath) - log.Printf(cmdGoModTidy.Dir) - cmdGoModTidy.Env = append(os.Environ(), - "GO111MODULE=on", - ) - cmdGoModTidy.Stderr = os.Stderr - cmdGoModTidy.Stdout = os.Stdout - err = cmdGoModTidy.Run() - if err != nil { - log.Errorf("Go mod tidy failed: %v", err) - os.Exit(1) - } + initializeGoModule(projectPath) + log.Printf("Available plugin for this project:") + pluginListCmd.Run(cmd, []string{}) }, } - -func copyAsset(boxed, to string) { - file, err := os.Create(to) - if err != nil { - log.Errorf("Failed to create %s: %v", to, err) - os.Exit(1) - } - defer file.Close() - boxedFile, err := assetsBox.Open(boxed) - if err != nil { - log.Errorf("Failed to find boxed file %s: %v", boxed, err) - os.Exit(1) - } - defer boxedFile.Close() - _, err = io.Copy(file, boxedFile) - if err != nil { - log.Errorf("Failed to write file %s: %v", to, err) - os.Exit(1) - } -} diff --git a/cmd/plugins.go b/cmd/plugins.go new file mode 100644 index 00000000..68f33b85 --- /dev/null +++ b/cmd/plugins.go @@ -0,0 +1,535 @@ +package cmd + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + "time" + + "github.com/go-flutter-desktop/hover/internal/fileutils" + "github.com/go-flutter-desktop/hover/internal/log" + "github.com/pkg/errors" + "github.com/spf13/cobra" + yaml "gopkg.in/yaml.v2" +) + +const standaloneImplementationListAPI = "https://raw.githubusercontent.com/go-flutter-desktop/plugins/master/list.json" + +var ( + listAllPluginDependencies bool + tidyPurge bool + dryRun bool + reImport bool +) + +func init() { + pluginTidyCmd.Flags().BoolVar(&tidyPurge, "purge", false, "Remove all go platform plugins imports from the project.") + pluginListCmd.Flags().BoolVarP(&listAllPluginDependencies, "all", "a", false, "List all platform plugins dependencies, even the one have no go-flutter support") + pluginGetCmd.Flags().BoolVar(&reImport, "force", false, "Re-import already imported plugins.") + + pluginCmd.PersistentFlags().BoolVarP(&dryRun, "dry-run", "n", false, "Perform a trial run with no changes made.") + + pluginCmd.AddCommand(pluginListCmd) + pluginCmd.AddCommand(pluginGetCmd) + pluginCmd.AddCommand(pluginTidyCmd) + rootCmd.AddCommand(pluginCmd) +} + +var pluginCmd = &cobra.Command{ + Use: "plugins", + Short: "Tools for plugins", + Long: "A collection of commands to help with finding/importing go-flutter implementations of plugins.", +} + +// PubSpecLock contains the parsed contents of pubspec.lock +type PubSpecLock struct { + Packages map[string]PubDep +} + +// PubDep contains one entry of the pubspec.lock yaml list +type PubDep struct { + Dependency string + Description interface{} + Source string + Version string + + // Fields set by hover + name string + android bool + ios bool + desktop bool + // optional description values + path string // correspond to the path field in lock file + host string // correspond to the host field in lock file + // contain a import.go.tmpl file used for import + autoImport bool + // the path/URL to the go code of the plugin is stored + pluginGoSource string + // whether or not the go plugin source code is located on another VCS repo. + standaloneImpl bool +} + +func (p PubDep) imported() bool { + pluginImportOutPath := filepath.Join(buildPath, "cmd", fmt.Sprintf("import-%s-plugin.go", p.name)) + if _, err := os.Stat(pluginImportOutPath); err == nil { + return true + } + return false +} + +func (p PubDep) platforms() []string { + var platforms []string + if p.android { + platforms = append(platforms, "android") + } + if p.ios { + platforms = append(platforms, "ios") + } + if p.desktop { + platforms = append(platforms, buildPath) + } + return platforms +} + +var pluginListCmd = &cobra.Command{ + Use: "list", + Short: "List golang platform plugins in the application", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + return errors.New("does not take arguments") + } + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + assertInFlutterProject() + dependencyList, err := listPlatformPlugin() + if err != nil { + log.Errorf("%v", err) + os.Exit(1) + } + + var hasNewPlugin bool + var hasPlugins bool + for _, dep := range dependencyList { + if !(dep.desktop || listAllPluginDependencies) { + continue + } + + if hasPlugins { + fmt.Println("") + } + hasPlugins = true + + fmt.Printf(" - %s\n", dep.name) + fmt.Printf(" version: %s\n", dep.Version) + fmt.Printf(" platforms: [%s]\n", strings.Join(dep.platforms(), ", ")) + if dep.desktop { + if dep.standaloneImpl { + fmt.Printf(" source: This go plugin isn't maintained by the official plugin creator.\n") + } + if dep.imported() { + fmt.Println(" import: [OK] The plugin is already imported in the project.") + continue + } + if dep.autoImport || dep.standaloneImpl { + hasNewPlugin = true + fmt.Println(" import: [Missing] The plugin can be imported by hover.") + } else { + fmt.Println(" import: [Manual import] The plugin is missing the import.go.tmpl file required for hover import.") + } + if dep.path != "" { + fmt.Printf(" dev: Plugin replaced in go.mod to path: '%s'\n", dep.path) + } + } + } + if hasNewPlugin { + log.Infof(fmt.Sprintf("run `%s` to import the missing plugins!", log.Au().Magenta("hover plugins get"))) + } + }, +} + +var pluginTidyCmd = &cobra.Command{ + Use: "tidy", + Short: "Removes unused platform plugins.", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + return errors.New("does not take arguments") + } + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + assertInFlutterProject() + assertHoverInitialized() + + desktopCmdPath := filepath.Join(buildPath, "cmd") + dependencyList, err := listPlatformPlugin() + if err != nil { + log.Errorf("%v", err) + os.Exit(1) + } + + importedPlugins, err := ioutil.ReadDir(desktopCmdPath) + if err != nil { + log.Errorf("Failed to search for plugins: %v", err) + os.Exit(1) + } + + for _, f := range importedPlugins { + isPlugin := strings.HasPrefix(f.Name(), "import-") + isPlugin = isPlugin && strings.HasSuffix(f.Name(), "-plugin.go") + + if isPlugin { + pluginName := strings.TrimPrefix(f.Name(), "import-") + pluginName = strings.TrimSuffix(pluginName, "-plugin.go") + + pluginInUse := false + for _, dep := range dependencyList { + if dep.name == pluginName { + // plugin in pubspec.lock + pluginInUse = true + break + } + } + + if !pluginInUse || tidyPurge { + if dryRun { + fmt.Printf(" plugin: [%s] can be removed\n", pluginName) + continue + } + pluginImportPath := filepath.Join(desktopCmdPath, f.Name()) + + // clean-up go.mod + pluginImportStr, _ := readPluginGoImport(pluginImportPath, pluginName) + // Delete the 'replace' and 'require' import strings from go.mod. + // Not mission critical, if the plugins not correctly removed from + // the go.mod file, the project still works and the plugin is + // successfully removed from the flutter.Application. + if err != nil || pluginImportStr == "" { + log.Warnf("Couldn't clean the '%s' plugin from the 'go.mod' file. Error: %v", pluginName, err) + } else { + fileutils.RemoveLinesFromFile(filepath.Join(buildPath, "go.mod"), pluginImportStr) + } + + // remove import file + err = os.Remove(pluginImportPath) + if err != nil { + log.Warnf("Couldn't remove plugin %s: %v", pluginName, err) + continue + } + fmt.Printf(" plugin: [%s] removed\n", pluginName) + } + } + } + }, +} + +var pluginGetCmd = &cobra.Command{ + Use: "get", + Short: "Imports missing platform plugins in the application", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) > 0 { + return errors.New("does not take arguments") + } + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + assertInFlutterProject() + assertHoverInitialized() + hoverPluginGet(false) + }, +} + +func hoverPluginGet(dryRun bool) bool { + dependencyList, err := listPlatformPlugin() + if err != nil { + log.Errorf("%v", err) + os.Exit(1) + } + + for _, dep := range dependencyList { + + if !dep.desktop { + continue + } + + if !dep.autoImport { + fmt.Printf(" plugin: [%s] couldn't be imported, check the plugin's README for manual instructions\n", dep.name) + continue + } + + if dryRun { + if dep.imported() { + fmt.Printf(" plugin: [%s] can be updated\n", dep.name) + } else { + fmt.Printf(" plugin: [%s] can be imported\n", dep.name) + } + continue + } + + pluginImportOutPath := filepath.Join(buildPath, "cmd", fmt.Sprintf("import-%s-plugin.go", dep.name)) + + if dep.imported() && !reImport { + pluginImportStr, err := readPluginGoImport(pluginImportOutPath, dep.name) + if err != nil { + log.Warnf("Couldn't read the plugin '%s' import URL", dep.name) + log.Warnf("Fallback to the latest version installed.") + continue + } + + if !goGetModuleSuccess(pluginImportStr, dep.Version) { + log.Warnf("Couldn't download version '%s' of plugin '%s'", dep.Version, dep.name) + log.Warnf("Fallback to the latest version installed.") + continue + } + + fmt.Printf(" plugin: [%s] updated\n", dep.name) + continue + } + + if dep.standaloneImpl { + fileutils.DownloadFile(dep.pluginGoSource, pluginImportOutPath) + } else { + autoImportTemplatePath := filepath.Join(dep.pluginGoSource, "import.go.tmpl") + fileutils.CopyFile(autoImportTemplatePath, pluginImportOutPath) + + pluginImportStr, err := readPluginGoImport(pluginImportOutPath, dep.name) + if err != nil { + log.Warnf("Couldn't read the plugin '%s' import URL", dep.name) + log.Warnf("Fallback to the latest version available on github.") + continue + } + + // if remote plugin, get the correct version + if dep.path == "" { + if !goGetModuleSuccess(pluginImportStr, dep.Version) { + log.Warnf("Couldn't download version '%s' of plugin '%s'", dep.Version, dep.name) + log.Warnf("Fallback to the latest version available on github.") + } + } + + // if local plugin + if dep.path != "" { + path, err := filepath.Abs(filepath.Join(dep.path, "go")) + if err != nil { + log.Errorf("Failed to resolve absolute path for plugin '%s': %v", dep.name, err) + os.Exit(1) + } + fileutils.AddLineToFile(filepath.Join(buildPath, "go.mod"), fmt.Sprintf("replace %s => %s", pluginImportStr, path)) + } + + fmt.Printf(" plugin: [%s] imported\n", dep.name) + } + } + + return len(dependencyList) != 0 +} + +func listPlatformPlugin() ([]PubDep, error) { + onlineList, err := fetchStandaloneImplementationList() + if err != nil { + log.Warnf("Warning, couldn't read the online plugin list: %v", err) + } + + pubcachePath, err := findPubcachePath() + if err != nil { + return nil, errors.Wrap(err, "failed to find path for pub-cache") + } + + var list []PubDep + pubLock, err := readPubSpecLock() + + for name, entry := range pubLock.Packages { + entry.name = name + + switch i := entry.Description.(type) { + case string: + if i == "flutter" { + continue + } + case map[interface{}]interface{}: + if value, ok := i["path"]; ok { + entry.path = value.(string) + } + if value, ok := i["url"]; ok { + url, err := url.Parse(value.(string)) + if err != nil { + return nil, errors.Wrap(err, "failed to parse URL from string %s"+value.(string)) + } + entry.host = url.Host + } + } + + pluginPath := filepath.Join(pubcachePath, "hosted", entry.host, entry.name+"-"+entry.Version) + if entry.path != "" { + pluginPath = entry.path + } + + pluginPubspecPath := filepath.Join(pluginPath, "pubspec.yaml") + pluginPubspec, err := readPubSpecFile(pluginPubspecPath) + if err != nil { + continue + } + + // Non plugin package are likely to contain android/ios folders (even + // through they aren't used). + // To check if the package is really a platform plugin, we need to read + // the pubspec.yaml file. If he contains a Flutter/plugin entry, then + // it's a platform plugin. + if _, ok := pluginPubspec.Flutter["plugin"]; !ok { + continue + } + + detectPlatformPlugin := func(platform string) (bool, error) { + platformPath := filepath.Join(pluginPath, platform) + stat, err := os.Stat(platformPath) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, errors.Wrapf(err, "failed to stat %s", platformPath) + } + return stat.IsDir(), nil + } + + entry.android, err = detectPlatformPlugin("android") + if err != nil { + return nil, err + } + entry.ios, err = detectPlatformPlugin("ios") + if err != nil { + return nil, err + } + entry.desktop, err = detectPlatformPlugin(buildPath) + if err != nil { + return nil, err + } + + if entry.desktop { + entry.pluginGoSource = filepath.Join(pluginPath, buildPath) + autoImportTemplate := filepath.Join(entry.pluginGoSource, "import.go.tmpl") + _, err := os.Stat(autoImportTemplate) + entry.autoImport = true + if err != nil { + entry.autoImport = false + if !os.IsNotExist(err) { + return nil, errors.Wrapf(err, "failed to stat %s", autoImportTemplate) + } + } + } else { + // check if the plugin is available in github.com/go-flutter-desktop/plugins + for _, plugin := range onlineList { + if entry.name == plugin.Name { + entry.desktop = true + entry.standaloneImpl = true + entry.autoImport = true + entry.pluginGoSource = plugin.ImportFile + break + } + } + } + + list = append(list, entry) + + } + return list, nil +} + +// readLocal reads pubspec.lock in the current working directory. +func readPubSpecLock() (*PubSpecLock, error) { + p := &PubSpecLock{} + file, err := os.Open("pubspec.lock") + if err != nil { + if os.IsNotExist(err) { + return nil, errors.New("no pubspec.lock file found") + + } + return nil, errors.Wrap(err, "failed to open pubspec.lock") + } + defer file.Close() + + err = yaml.NewDecoder(file).Decode(p) + if err != nil { + return nil, errors.Wrap(err, "failed to decode pubspec.lock") + } + return p, nil +} + +func readPluginGoImport(pluginImportOutPath, pluginName string) (string, error) { + pluginImportBytes, err := ioutil.ReadFile(pluginImportOutPath) + if err != nil && !os.IsNotExist(err) { + return "", err + } + + re := regexp.MustCompile(fmt.Sprintf(`\s+%s\s"(\S*)"`, pluginName)) + + match := re.FindStringSubmatch(string(pluginImportBytes)) + if len(match) < 2 { + err = errors.New("Failed to parse the import path, plugin name in the import must have been changed") + return "", err + } + return match[1], nil +} + +type onlineList struct { + List []StandaloneImplementation `json:"standaloneImplementation"` +} + +// StandaloneImplementation contains the go-flutter compatible plugins that +// aren't merged into original VSC repo. +type StandaloneImplementation struct { + Name string `json:"name"` + ImportFile string `json:"importFile"` +} + +func fetchStandaloneImplementationList() ([]StandaloneImplementation, error) { + remoteList := &onlineList{} + + client := http.Client{ + Timeout: time.Second * 20, // Maximum of 10 secs + } + + req, err := http.NewRequest(http.MethodGet, standaloneImplementationListAPI, nil) + if err != nil { + return remoteList.List, err + } + + res, err := client.Do(req) + if err != nil { + return remoteList.List, err + } + + body, err := ioutil.ReadAll(res.Body) + if err != nil { + return remoteList.List, err + } + + if res.StatusCode != 200 { + return remoteList.List, errors.New(strings.TrimRight(string(body), "\r\n")) + } + + err = json.Unmarshal(body, remoteList) + if err != nil { + return remoteList.List, err + } + return remoteList.List, nil +} + +// goGetModuleSuccess updates a module at a version, if it fails, return false. +func goGetModuleSuccess(pluginImportStr, version string) bool { + cmdGoGetU := exec.Command(goBin, "get", "-u", pluginImportStr+"@v"+version) + cmdGoGetU.Dir = filepath.Join(buildPath) + cmdGoGetU.Env = append(os.Environ(), + "GOPROXY=direct", // github.com/golang/go/issues/32955 (allows '/' in branch name) + "GO111MODULE=on", + ) + cmdGoGetU.Stderr = os.Stderr + cmdGoGetU.Stdout = os.Stdout + return cmdGoGetU.Run() == nil +} diff --git a/cmd/publish-plugin.go b/cmd/publish-plugin.go new file mode 100644 index 00000000..114ee6be --- /dev/null +++ b/cmd/publish-plugin.go @@ -0,0 +1,132 @@ +package cmd + +import ( + "errors" + "fmt" + "net/url" + "os" + "os/exec" + "path/filepath" + "regexp" + "strings" + + "github.com/go-flutter-desktop/hover/internal/log" + "github.com/spf13/cobra" +) + +func init() { + rootCmd.AddCommand(publishPluginCmd) +} + +var publishPluginCmd = &cobra.Command{ + Use: "publish-plugin", + Short: "Publish your go-flutter plugin as golang module in your github repo.", + Args: func(cmd *cobra.Command, args []string) error { + if len(args) != 0 { + return errors.New("does not take arguments") + } + return nil + }, + Run: func(cmd *cobra.Command, args []string) { + assertInFlutterPluginProject() + + if goBin == "" { + log.Errorf("Failed to lookup `git` executable. Please install git") + os.Exit(1) + } + + // check if dir 'go' is tracked + goCheckTrackedCmd := exec.Command(gitBin, "ls-files", "--error-unmatch", buildPath) + goCheckTrackedCmd.Stderr = os.Stderr + err := goCheckTrackedCmd.Run() + if err != nil { + log.Errorf("The '%s' directory doesn't seems to be tracked by git. Error: %v", buildPath, err) + os.Exit(1) + } + + // check if dir 'go' is clean (all tracked files are commited) + goCheckCleanCmd := exec.Command(gitBin, "status", "--untracked-file=no", "--porcelain", buildPath) + goCheckCleanCmd.Stderr = os.Stderr + cleanOut, err := goCheckCleanCmd.Output() + if err != nil { + log.Errorf("Failed to check if '%s' is clean.", buildPath, err) + os.Exit(1) + } + if len(cleanOut) != 0 { + log.Errorf("The '%s' directory doesn't seems to be clean. (make sure tracked files are commited)", buildPath) + os.Exit(1) + } + + // check if one of the git remote urls equals the package import 'url' + pluginImportStr, err := readPluginGoImport(filepath.Join("go", "import.go.tmpl"), getPubSpec().Name) + if err != nil { + log.Errorf("Failed to read the plugin import url: %v", err) + log.Infof("The file go/import.go.tmpl should look something like this:") + fmt.Printf(`package main + +import ( + flutter "github.com/go-flutter-desktop/go-flutter" + %s "github.com/my-organization/%s/go" +) + +// .. [init function] .. + `, getPubSpec().Name, getPubSpec().Name) + os.Exit(1) + } + url, err := url.Parse("https://" + pluginImportStr) + if err != nil { + log.Errorf("Failed to parse %s: %v", pluginImportStr, err) + os.Exit(1) + } + // from go import string "github.com/my-organization/test_hover/go" + // check if `git remote -v` has a match on: + // origin ?github.com?my-organization/test_hover.git + // this regex works on https and ssh remotes. + path := strings.TrimPrefix(url.Path, "/") + path = strings.TrimSuffix(path, "/go") + re := regexp.MustCompile(`(\w+)\s+(\S+)` + url.Host + "." + path + ".git") + goCheckRemote := exec.Command(gitBin, "remote", "-v") + goCheckRemote.Stderr = os.Stderr + remoteOut, err := goCheckRemote.Output() + if err != nil { + log.Errorf("Failed to get git remotes: %v", err) + os.Exit(1) + } + match := re.FindStringSubmatch(string(remoteOut)) + if len(match) < 1 { + log.Errorf("At least one git remote urls must matchs the plugin golang import URL.") + log.Printf("go import URL: %s", pluginImportStr) + log.Printf("git remote -v:\n%s\n", string(remoteOut)) + goCheckRemote.Stdout = os.Stdout + os.Exit(1) + } + + tag := "go/v" + getPubSpec().Version + + log.Infof("Your plugin at version '%s' is ready to be publish as a golang module.", getPubSpec().Version) + log.Infof("Please run: `%s`", log.Au().Magenta("git tag "+tag)) + log.Infof(" `%s`", log.Au().Magenta("git push "+match[1]+" "+tag)) + + log.Infof(fmt.Sprintf("Let hover run those commands? ")) + if askForConfirmation() { + gitTag := exec.Command(gitBin, "tag", tag) + gitTag.Stderr = os.Stderr + gitTag.Stdout = os.Stdout + err = gitTag.Run() + if err != nil { + log.Errorf("The git command '%s' failed. Error: %v", gitTag.String(), err) + os.Exit(1) + } + + gitPush := exec.Command(gitBin, "push", match[1], tag) + gitPush.Stderr = os.Stderr + gitPush.Stdout = os.Stdout + err = gitPush.Run() + if err != nil { + log.Errorf("The git command '%s' failed. Error: %v", gitPush.String(), err) + os.Exit(1) + } + } + + }, +} diff --git a/go.sum b/go.sum index 3fe49b6d..fdbea4cd 100644 --- a/go.sum +++ b/go.sum @@ -37,14 +37,12 @@ github.com/otiai10/copy v1.0.2/go.mod h1:c7RpqBkwMom4bYTSkLSym4VSJz/XtncWRAj/J4P github.com/otiai10/curr v0.0.0-20150429015615-9b4961190c95/go.mod h1:9qAhocn7zKJG+0mI8eUu6xqkFDYS2kb2saOteoSB3cE= github.com/otiai10/curr v0.0.0-20190513014714-f5a3d24e5776 h1:o59bHXu8Ejas8Kq6pjoVJQ9/neN66SM8AKh6wI42BBs= github.com/otiai10/curr v0.0.0-20190513014714-f5a3d24e5776/go.mod h1:3HNVkVOU7vZeFXocWuvtcS0XSFLcf2XUSDHkq9t1jU4= -github.com/otiai10/mint v1.2.4 h1:DxYL0itZyPaR5Z9HILdxSoHx+gNs6Yx+neOGS3IVUk0= github.com/otiai10/mint v1.2.4/go.mod h1:d+b7n/0R3tdyUYYylALXpWQ/kTN+QobSq/4SRGBkR3M= github.com/otiai10/mint v1.3.0 h1:Ady6MKVezQwHBkGzLFbrsywyp09Ah7rkmfjV3Bcr5uc= github.com/otiai10/mint v1.3.0/go.mod h1:F5AjcsTsWUqX+Na9fpHb52P8pcRX2CI6A3ctIT91xUo= github.com/pelletier/go-toml v1.2.0/go.mod h1:5z9KED0ma1S8pY6P1sdut58dfprrGBbd/94hg7ilaic= github.com/pkg/errors v0.8.1 h1:iURUrRGxPUNPdy5/HRSm+Yj6okJ6UtLINN0Q9M4+h3I= github.com/pkg/errors v0.8.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= -github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/russross/blackfriday v1.5.2/go.mod h1:JO/DiYxRf+HjHt06OyowR9PTA263kcR/rfWxYHBV53g= github.com/spf13/afero v1.1.2/go.mod h1:j4pytiNVoe2o6bmDsKpLACNPDBIoEAkihy7loJ1B0CQ= diff --git a/internal/fileutils/file.go b/internal/fileutils/file.go new file mode 100644 index 00000000..e14921c8 --- /dev/null +++ b/internal/fileutils/file.go @@ -0,0 +1,155 @@ +package fileutils + +import ( + "io" + "io/ioutil" + "net/http" + "os" + "strings" + "text/template" + + rice "github.com/GeertJohan/go.rice" + "github.com/go-flutter-desktop/hover/internal/log" +) + +// RemoveLinesFromFile removes lines to a file if the text is present in the line +func RemoveLinesFromFile(filePath, text string) { + input, err := ioutil.ReadFile(filePath) + if err != nil { + log.Errorf("Failed to read file %s: %v\n", filePath, err) + os.Exit(1) + } + + lines := strings.Split(string(input), "\n") + + tmp := lines[:0] + for _, line := range lines { + if !strings.Contains(line, text) { + tmp = append(tmp, line) + } + } + output := strings.Join(tmp, "\n") + err = ioutil.WriteFile(filePath, []byte(output), 0644) + if err != nil { + log.Errorf("Failed to write file %s: %v\n", filePath, err) + os.Exit(1) + } +} + +// AddLineToFile appends a newLine to a file if the line isn't +// already present. +func AddLineToFile(filePath, newLine string) { + f, err := os.OpenFile(filePath, + os.O_RDWR|os.O_APPEND, 0660) + if err != nil { + log.Errorf("Failed to open file %s: %v\n", filePath, err) + os.Exit(1) + } + defer f.Close() + content, err := ioutil.ReadAll(f) + if err != nil { + log.Errorf("Failed to read file %s: %v\n", filePath, err) + os.Exit(1) + } + lines := make(map[string]struct{}) + for _, w := range strings.Split(string(content), "\n") { + lines[w] = struct{}{} + } + _, ok := lines[newLine] + if ok { + return + } + if _, err := f.WriteString(newLine + "\n"); err != nil { + log.Errorf("Failed to append '%s' to the file (%s): %v\n", newLine, filePath, err) + os.Exit(1) + } +} + +// CopyFile from one file to another +func CopyFile(src, to string) { + in, err := os.Open(src) + if err != nil { + log.Errorf("Failed to read %s: %v\n", src, err) + os.Exit(1) + } + defer in.Close() + file, err := os.Create(to) + if err != nil { + log.Errorf("Failed to create %s: %v\n", to, err) + os.Exit(1) + } + defer file.Close() + + _, err = io.Copy(file, in) + if err != nil { + log.Errorf("Failed to copy %s to %s: %v\n", src, to, err) + os.Exit(1) + } +} + +// CopyTemplate create file from a tempalte asset +func CopyTemplate(boxed, to string, assetsBox *rice.Box, templateData interface{}) { + templateString, err := assetsBox.String(boxed) + if err != nil { + log.Errorf("Failed to find plugin template file: %v\n", err) + os.Exit(1) + } + tmplFile, err := template.New("").Parse(templateString) + if err != nil { + log.Errorf("Failed to parse plugin template file: %v\n", err) + os.Exit(1) + } + + toFile, err := os.Create(to) + if err != nil { + log.Errorf("Failed to create '%s': %v\n", to, err) + os.Exit(1) + } + defer toFile.Close() + + tmplFile.Execute(toFile, templateData) +} + +// CopyAsset copies a file from asset +func CopyAsset(boxed, to string, assetsBox *rice.Box) { + file, err := os.Create(to) + if err != nil { + log.Errorf("Failed to create %s: %v", to, err) + os.Exit(1) + } + defer file.Close() + boxedFile, err := assetsBox.Open(boxed) + if err != nil { + log.Errorf("Failed to find boxed file %s: %v", boxed, err) + os.Exit(1) + } + defer boxedFile.Close() + _, err = io.Copy(file, boxedFile) + if err != nil { + log.Errorf("Failed to write file %s: %v", to, err) + os.Exit(1) + } +} + +// DownloadFile will download a url to a local file. +func DownloadFile(url string, filepath string) { + resp, err := http.Get(url) + if err != nil { + log.Errorf("Failed to download '%v': %v\n", url, err) + os.Exit(1) + } + defer resp.Body.Close() + + out, err := os.Create(filepath) + if err != nil { + log.Errorf("Failed to create file '%s': %v\n", filepath, err) + os.Exit(1) + } + defer out.Close() + + _, err = io.Copy(out, resp.Body) + if err != nil { + log.Errorf("Failed to write file '%s': %v\n", filepath, err) + os.Exit(1) + } +} diff --git a/internal/versioncheck/version.go b/internal/versioncheck/version.go index 1255a212..461c44fc 100644 --- a/internal/versioncheck/version.go +++ b/internal/versioncheck/version.go @@ -9,6 +9,7 @@ import ( "strings" "time" + "github.com/go-flutter-desktop/hover/internal/fileutils" "github.com/go-flutter-desktop/hover/internal/log" "github.com/pkg/errors" "github.com/tcnksm/go-latest" @@ -40,7 +41,7 @@ func CheckForGoFlutterUpdate(goDirectoryPath string, currentTag string) { // If needed, update the hover's .gitignore file with a new entry. hoverGitignore := filepath.Join(goDirectoryPath, ".gitignore") - addLineToFile(hoverGitignore, ".last_goflutter_check") + fileutils.AddLineToFile(hoverGitignore, ".last_goflutter_check") return } @@ -120,32 +121,3 @@ func CurrentGoFlutterTag(goDirectoryPath string) (currentTag string, err error) currentTag = match[1] return } - -// addLineToFile appends a newLine to a file if the line isn't -// already present. -func addLineToFile(filePath, newLine string) { - f, err := os.OpenFile(filePath, - os.O_RDWR|os.O_APPEND, 0660) - if err != nil { - log.Errorf("Failed to open file %s: %v", filePath, err) - os.Exit(1) - } - defer f.Close() - content, err := ioutil.ReadAll(f) - if err != nil { - log.Errorf("Failed to read file %s: %v", filePath, err) - os.Exit(1) - } - words := make(map[string]struct{}) - for _, w := range strings.Fields(strings.ToLower(string(content))) { - words[w] = struct{}{} - } - _, ok := words[newLine] - if ok { - return - } - if _, err := f.WriteString(newLine + "\n"); err != nil { - log.Errorf("Failed to append '%s' to the file (%s): %v", newLine, filePath, err) - os.Exit(1) - } -}