diff --git a/mkdocs-website/shared/alpha2.csv b/mkdocs-website/shared/alpha2.csv index 2dd5262c0..a65bac128 100644 --- a/mkdocs-website/shared/alpha2.csv +++ b/mkdocs-website/shared/alpha2.csv @@ -2,4 +2,4 @@ `wails init`,:material-check-bold:,:material-check-bold:,:material-check-bold: `wails build`,:material-check-bold:,:material-check-bold:,:material-check-bold: `wails dev`," "," "," " -`wails package`," ",:material-check-bold:," " +`wails package`," ",:material-check-bold:,:material-check-bold: diff --git a/v3/cmd/wails3/main.go b/v3/cmd/wails3/main.go index 6472556b2..b993072d3 100644 --- a/v3/cmd/wails3/main.go +++ b/v3/cmd/wails3/main.go @@ -45,6 +45,8 @@ func main() { generate.NewSubCommandFunction("bindings", "Generate bindings + models", commands.GenerateBindings) generate.NewSubCommandFunction("constants", "Generate JS constants from Go", commands.GenerateConstants) generate.NewSubCommandFunction(".desktop", "Generate .desktop file", commands.GenerateDotDesktop) + generate.NewSubCommandFunction("appimage", "Generate Linux AppImage", commands.GenerateAppImage) + plugin := app.NewSubCommand("plugin", "Plugin tools") //plugin.NewSubCommandFunction("list", "List plugins", commands.PluginList) plugin.NewSubCommandFunction("init", "Initialise a new plugin", commands.PluginInit) diff --git a/v3/go.mod b/v3/go.mod index a94caafd5..a40732f36 100644 --- a/v3/go.mod +++ b/v3/go.mod @@ -10,6 +10,7 @@ require ( github.com/go-task/task/v3 v3.31.0 github.com/godbus/dbus/v5 v5.1.0 github.com/google/go-cmp v0.5.9 + github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 github.com/google/uuid v1.3.0 github.com/gorilla/pat v1.0.1 github.com/gorilla/sessions v1.2.1 diff --git a/v3/go.sum b/v3/go.sum index a867c1c65..88b9feb81 100644 --- a/v3/go.sum +++ b/v3/go.sum @@ -173,6 +173,8 @@ github.com/google/pprof v0.0.0-20200905233945-acf8798be1f7/go.mod h1:ZgVRPoUq/hf github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26 h1:Xim43kblpZXfIBQsbuBVKCudVG457BR2GZFIz3uw3hQ= github.com/google/pprof v0.0.0-20221118152302-e6195bd50e26/go.mod h1:dDKJzRmX4S37WGHujM7tX//fmj1uioxKzKxz3lo4HJo= github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= +github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= github.com/google/uuid v1.3.0 h1:t6JiXgmwXMjEs8VusXIJk2BXHsn+wx8BZdTaoZ5fu7I= github.com/google/uuid v1.3.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg= diff --git a/v3/internal/commands/appimage.go b/v3/internal/commands/appimage.go new file mode 100644 index 000000000..90b7599ff --- /dev/null +++ b/v3/internal/commands/appimage.go @@ -0,0 +1,173 @@ +package commands + +import ( + _ "embed" + "fmt" + "github.com/pterm/pterm" + "github.com/wailsapp/wails/v3/internal/s" + "os" + "path/filepath" + "sync" +) + +//go:embed linuxdeploy-plugin-gtk.sh +var gtkPlugin []byte + +func log(p *pterm.ProgressbarPrinter, message string) { + p.UpdateTitle(message) + pterm.Info.Println(message) + p.Increment() +} + +type GenerateAppImageOptions struct { + Binary string `description:"The binary to package including path"` + Icon string `description:"Path to the icon"` + DesktopFile string `description:"Path to the desktop file"` + OutputDir string `description:"Path to the output directory" default:"."` + BuildDir string `description:"Path to the build directory"` +} + +func GenerateAppImage(options *GenerateAppImageOptions) error { + + defer func() { + pterm.DefaultSpinner.Stop() + }() + + if options.Binary == "" { + return fmt.Errorf("binary not provided") + } + if options.Icon == "" { + return fmt.Errorf("icon path not provided") + } + if options.DesktopFile == "" { + return fmt.Errorf("desktop file path not provided") + } + if options.BuildDir == "" { + // Create temp directory + var err error + options.BuildDir, err = os.MkdirTemp("", "wails-appimage-*") + if err != nil { + return err + } + } + var err error + options.OutputDir, err = filepath.Abs(options.OutputDir) + if err != nil { + return err + } + + return generateAppImage(options) +} + +func generateAppImage(options *GenerateAppImageOptions) error { + numberOfSteps := 5 + p, _ := pterm.DefaultProgressbar.WithTotal(numberOfSteps).WithTitle("Generating AppImage").Start() + + // Get the last path of the binary and normalise the name + name := normaliseName(filepath.Base(options.Binary)) + + appDir := filepath.Join(options.BuildDir, name+"-x86_64.AppDir") + s.RMDIR(appDir) + + log(p, "Preparing AppImage Directory: "+appDir) + + usrBin := filepath.Join(appDir, "usr", "bin") + s.MKDIR(options.BuildDir) + s.MKDIR(usrBin) + s.COPY(options.Binary, usrBin) + s.CHMOD(filepath.Join(usrBin, filepath.Base(options.Binary)), 0755) + dotDirIcon := filepath.Join(appDir, ".DirIcon") + s.COPY(options.Icon, dotDirIcon) + iconLink := filepath.Join(appDir, filepath.Base(options.Icon)) + s.DELETE(iconLink) + s.SYMLINK(".DirIcon", iconLink) + s.COPY(options.DesktopFile, appDir) + + // Download linuxdeploy and make it executable + s.CD(options.BuildDir) + + // Download necessary files + log(p, "Downloading AppImage tooling") + var wg sync.WaitGroup + wg.Add(2) + go func() { + if !s.EXISTS(filepath.Join(options.BuildDir, "linuxdeploy-x86_64.AppImage")) { + s.DOWNLOAD("https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage", filepath.Join(options.BuildDir, "linuxdeploy-x86_64.AppImage")) + } + s.CHMOD(filepath.Join(options.BuildDir, "linuxdeploy-x86_64.AppImage"), 0755) + wg.Done() + }() + go func() { + target := filepath.Join(appDir, "AppRun") + if !s.EXISTS(target) { + s.DOWNLOAD("https://github.com/AppImage/AppImageKit/releases/download/continuous/AppRun-x86_64", target) + } + s.CHMOD(target, 0755) + wg.Done() + }() + wg.Wait() + + log(p, "Processing GTK files.") + files := s.FINDFILES("/usr/lib", "WebKitNetworkProcess", "WebKitWebProcess", "libwebkit2gtkinjectedbundle.so") + if len(files) != 3 { + return fmt.Errorf("unable to locate all WebKit libraries") + } + s.CD(appDir) + for _, file := range files { + targetDir := filepath.Dir(file) + // Strip leading forward slash + if targetDir[0] == '/' { + targetDir = targetDir[1:] + } + var err error + targetDir, err = filepath.Abs(targetDir) + if err != nil { + return err + } + s.MKDIR(targetDir) + s.COPY(file, targetDir) + } + // Copy GTK Plugin + err := os.WriteFile(filepath.Join(options.BuildDir, "linuxdeploy-plugin-gtk.sh"), gtkPlugin, 0755) + if err != nil { + return err + } + + // Determine GTK Version + // Run ldd on the binary and capture the output + targetBinary := filepath.Join(appDir, "usr", "bin", options.Binary) + lddOutput, err := s.EXEC(fmt.Sprintf("ldd %s", targetBinary)) + if err != nil { + println(string(lddOutput)) + return err + } + lddString := string(lddOutput) + // Check if GTK3 is present + var DeployGtkVersion string + if s.CONTAINS(lddString, "libgtk-x11-2.0.so") { + DeployGtkVersion = "2" + } else if s.CONTAINS(lddString, "libgtk-3.so") { + DeployGtkVersion = "3" + } else if s.CONTAINS(lddString, "libgtk-4.so") { + DeployGtkVersion = "4" + } else { + return fmt.Errorf("unable to determine GTK version") + } + // Run linuxdeploy to bundle the application + s.CD(options.BuildDir) + log(p, "Generating AppImage (This may take a while...)") + cmd := fmt.Sprintf("./linuxdeploy-x86_64.AppImage --appimage-extract-and-run --appdir %s --output appimage --plugin gtk", appDir) + s.SETENV("DEPLOY_GTK_VERSION", DeployGtkVersion) + output, err := s.EXEC(cmd) + if err != nil { + println(output) + return err + } + + // Move file to output directory + targetFile := filepath.Join(options.BuildDir, name+"-x86_64.AppImage") + s.MOVE(targetFile, options.OutputDir) + + log(p, "AppImage created: "+targetFile) + return nil +} diff --git a/v3/internal/commands/appimage_test.go b/v3/internal/commands/appimage_test.go new file mode 100644 index 000000000..23e81efaf --- /dev/null +++ b/v3/internal/commands/appimage_test.go @@ -0,0 +1,80 @@ +//go:build full_test + +package commands_test + +import ( + "github.com/wailsapp/wails/v3/internal/commands" + "github.com/wailsapp/wails/v3/internal/s" + "testing" +) + +func Test_generateAppImage(t *testing.T) { + + tests := []struct { + name string + options *commands.GenerateAppImageOptions + wantErr bool + setup func() + teardown func() + }{ + { + name: "Should fail if binary path is not provided", + options: &commands.GenerateAppImageOptions{}, + wantErr: true, + }, + { + name: "Should fail if Icon is not provided", + options: &commands.GenerateAppImageOptions{ + Binary: "testapp", + }, + wantErr: true, + }, + + { + name: "Should fail if desktop file is not provided", + options: &commands.GenerateAppImageOptions{ + Binary: "testapp", + Icon: "testicon", + }, + wantErr: true, + }, + { + name: "Should work if inputs are valid", + options: &commands.GenerateAppImageOptions{ + Binary: "testapp", + Icon: "appicon.png", + DesktopFile: "testapp.desktop", + }, + setup: func() { + // Compile the test application + s.CD("appimage_testfiles") + testDir := s.CWD() + _, err := s.EXEC(`go build -ldflags="-s -w" -o testapp`) + if err != nil { + t.Fatal(err) + } + s.DEFER(func() { + s.CD(testDir) + s.RM("testapp") + s.RM("testapp-x86_64.AppImage") + }) + }, + teardown: func() { + s.CALLDEFER() + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.setup != nil { + tt.setup() + } + if err := commands.GenerateAppImage(tt.options); (err != nil) != tt.wantErr { + t.Errorf("generateAppImage() error = %v, wantErr %v", err, tt.wantErr) + } + if tt.teardown != nil { + tt.teardown() + } + }) + } +} diff --git a/v3/internal/commands/appimage_testfiles/appicon.png b/v3/internal/commands/appimage_testfiles/appicon.png new file mode 100644 index 000000000..63617fe4f Binary files /dev/null and b/v3/internal/commands/appimage_testfiles/appicon.png differ diff --git a/v3/internal/commands/appimage_testfiles/main.go b/v3/internal/commands/appimage_testfiles/main.go new file mode 100644 index 000000000..609051bc0 --- /dev/null +++ b/v3/internal/commands/appimage_testfiles/main.go @@ -0,0 +1,417 @@ +package main + +import ( + _ "embed" + "fmt" + "log" + "math/rand" + "runtime" + "strconv" + "time" + + "github.com/samber/lo" + "github.com/wailsapp/wails/v3/pkg/events" + + "github.com/wailsapp/wails/v3/pkg/application" +) + +func main() { + app := application.New(application.Options{ + Name: "WebviewWindow Demo", + Description: "A demo of the WebviewWindow API", + Assets: application.AlphaAssets, + Mac: application.MacOptions{ + ApplicationShouldTerminateAfterLastWindowClosed: false, + }, + }) + app.On(events.Mac.ApplicationDidFinishLaunching, func(event *application.Event) { + log.Println("ApplicationDidFinishLaunching") + }) + + var hiddenWindows []*application.WebviewWindow + + currentWindow := func(fn func(window *application.WebviewWindow)) { + if app.CurrentWindow() != nil { + fn(app.CurrentWindow()) + } else { + println("Current WebviewWindow is nil") + } + } + + // Create a custom menu + menu := app.NewMenu() + menu.AddRole(application.AppMenu) + + windowCounter := 1 + + // Let's make a "Demo" menu + myMenu := menu.AddSubmenu("New") + + myMenu.Add("New WebviewWindow"). + SetAccelerator("CmdOrCtrl+N"). + OnClick(func(ctx *application.Context) { + app.NewWebviewWindow(). + SetTitle("WebviewWindow "+strconv.Itoa(windowCounter)). + SetRelativePosition(rand.Intn(1000), rand.Intn(800)). + SetURL("https://wails.io"). + Show() + windowCounter++ + }) + myMenu.Add("New WebviewWindow (Hides on Close one time)"). + SetAccelerator("CmdOrCtrl+H"). + OnClick(func(ctx *application.Context) { + app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{ + // This will be called when the user clicks the close button + // on the window. It will hide the window for 5 seconds. + // If the user clicks the close button again, the window will + // close. + ShouldClose: func(window *application.WebviewWindow) bool { + if !lo.Contains(hiddenWindows, window) { + hiddenWindows = append(hiddenWindows, window) + go func() { + time.Sleep(5 * time.Second) + window.Show() + }() + window.Hide() + return false + } + // Remove the window from the hiddenWindows list + hiddenWindows = lo.Without(hiddenWindows, window) + return true + }, + }). + SetTitle("WebviewWindow "+strconv.Itoa(windowCounter)). + SetRelativePosition(rand.Intn(1000), rand.Intn(800)). + SetURL("https://wails.io"). + Show() + windowCounter++ + }) + myMenu.Add("New Frameless WebviewWindow"). + SetAccelerator("CmdOrCtrl+F"). + OnClick(func(ctx *application.Context) { + app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{ + X: rand.Intn(1000), + Y: rand.Intn(800), + BackgroundColour: application.NewRGB(33, 37, 41), + Frameless: true, + Mac: application.MacWindow{ + InvisibleTitleBarHeight: 50, + }, + }).Show() + windowCounter++ + }) + myMenu.Add("New WebviewWindow (ignores mouse events"). + SetAccelerator("CmdOrCtrl+F"). + OnClick(func(ctx *application.Context) { + app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{ + HTML: "
", + X: rand.Intn(1000), + Y: rand.Intn(800), + IgnoreMouseEvents: true, + BackgroundType: application.BackgroundTypeTransparent, + Mac: application.MacWindow{ + InvisibleTitleBarHeight: 50, + }, + }).Show() + windowCounter++ + }) + if runtime.GOOS == "darwin" { + myMenu.Add("New WebviewWindow (MacTitleBarHiddenInset)"). + OnClick(func(ctx *application.Context) { + app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{ + Mac: application.MacWindow{ + TitleBar: application.MacTitleBarHiddenInset, + InvisibleTitleBarHeight: 25, + }, + }). + SetBackgroundColour(application.NewRGB(33, 37, 41)). + SetTitle("WebviewWindow "+strconv.Itoa(windowCounter)). + SetRelativePosition(rand.Intn(1000), rand.Intn(800)). + SetHTML("

A MacTitleBarHiddenInset WebviewWindow example

"). + Show() + windowCounter++ + }) + myMenu.Add("New WebviewWindow (MacTitleBarHiddenInsetUnified)"). + OnClick(func(ctx *application.Context) { + app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{ + Mac: application.MacWindow{ + TitleBar: application.MacTitleBarHiddenInsetUnified, + InvisibleTitleBarHeight: 50, + }, + }). + SetTitle("WebviewWindow "+strconv.Itoa(windowCounter)). + SetRelativePosition(rand.Intn(1000), rand.Intn(800)). + SetHTML("

A MacTitleBarHiddenInsetUnified WebviewWindow example

"). + Show() + windowCounter++ + }) + myMenu.Add("New WebviewWindow (MacTitleBarHidden)"). + OnClick(func(ctx *application.Context) { + app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{ + Mac: application.MacWindow{ + TitleBar: application.MacTitleBarHidden, + InvisibleTitleBarHeight: 25, + }, + }). + SetTitle("WebviewWindow "+strconv.Itoa(windowCounter)). + SetRelativePosition(rand.Intn(1000), rand.Intn(800)). + SetHTML("

A MacTitleBarHidden WebviewWindow example

"). + Show() + windowCounter++ + }) + } + if runtime.GOOS == "windows" { + myMenu.Add("New WebviewWindow (Mica)"). + OnClick(func(ctx *application.Context) { + app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{ + Title: "WebviewWindow " + strconv.Itoa(windowCounter), + X: rand.Intn(1000), + Y: rand.Intn(800), + BackgroundType: application.BackgroundTypeTranslucent, + HTML: "", + Windows: application.WindowsWindow{ + BackdropType: application.Mica, + }, + }).Show() + windowCounter++ + }) + myMenu.Add("New WebviewWindow (Acrylic)"). + OnClick(func(ctx *application.Context) { + app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{ + Title: "WebviewWindow " + strconv.Itoa(windowCounter), + X: rand.Intn(1000), + Y: rand.Intn(800), + BackgroundType: application.BackgroundTypeTranslucent, + HTML: "", + Windows: application.WindowsWindow{ + BackdropType: application.Acrylic, + }, + }).Show() + windowCounter++ + }) + myMenu.Add("New WebviewWindow (Tabbed)"). + OnClick(func(ctx *application.Context) { + app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{ + Title: "WebviewWindow " + strconv.Itoa(windowCounter), + X: rand.Intn(1000), + Y: rand.Intn(800), + BackgroundType: application.BackgroundTypeTranslucent, + HTML: "", + Windows: application.WindowsWindow{ + BackdropType: application.Tabbed, + }, + }).Show() + windowCounter++ + }) + } + + sizeMenu := menu.AddSubmenu("Size") + sizeMenu.Add("Set Size (800,600)").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + w.SetSize(800, 600) + }) + }) + + sizeMenu.Add("Set Size (Random)").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + w.SetSize(rand.Intn(800)+200, rand.Intn(600)+200) + }) + }) + sizeMenu.Add("Set Min Size (200,200)").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + w.SetMinSize(200, 200) + }) + }) + sizeMenu.Add("Set Max Size (600,600)").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + w.SetFullscreenButtonEnabled(false) + w.SetMaxSize(600, 600) + }) + }) + sizeMenu.Add("Get Current WebviewWindow Size").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + width, height := w.Size() + application.InfoDialog().SetTitle("Current WebviewWindow Size").SetMessage("Width: " + strconv.Itoa(width) + " Height: " + strconv.Itoa(height)).Show() + }) + }) + + sizeMenu.Add("Reset Min Size").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + w.SetMinSize(0, 0) + }) + }) + + sizeMenu.Add("Reset Max Size").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + w.SetMaxSize(0, 0) + w.SetFullscreenButtonEnabled(true) + }) + }) + positionMenu := menu.AddSubmenu("Position") + positionMenu.Add("Set Relative Position (0,0)").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + w.SetRelativePosition(0, 0) + }) + }) + positionMenu.Add("Set Relative Position (Random)").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + w.SetRelativePosition(rand.Intn(1000), rand.Intn(800)) + }) + }) + + positionMenu.Add("Get Relative Position").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + x, y := w.RelativePosition() + application.InfoDialog().SetTitle("Current WebviewWindow Position").SetMessage("X: " + strconv.Itoa(x) + " Y: " + strconv.Itoa(y)).Show() + }) + }) + + positionMenu.Add("Set Absolute Position (0,0)").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + w.SetAbsolutePosition(0, 0) + }) + }) + + positionMenu.Add("Set Absolute Position (Random)").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + w.SetAbsolutePosition(rand.Intn(1000), rand.Intn(800)) + }) + }) + + positionMenu.Add("Get Absolute Position").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + x, y := w.AbsolutePosition() + application.InfoDialog().SetTitle("Current WebviewWindow Position").SetMessage("X: " + strconv.Itoa(x) + " Y: " + strconv.Itoa(y)).Show() + }) + }) + + positionMenu.Add("Center").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + w.Center() + }) + }) + stateMenu := menu.AddSubmenu("State") + stateMenu.Add("Minimise (for 2 secs)").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + w.Minimise() + time.Sleep(2 * time.Second) + w.Restore() + }) + }) + stateMenu.Add("Maximise").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + w.Maximise() + }) + }) + stateMenu.Add("Fullscreen").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + w.Fullscreen() + }) + }) + stateMenu.Add("UnFullscreen").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + w.UnFullscreen() + }) + }) + stateMenu.Add("Restore").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + w.Restore() + }) + }) + stateMenu.Add("Hide (for 2 seconds)").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + w.Hide() + time.Sleep(2 * time.Second) + w.Show() + }) + }) + stateMenu.Add("Always on Top").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + w.SetAlwaysOnTop(true) + }) + }) + stateMenu.Add("Not always on Top").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + w.SetAlwaysOnTop(false) + }) + }) + stateMenu.Add("Google.com").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + w.SetURL("https://google.com") + }) + }) + stateMenu.Add("wails.io").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + w.SetURL("https://wails.io") + }) + }) + stateMenu.Add("Get Primary Screen").OnClick(func(ctx *application.Context) { + screen, err := app.GetPrimaryScreen() + if err != nil { + application.ErrorDialog().SetTitle("Error").SetMessage(err.Error()).Show() + return + } + msg := fmt.Sprintf("Screen: %+v", screen) + application.InfoDialog().SetTitle("Primary Screen").SetMessage(msg).Show() + }) + stateMenu.Add("Get Screens").OnClick(func(ctx *application.Context) { + screens, err := app.GetScreens() + if err != nil { + application.ErrorDialog().SetTitle("Error").SetMessage(err.Error()).Show() + return + } + for _, screen := range screens { + msg := fmt.Sprintf("Screen: %+v", screen) + application.InfoDialog().SetTitle(fmt.Sprintf("Screen %s", screen.ID)).SetMessage(msg).Show() + } + }) + stateMenu.Add("Get Screen for WebviewWindow").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + screen, err := w.GetScreen() + if err != nil { + application.ErrorDialog().SetTitle("Error").SetMessage(err.Error()).Show() + return + } + msg := fmt.Sprintf("Screen: %+v", screen) + application.InfoDialog().SetTitle(fmt.Sprintf("Screen %s", screen.ID)).SetMessage(msg).Show() + }) + }) + stateMenu.Add("Disable for 5s").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + w.SetEnabled(false) + time.Sleep(5 * time.Second) + w.SetEnabled(true) + }) + }) + + if runtime.GOOS == "windows" { + stateMenu.Add("Flash Start").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + time.Sleep(2 * time.Second) + w.Flash(true) + }) + }) + } + + printMenu := menu.AddSubmenu("Print") + printMenu.Add("Print").OnClick(func(ctx *application.Context) { + currentWindow(func(w *application.WebviewWindow) { + _ = w.Print() + }) + }) + + app.NewWebviewWindowWithOptions(application.WebviewWindowOptions{ + BackgroundColour: application.NewRGB(33, 37, 41), + Mac: application.MacWindow{ + DisableShadow: true, + }, + }) + + app.SetMenu(menu) + err := app.Run() + + if err != nil { + log.Fatal(err) + } + +} diff --git a/v3/internal/commands/appimage_testfiles/testapp.desktop b/v3/internal/commands/appimage_testfiles/testapp.desktop new file mode 100644 index 000000000..27bbed876 --- /dev/null +++ b/v3/internal/commands/appimage_testfiles/testapp.desktop @@ -0,0 +1,10 @@ +[Desktop Entry] +Type=Application +Name=testapp +Exec=testapp +Icon=appicon +Categories=Development; +Terminal=false +Keywords=wails +Version=1.0 +StartupNotify=false diff --git a/v3/internal/commands/dot_desktop.go b/v3/internal/commands/dot_desktop.go index 0fd674d9b..1e76163a7 100644 --- a/v3/internal/commands/dot_desktop.go +++ b/v3/internal/commands/dot_desktop.go @@ -35,9 +35,7 @@ func (d *DotDesktopOptions) asBytes() []byte { if d.Icon != "" { buf.WriteString(fmt.Sprintf("Icon=%s\n", d.Icon)) } - if d.Categories != "" { - buf.WriteString(fmt.Sprintf("Categories=%s\n", d.Categories)) - } + buf.WriteString(fmt.Sprintf("Categories=%s\n", d.Categories)) if d.Comment != "" { buf.WriteString(fmt.Sprintf("Comment=%s\n", d.Comment)) } diff --git a/v3/internal/commands/linuxdeploy-plugin-gtk.sh b/v3/internal/commands/linuxdeploy-plugin-gtk.sh new file mode 100644 index 000000000..51c1231dd --- /dev/null +++ b/v3/internal/commands/linuxdeploy-plugin-gtk.sh @@ -0,0 +1,376 @@ +#! /usr/bin/env bash + +# Source: https://raw.githubusercontent.com/linuxdeploy/linuxdeploy-plugin-gtk/master/linuxdeploy-plugin-gtk.sh +# License: MIT (https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/blob/master/LICENSE.txt) + +# GTK3 environment variables: https://developer.gnome.org/gtk3/stable/gtk-running.html +# GTK4 environment variables: https://developer.gnome.org/gtk4/stable/gtk-running.html + +# abort on all errors +set -e + +if [ "$DEBUG" != "" ]; then + set -x + verbose="--verbose" +fi + +SCRIPT="$(basename "$(readlink -f "$0")")" + +show_usage() { + echo "Usage: $SCRIPT --appdir " + echo + echo "Bundles resources for applications that use GTK into an AppDir" + echo + echo "Required variables:" + echo " LINUXDEPLOY=\".../linuxdeploy\" path to linuxdeploy (e.g., AppImage); set automatically when plugin is run directly by linuxdeploy" + echo + echo "Optional variables:" + echo " DEPLOY_GTK_VERSION (major version of GTK to deploy, e.g. '2', '3' or '4'; auto-detect by default)" +} + +variable_is_true() { + local var="$1" + + if [ -n "$var" ] && { [ "$var" == "true" ] || [ "$var" -gt 0 ]; } 2> /dev/null; then + return 0 # true + else + return 1 # false + fi +} + +get_pkgconf_variable() { + local variable="$1" + local library="$2" + local default_value="$3" + + pkgconfig_ret="$("$PKG_CONFIG" --variable="$variable" "$library")" + if [ -n "$pkgconfig_ret" ]; then + echo "$pkgconfig_ret" + elif [ -n "$default_value" ]; then + echo "$default_value" + else + echo "$0: there is no '$variable' variable for '$library' library." > /dev/stderr + echo "Please check the '$library.pc' file is present in \$PKG_CONFIG_PATH (you may need to install the appropriate -dev/-devel package)." > /dev/stderr + exit 1 + fi +} + +copy_tree() { + local src=("${@:1:$#-1}") + local dst="${*:$#}" + + for elem in "${src[@]}"; do + mkdir -p "${dst::-1}$elem" + cp "$elem" --archive --parents --target-directory="$dst" $verbose + done +} + +copy_lib_tree() { + # The source lib directory could be /usr/lib, /usr/lib64, or /usr/lib/x86_64-linux-gnu + # Therefore, when copying lib directories, we need to transform that target path + # to a consistent /usr/lib + local src=("${@:1:$#-1}") + local dst="${*:$#}" + + for elem in "${src[@]}"; do + mkdir -p "${dst::-1}${elem/$LD_GTK_LIBRARY_PATH//usr/lib}" + pushd "$LD_GTK_LIBRARY_PATH" + cp "$(realpath --relative-to="$LD_GTK_LIBRARY_PATH" "$elem")" --archive --parents --target-directory="$dst/usr/lib" $verbose + popd + done +} + +get_triplet_path() { + if command -v dpkg-architecture > /dev/null; then + echo "/usr/lib/$(dpkg-architecture -qDEB_HOST_MULTIARCH)" + fi +} + + + +search_library_path() { + PATH_ARRAY=( + "$(get_triplet_path)" + "/usr/lib64" + "/usr/lib" + ) + + for path in "${PATH_ARRAY[@]}"; do + if [ -d "$path" ]; then + echo "$path" + return 0 + fi + done +} + +search_tool() { + local tool="$1" + local directory="$2" + + if command -v "$tool"; then + return 0 + fi + + PATH_ARRAY=( + "$(get_triplet_path)/$directory/$tool" + "/usr/lib64/$directory/$tool" + "/usr/lib/$directory/$tool" + "/usr/bin/$tool" + "/usr/bin/$tool-64" + "/usr/bin/$tool-32" + ) + + for path in "${PATH_ARRAY[@]}"; do + if [ -x "$path" ]; then + echo "$path" + return 0 + fi + done +} + +DEPLOY_GTK_VERSION="${DEPLOY_GTK_VERSION:-0}" # When not set by user, this variable use the integer '0' as a sentinel value +APPDIR= + +while [ "$1" != "" ]; do + case "$1" in + --plugin-api-version) + echo "0" + exit 0 + ;; + --appdir) + APPDIR="$2" + shift + shift + ;; + --help) + show_usage + exit 0 + ;; + *) + echo "Invalid argument: $1" + echo + show_usage + exit 1 + ;; + esac +done + +if [ "$APPDIR" == "" ]; then + show_usage + exit 1 +fi + +APPDIR="$(realpath "$APPDIR")" +mkdir -p "$APPDIR" + +. /etc/os-release +if [ "$ID" = "debian" ] || [ "$ID" = "ubuntu" ]; then + if ! command -v dpkg-architecture &>/dev/null; then + echo -e "$0: dpkg-architecture not found.\nInstall dpkg-dev then re-run the plugin." + exit 1 + fi +fi + +if command -v pkgconf > /dev/null; then + PKG_CONFIG="pkgconf" +elif command -v pkg-config > /dev/null; then + PKG_CONFIG="pkg-config" +else + echo "$0: pkg-config/pkgconf not found in PATH, aborting" + exit 1 +fi + +# GTK's library path *must not* have a trailing slash for later parameter substitution to work properly +LD_GTK_LIBRARY_PATH="$(realpath "${LD_GTK_LIBRARY_PATH:-$(search_library_path)}")" + +if ! command -v find &>/dev/null && ! type find &>/dev/null; then + echo -e "$0: find not found.\nInstall findutils then re-run the plugin." + exit 1 +fi + +if [ -z "$LINUXDEPLOY" ]; then + echo -e "$0: LINUXDEPLOY environment variable is not set.\nDownload a suitable linuxdeploy AppImage, set the environment variable and re-run the plugin." + exit 1 +fi + +gtk_versions=0 # Count major versions of GTK when auto-detect GTK version +if [ "$DEPLOY_GTK_VERSION" -eq 0 ]; then + echo "Determining which GTK version to deploy" + while IFS= read -r -d '' file; do + if [ "$DEPLOY_GTK_VERSION" -ne 2 ] && ldd "$file" | grep -q "libgtk-x11-2.0.so"; then + DEPLOY_GTK_VERSION=2 + gtk_versions="$((gtk_versions+1))" + fi + if [ "$DEPLOY_GTK_VERSION" -ne 3 ] && ldd "$file" | grep -q "libgtk-3.so"; then + DEPLOY_GTK_VERSION=3 + gtk_versions="$((gtk_versions+1))" + fi + if [ "$DEPLOY_GTK_VERSION" -ne 4 ] && ldd "$file" | grep -q "libgtk-4.so"; then + DEPLOY_GTK_VERSION=4 + gtk_versions="$((gtk_versions+1))" + fi + done < <(find "$APPDIR/usr/bin" -executable -type f -print0) +fi + +if [ "$gtk_versions" -gt 1 ]; then + echo "$0: can not deploy multiple GTK versions at the same time." + echo "Please set DEPLOY_GTK_VERSION to {2, 3, 4}." + exit 1 +elif [ "$DEPLOY_GTK_VERSION" -eq 0 ]; then + echo "$0: failed to auto-detect GTK version." + echo "Please set DEPLOY_GTK_VERSION to {2, 3, 4}." + exit 1 +fi + +echo "Installing AppRun hook" +HOOKSDIR="$APPDIR/apprun-hooks" +HOOKFILE="$HOOKSDIR/linuxdeploy-plugin-gtk.sh" +mkdir -p "$HOOKSDIR" +cat > "$HOOKFILE" <<\EOF +#! /usr/bin/env bash + +COLOR_SCHEME="$(dbus-send --session --dest=org.freedesktop.portal.Desktop --type=method_call --print-reply --reply-timeout=1000 /org/freedesktop/portal/desktop org.freedesktop.portal.Settings.Read 'string:org.freedesktop.appearance' 'string:color-scheme' 2> /dev/null | tail -n1 | cut -b35- | cut -d' ' -f2 || printf '')" +if [ -z "$COLOR_SCHEME" ]; then + COLOR_SCHEME="$(gsettings get org.gnome.desktop.interface color-scheme 2> /dev/null || printf '')" +fi +case "$COLOR_SCHEME" in + "1"|"'prefer-dark'") GTK_THEME_VARIANT="dark";; + "2"|"'prefer-light'") GTK_THEME_VARIANT="light";; + *) GTK_THEME_VARIANT="light";; +esac +APPIMAGE_GTK_THEME="${APPIMAGE_GTK_THEME:-"Adwaita:$GTK_THEME_VARIANT"}" # Allow user to override theme (discouraged) + +export APPDIR="${APPDIR:-"$(dirname "$(realpath "$0")")"}" # Workaround to run extracted AppImage +export GTK_DATA_PREFIX="$APPDIR" +export GTK_THEME="$APPIMAGE_GTK_THEME" # Custom themes are broken +export GDK_BACKEND=x11 # Crash with Wayland backend on Wayland +export XDG_DATA_DIRS="$APPDIR/usr/share:/usr/share:$XDG_DATA_DIRS" # g_get_system_data_dirs() from GLib +EOF + +echo "Installing GLib schemas" +# Note: schemasdir is undefined on Ubuntu 16.04 +glib_schemasdir="$(get_pkgconf_variable "schemasdir" "gio-2.0" "/usr/share/glib-2.0/schemas")" +copy_tree "$glib_schemasdir" "$APPDIR/" +glib-compile-schemas "$APPDIR/$glib_schemasdir" +cat >> "$HOOKFILE" <> "$HOOKFILE" <> "$HOOKFILE" < "$APPDIR/${gtk3_immodules_cache_file/$LD_GTK_LIBRARY_PATH//usr/lib}" + else + echo "WARNING: gtk-query-immodules-3.0 not found" + fi + if [ ! -f "$APPDIR/${gtk3_immodules_cache_file/$LD_GTK_LIBRARY_PATH//usr/lib}" ]; then + echo "WARNING: immodules.cache file is missing" + fi + sed -i "s|$gtk3_libdir/3.0.0/immodules/||g" "$APPDIR/${gtk3_immodules_cache_file/$LD_GTK_LIBRARY_PATH//usr/lib}" + ;; + 4) + echo "Installing GTK 4.0 modules" + gtk4_exec_prefix="$(get_pkgconf_variable "exec_prefix" "gtk4" "/usr")" + gtk4_libdir="$(get_pkgconf_variable "libdir" "gtk4")/gtk-4.0" + gtk4_path="$gtk4_libdir" + copy_lib_tree "$gtk4_libdir" "$APPDIR/" + cat >> "$HOOKFILE" <> "$HOOKFILE" < "$APPDIR/${gdk_pixbuf_cache_file/$LD_GTK_LIBRARY_PATH//usr/lib}" +else + echo "WARNING: gdk-pixbuf-query-loaders not found" +fi +if [ ! -f "$APPDIR/${gdk_pixbuf_cache_file/$LD_GTK_LIBRARY_PATH//usr/lib}" ]; then + echo "WARNING: loaders.cache file is missing" +fi +sed -i "s|$gdk_pixbuf_moduledir/||g" "$APPDIR/${gdk_pixbuf_cache_file/$LD_GTK_LIBRARY_PATH//usr/lib}" + +echo "Copying more libraries" +gobject_libdir="$(get_pkgconf_variable "libdir" "gobject-2.0" "$LD_GTK_LIBRARY_PATH")" +gio_libdir="$(get_pkgconf_variable "libdir" "gio-2.0" "$LD_GTK_LIBRARY_PATH")" +librsvg_libdir="$(get_pkgconf_variable "libdir" "librsvg-2.0" "$LD_GTK_LIBRARY_PATH")" +pango_libdir="$(get_pkgconf_variable "libdir" "pango" "$LD_GTK_LIBRARY_PATH")" +pangocairo_libdir="$(get_pkgconf_variable "libdir" "pangocairo" "$LD_GTK_LIBRARY_PATH")" +pangoft2_libdir="$(get_pkgconf_variable "libdir" "pangoft2" "$LD_GTK_LIBRARY_PATH")" +FIND_ARRAY=( + "$gdk_libdir" "libgdk_pixbuf-*.so*" + "$gobject_libdir" "libgobject-*.so*" + "$gio_libdir" "libgio-*.so*" + "$librsvg_libdir" "librsvg-*.so*" + "$pango_libdir" "libpango-*.so*" + "$pangocairo_libdir" "libpangocairo-*.so*" + "$pangoft2_libdir" "libpangoft2-*.so*" +) +LIBRARIES=() +for (( i=0; i<${#FIND_ARRAY[@]}; i+=2 )); do + directory=${FIND_ARRAY[i]} + library=${FIND_ARRAY[i+1]} + while IFS= read -r -d '' file; do + LIBRARIES+=( "--library=$file" ) + done < <(find "$directory" \( -type l -o -type f \) -name "$library" -print0) +done + +env LINUXDEPLOY_PLUGIN_MODE=1 "$LINUXDEPLOY" --appdir="$APPDIR" "${LIBRARIES[@]}" + +# Create symbolic links as a workaround +# Details: https://github.com/linuxdeploy/linuxdeploy-plugin-gtk/issues/24#issuecomment-1030026529 +echo "Manually setting rpath for GTK modules" +PATCH_ARRAY=( + "$gtk3_immodulesdir" + "$gtk3_printbackendsdir" + "$gdk_pixbuf_moduledir" +) +for directory in "${PATCH_ARRAY[@]}"; do + while IFS= read -r -d '' file; do + ln $verbose -sf "${file/$LD_GTK_LIBRARY_PATH\//}" "$APPDIR/usr/lib" + done < <(find "$directory" -name '*.so' -print0) +done \ No newline at end of file diff --git a/v3/internal/commands/task.go b/v3/internal/commands/task.go index 5725d4db8..e11608d7b 100644 --- a/v3/internal/commands/task.go +++ b/v3/internal/commands/task.go @@ -4,7 +4,6 @@ import ( "context" "fmt" "github.com/go-task/task/v3/errors" - "log" "os" "path/filepath" "strings" @@ -21,6 +20,11 @@ import ( // BuildSettings contains the CLI build settings var BuildSettings = map[string]string{} +func fatal(message string) { + pterm.Error.Println(message) + os.Exit(1) +} + type RunTaskOptions struct { Name string `pos:"1"` Help bool `name:"h" description:"shows Task usage"` @@ -111,7 +115,7 @@ func RunTask(options *RunTaskOptions, otherArgs []string) error { var listOptions = task.NewListOptions(options.List, options.ListAll, options.ListJSON) if err := listOptions.Validate(); err != nil { - log.Fatal(err) + fatal(err.Error()) } if (listOptions.ShouldListTasks()) && options.Silent { @@ -120,7 +124,7 @@ func RunTask(options *RunTaskOptions, otherArgs []string) error { } if err := e.Setup(); err != nil { - log.Fatal(err) + fatal(err.Error()) } if listOptions.ShouldListTasks() { diff --git a/v3/internal/s/s.go b/v3/internal/s/s.go new file mode 100644 index 000000000..82966aed8 --- /dev/null +++ b/v3/internal/s/s.go @@ -0,0 +1,417 @@ +package s + +import ( + "crypto/md5" + "fmt" + "github.com/google/shlex" + "io" + "net/http" + "os" + "os/exec" + "path/filepath" + "strings" +) + +var ( + Output io.Writer = io.Discard + IndentSize int + originalOutput io.Writer + currentIndent int + dryRun bool + deferred []func() +) + +func checkError(err error) { + if err != nil { + println("\nERROR:", err.Error()) + os.Exit(1) + } +} + +func mute() { + originalOutput = Output + Output = io.Discard +} + +func unmute() { + Output = originalOutput +} + +func indent() { + currentIndent += IndentSize +} + +func unindent() { + currentIndent -= IndentSize +} + +func log(message string, args ...interface{}) { + indent := strings.Repeat(" ", currentIndent) + _, err := fmt.Fprintf(Output, indent+message+"\n", args...) + checkError(err) +} + +// RENAME a file or directory +func RENAME(source string, target string) { + log("RENAME %s -> %s", source, target) + err := os.Rename(source, target) + checkError(err) +} + +// MUSTDELETE a file. +func MUSTDELETE(filename string) { + log("DELETE %s", filename) + err := os.Remove(filepath.Join(CWD(), filename)) + checkError(err) +} + +// DELETE a file. +func DELETE(filename string) { + log("DELETE %s", filename) + _ = os.Remove(filepath.Join(CWD(), filename)) +} + +func CONTAINS(list string, item string) bool { + result := strings.Contains(list, item) + listTrimmed := list + if len(listTrimmed) > 30 { + listTrimmed = listTrimmed[:30] + "..." + } + log("CONTAINS %s in %s: %t", item, listTrimmed, result) + return result +} + +func SETENV(key string, value string) { + log("SETENV %s=%s", key, value) + err := os.Setenv(key, value) + checkError(err) +} + +func CD(dir string) { + err := os.Chdir(dir) + checkError(err) + log("CD %s", dir) +} +func MKDIR(path string, mode ...os.FileMode) { + var perms os.FileMode + perms = 0755 + if len(mode) == 1 { + perms = mode[0] + } + log("MKDIR %s (perms: %v)", path, perms) + err := os.MkdirAll(path, perms) + checkError(err) +} + +// ENDIR ensures that the path gets created if it doesn't exist +func ENDIR(path string, mode ...os.FileMode) { + var perms os.FileMode + perms = 0755 + if len(mode) == 1 { + perms = mode[0] + } + _ = os.MkdirAll(path, perms) +} + +// COPYDIR recursively copies a directory tree, attempting to preserve permissions. +// Source directory must exist, destination directory must *not* exist. +// Symlinks are ignored and skipped. +// Credit: https://gist.github.com/r0l1/92462b38df26839a3ca324697c8cba04 +func COPYDIR(src string, dst string) { + log("COPYDIR %s -> %s", src, dst) + src = filepath.Clean(src) + dst = filepath.Clean(dst) + + si, err := os.Stat(src) + checkError(err) + if !si.IsDir() { + checkError(fmt.Errorf("source is not a directory")) + } + + _, err = os.Stat(dst) + if err != nil && !os.IsNotExist(err) { + checkError(err) + } + if err == nil { + checkError(fmt.Errorf("destination already exists")) + } + + indent() + MKDIR(dst) + + entries, err := os.ReadDir(src) + checkError(err) + + for _, entry := range entries { + srcPath := filepath.Join(src, entry.Name()) + dstPath := filepath.Join(dst, entry.Name()) + + if entry.IsDir() { + COPYDIR(srcPath, dstPath) + } else { + // Skip symlinks. + if entry.Type()&os.ModeSymlink != 0 { + continue + } + + COPY(srcPath, dstPath) + } + } + unindent() +} + +func SYMLINK(source string, target string) { + // trim string to first 30 chars + var trimTarget = target + if len(trimTarget) > 30 { + trimTarget = trimTarget[:30] + "..." + } + log("SYMLINK %s -> %s", source, trimTarget) + err := os.Symlink(source, target) + checkError(err) +} + +// COPY file from source to target +func COPY(source string, target string) { + log("COPY %s -> %s", source, target) + src, err := os.Open(source) + checkError(err) + defer closefile(src) + if ISDIR(target) { + target = filepath.Join(target, filepath.Base(source)) + } + d, err := os.Create(target) + checkError(err) + _, err = io.Copy(d, src) + checkError(err) +} + +// Move file from source to target +func MOVE(source string, target string) { + // If target is a directory, append the source filename + if ISDIR(target) { + target = filepath.Join(target, filepath.Base(source)) + } + log("MOVE %s -> %s", source, target) + err := os.Rename(source, target) + checkError(err) +} + +func CWD() string { + result, err := os.Getwd() + checkError(err) + log("CWD %s", result) + return result +} + +func RMDIR(target string) { + log("RMDIR %s", target) + err := os.RemoveAll(target) + checkError(err) +} + +func RM(target string) { + log("RM %s", target) + err := os.Remove(target) + checkError(err) +} + +func ECHO(message string) { + println(message) +} + +func TOUCH(filepath string) { + log("TOUCH %s", filepath) + f, err := os.Create(filepath) + checkError(err) + closefile(f) +} + +func EXEC(command string) ([]byte, error) { + log("EXEC %s", command) + + // Split input using shlex + args, err := shlex.Split(command) + checkError(err) + // Execute command + cmd := exec.Command(args[0], args[1:]...) + cmd.Dir = CWD() + cmd.Env = os.Environ() + return cmd.CombinedOutput() +} + +func CHMOD(path string, mode os.FileMode) { + log("CHMOD %s %v", path, mode) + err := os.Chmod(path, mode) + checkError(err) +} + +// EXISTS - Returns true if the given path exists +func EXISTS(path string) bool { + _, err := os.Lstat(path) + log("EXISTS %s -> %t", path, err == nil) + return err == nil +} + +// ISDIR returns true if the given directory exists +func ISDIR(path string) bool { + fi, err := os.Lstat(path) + if err != nil { + return false + } + + return fi.Mode().IsDir() +} + +// ISDIREMPTY returns true if the given directory is empty +func ISDIREMPTY(dir string) bool { + + // CREDIT: https://stackoverflow.com/a/30708914/8325411 + f, err := os.Open(dir) + checkError(err) + defer closefile(f) + + _, err = f.Readdirnames(1) // Or f.Readdir(1) + if err == io.EOF { + return true + } + return false +} + +// ISFILE returns true if the given file exists +func ISFILE(path string) bool { + fi, err := os.Lstat(path) + if err != nil { + return false + } + + return fi.Mode().IsRegular() +} + +// SUBDIRS returns a list of subdirectories for the given directory +func SUBDIRS(rootDir string) []string { + var result []string + + // Iterate root dir + err := filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error { + checkError(err) + // If we have a directory, save it + if info.IsDir() { + result = append(result, path) + } + return nil + }) + checkError(err) + return result +} + +// SAVESTRING will create a file with the given string +func SAVESTRING(filename string, data string) { + log("SAVESTRING %s", filename) + mute() + SAVEBYTES(filename, []byte(data)) + unmute() +} + +// LOADSTRING returns the contents of the given filename as a string +func LOADSTRING(filename string) string { + log("LOADSTRING %s", filename) + mute() + data := LOADBYTES(filename) + unmute() + return string(data) +} + +// SAVEBYTES will create a file with the given string +func SAVEBYTES(filename string, data []byte) { + log("SAVEBYTES %s", filename) + err := os.WriteFile(filename, data, 0755) + checkError(err) +} + +// LOADBYTES returns the contents of the given filename as a string +func LOADBYTES(filename string) []byte { + log("LOADBYTES %s", filename) + data, err := os.ReadFile(filename) + checkError(err) + return data +} + +func closefile(f *os.File) { + err := f.Close() + checkError(err) +} + +// MD5FILE returns the md5sum of the given file +func MD5FILE(filename string) string { + f, err := os.Open(filename) + checkError(err) + defer closefile(f) + + h := md5.New() + _, err = io.Copy(h, f) + checkError(err) + + return fmt.Sprintf("%x", h.Sum(nil)) +} + +// Sub is the substitution type +type Sub map[string]string + +// REPLACEALL replaces all substitution keys with associated values in the given file +func REPLACEALL(filename string, substitutions Sub) { + log("REPLACEALL %s (%v)", filename, substitutions) + data := LOADSTRING(filename) + for old, newText := range substitutions { + data = strings.ReplaceAll(data, old, newText) + } + SAVESTRING(filename, data) +} + +func DOWNLOAD(url string, target string) { + log("DOWNLOAD %s -> %s", url, target) + // create HTTP client + resp, err := http.Get(url) + checkError(err) + defer resp.Body.Close() + + out, err := os.Create(target) + checkError(err) + defer out.Close() + + // Write the body to file + _, err = io.Copy(out, resp.Body) + checkError(err) +} + +func FINDFILES(root string, filenames ...string) []string { + var result []string + // Walk the root directory trying to find all the files + err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + checkError(err) + // If we have a file, check if it is in the list + if info.Mode().IsRegular() { + for _, filename := range filenames { + if info.Name() == filename { + result = append(result, path) + } + } + } + return nil + }) + checkError(err) + log("FINDFILES in %s -> [%v]", root, strings.Join(result, ", ")) + return result +} + +func DEFER(fn func()) { + log("DEFER") + deferred = append(deferred, fn) +} + +func CALLDEFER() { + log("CALLDEFER") + for _, fn := range deferred { + fn() + } +} diff --git a/v3/internal/templates/_common/Taskfile.tmpl.yml b/v3/internal/templates/_common/Taskfile.tmpl.yml index d91458f00..8d2d6b969 100644 --- a/v3/internal/templates/_common/Taskfile.tmpl.yml +++ b/v3/internal/templates/_common/Taskfile.tmpl.yml @@ -287,6 +287,7 @@ tasks: create:appimage: summary: Creates an AppImage + dir: build/appimage platforms: [ linux ] deps: - task: build:linux @@ -294,44 +295,44 @@ tasks: PRODUCTION: "true" - task: generate:linux:dotdesktop cmds: - - chmod +x {{ "{{.BIN_DIR}}" }}/{{ "{{.APP_NAME}}" }} - - chmod +x build/appimage/build.sh - - sh: build/appimage/build.sh - env: - - APP_NAME: {{ "'{{.APP_NAME}}'" }} - - APP_BINARY: '{{ "{{.ROOT_DIR}}"}}/{{ "{{.BIN_DIR}}" }}/{{ "{{.APP_NAME}}" }}' - - ICON_PATH: '{{ "{{.ROOT_DIR}}"}}/build/appicon.png' - - DESKTOP_FILE: '{{ "{{.ROOT_DIR}}"}}/build/{{ "{{.APP_NAME}}" }}.desktop' + # Copy binary + icon to appimage dir + - cp {{ "{{.APP_BINARY}}" }} {{ "{{.APP_NAME}}" }} + - cp ../appicon.png appicon.png + # Generate AppImage + - wails3 generate appimage -binary {{ "{{.APP_NAME}}" }} -icon {{ "{{.ICON}}" }} -desktopfile {{ "{{.DESKTOP_FILE}}" }} -outputdir {{ "{{.OUTPUT_DIR}}" }} -builddir {{ "{{.ROOT_DIR}}" }}/build/appimage + vars: + APP_NAME: '{{ "{{.APP_NAME}}" }}' + APP_BINARY: '../../bin/{{ "{{.APP_NAME}}" }}' + ICON: '../appicon.png' + DESKTOP_FILE: '{{ "{{.APP_NAME}}" }}.desktop' + OUTPUT_DIR: '../../bin' - generate:linux:dotdesktop: - summary: Generates a `.desktop` file - dir: build - sources: - - "appicon.png" - generates: - - "{{ "{{.ROOT_DIR}}"}}/build/appimage/{{ "{{.APP_NAME}}" }}.desktop" - cmds: - # Run `wails3 generate .desktop -help` for all the options - - | - wails3 generate .desktop - -name "{{ "{{.APP_NAME}}" }}" - -exec "{{ "{{.EXEC}}" }}" - -icon "{{ "{{.ICON}}" }}" - -outputfile {{ "{{.ROOT_DIR}}"}}/build/appimage/{{ "{{.APP_NAME}}" }}.desktop - # -categories "Development;Graphics;" - # -comment "A comment" - # -terminal "true" - # -version "1.0" - # -genericname "Generic Name" - # -keywords "keyword1;keyword2;" - # -startupnotify "true" - # -mimetype "application/x-extension1;application/x-extension2;" + generate:linux:dotdesktop: + summary: Generates a `.desktop` file + dir: build + sources: + - "appicon.png" + generates: + - '{{ "{{.ROOT_DIR}}"}}/build/appimage/{{ "{{.APP_NAME}}" }}.desktop' + cmds: + - mkdir -p {{ "{{.ROOT_DIR}}"}}/build/appimage + # Run `wails3 generate .desktop -help` for all the options + - wails3 generate .desktop -name "{{ "{{.APP_NAME}}" }}" -exec "{{ "{{.EXEC}}" }}" -icon "{{ "{{.ICON}}" }}" -outputfile {{ "{{.ROOT_DIR}}"}}/build/appimage/{{ "{{.APP_NAME}}" }}.desktop -categories "{{ "{{.CATEGORIES}}" }}" + # -comment "A comment" + # -terminal "true" + # -version "1.0" + # -genericname "Generic Name" + # -keywords "keyword1;keyword2;" + # -startupnotify "true" + # -mimetype "application/x-extension1;application/x-extension2;" + + vars: + APP_NAME: '{{ "{{.APP_NAME}}" }}' + EXEC: '{{ "{{.APP_NAME}}" }}' + ICON: 'appicon' + CATEGORIES: 'Development;' + OUTPUTFILE: '{{ "{{.ROOT_DIR}}"}}/build/appimage/{{ "{{.APP_NAME}}" }}.desktop' - vars: - APP_NAME: '-name \"{{ "{{.APP_NAME}}" }}\"' - EXEC: '{{ "{{.ROOT_DIR}}"}}/{{ "{{.BIN_DIR}}" }}/{{ "{{.APP_NAME}}" }}' - ICON: '{{ "{{.ROOT_DIR}}"}}/build/appicon.png' - OUTPUTFILE: '-outputfile {{ "{{.ROOT_DIR}}"}}/build/appimage/{{ "{{.APP_NAME}}" }}.desktop' ## -------------------------- Misc -------------------------- ##