├── .github ├── release.yml └── workflows │ ├── ci.yml │ └── release.yml ├── .gitignore ├── LICENSE ├── README.md ├── cmd ├── exwrap │ └── main.go └── wrapper │ └── main.go ├── examples └── django-app-exwrap.json ├── go.mod ├── go.sum ├── impl ├── cmd.go ├── config.go ├── constants.go ├── embed.go ├── generate.go ├── platform.go ├── scripts.go ├── shared.go ├── unzip.go └── utils.go ├── resources └── Info.plist ├── scripts └── build.sh └── test └── test.py /.github/release.yml: -------------------------------------------------------------------------------- 1 | changelog: 2 | exclude: 3 | labels: 4 | - ignore-for-release 5 | authors: 6 | - octocat 7 | categories: 8 | - title: Breaking Changes 🛠 9 | labels: 10 | - Semver-Major 11 | - breaking-change 12 | exclude: 13 | labels: 14 | - dependencies 15 | - title: Exciting New Features 🎉 16 | labels: 17 | - Semver-Minor 18 | - enhancement 19 | exclude: 20 | labels: 21 | - dependencies 22 | - title: Other Changes 23 | labels: 24 | - "*" -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | push: 5 | pull_request: 6 | branches: [ main ] 7 | 8 | jobs: 9 | build: 10 | strategy: 11 | matrix: 12 | os: [darwin, linux, windows] 13 | arch: [386, amd64, arm, arm64] 14 | exclude: 15 | # excludes 386 and arm on darwin 16 | - os: darwin 17 | arch: 386 18 | - os: darwin 19 | arch: arm 20 | fail-fast: false 21 | runs-on: ubuntu-latest 22 | permissions: 23 | actions: read 24 | contents: read 25 | security-events: write 26 | steps: 27 | - uses: actions/checkout@v4 28 | - name: Setup Go 1.24.x 29 | uses: actions/setup-go@v5 30 | with: 31 | go-version: '1.21.x' 32 | - name: Install dependencies 33 | run: | 34 | go get github.com/maja42/ember/ 35 | - name: Build 36 | run: | 37 | ./scripts/build.sh 38 | - name: Generate Artifacts 39 | if: '!cancelled()' 40 | uses: actions/upload-artifact@v4 41 | with: 42 | name: exwrap-${{ matrix.os }}-${{ matrix.arch }}.zip 43 | path: ${{github.workspace}}/build/${{ matrix.os }}/${{ matrix.arch }} 44 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Create Release 2 | 3 | on: 4 | release: 5 | types: [published] 6 | 7 | jobs: 8 | build: 9 | strategy: 10 | matrix: 11 | os: [darwin, linux, windows] 12 | arch: [386, amd64, arm, arm64] 13 | exclude: 14 | # excludes 386 and arm on darwin 15 | - os: darwin 16 | arch: 386 17 | - os: darwin 18 | arch: arm 19 | fail-fast: false 20 | runs-on: ubuntu-latest 21 | steps: 22 | - uses: actions/checkout@v4 23 | - name: Setup Go 1.24.x 24 | uses: actions/setup-go@v5 25 | with: 26 | go-version: '1.21.x' 27 | - name: Install dependencies 28 | run: | 29 | go get github.com/maja42/ember/ 30 | - name: Build 31 | run: | 32 | ./scripts/build.sh 33 | zip -r9 --symlinks app-${{ matrix.os }}-${{ matrix.arch }}.zip ${{github.workspace}}/build/${{ matrix.os }}/${{ matrix.arch }} 34 | - name: Upload Artifact to Release 35 | uses: actions/upload-release-asset@v1.0.1 36 | env: 37 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 38 | with: 39 | upload_url: ${{ github.event.release.upload_url }} 40 | asset_path: app-${{ matrix.os }}-${{ matrix.arch }}.zip 41 | asset_name: exwrap-${{ github.event.release.tag_name }}-${{ matrix.os }}-${{ matrix.arch }}.zip 42 | asset_content_type: application/zip 43 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /build/ 2 | /exwrap 3 | /pkg/ 4 | /exwrap.json 5 | .DS_Store 6 | /.idea -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Richard Ore 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ExWrap 2 | 3 | A general purpose executable wrapper that can turn any application written in any programming language into an executable file. 4 | 5 | ## Why? 6 | 7 | Though there are numerous systems available today that attempts to solve the problem of converting applications usually written in scripting languages into executable apps that can easily be distributed (Electron, PyInstaller, etc.), they all usually have one or more of the following problems. 8 | 9 | - Targeted at applications written in a particular programming language (E.g. Electron and PyInstaller). 10 | - Not cross platform (You need to be on a similar machine to the target to be able to compile for such platforms). 11 | - Have bloated configurations and/or require too many steps setting up and using. 12 | 13 | ## What does `ExWrap` do different? 14 | 15 | - `ExWrap` takes a different approach by allowing you to convert any application written in any language into an executable without changing any part of your code or altering how your code works or having to write with a set of fixed APIs. 16 | - By leveraging the power of Go, `ExWrap` is cross-platform. This means you don't have to get a different machine to build for a new set of users. You can build for Windows from your MacBook. All (first-class) Golang compiler OS/Arch pairs are supported. 17 | - `ExWrap` tries its best to reduce configurations to the bearest minimum while maintaining the most familiar representation of objects you can have today (JSON) for its configuration meaning you usually don't have to learn anything different to start working with `ExWrap`. 18 | - Despite this simplification, `ExWrap` still takes the do it yourself (DIY) approach to doing things. Meaning you get to decide what an executable is and what it contains. 19 | 20 | ## Roadmap 21 | 22 | - [x] Generate Installers and Applications for: 23 | - [x] Windows 24 | - [x] Linux 25 | - [x] MacOS (Exe and .APP bundles) 26 | - [x] Support for major compiler architectures: 27 | - [x] i386 28 | - [x] AMD64 29 | - [x] ARM 30 | - [x] ARM64 31 | - [x] Cross-platform executable generation 32 | - [x] Pre-Install commands 33 | - [x] Post-Install commands 34 | 35 | ## Installation 36 | 37 | We do not have installation via package managers yet as `ExWrap` is still in the `Pre-Alpha` phase. You can download and built it yourself, or download a pre-built release. 38 | 39 | To build it yourself, simply run the `scripts/build.sh` (for Linux and MacOS users) or `scripts/build.cmd` (for Windows user) file. Ensure you have `go` installed and available in user or system PATH. 40 | 41 | > **NOTE:** 42 | > 43 | > You may also need to add `exwrap` to PATH. 44 | 45 | ## Configuration 46 | 47 | The documentation for this is in progress. However, you can check the examples folder for sample configurations. 48 | 49 | For now, you can simply consult the [impl/config.go](https://github.com/mcfriend99/exwrap/blob/main/impl/config.go) file to see the available configurations. 50 | 51 | The ONLY requirement for `ExWrap` is the existence of the `exwrap.json` file (or whatever name you choose). For simplicity, it might be preferred to always keep this files at the root of the application directory similar to how `composer.json` and `package.json` are being used today. However, the file can be anywhere on your system. 52 | 53 | **The only required configuration element is the _`entry_point`_. This is what specifies which command will be run when the application is launched.** 54 | 55 | ## Running `ExWrap` 56 | 57 | Simply run the command `exwrap` from the directory containing the `exwrap.json` file. 58 | 59 | Alternatively, you can run the following to point to another configuration file other than `exwrap.json`.: 60 | 61 | ```sh 62 | exwrap -config /path/to/my-custom-exwrap-file.json 63 | ``` 64 | 65 | By default, `ExWrap` generates build into the `build` directory at the location from which the `exwrap` command was called. It will create this directory if it doesn't exist. The final executable can be found in that directory. However, it is possible to change the target directory by using the `-dir` flag. 66 | 67 | ```sh 68 | exwrap -dir MyCustomBuildDirectory 69 | ``` 70 | 71 | You can type `exwrap --help` for more. 72 | 73 | ## NOTICE 74 | 75 | > **Notice for all users** 76 | > 77 | > - When the installers are run, they'll automatically install the app into the configured install directory (or `C:\Program Files\` and `/home/$USERNAME/` for Windows and Linux respectively if none is configured). 78 | > - The name of the executable will be the name set in `target_name` or the name of the root directory if `target_name` is not set. 79 | 80 | > **Notice For MacOS users:** 81 | > 82 | > - While `ExWrap` generates an installer Windows and Linux, for MacOS, it does not generate a DMG based installer but rather an executable installer. This is deliberate as there is currently no efficient cross-platform way to programmatically create DMG file on other operating systems without need for users to install extra dependencies which may of their own introduce new bottlenecks. 83 | > - For this reason, `ExWrap` has been enabled with the capability to generate an application (`.app`) file as an opt-in. To enable this, set the darwin > create_app config to `true`. 84 | > 85 | > **ExWrap is not a runtime** 86 | > ExWrap is not a runtime but an executable and installer generator. 87 | > If any app makes system specific calls that are not available on other platforms, then it won't run on other platforms. 88 | > However, ExWrap will still go ahead and generate a valid exe for other platforms. 89 | > The app generated by ExWrap will definitely start on other platforms, but will crash since the code itself is making system specific calls. 90 | > An app not running on other platforms is not a problem of ExWrap but a problem of the app itself. However, ExWrap will still do its own work (generating an exe) without you having to make any changes to your code. 91 | 92 | 93 | ## Contributing 94 | 95 | All forms of contribution is welcomed and appreciated. Kindly open an issue feature requests, bugs, pull requests, and/or suggestions. Please star the project to help booster visibility and increase the community. 96 | 97 | 98 | -------------------------------------------------------------------------------- /cmd/exwrap/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | 6 | "github.com/mcfriend99/exwrap/impl" 7 | ) 8 | 9 | var OsMatrix []string = []string{"windows", "linux", "darwin"} 10 | var ArchMatrix []string = []string{"windows", "linux", "darwin"} 11 | 12 | func main() { 13 | var cmd impl.CommandLine 14 | flag.StringVar(&cmd.ConfigFile, "config", impl.DefaultConfigFile, "The exwrap configuration file.") 15 | flag.StringVar(&cmd.BuildDirectory, "dir", impl.DefaultBuildDirectory, "The exwrap build directory.") 16 | flag.Parse() 17 | 18 | // load config file 19 | _ = impl.Generate(impl.LoadConfig(cmd), cmd) 20 | } 21 | -------------------------------------------------------------------------------- /cmd/wrapper/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "log" 7 | "os" 8 | "os/exec" 9 | "path" 10 | "runtime" 11 | "strings" 12 | 13 | "github.com/maja42/ember" 14 | "github.com/maja42/ember/embedding" 15 | "github.com/mcfriend99/exwrap/impl" 16 | ) 17 | 18 | type output struct { 19 | out []byte 20 | err error 21 | } 22 | 23 | func damaged(err error) { 24 | log.Fatalln("Damaged executable:", err.Error()) 25 | } 26 | 27 | func failed(err error) { 28 | log.Fatalln("Failed executable:", err.Error()) 29 | } 30 | 31 | func readEmbededConfig(r ember.Reader, v any) { 32 | if buffer, err := io.ReadAll(r); err == nil { 33 | if err = json.Unmarshal(buffer, v); err != nil { 34 | damaged(err) 35 | } 36 | } else { 37 | damaged(err) 38 | } 39 | } 40 | 41 | func extractEmbededFile(r ember.Reader, v any) { 42 | if buffer, err := io.ReadAll(r); err == nil { 43 | if err = json.Unmarshal(buffer, v); err != nil { 44 | damaged(err) 45 | } 46 | } else { 47 | damaged(err) 48 | } 49 | } 50 | 51 | func main() { 52 | embedding.SkipCompatibilityCheck = true 53 | attachments, err := ember.Open() 54 | if err != nil { 55 | log.Fatal(err) 56 | } 57 | defer attachments.Close() 58 | 59 | contents := attachments.List() 60 | 61 | var setup impl.SetupScript 62 | var launch impl.LaunchScript 63 | 64 | hasArchive := false 65 | _ = os.RemoveAll(impl.GetInstallExtractDir()) 66 | 67 | for _, name := range contents { 68 | // s := attachments.Size(name) 69 | // fmt.Printf("\nAttachment %q has %d bytes:\n", name, s) 70 | r := attachments.Reader(name) 71 | 72 | switch name { 73 | case impl.EmbededArchiveName: 74 | hasArchive = true 75 | os.MkdirAll(impl.GetInstallExtractDir(), 0755) 76 | 77 | if file, err := os.OpenFile(impl.GetInstallExtractFile(), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0755); err == nil { 78 | if _, err := io.Copy(file, r); err != nil { 79 | damaged(err) 80 | } 81 | } else { 82 | damaged(err) 83 | } 84 | 85 | break 86 | case impl.EmbededSetupScript: 87 | readEmbededConfig(r, &setup) 88 | break 89 | case impl.EmbededLaunchScript: 90 | readEmbededConfig(r, &launch) 91 | break 92 | } 93 | } 94 | 95 | if hasArchive { 96 | install(setup, launch) 97 | } else { 98 | launchApp() 99 | } 100 | } 101 | 102 | func install(setup impl.SetupScript, launch impl.LaunchScript) { 103 | target := impl.GetInstallDir(setup.InstallDirectory) 104 | _ = os.RemoveAll(target) 105 | os.MkdirAll(target, 0755) 106 | 107 | workingDir, err := os.Getwd() 108 | if err != nil { 109 | log.Fatalln("Could not determine current path") 110 | } 111 | 112 | // we are entring the target directory incase any pre-install 113 | // or post-install command depends on that (or use relative paths) 114 | err = os.Chdir(target) 115 | if err != nil { 116 | log.Fatalln("Could not resolve target path") 117 | } 118 | 119 | // run pre-install commands 120 | if len(setup.PreInstallCommands) > 0 { 121 | runSetupCommand(target, setup.PreInstallCommands) 122 | } 123 | 124 | if err := impl.Unzip(impl.GetInstallExtractFile(), target); err != nil { 125 | damaged(err) 126 | } 127 | 128 | if exe, err := os.Executable(); err == nil { 129 | exeTarget := path.Join(target, setup.ExeName) 130 | if impl.FileExists(exeTarget) { 131 | _ = os.RemoveAll(exeTarget) 132 | } 133 | 134 | if err = impl.RemoveEmbed(exe, exeTarget); err != nil { 135 | damaged(err) 136 | } 137 | } else { 138 | failed(err) 139 | } 140 | 141 | if runtime.GOOS != "windows" { 142 | for _, file := range setup.Executables { 143 | file = path.Join(target, file) 144 | if stat, err := os.Stat(file); err == nil { 145 | os.Chmod(file, stat.Mode()|0111) 146 | } 147 | } 148 | } 149 | 150 | if data, err := json.Marshal(launch.EntryPoint); err == nil { 151 | os.WriteFile(impl.GetLaunchScript(target), data, os.ModePerm) 152 | } else { 153 | log.Fatalln("Corrupt entrypoint.") 154 | } 155 | 156 | _ = os.RemoveAll(impl.GetInstallExtractDir()) 157 | 158 | // run post-install commands 159 | if len(setup.PostInstallCommands) > 0 { 160 | runSetupCommand(target, setup.PostInstallCommands) 161 | } 162 | 163 | err = os.Chdir(workingDir) 164 | if err != nil { 165 | // TODO: decide what to do here. 166 | // For now, do nothing... 167 | } 168 | 169 | log.Println("Installation Completed!") 170 | } 171 | 172 | func launchApp() { 173 | 174 | // move to app directory 175 | appDir := impl.GetAppDir() 176 | command := impl.GetLaunchCommand(appDir) 177 | if len(command) == 0 { 178 | log.Fatalln("Missing entrypoint.") 179 | } 180 | 181 | ch := make(chan output) 182 | 183 | go func() { 184 | // move into app directory 185 | hasDarwinAppLock := impl.FileExists(path.Join(appDir, impl.DarwinAppLockfile)) 186 | 187 | runtimeDir := appDir 188 | if runtime.GOOS == "darwin" && hasDarwinAppLock { 189 | runtimeDir = path.Join(appDir, "../Resources") 190 | } 191 | os.Chdir(runtimeDir) 192 | 193 | var cmd *exec.Cmd 194 | program := impl.GetAbsoluteCommandProgram( 195 | command[0], 196 | runtime.GOOS == "darwin" && hasDarwinAppLock, 197 | ) 198 | 199 | if len(command) > 1 { 200 | cmd = exec.Command(program, command[1:]...) 201 | } else { 202 | cmd = exec.Command(program) 203 | } 204 | 205 | out, err := cmd.CombinedOutput() 206 | ch <- output{out, err} 207 | }() 208 | 209 | select { 210 | case x := <-ch: 211 | if x.err != nil { 212 | log.Fatalln(x.err.Error()) 213 | } 214 | } 215 | } 216 | 217 | func runSetupCommand(root string, commands []string) { 218 | if len(commands) == 0 { 219 | return 220 | } 221 | 222 | for _, cmd := range commands { 223 | command := strings.Split(cmd, " ") 224 | if len(command) == 0 { 225 | continue 226 | } 227 | 228 | var cmd *exec.Cmd 229 | program := path.Join(root, command[0]) 230 | 231 | if len(command) > 1 { 232 | cmd = exec.Command(program, command[1:]...) 233 | } else { 234 | cmd = exec.Command(program) 235 | } 236 | 237 | _, err := cmd.CombinedOutput() 238 | if err != nil { 239 | log.Fatalln(err.Error()) 240 | } 241 | } 242 | } 243 | -------------------------------------------------------------------------------- /examples/django-app-exwrap.json: -------------------------------------------------------------------------------- 1 | { 2 | "root": "/Users/myuser/PythonVenv/projects/ecc", 3 | "entry_point" : [".venv/bin/python", "ecc-admin.py"], 4 | "exclude_dirs": ["/Users/myuser/PythonVenv/projects/ecc/.git", "/Users/myuser/PythonVenv/projects/ecc/uploads"], 5 | "exclude_files": ["test/test.py"], 6 | "extra_dirs": { 7 | "/opt/homebrew/Cellar/python@3.10/3.10.13_2/Frameworks/Python.framework/Versions/3.10/lib/python3.10": ".venv/lib/python3.10" 8 | }, 9 | "executables": [".venv/bin/python"], 10 | "icon": "/Users/myuser/PythonVenv/projects/ecc/icons/ecc-admin" 11 | } 12 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mcfriend99/exwrap 2 | 3 | go 1.22.2 4 | 5 | require github.com/maja42/ember v1.2.1 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 2 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 3 | github.com/maja42/ember v1.2.1 h1:ZRpyv5JAFT/wOGMUNJ0+ULzaRd6IbzrDKcFYiYVqaeE= 4 | github.com/maja42/ember v1.2.1/go.mod h1:PxP2TOhl/uQKXh63H+kQctWFT1LgxuDGFta++Pfhe0E= 5 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 6 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 7 | github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= 8 | github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= 9 | github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= 10 | github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 11 | github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= 12 | github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= 13 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 14 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 15 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 16 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 17 | -------------------------------------------------------------------------------- /impl/cmd.go: -------------------------------------------------------------------------------- 1 | package impl 2 | 3 | type CommandLine struct { 4 | ConfigFile string 5 | BuildDirectory string 6 | } 7 | -------------------------------------------------------------------------------- /impl/config.go: -------------------------------------------------------------------------------- 1 | package impl 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "os" 7 | "path" 8 | "runtime" 9 | "strings" 10 | ) 11 | 12 | // MacOS specific configuration 13 | type DarwinConfig struct { 14 | // Allows user to point to their own plist file for macos. 15 | PlistFile string `json:"plist,omitempty"` 16 | 17 | // When true, exwrap generates and app bundle instead of an installer. 18 | // Default: false 19 | CreateApp bool `json:"create_app,omitempty"` 20 | } 21 | 22 | type Config struct { 23 | // The root of the entire application. 24 | // Defaults to the current working directory. 25 | Root string `json:"root,omitempty"` 26 | 27 | // The entry point describes the command to run when 28 | // the executable starts. For example, ["python", "app.py"] 29 | EntryPoint []string `json:"entry_point"` 30 | 31 | // The name of the final executable. 32 | // Defaults to the name of the root folder. 33 | TargetName string `json:"target_name,omitempty"` 34 | 35 | // A list of commands to be run in order before installation begins 36 | PostInstallCommands []string `json:"post_install_cmds,omitempty"` 37 | 38 | // A list of commands to be run in order after installation completes. 39 | PreInstallCommands []string `json:"pre_install_cmds,omitempty"` 40 | 41 | // The OS on which exwrap is being run on (Defaults to your OS) 42 | SourceOs string `json:"source_os,omitempty"` 43 | 44 | // The processor architecture on which exwrap is being run on. 45 | // Defaults to your processor architecture 46 | SourceArch string `json:"source_arch,omitempty"` 47 | 48 | // The OS for which you are generating an executable for. 49 | // Defaults to your OS. 50 | TargetOs string `json:"os,omitempty"` 51 | 52 | // The processor architecture for which you are generating an 53 | // executable for. 54 | // Defaults to your processor architecture. 55 | TargetArch string `json:"arch,omitempty"` 56 | 57 | // A key/value pair that tells exwrap to replace any path the 58 | // matches or starts with the pattern indicated in the value 59 | // with the one indicated in the key. 60 | // In the format "override => match" 61 | PathOverrides map[string]string `json:"path_overrides,omitempty"` 62 | 63 | // Extra directories to add to the final executable. 64 | // In the format "source => destination" 65 | ExtraDirectories map[string]string `json:"extra_dirs"` 66 | 67 | // Extra files to add to the final executable. 68 | // In the format "source => destination" 69 | ExtraFiles map[string]string `json:"extra_files"` 70 | 71 | // A list of directories to not add to the final executable. 72 | ExcludeDirectories []string `json:"exclude_dirs"` 73 | 74 | // A list of files to not add to the final executable. 75 | ExcludeFiles []string `json:"exclude_files"` 76 | 77 | // The path that the final executable should be installed on. 78 | // 79 | // It is advisable that the install path should be a relative path. 80 | // On windows, it is relative to C:\Program Files\, 81 | // On Unix, it is relative to /home/$USERNAME/. 82 | // If empty, it defaults to the app name. 83 | // If an absolute path is given, it's used as is. 84 | InstallPath string `json:"install_path,omitempty"` 85 | 86 | // List of files that must be granted execute permission 87 | // when installation is extracted. 88 | Executables []string `json:"executables,omitempty"` 89 | 90 | // The application icon. This path should omit the extension as 91 | // exwrap will add the appropriate extension to the file. 92 | // Best practice is to have the icon in .icns (MacOS), 93 | // .ico (Windows), and .svg (Linux) format in a directory with 94 | // the same name 95 | Icon string `json:"icon,omitempty"` 96 | 97 | // Darwin (MacOS) specific configurations. 98 | Darwin DarwinConfig `json:"mac_os,omitempty"` 99 | } 100 | 101 | func LoadConfig(cmd CommandLine) Config { 102 | config := Config{} 103 | 104 | if data, err := os.ReadFile(cmd.ConfigFile); err == nil { 105 | if err = json.Unmarshal(data, &config); err != nil { 106 | log.Fatalln(err.Error()) 107 | } 108 | } else { 109 | log.Fatalln(err.Error()) 110 | } 111 | 112 | if len(config.EntryPoint) == 0 { 113 | log.Fatalln("Entrypoint required.") 114 | } 115 | 116 | if config.Root == "" || config.Root == "." { 117 | if dir, err := os.Getwd(); err == nil { 118 | config.Root = dir 119 | } else { 120 | log.Fatalln("Could not detect root directory!") 121 | } 122 | } else { 123 | if absPath, err := getFileAbsPath(config.Root); err == nil { 124 | config.Root = absPath 125 | } else { 126 | log.Fatalln("Failed to resolve root directory:", err.Error()) 127 | } 128 | } 129 | 130 | // ensure some major compatibilities 131 | config.SourceOs = strings.ToLower(config.SourceOs) 132 | config.SourceArch = strings.ToLower(config.SourceArch) 133 | config.TargetOs = strings.ToLower(config.TargetOs) 134 | config.TargetArch = strings.ToLower(config.TargetArch) 135 | 136 | // set defaults 137 | if config.TargetName == "" { 138 | config.TargetName = path.Base(config.Root) 139 | } 140 | if config.InstallPath == "" { 141 | config.InstallPath = config.TargetName 142 | } 143 | 144 | if config.SourceOs == "" { 145 | config.SourceOs = runtime.GOOS 146 | } else { 147 | config.SourceOs = strings.ToLower(config.SourceOs) 148 | } 149 | if config.SourceArch == "" { 150 | config.SourceArch = runtime.GOARCH 151 | } else { 152 | config.SourceArch = strings.ToLower(config.SourceArch) 153 | } 154 | 155 | if config.TargetOs == "" { 156 | config.TargetOs = config.SourceOs 157 | } else { 158 | config.TargetOs = strings.ToLower(config.TargetOs) 159 | } 160 | if config.TargetArch == "" { 161 | config.TargetArch = config.SourceArch 162 | } else { 163 | config.TargetArch = strings.ToLower(config.TargetArch) 164 | } 165 | 166 | if config.PathOverrides == nil { 167 | config.PathOverrides = make(map[string]string, 0) 168 | } else { 169 | for i, x := range config.PathOverrides { 170 | if abs, err := getFileAbsPath(i); err == nil { 171 | delete(config.PathOverrides, i) 172 | config.PathOverrides[abs] = x 173 | } 174 | } 175 | } 176 | 177 | if config.ExtraDirectories == nil { 178 | config.ExtraDirectories = make(map[string]string, 0) 179 | } else { 180 | for i, x := range config.ExtraDirectories { 181 | if abs, err := getFileAbsPath(i); err == nil { 182 | config.ExtraDirectories[abs] = x 183 | } else { 184 | delete(config.ExtraDirectories, i) 185 | } 186 | } 187 | } 188 | 189 | if config.ExtraFiles == nil { 190 | config.ExtraFiles = make(map[string]string, 0) 191 | } else { 192 | for i, x := range config.ExtraFiles { 193 | if abs, err := getFileAbsPath(i); err == nil { 194 | config.ExtraFiles[abs] = x 195 | } else { 196 | delete(config.ExtraFiles, i) 197 | } 198 | } 199 | } 200 | 201 | if config.ExcludeDirectories == nil { 202 | config.ExcludeDirectories = make([]string, 0) 203 | config.ExcludeDirectories = append(config.ExcludeDirectories, cmd.BuildDirectory) 204 | } 205 | 206 | if config.Executables == nil { 207 | config.Executables = make([]string, 0) 208 | } 209 | 210 | newExcludedDirList := make([]string, 0) 211 | for _, x := range config.ExcludeDirectories { 212 | if abs, err := getFileAbsPath(x); err == nil { 213 | newExcludedDirList = append(newExcludedDirList, abs) 214 | } 215 | } 216 | config.ExcludeDirectories = newExcludedDirList 217 | 218 | if config.ExcludeFiles == nil { 219 | config.ExcludeFiles = make([]string, 0) 220 | } else { 221 | newList := make([]string, 0) 222 | 223 | for _, x := range config.ExcludeFiles { 224 | if abs, err := getFileAbsPath(x); err == nil { 225 | newList = append(newList, abs) 226 | } 227 | } 228 | 229 | config.ExcludeFiles = newList 230 | } 231 | 232 | if config.Darwin.PlistFile == "" { 233 | config.Darwin.PlistFile = path.Join(getResourcesDirectory(), "Info.plist") 234 | } 235 | 236 | if config.PreInstallCommands == nil { 237 | config.PreInstallCommands = make([]string, 0) 238 | } 239 | 240 | if config.PostInstallCommands == nil { 241 | config.PostInstallCommands = make([]string, 0) 242 | } 243 | 244 | return config 245 | } 246 | 247 | func LoadDefaultConfig() Config { 248 | return LoadConfig(CommandLine{ 249 | BuildDirectory: DefaultBuildDirectory, 250 | ConfigFile: DefaultConfigFile, 251 | }) 252 | } 253 | -------------------------------------------------------------------------------- /impl/constants.go: -------------------------------------------------------------------------------- 1 | package impl 2 | 3 | const ( 4 | DefaultConfigFile = "exwrap.json" 5 | DefaultBuildDirectory = "build" 6 | AppArchiveName = "app.zip" 7 | DarwinAppArchiveName = "app.app" 8 | DarwinAppLockfile = ".exdarwin" 9 | AppEmbedExeName = "embed" 10 | EmbededArchiveName = "archive" 11 | EmbededSetupScript = "setup" 12 | EmbededLaunchScript = "launch" 13 | TmpExtractDir = ".exwraptmp" 14 | WinTmpExtractDir = "~exwraptmp" 15 | ExtractDstDir = "tmp" 16 | ExtractDstFile = "app.zip" 17 | ) 18 | -------------------------------------------------------------------------------- /impl/embed.go: -------------------------------------------------------------------------------- 1 | package impl 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/maja42/ember/embedding" 8 | ) 9 | 10 | func Embed(base string, destination string, attachments map[string]string) error { 11 | // Open executable 12 | exe, err := os.Open(base) 13 | if err != nil { 14 | return fmt.Errorf("Failed to open executable %q: %s", base, err) 15 | } 16 | defer exe.Close() 17 | 18 | // Open output 19 | out, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0755) 20 | if err != nil { 21 | return fmt.Errorf("Failed to open output file %q: %s", destination, err) 22 | } 23 | defer func() { 24 | _ = out.Close() 25 | if err := recover(); err != nil { // execution failed; delete created output file 26 | _ = os.Remove(destination) 27 | } 28 | }() 29 | 30 | logger := func(format string, args ...interface{}) { 31 | fmt.Printf("\t"+format+"\n", args...) 32 | } 33 | 34 | embedding.SkipCompatibilityCheck = true 35 | return embedding.EmbedFiles(out, exe, attachments, logger) 36 | } 37 | 38 | func RemoveEmbed(base string, destination string) error { 39 | // Open executable 40 | exe, err := os.Open(base) 41 | if err != nil { 42 | return fmt.Errorf("Failed to open executable %q: %s", base, err) 43 | } 44 | defer exe.Close() 45 | 46 | // Open output 47 | out, err := os.OpenFile(destination, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0755) 48 | if err != nil { 49 | return fmt.Errorf("Failed to open output file %q: %s", destination, err) 50 | } 51 | defer func() { 52 | _ = out.Close() 53 | if err := recover(); err != nil { // execution failed; delete created output file 54 | _ = os.Remove(destination) 55 | } 56 | }() 57 | 58 | logger := func(format string, args ...interface{}) { 59 | // fmt.Printf("\t"+format+"\n", args...) 60 | } 61 | 62 | embedding.SkipCompatibilityCheck = true 63 | return embedding.RemoveEmbedding(out, exe, logger) 64 | } 65 | -------------------------------------------------------------------------------- /impl/generate.go: -------------------------------------------------------------------------------- 1 | package impl 2 | 3 | import ( 4 | "archive/zip" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "io/fs" 9 | "log" 10 | "os" 11 | "path" 12 | "path/filepath" 13 | "strings" 14 | ) 15 | 16 | func makeAttachements(config Config, files []string) map[string]string { 17 | attachments := make(map[string]string, 0) 18 | 19 | MainLoop: 20 | for _, file := range files { 21 | if !hasMatchInList(config.ExcludeFiles, file) && !hasMatchInList(config.ExcludeDirectories, file) { 22 | mainKey := trimRoot(file, config.Root) 23 | 24 | if val, ok := config.PathOverrides[file]; ok { 25 | if val != "" { 26 | attachments[val] = file 27 | } 28 | continue 29 | } 30 | 31 | if val, ok := config.ExtraFiles[file]; ok { 32 | if val != "" { 33 | attachments[val] = file 34 | } 35 | continue 36 | } 37 | 38 | for base, override := range config.PathOverrides { 39 | if strings.HasPrefix(file, base) { 40 | attachments[trimRoot(file, base)] = strings.ReplaceAll(file, base, override) 41 | continue MainLoop 42 | } 43 | } 44 | 45 | for dir, targetDir := range config.ExtraDirectories { 46 | if strings.HasPrefix(file, dir) { 47 | newKey := strings.ReplaceAll(file, dir, targetDir) 48 | attachments[newKey] = file 49 | continue MainLoop 50 | } 51 | } 52 | 53 | if mainKey != "" { 54 | attachments[mainKey] = file 55 | } 56 | } 57 | } 58 | 59 | return attachments 60 | } 61 | 62 | func generateAttachments(config Config) map[string]string { 63 | attachments := make(map[string]string, 0) 64 | 65 | for k, v := range makeAttachements(config, listFiles(config.Root)) { 66 | attachments[v] = k 67 | } 68 | 69 | for dir := range config.ExtraDirectories { 70 | if _, err := os.Stat(dir); os.IsExist(err) { 71 | for k, v := range makeAttachements(config, listFiles(dir)) { 72 | attachments[v] = k 73 | } 74 | } else { 75 | log.Printf("Extra directory %s does not exist, skipping...\n", dir) 76 | } 77 | } 78 | 79 | for file := range config.ExtraFiles { 80 | if _, err := os.Stat(file); os.IsExist(err) { 81 | for k, v := range makeAttachements(config, []string{file}) { 82 | attachments[v] = k 83 | } 84 | } else { 85 | log.Printf("Extra file %s does not exist, skipping...\n", file) 86 | } 87 | } 88 | 89 | return attachments 90 | } 91 | 92 | func Generate(config Config, cmd CommandLine) string { 93 | // ensure we're trying to build a supported os/arch combination. 94 | failFormat := "Unsupported Os/Arch combination: %s/%s" 95 | rootNotFoundFormat := "Unsupported Os/Arch combination: %s/%s" 96 | 97 | if combo, ok := BuildCombinations[OSArch{config.TargetOs, config.TargetArch}]; ok { 98 | 99 | // For now, we're only supporting first-class build targets. 100 | // TODO: Support non first-class targets 101 | if !combo.FirstClass { 102 | log.Fatalf(failFormat, config.TargetOs, config.TargetArch) 103 | } 104 | } else { 105 | log.Fatalf(failFormat, config.TargetOs, config.TargetArch) 106 | } 107 | 108 | if _, err := os.Stat(config.Root); os.IsNotExist(err) { 109 | log.Fatalf(rootNotFoundFormat, config.TargetOs, config.TargetArch) 110 | } 111 | 112 | _ = os.RemoveAll(getBuildDir(cmd)) 113 | 114 | if config.TargetOs == "darwin" && config.Darwin.CreateApp { 115 | return GenerateDarwin(config, cmd) 116 | } else { 117 | return GenerateDefault(config, cmd) 118 | } 119 | } 120 | 121 | func GenerateDefault(config Config, cmd CommandLine) string { 122 | if err := os.MkdirAll(getBuildDir(cmd), os.ModePerm); err == nil { 123 | attachments := generateAttachments(config) 124 | 125 | // create build archive target 126 | targetArchive := getTargetBuildArchive(config, cmd) 127 | 128 | zipfile, err := os.Create(targetArchive) 129 | if err != nil { 130 | log.Fatalln("Failed to create application archive.") 131 | } 132 | defer zipfile.Close() 133 | 134 | // init zip file 135 | archive := zip.NewWriter(zipfile) 136 | 137 | // write files into it. 138 | for src, dest := range attachments { 139 | fmt.Printf("File discovered: %s => %s\n", src, dest) 140 | 141 | if file, err := os.Open(src); err == nil { 142 | if zf, err := archive.Create(dest); err == nil { 143 | _, err = io.Copy(zf, file) 144 | } 145 | 146 | file.Close() 147 | } 148 | } 149 | archive.Close() 150 | 151 | clear(attachments) 152 | 153 | srcWrapper := getPkgExeFromConfig(config) 154 | targetBase := getTargetBaseName(cmd, config) 155 | err = copyFile(srcWrapper, targetBase) 156 | if err != nil { 157 | log.Fatalln("Failed to copy application wrapper:", err.Error()) 158 | } 159 | attachments[EmbededArchiveName] = targetArchive 160 | 161 | targetExe := getTargetExeName(cmd, config) 162 | if FileExists(targetExe) { 163 | os.Remove(targetExe) 164 | } 165 | 166 | // Create the setup script 167 | setupScript := SetupScript{ 168 | InstallDirectory: config.InstallPath, 169 | Executables: config.Executables, 170 | ExeName: config.TargetName, 171 | PreInstallCommands: config.PreInstallCommands, 172 | PostInstallCommands: config.PostInstallCommands, 173 | } 174 | setupName := getBuildSetupScriptName(cmd) 175 | if data, err := json.Marshal(setupScript); err == nil { 176 | if err = os.WriteFile(setupName, data, fs.ModePerm); err != nil { 177 | log.Fatalln("Failed to create setup script.") 178 | } 179 | } else { 180 | log.Fatalln("Failed to create setup script.") 181 | } 182 | attachments[EmbededSetupScript] = setupName 183 | 184 | // Create the launch script 185 | launchScript := LaunchScript{ 186 | EntryPoint: config.EntryPoint, 187 | } 188 | launchName := getBuildLaunchScriptName(cmd) 189 | if data, err := json.Marshal(launchScript); err == nil { 190 | if err = os.WriteFile(launchName, data, fs.ModePerm); err != nil { 191 | log.Fatalln("Failed to create launch script.") 192 | } 193 | } else { 194 | log.Fatalln("Failed to create launch script.") 195 | } 196 | attachments[EmbededLaunchScript] = launchName 197 | 198 | err = Embed(targetBase, targetExe, attachments) 199 | if err != nil { 200 | log.Fatalln(err.Error()) 201 | } 202 | 203 | // delete redundant files... 204 | os.Remove(targetArchive) 205 | os.Remove(targetBase) 206 | os.Remove(setupName) 207 | os.Remove(launchName) 208 | 209 | return targetExe 210 | } else { 211 | log.Fatalln("Failed to create build directory!") 212 | } 213 | 214 | return "" 215 | } 216 | 217 | func GenerateDarwin(config Config, cmd CommandLine) string { 218 | if err := os.MkdirAll(getBuildDir(cmd), os.ModePerm); err == nil { 219 | attachments := generateAttachments(config) 220 | 221 | // create build archive target 222 | targetArchive := getTargetBuildArchive(config, cmd) 223 | 224 | macosDir := path.Join(targetArchive, "Contents", "MacOS") 225 | resourcesDir := path.Join(targetArchive, "Contents", "Resources") 226 | frameworksDir := path.Join(targetArchive, "Contents", "Frameworks") 227 | 228 | // create required dirs 229 | os.MkdirAll(macosDir, os.ModePerm) 230 | os.MkdirAll(resourcesDir, os.ModePerm) 231 | os.MkdirAll(frameworksDir, os.ModePerm) 232 | 233 | // write files into it. 234 | for src, dest := range attachments { 235 | fmt.Printf("File discovered: %s => %s\n", src, dest) 236 | tmpDst := dest 237 | 238 | dest = path.Join(resourcesDir, dest) 239 | os.MkdirAll(filepath.Dir(dest), os.ModePerm) 240 | 241 | if file, err := os.Open(src); err == nil { 242 | mode := os.ModePerm 243 | if stat, err := file.Stat(); err == nil { 244 | mode = stat.Mode() 245 | } 246 | 247 | if zf, err := os.OpenFile(dest, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, mode); err == nil { 248 | _, err = io.Copy(zf, file) 249 | 250 | // process executables list 251 | if stringListContains(config.Executables, tmpDst) { 252 | os.Chmod(dest, mode|0111) 253 | } 254 | } 255 | 256 | file.Close() 257 | } 258 | } 259 | 260 | clear(attachments) 261 | 262 | // Create the launch script 263 | launchScript := path.Join(macosDir, getLaunchScriptForDarwinApp(config)) 264 | 265 | if data, err := json.Marshal(config.EntryPoint); err == nil { 266 | if err = os.WriteFile(launchScript, data, fs.ModePerm); err != nil { 267 | log.Fatalln("Failed to create launch script:", err.Error()) 268 | } 269 | } else { 270 | log.Fatalln("Failed to create launch script:", err.Error()) 271 | } 272 | 273 | // indicate this is a darwin app 274 | _ = os.WriteFile(path.Join(macosDir, DarwinAppLockfile), []byte{}, os.ModePerm) 275 | 276 | srcWrapper := getPkgExeFromConfig(config) 277 | targetExe := path.Join(macosDir, config.TargetName) 278 | 279 | if err = copyFile(srcWrapper, targetExe); err != nil { 280 | log.Fatalln("Failed to create launch file:", err.Error()) 281 | } else { 282 | if stat, err := os.Stat(targetExe); err == nil { 283 | os.Chmod(targetExe, stat.Mode()|0111) 284 | } 285 | } 286 | 287 | // Create the info.plist file 288 | if data, err := os.ReadFile(config.Darwin.PlistFile); err == nil { 289 | data = []byte(strings.ReplaceAll(string(data), "${EXE}", config.TargetName)) 290 | 291 | if err = os.WriteFile(path.Join(targetArchive, "Contents", "Info.plist"), data, os.ModePerm); err != nil { 292 | log.Fatalln("Plist creation failed:", err.Error()) 293 | } 294 | } else { 295 | log.Fatalln("Plist read failed:", err.Error()) 296 | } 297 | 298 | // add the icon file if set 299 | icon := getIconFile(config) 300 | if icon != "" { 301 | if err = copyFile(icon, path.Join(resourcesDir, "icon.icns")); err != nil { 302 | // do nothing (because apps will still run without their icons)... 303 | } 304 | } 305 | 306 | return targetArchive 307 | } else { 308 | log.Fatalln("Failed to create build directory:", err.Error()) 309 | } 310 | 311 | return "" 312 | } 313 | -------------------------------------------------------------------------------- /impl/platform.go: -------------------------------------------------------------------------------- 1 | // copied from golang internal/platform module. 2 | 3 | package impl 4 | 5 | type OsArchInfo struct { 6 | CgoSupported bool 7 | FirstClass bool 8 | Broken bool 9 | } 10 | 11 | type OSArch struct { 12 | GOOS, GOARCH string 13 | } 14 | 15 | var BuildCombinations = map[OSArch]OsArchInfo{ 16 | {"aix", "ppc64"}: {CgoSupported: true}, 17 | {"android", "386"}: {CgoSupported: true}, 18 | {"android", "amd64"}: {CgoSupported: true}, 19 | {"android", "arm"}: {CgoSupported: true}, 20 | {"android", "arm64"}: {CgoSupported: true}, 21 | {"darwin", "amd64"}: {CgoSupported: true, FirstClass: true}, 22 | {"darwin", "arm64"}: {CgoSupported: true, FirstClass: true}, 23 | {"dragonfly", "amd64"}: {CgoSupported: true}, 24 | {"freebsd", "386"}: {CgoSupported: true}, 25 | {"freebsd", "amd64"}: {CgoSupported: true}, 26 | {"freebsd", "arm"}: {CgoSupported: true}, 27 | {"freebsd", "arm64"}: {CgoSupported: true}, 28 | {"freebsd", "riscv64"}: {CgoSupported: true}, 29 | {"illumos", "amd64"}: {CgoSupported: true}, 30 | {"ios", "amd64"}: {CgoSupported: true}, 31 | {"ios", "arm64"}: {CgoSupported: true}, 32 | {"js", "wasm"}: {}, 33 | {"linux", "386"}: {CgoSupported: true, FirstClass: true}, 34 | {"linux", "amd64"}: {CgoSupported: true, FirstClass: true}, 35 | {"linux", "arm"}: {CgoSupported: true, FirstClass: true}, 36 | {"linux", "arm64"}: {CgoSupported: true, FirstClass: true}, 37 | {"linux", "loong64"}: {CgoSupported: true}, 38 | {"linux", "mips"}: {CgoSupported: true}, 39 | {"linux", "mips64"}: {CgoSupported: true}, 40 | {"linux", "mips64le"}: {CgoSupported: true}, 41 | {"linux", "mipsle"}: {CgoSupported: true}, 42 | {"linux", "ppc64"}: {}, 43 | {"linux", "ppc64le"}: {CgoSupported: true}, 44 | {"linux", "riscv64"}: {CgoSupported: true}, 45 | {"linux", "s390x"}: {CgoSupported: true}, 46 | {"linux", "sparc64"}: {CgoSupported: true, Broken: true}, 47 | {"netbsd", "386"}: {CgoSupported: true}, 48 | {"netbsd", "amd64"}: {CgoSupported: true}, 49 | {"netbsd", "arm"}: {CgoSupported: true}, 50 | {"netbsd", "arm64"}: {CgoSupported: true}, 51 | {"openbsd", "386"}: {CgoSupported: true}, 52 | {"openbsd", "amd64"}: {CgoSupported: true}, 53 | {"openbsd", "arm"}: {CgoSupported: true}, 54 | {"openbsd", "arm64"}: {CgoSupported: true}, 55 | {"openbsd", "mips64"}: {CgoSupported: true, Broken: true}, 56 | {"openbsd", "ppc64"}: {}, 57 | {"openbsd", "riscv64"}: {Broken: true}, 58 | {"plan9", "386"}: {}, 59 | {"plan9", "amd64"}: {}, 60 | {"plan9", "arm"}: {}, 61 | {"solaris", "amd64"}: {CgoSupported: true}, 62 | {"wasip1", "wasm"}: {}, 63 | {"windows", "386"}: {CgoSupported: true, FirstClass: true}, 64 | {"windows", "amd64"}: {CgoSupported: true, FirstClass: true}, 65 | {"windows", "arm"}: {}, 66 | {"windows", "arm64"}: {CgoSupported: true}, 67 | } 68 | -------------------------------------------------------------------------------- /impl/scripts.go: -------------------------------------------------------------------------------- 1 | package impl 2 | 3 | type SetupScript struct { 4 | InstallDirectory string `json:"install_dir"` 5 | ExeName string `json:"exe_name"` 6 | Executables []string `json:"executables"` 7 | PreInstallCommands []string `json:"pre_install_cmds"` 8 | PostInstallCommands []string `json:"post_install_cmds"` 9 | } 10 | 11 | type LaunchScript struct { 12 | EntryPoint []string `json:"entrypoint"` 13 | } 14 | -------------------------------------------------------------------------------- /impl/shared.go: -------------------------------------------------------------------------------- 1 | package impl 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "os" 8 | "path" 9 | "path/filepath" 10 | "runtime" 11 | ) 12 | 13 | var cachedInstallExtractDir string = "" 14 | var cachedInstallDir string = "" 15 | var cachedLaunchCommand []string = []string{} 16 | 17 | func GetInstallExtractDir() string { 18 | if cachedInstallExtractDir == "" { 19 | if dir, err := os.Getwd(); err == nil { 20 | if runtime.GOOS == "windows" { 21 | cachedInstallExtractDir = path.Join(dir, WinTmpExtractDir) 22 | } else { 23 | cachedInstallExtractDir = path.Join(dir, TmpExtractDir) 24 | } 25 | } else { 26 | cachedInstallExtractDir = WinTmpExtractDir 27 | } 28 | } 29 | 30 | return cachedInstallExtractDir 31 | } 32 | 33 | func GetSelfInstallExtractFile() string { 34 | if exe, err := os.Executable(); err == nil { 35 | return path.Join(GetInstallExtractDir(), path.Base(exe)) 36 | } 37 | 38 | return "" 39 | } 40 | 41 | func GetInstallExtractFile() string { 42 | return path.Join(GetInstallExtractDir(), ExtractDstFile) 43 | } 44 | 45 | func GetInstallDir(installPath string) string { 46 | if cachedInstallDir == "" { 47 | if filepath.IsAbs(installPath) { 48 | cachedInstallDir = installPath 49 | } else if runtime.GOOS == "windows" { 50 | if home, err := os.UserHomeDir(); err == nil { 51 | cachedInstallDir = path.Join(filepath.VolumeName(home), "Program Files", installPath) 52 | } 53 | } else { 54 | if home, err := os.UserHomeDir(); err == nil { 55 | cachedInstallDir = filepath.Join(home, installPath) 56 | } 57 | } 58 | 59 | // if it is still empty, default to as is. 60 | if cachedInstallDir == "" { 61 | cachedInstallDir = installPath 62 | } 63 | } 64 | 65 | return cachedInstallDir 66 | } 67 | 68 | func FileExists(filename string) bool { 69 | info, err := os.Stat(filename) 70 | if os.IsNotExist(err) { 71 | return false 72 | } 73 | return info != nil && !info.IsDir() 74 | } 75 | 76 | func GetAppDir() string { 77 | if cachedAppDir == "" { 78 | if ex, err := os.Executable(); err == nil { 79 | cachedAppDir = filepath.Dir(ex) 80 | } else { 81 | log.Fatalln("Failed to get application directory") 82 | } 83 | } 84 | 85 | return cachedAppDir 86 | } 87 | 88 | func GetAppName() string { 89 | if ex, err := os.Executable(); err == nil { 90 | return filepath.Base(ex) 91 | } 92 | 93 | log.Fatalln("Failed to get application directory") 94 | return "" 95 | } 96 | 97 | func GetLaunchScript(installPath string) string { 98 | return path.Join(GetInstallDir(installPath), fmt.Sprintf("%s.launch", GetAppName())) 99 | } 100 | 101 | func GetLaunchCommand(installPath string) []string { 102 | if len(cachedLaunchCommand) == 0 { 103 | launchFile := GetLaunchScript(installPath) 104 | if data, err := os.ReadFile(launchFile); err == nil { 105 | _ = json.Unmarshal(data, &cachedLaunchCommand) 106 | } 107 | } 108 | 109 | return cachedLaunchCommand 110 | } 111 | 112 | func GetAbsoluteCommandProgram(cmd string, isDarwin bool) string { 113 | if filepath.IsAbs(cmd) { 114 | return cmd 115 | } 116 | 117 | if isDarwin { 118 | return path.Join(GetAppDir(), "../Resources", cmd) 119 | } 120 | 121 | return path.Join(GetAppDir(), cmd) 122 | } 123 | -------------------------------------------------------------------------------- /impl/unzip.go: -------------------------------------------------------------------------------- 1 | package impl 2 | 3 | import ( 4 | "archive/zip" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | func Unzip(src, dest string) error { 13 | r, err := zip.OpenReader(src) 14 | if err != nil { 15 | return err 16 | } 17 | defer func() { 18 | if err := r.Close(); err != nil { 19 | panic(err) 20 | } 21 | }() 22 | 23 | os.MkdirAll(dest, 0755) 24 | 25 | // Closure to address file descriptors issue with all the deferred .Close() methods 26 | extractAndWriteFile := func(f *zip.File) error { 27 | rc, err := f.Open() 28 | if err != nil { 29 | return err 30 | } 31 | defer func() { 32 | if err := rc.Close(); err != nil { 33 | panic(err) 34 | } 35 | }() 36 | 37 | path := filepath.Join(dest, f.Name) 38 | 39 | // Check for ZipSlip (Directory traversal) 40 | if !strings.HasPrefix(path, filepath.Clean(dest)+string(os.PathSeparator)) { 41 | return fmt.Errorf("illegal file path: %s", path) 42 | } 43 | 44 | if f.FileInfo().IsDir() { 45 | os.MkdirAll(path, os.ModePerm) 46 | } else { 47 | os.MkdirAll(filepath.Dir(path), os.ModePerm) 48 | f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, f.Mode()) 49 | if err != nil { 50 | return err 51 | } 52 | defer func() { 53 | if err := f.Close(); err != nil { 54 | panic(err) 55 | } 56 | }() 57 | 58 | _, err = io.Copy(f, rc) 59 | if err != nil { 60 | return err 61 | } 62 | } 63 | return nil 64 | } 65 | 66 | for _, f := range r.File { 67 | err := extractAndWriteFile(f) 68 | if err != nil { 69 | return err 70 | } 71 | } 72 | 73 | return nil 74 | } 75 | -------------------------------------------------------------------------------- /impl/utils.go: -------------------------------------------------------------------------------- 1 | package impl 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "io/fs" 7 | "log" 8 | "os" 9 | "path" 10 | "path/filepath" 11 | "strings" 12 | ) 13 | 14 | var cachedAppDir string = "" 15 | var cachedBuildDir string = "" 16 | 17 | func listFiles(root string) []string { 18 | files := make([]string, 0) 19 | 20 | if err := filepath.Walk(root, func(path string, info fs.FileInfo, err error) error { 21 | // make sure we resolve the path first for symbolic links before 22 | // we proceed. 23 | // this is where we ensure that only the actual files are being 24 | // included in the final artefact. 25 | path, info = resolveFile(path, info) 26 | if err == nil && !info.IsDir() { 27 | files = append(files, path) 28 | } 29 | 30 | return err 31 | }); err != nil { 32 | log.Fatalln("Failed to read root directory:", err.Error()) 33 | } 34 | 35 | return files 36 | } 37 | 38 | func stringListContains(list []string, key string) bool { 39 | for _, x := range list { 40 | if x == key { 41 | return true 42 | } 43 | } 44 | 45 | return false 46 | } 47 | 48 | func hasMatchInList(list []string, key string) bool { 49 | for _, x := range list { 50 | if x == key || strings.HasPrefix(key, x) { 51 | return true 52 | } 53 | } 54 | 55 | return false 56 | } 57 | 58 | func getFileAbsPath(path string) (string, error) { 59 | if absPath, err := filepath.Abs(path); err == nil { 60 | return absPath, nil 61 | } else { 62 | return "", err 63 | } 64 | } 65 | 66 | func resolveFile(file string, info fs.FileInfo) (string, fs.FileInfo) { 67 | if info.Mode()&os.ModeSymlink != 0 { 68 | // is symlink 69 | if link, err := os.Readlink(file); err == nil { 70 | if !filepath.IsAbs(link) { 71 | link = path.Join(path.Dir(file), link) 72 | } 73 | 74 | if stat, err := os.Stat(link); err == nil { 75 | return link, stat 76 | } 77 | 78 | return link, info 79 | } 80 | } 81 | 82 | return file, info 83 | } 84 | 85 | func trimRoot(path string, root string) string { 86 | return strings.TrimLeft(strings.ReplaceAll(path, root, ""), "/\\") 87 | } 88 | 89 | func getPkgExeName(exeOs string, arch string) string { 90 | var filepath string 91 | if exeOs == "windows" { 92 | filepath = path.Join(GetAppDir(), "pkg", fmt.Sprintf("wrapper-windows-%s.exe", arch)) 93 | } else { 94 | filepath = path.Join(GetAppDir(), "pkg", fmt.Sprintf("wrapper-%s-%s", exeOs, arch)) 95 | } 96 | 97 | if !FileExists(filepath) { 98 | log.Fatalf("Unsupported packaging combination %s/%s. File %s cannot be located!", exeOs, arch, filepath) 99 | } 100 | 101 | return filepath 102 | } 103 | 104 | func getEmbedExeName(cmd CommandLine, config Config) string { 105 | var filepath string 106 | if config.TargetOs == "windows" { 107 | filepath = path.Join(getBuildDir(cmd), fmt.Sprintf("%s.exe", AppEmbedExeName)) 108 | } else { 109 | filepath = path.Join(getBuildDir(cmd), AppEmbedExeName) 110 | } 111 | 112 | return filepath 113 | } 114 | 115 | func getTargetExeName(cmd CommandLine, config Config) string { 116 | var filepath string 117 | if config.TargetOs == "windows" { 118 | filepath = path.Join(getBuildDir(cmd), fmt.Sprintf("%s.exe", config.TargetName)) 119 | } else { 120 | filepath = path.Join(getBuildDir(cmd), config.TargetName) 121 | } 122 | 123 | return filepath 124 | } 125 | 126 | func getTargetBaseName(cmd CommandLine, config Config) string { 127 | var filepath string 128 | if config.TargetOs == "windows" { 129 | filepath = path.Join(getBuildDir(cmd), fmt.Sprintf("%s-base.exe", config.TargetName)) 130 | } else { 131 | filepath = path.Join(getBuildDir(cmd), fmt.Sprintf("%s-base", config.TargetName)) 132 | } 133 | 134 | return filepath 135 | } 136 | 137 | func getTargetWrapperName(cmd CommandLine, config Config) string { 138 | var filepath string 139 | if config.TargetOs == "windows" { 140 | filepath = path.Join(getBuildDir(cmd), fmt.Sprintf("%s-wrap.exe", config.TargetName)) 141 | } else { 142 | filepath = path.Join(getBuildDir(cmd), fmt.Sprintf("%s-wrap", config.TargetName)) 143 | } 144 | 145 | return filepath 146 | } 147 | 148 | func getResourcesDirectory() string { 149 | return path.Join(GetAppDir(), "Resources") 150 | } 151 | 152 | func getPkgExeFromConfig(config Config) string { 153 | return getPkgExeName(config.TargetOs, config.TargetArch) 154 | } 155 | 156 | func getLaunchScriptTempName() string { 157 | return fmt.Sprintf("%s.json", EmbededLaunchScript) 158 | } 159 | 160 | func getLaunchScriptForDarwinApp(config Config) string { 161 | return fmt.Sprintf("%s.launch", config.TargetName) 162 | } 163 | 164 | func getSetupScriptTempName() string { 165 | return fmt.Sprintf("%s.json", EmbededSetupScript) 166 | } 167 | 168 | func getBuildLaunchScriptName(cmd CommandLine) string { 169 | return path.Join(getBuildDir(cmd), getLaunchScriptTempName()) 170 | } 171 | 172 | func getBuildSetupScriptName(cmd CommandLine) string { 173 | return path.Join(getBuildDir(cmd), getSetupScriptTempName()) 174 | } 175 | 176 | func getBuildDir(cmd CommandLine) string { 177 | if filepath.IsAbs(cmd.BuildDirectory) { 178 | return cmd.BuildDirectory 179 | } 180 | 181 | if cachedBuildDir == "" { 182 | if dir, err := os.Getwd(); err == nil { 183 | cachedBuildDir = path.Join(dir, cmd.BuildDirectory) 184 | } 185 | cachedBuildDir = cmd.BuildDirectory 186 | } 187 | 188 | return cachedBuildDir 189 | } 190 | 191 | func getTargetBuildArchive(config Config, cmd CommandLine) string { 192 | if config.TargetOs == "darwin" { 193 | return path.Join(getBuildDir(cmd), fmt.Sprintf("%s.app", config.TargetName)) 194 | } else { 195 | return path.Join(getBuildDir(cmd), AppArchiveName) 196 | } 197 | } 198 | 199 | func copyFile(srcpath, dstpath string) error { 200 | if FileExists(dstpath) { 201 | return nil 202 | } 203 | 204 | r, err := os.Open(srcpath) 205 | if err != nil { 206 | return err 207 | } 208 | defer r.Close() // ignore error: file was opened read-only. 209 | 210 | w, err := os.Create(dstpath) 211 | if err != nil { 212 | return err 213 | } 214 | 215 | defer func() { 216 | // Report the error, if any, from Close, but do so 217 | // only if there isn't already an outgoing error. 218 | if c := w.Close(); err == nil { 219 | err = c 220 | } 221 | }() 222 | 223 | _, err = io.Copy(w, r) 224 | return err 225 | } 226 | 227 | func getIconFile(config Config) string { 228 | if config.Icon != "" { 229 | ext := "" 230 | switch config.TargetOs { 231 | case "windows": 232 | ext = "ico" 233 | break 234 | case "darwin": 235 | ext = "icns" 236 | break 237 | case "linux": 238 | ext = "svg" 239 | break 240 | } 241 | 242 | return fmt.Sprintf("%s.%s", config.Icon, ext) 243 | } 244 | 245 | return "" 246 | } 247 | -------------------------------------------------------------------------------- /resources/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleDevelopmentRegion 6 | English 7 | CFBundleExecutable 8 | ${EXE} 9 | CFBundleIconFile 10 | icon.icns 11 | CFBundleIdentifier 12 | ${EXE} 13 | CFBundlePackageType 14 | APPL 15 | NSHighResolutionCapable 16 | True 17 | 18 | 19 | -------------------------------------------------------------------------------- /scripts/build.sh: -------------------------------------------------------------------------------- 1 | # Build PKGs 2 | mkdir -p ./pkg 3 | 4 | GOOS=darwin GOARCH=amd64 go build -o ./pkg/wrapper-darwin-amd64 ./cmd/wrapper/main.go 5 | GOOS=darwin GOARCH=arm64 go build -o ./pkg/wrapper-darwin-arm64 ./cmd/wrapper/main.go 6 | GOOS=linux GOARCH=386 go build -o ./pkg/wrapper-linux-386 ./cmd/wrapper/main.go 7 | GOOS=linux GOARCH=amd64 go build -o ./pkg/wrapper-linux-amd64 ./cmd/wrapper/main.go 8 | GOOS=linux GOARCH=arm go build -o ./pkg/wrapper-linux-arm ./cmd/wrapper/main.go 9 | GOOS=linux GOARCH=arm64 go build -o ./pkg/wrapper-linux-arm64 ./cmd/wrapper/main.go 10 | GOOS=windows GOARCH=386 go build -o ./pkg/wrapper-windows-386.exe ./cmd/wrapper/main.go 11 | GOOS=windows GOARCH=amd64 go build -o ./pkg/wrapper-windows-amd64.exe ./cmd/wrapper/main.go 12 | GOOS=windows GOARCH=arm go build -o ./pkg/wrapper-windows-arm.exe ./cmd/wrapper/main.go 13 | GOOS=windows GOARCH=arm64 go build -o ./pkg/wrapper-windows-arm64.exe ./cmd/wrapper/main.go 14 | 15 | # Build Apps 16 | 17 | # Windows 18 | mkdir -p ./build/windows/386 19 | mkdir -p ./build/windows/amd64 20 | mkdir -p ./build/windows/arm 21 | mkdir -p ./build/windows/arm64 22 | 23 | GOOS=windows GOARCH=386 go build -o ./build/windows/386/exwrap.exe ./cmd/exwrap/ 24 | GOOS=windows GOARCH=amd64 go build -o ./build/windows/amd64/exwrap.exe ./cmd/exwrap/ 25 | GOOS=windows GOARCH=arm go build -o ./build/windows/arm/exwrap.exe ./cmd/exwrap/ 26 | GOOS=windows GOARCH=arm64 go build -o ./build/windows/arm64/exwrap.exe ./cmd/exwrap/ 27 | 28 | # Linux 29 | mkdir -p ./build/linux/386 30 | mkdir -p ./build/linux/amd64 31 | mkdir -p ./build/linux/arm 32 | mkdir -p ./build/linux/arm64 33 | 34 | GOOS=linux GOARCH=386 go build -o ./build/linux/386/exwrap ./cmd/exwrap/ 35 | GOOS=linux GOARCH=amd64 go build -o ./build/linux/amd64/exwrap ./cmd/exwrap/ 36 | GOOS=linux GOARCH=arm go build -o ./build/linux/arm/exwrap ./cmd/exwrap/ 37 | GOOS=linux GOARCH=arm64 go build -o ./build/linux/arm64/exwrap ./cmd/exwrap/ 38 | 39 | # OSX 40 | mkdir -p ./build/darwin/amd64 41 | mkdir -p ./build/darwin/arm64 42 | 43 | GOOS=darwin GOARCH=amd64 go build -o ./build/darwin/amd64/exwrap ./cmd/exwrap/ 44 | GOOS=darwin GOARCH=arm64 go build -o ./build/darwin/arm64/exwrap ./cmd/exwrap/ 45 | 46 | # Copy the PKGs 47 | 48 | cp -r ./pkg ./build/windows/386/pkg 49 | cp -r ./pkg ./build/windows/amd64/pkg 50 | cp -r ./pkg ./build/windows/arm/pkg 51 | cp -r ./pkg ./build/windows/arm64/pkg 52 | cp -r ./pkg ./build/linux/386/pkg 53 | cp -r ./pkg ./build/linux/amd64/pkg 54 | cp -r ./pkg ./build/linux/arm/pkg 55 | cp -r ./pkg ./build/linux/arm64/pkg 56 | cp -r ./pkg ./build/darwin/amd64/pkg 57 | cp -r ./pkg ./build/darwin/arm64/pkg 58 | 59 | # Fix executables 60 | find ./build -name "*-386" -exec chmod +x {} \; 61 | find ./build -name "*-amd64" -exec chmod +x {} \; 62 | find ./build -name "*-arm" -exec chmod +x {} \; 63 | find ./build -name "*-arm64" -exec chmod +x {} \; 64 | find ./build -name "*exwrap" -exec chmod +x {} \; 65 | 66 | -------------------------------------------------------------------------------- /test/test.py: -------------------------------------------------------------------------------- 1 | print('Test python script') --------------------------------------------------------------------------------