├── .github └── workflows │ └── release.yaml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE.md ├── README.md ├── SECURITY.md ├── assets └── darwin │ ├── Info.plist │ └── icon.icns ├── go.mod ├── go.sum ├── main.go └── src ├── app.go ├── bindings ├── app.go ├── bindings.go ├── browser-ext.go ├── browser.go ├── connect.go ├── crypto-helper.go ├── crypto-id.go ├── crypto-lock.go ├── data.go ├── file.go ├── http.go ├── os-dialog.go ├── os-native.go ├── os-screenshot.go ├── proxy.go ├── server.go ├── steam.go └── url.go ├── browser ├── browser.go ├── ext.go ├── find.go └── launch.go ├── file ├── file.go ├── key.go └── pack.go ├── helper ├── crypto.go ├── crypto_aes.go ├── crypto_rsa.go ├── open.go └── password.go ├── identity └── id.go ├── options ├── open.go ├── options.go ├── realm.go ├── storage.go ├── ui.go ├── webview_default.go ├── webview_linux.go └── webview_windows.go ├── steam ├── close.go ├── find.go └── run.go └── ui ├── scripts.go ├── ui.go ├── ui_playwright.go └── ui_webview.go /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [created] 4 | push: 5 | 6 | permissions: 7 | contents: write 8 | packages: write 9 | 10 | jobs: 11 | linux: 12 | name: Release Linux Binary 13 | runs-on: ubuntu-latest 14 | steps: 15 | - uses: actions/checkout@v4 16 | - uses: actions/setup-go@v5 17 | with: 18 | go-version: "1.24.x" 19 | - name: Install webview dependencies 20 | run: | 21 | sudo apt-get update 22 | sudo apt-get install libgtk-3-dev libwebkit2gtk-4.1-dev 23 | - name: Build 24 | run: | 25 | go build -ldflags="-s -w" -o bin/sage-linux-amd64 . 26 | - name: Upload artifacts 27 | uses: actions/upload-artifact@v4 28 | with: 29 | name: sage-linux-amd64 30 | path: ./bin/sage-linux-* 31 | if-no-files-found: error 32 | - if: github.event_name == 'release' 33 | name: Upload assets to release 34 | uses: svenstaro/upload-release-action@v2 35 | with: 36 | file: ./bin/sage-linux-* 37 | file_glob: true 38 | 39 | windows: 40 | name: Release Windows Binary 41 | runs-on: windows-latest 42 | steps: 43 | - uses: actions/checkout@v4 44 | - uses: actions/setup-go@v5 45 | with: 46 | go-version: "1.24.x" 47 | - name: Build 48 | run: | 49 | go build -ldflags="-s -w" -o bin/sage-windows-amd64.exe . 50 | - name: Upload artifacts 51 | uses: actions/upload-artifact@v4 52 | with: 53 | name: sage-windows-amd64 54 | path: ./bin/sage-windows-* 55 | if-no-files-found: error 56 | - if: github.event_name == 'release' 57 | name: Upload assets to release 58 | uses: svenstaro/upload-release-action@v2 59 | with: 60 | file: ./bin/sage-windows-* 61 | file_glob: true 62 | 63 | darwin: 64 | strategy: 65 | matrix: 66 | include: 67 | - runs-on: macos-13 68 | arch: amd64 69 | - runs-on: macos-14 70 | arch: arm64 71 | 72 | name: Release Darwin ${{ matrix.arch }} Binary 73 | runs-on: ${{ matrix.runs-on }} 74 | steps: 75 | - uses: actions/checkout@v4 76 | - uses: actions/setup-go@v5 77 | with: 78 | go-version: "1.24.x" 79 | - name: Build 80 | run: | 81 | go build -ldflags="-s -w" -o bin/sage-darwin-${{ matrix.arch }} -tags CI . 82 | - name: Package 83 | run: | 84 | mkdir -p SAGE.app/Contents/MacOS SAGE.app/Contents/Resources 85 | cp bin/sage-darwin-${{ matrix.arch }} SAGE.app/Contents/MacOS/SAGE 86 | cp assets/darwin/Info.plist SAGE.app/Contents/Info.plist 87 | cp assets/darwin/icon.icns SAGE.app/Contents/Resources/icon.icns 88 | tar -czf bin/sage-darwin-${{ matrix.arch }}.app.tar.gz SAGE.app 89 | - name: Upload artifacts 90 | uses: actions/upload-artifact@v4 91 | with: 92 | name: sage-darwin-${{ matrix.arch }} 93 | path: ./bin/sage-darwin-* 94 | if-no-files-found: error 95 | - if: github.event_name == 'release' 96 | name: Upload assets to release 97 | uses: svenstaro/upload-release-action@v2 98 | with: 99 | file: ./bin/sage-darwin-* 100 | file_glob: true 101 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | native-app 8 | 9 | # Test binary, built with `go test -c` 10 | *.test 11 | 12 | # Output of the go coverage tool, specifically when used with LiteIDE 13 | *.out 14 | 15 | # Dependency directories (remove the comment below to include it) 16 | # vendor/ 17 | 18 | # Go workspace file 19 | go.work 20 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thank you for your interest in our project! 4 | 5 | This project is source-available, meaning you can view and fork the code for 6 | your own use. However, due to licensing complications, we are currently not 7 | accepting any third-party contributions. 8 | 9 | We appreciate your understanding and support. If you have any questions or need 10 | further clarification, please open an issue and we'll do our best to assist you. 11 | 12 | Thank you! 13 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | # Source Available License 2 | 3 | Copyright (c) 2024, SAGE 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to compile 7 | and use the Software for private, non-commercial purposes. 8 | 9 | The software may not be used to create derivative works based on the Software, 10 | and the Software (including compiled versions) may not be distributed or shared 11 | with third parties. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 15 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 16 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 17 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 18 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # native-app 2 | 3 | This is the go native backend for the SAGE App. 4 | 5 | ## Building 6 | 7 | To build the app, you need to have the go compiler installed. You can download 8 | it from [here](https://golang.org/dl/). 9 | 10 | On linux, you will also need to install 11 | [GTK related libraries](https://github.com/webview/webview?tab=readme-ov-file#prerequisites) 12 | (make sure to use version 4.1). 13 | 14 | Once you have the go compiler installed, you can run the following command to 15 | build the app: 16 | 17 | ```bash 18 | go build 19 | ``` 20 | 21 | > [!NOTE] 22 | > If you are having issues building, you should run `git tag` and checkout the 23 | > latest tag. This will ensure that you are building the latest version of the 24 | > app which is known to work. 25 | 26 | You can also find pre-built binaries in the 27 | [releases](https://github.com/sag-enhanced/native-app/releases) section. 28 | 29 | ## Issues 30 | 31 | If you have any issues with the app, please open an issue in the 32 | [issue tracker](https://github.com/sag-enhanced/sage-issues/issues). See 33 | [SECURITY.md](SECURITY.md) for more information on how to report security 34 | issues. 35 | 36 | ## LICENSE 37 | 38 | This project is licensed under a SOURCE AVAILABLE license. See the 39 | [LICENSE](LICENSE.md) file for more details. 40 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Supported Versions 4 | 5 | We support the latest release of SAGE and the current master branch. 6 | 7 | If you find a security issue in an older release, please still report it to us, 8 | to force a software upgrade to all affected users. 9 | 10 | ## Reporting a Vulnerability 11 | 12 | Please report security vulnerabilities to us by using our 13 | [contact methods](https://sage.party/contact). 14 | 15 | We will respond to security reports within 24 hours, and will keep you informed 16 | of our progress in fixing the vulnerability. 17 | 18 | ## Scope 19 | 20 | This security policy applies to the SAGE software and all of its components, for 21 | example: 22 | 23 | - bypassing file encryption 24 | - any XSS inside the UI 25 | - any potential abuse of the RPC interface, like: 26 | - stealing the user's private key 27 | - accessing the user's files (except in the SAGE directory) 28 | - native code execution 29 | -------------------------------------------------------------------------------- /assets/darwin/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleExecutable 6 | SAGE 7 | 8 | CFBundleIconFile 9 | icon.icns 10 | 11 | LSMinimumSystemVersion 12 | 10.15.7 13 | 14 | CFBundleIdentifier 15 | party.sage.app 16 | 17 | NSHighResolutionCapable 18 | 19 | 20 | LSUIElement 21 | 22 | 23 | 24 | -------------------------------------------------------------------------------- /assets/darwin/icon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/sag-enhanced/native-app/1170ad89296f6a1172bcc80b9006fb685dd124f2/assets/darwin/icon.icns -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/sag-enhanced/native-app 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/denisbrodbeck/machineid v1.0.1 7 | github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4 8 | github.com/go-jose/go-jose/v4 v4.0.5 9 | github.com/kbinani/screenshot v0.0.0-20250118074034-a3924b7bbc8c 10 | github.com/makiuchi-d/gozxing v0.1.1 11 | github.com/playwright-community/playwright-go v0.5001.0 12 | github.com/sag-enhanced/webview_go v0.0.0-20240815072320-127806c5f14b 13 | github.com/shirou/gopsutil/v3 v3.24.5 14 | github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 15 | github.com/wzshiming/anyproxy v0.7.19 16 | github.com/wzshiming/bridge v0.12.3 17 | golang.org/x/crypto v0.36.0 18 | golang.org/x/sys v0.31.0 19 | ) 20 | 21 | require ( 22 | github.com/Microsoft/go-winio v0.6.2 // indirect 23 | github.com/TheTitanrain/w32 v0.0.0-20200114052255-2654d97dbd3d // indirect 24 | github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da // indirect 25 | github.com/deckarep/golang-set/v2 v2.8.0 // indirect 26 | github.com/gen2brain/shm v0.1.1 // indirect 27 | github.com/go-jose/go-jose/v3 v3.0.4 // indirect 28 | github.com/go-ole/go-ole v1.3.0 // indirect 29 | github.com/go-stack/stack v1.8.1 // indirect 30 | github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 // indirect 31 | github.com/godbus/dbus/v5 v5.1.0 // indirect 32 | github.com/jezek/xgb v1.1.1 // indirect 33 | github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect 34 | github.com/lxn/win v0.0.0-20210218163916-a377121e959e // indirect 35 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d // indirect 36 | github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect 37 | github.com/shoenig/go-m1cpu v0.1.6 // indirect 38 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af // indirect 39 | github.com/tklauser/go-sysconf v0.3.15 // indirect 40 | github.com/tklauser/numcpus v0.10.0 // indirect 41 | github.com/wzshiming/cmux v0.4.2 // indirect 42 | github.com/wzshiming/commandproxy v0.2.1 // indirect 43 | github.com/wzshiming/hostmatcher v0.0.3 // indirect 44 | github.com/wzshiming/httpproxy v0.5.7 // indirect 45 | github.com/wzshiming/schedialer v0.6.1 // indirect 46 | github.com/wzshiming/shadowsocks v0.4.2 // indirect 47 | github.com/wzshiming/socks4 v0.3.3 // indirect 48 | github.com/wzshiming/socks5 v0.5.2 // indirect 49 | github.com/wzshiming/sshd v0.2.4 // indirect 50 | github.com/wzshiming/sshproxy v0.5.2 // indirect 51 | github.com/wzshiming/trie v0.3.1 // indirect 52 | github.com/yusufpapurcu/wmi v1.2.4 // indirect 53 | go.uber.org/multierr v1.11.0 // indirect 54 | golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 // indirect 55 | golang.org/x/net v0.37.0 // indirect 56 | golang.org/x/sync v0.12.0 // indirect 57 | golang.org/x/text v0.23.0 // indirect 58 | golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da // indirect 59 | ) 60 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= 2 | github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= 3 | github.com/TheTitanrain/w32 v0.0.0-20180517000239-4f5cfb03fabf/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I= 4 | github.com/TheTitanrain/w32 v0.0.0-20200114052255-2654d97dbd3d h1:2xp1BQbqcDDaikHnASWpVZRjibOxu7y9LhAv04whugI= 5 | github.com/TheTitanrain/w32 v0.0.0-20200114052255-2654d97dbd3d/go.mod h1:peYoMncQljjNS6tZwI9WVyQB3qZS6u79/N3mBOcnd3I= 6 | github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da h1:KjTM2ks9d14ZYCvmHS9iAKVt9AyzRSqNU1qabPih5BY= 7 | github.com/aead/chacha20 v0.0.0-20180709150244-8b13a72661da/go.mod h1:eHEWzANqSiWQsof+nXEI9bUVUyV6F53Fp89EuCh2EAA= 8 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 9 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 10 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 11 | github.com/deckarep/golang-set/v2 v2.6.0 h1:XfcQbWM1LlMB8BsJ8N9vW5ehnnPVIw0je80NsVHagjM= 12 | github.com/deckarep/golang-set/v2 v2.6.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= 13 | github.com/deckarep/golang-set/v2 v2.8.0 h1:swm0rlPCmdWn9mESxKOjWk8hXSqoxOp+ZlfuyaAdFlQ= 14 | github.com/deckarep/golang-set/v2 v2.8.0/go.mod h1:VAky9rY/yGXJOLEDv3OMci+7wtDpOF4IN+y82NBOac4= 15 | github.com/denisbrodbeck/machineid v1.0.1 h1:geKr9qtkB876mXguW2X6TU4ZynleN6ezuMSRhl4D7AQ= 16 | github.com/denisbrodbeck/machineid v1.0.1/go.mod h1:dJUwb7PTidGDeYyUBmXZ2GphQBbjJCrnectwCyxcUSI= 17 | github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4 h1:ygs9POGDQpQGLJPlq4+0LBUmMBNox1N4JSpw+OETcvI= 18 | github.com/gen2brain/beeep v0.0.0-20240516210008-9c006672e7f4/go.mod h1:0W7dI87PvXJ1Sjs0QPvWXKcQmNERY77e8l7GFhZB/s4= 19 | github.com/gen2brain/shm v0.1.0 h1:MwPeg+zJQXN0RM9o+HqaSFypNoNEcNpeoGp0BTSx2YY= 20 | github.com/gen2brain/shm v0.1.0/go.mod h1:UgIcVtvmOu+aCJpqJX7GOtiN7X2ct+TKLg4RTxwPIUA= 21 | github.com/gen2brain/shm v0.1.1 h1:1cTVA5qcsUFixnDHl14TmRoxgfWEEZlTezpUj1vm5uQ= 22 | github.com/gen2brain/shm v0.1.1/go.mod h1:UgIcVtvmOu+aCJpqJX7GOtiN7X2ct+TKLg4RTxwPIUA= 23 | github.com/go-jose/go-jose/v3 v3.0.3 h1:fFKWeig/irsp7XD2zBxvnmA/XaRWp5V3CBsZXJF7G7k= 24 | github.com/go-jose/go-jose/v3 v3.0.3/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 25 | github.com/go-jose/go-jose/v3 v3.0.4 h1:Wp5HA7bLQcKnf6YYao/4kpRpVMp/yf6+pJKV8WFSaNY= 26 | github.com/go-jose/go-jose/v3 v3.0.4/go.mod h1:5b+7YgP7ZICgJDBdfjZaIt+H/9L9T/YQrVfLAMboGkQ= 27 | github.com/go-jose/go-jose/v4 v4.0.2 h1:R3l3kkBds16bO7ZFAEEcofK0MkrAJt3jlJznWZG0nvk= 28 | github.com/go-jose/go-jose/v4 v4.0.2/go.mod h1:WVf9LFMHh/QVrmqrOfqun0C45tMe3RoiKJMPvgWwLfY= 29 | github.com/go-jose/go-jose/v4 v4.0.5 h1:M6T8+mKZl/+fNNuFHvGIzDz7BTLQPIounk/b9dw3AaE= 30 | github.com/go-jose/go-jose/v4 v4.0.5/go.mod h1:s3P1lRrkT8igV8D9OjyL4WRyHvjB6a4JSllnOrmmBOA= 31 | github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0= 32 | github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= 33 | github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= 34 | github.com/go-stack/stack v1.8.1 h1:ntEHSVwIt7PNXNpgPmVfMrNhLtgjlmnZha2kOpuRiDw= 35 | github.com/go-stack/stack v1.8.1/go.mod h1:dcoOX6HbPZSZptuspn9bctJ+N/CnF5gGygcUP3XYfe4= 36 | github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4 h1:qZNfIGkIANxGv/OqtnntR4DfOY2+BgwR60cAcu/i3SE= 37 | github.com/go-toast/toast v0.0.0-20190211030409-01e6764cf0a4/go.mod h1:kW3HQ4UdaAyrUCSSDR4xUzBKW6O2iA4uHhk7AtyYp10= 38 | github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= 39 | github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 40 | github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= 41 | github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= 42 | github.com/google/go-cmp v0.5.9/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 43 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 44 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 45 | github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= 46 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510 h1:El6M4kTTCOh6aBiKaUGG7oYTSPP8MxqL4YI3kZKwcP4= 47 | github.com/google/shlex v0.0.0-20191202100458-e7afc7fbc510/go.mod h1:pupxD2MaaD3pAXIBCelhxNneeOaAeabZDe5s4K6zSpQ= 48 | github.com/jezek/xgb v1.1.1 h1:bE/r8ZZtSv7l9gk6nU0mYx51aXrvnyb44892TwSaqS4= 49 | github.com/jezek/xgb v1.1.1/go.mod h1:nrhwO0FX/enq75I7Y7G8iN1ubpSGZEiA3v9e9GyRFlk= 50 | github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237 h1:YOp8St+CM/AQ9Vp4XYm4272E77MptJDHkwypQHIRl9Q= 51 | github.com/kbinani/screenshot v0.0.0-20230812210009-b87d31814237/go.mod h1:e7qQlOY68wOz4b82D7n+DdaptZAi+SHW0+yKiWZzEYE= 52 | github.com/kbinani/screenshot v0.0.0-20250118074034-a3924b7bbc8c h1:1IlzDla/ZATV/FsRn1ETf7ir91PHS2mrd4VMunEtd9k= 53 | github.com/kbinani/screenshot v0.0.0-20250118074034-a3924b7bbc8c/go.mod h1:Pmpz2BLf55auQZ67u3rvyI2vAQvNetkK/4zYUmpauZQ= 54 | github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae h1:dIZY4ULFcto4tAFlj1FYZl8ztUZ13bdq+PLY+NOfbyI= 55 | github.com/lufia/plan9stats v0.0.0-20240513124658-fba389f38bae/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k= 56 | github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc= 57 | github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= 58 | github.com/lxn/win v0.0.0-20210218163916-a377121e959e h1:H+t6A/QJMbhCSEH5rAuRxh+CtW96g0Or0Fxa9IKr4uc= 59 | github.com/lxn/win v0.0.0-20210218163916-a377121e959e/go.mod h1:KxxjdtRkfNoYDCUP5ryK7XJJNTnpC8atvtmTheChOtk= 60 | github.com/makiuchi-d/gozxing v0.1.1 h1:xxqijhoedi+/lZlhINteGbywIrewVdVv2wl9r5O9S1I= 61 | github.com/makiuchi-d/gozxing v0.1.1/go.mod h1:eRIHbOjX7QWxLIDJoQuMLhuXg9LAuw6znsUtRkNw9DU= 62 | github.com/mitchellh/go-ps v1.0.0 h1:i6ampVEEF4wQFF+bkYfwYgY+F/uYJDktmvLPf7qIgjc= 63 | github.com/mitchellh/go-ps v1.0.0/go.mod h1:J4lOc8z8yJs6vUwklHw2XEIiT4z4C40KtWVN3nvg8Pg= 64 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d h1:VhgPp6v9qf9Agr/56bj7Y/xa04UccTW04VP0Qed4vnQ= 65 | github.com/nu7hatch/gouuid v0.0.0-20131221200532-179d4d0c4d8d/go.mod h1:YUTz3bUH2ZwIWBy3CJBeOBEugqcmXREj14T+iG/4k4U= 66 | github.com/playwright-community/playwright-go v0.4401.1 h1:3EMTn9HUGETP3vjZLrVVNW+2xh+AtastOe7NHdT3fMs= 67 | github.com/playwright-community/playwright-go v0.4401.1/go.mod h1:bpArn5TqNzmP0jroCgw4poSOG9gSeQg490iLqWAaa7w= 68 | github.com/playwright-community/playwright-go v0.5001.0 h1:EY3oB+rU9cUp6CLHguWE8VMZTwAg+83Yyb7dQqEmGLg= 69 | github.com/playwright-community/playwright-go v0.5001.0/go.mod h1:kBNWs/w2aJ2ZUp1wEOOFLXgOqvppFngM5OS+qyhl+ZM= 70 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 71 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 72 | github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= 73 | github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= 74 | github.com/sag-enhanced/webview_go v0.0.0-20240815072320-127806c5f14b h1:KbKqu6JHUVRLTGHGTGWg7IrNuVpsLQcJtHiyMzK7gdM= 75 | github.com/sag-enhanced/webview_go v0.0.0-20240815072320-127806c5f14b/go.mod h1:8ZubNkw6Duh0B4ubSSOfKXPPWxawrYSbDONZRAVsLQ4= 76 | github.com/shirou/gopsutil/v3 v3.24.5 h1:i0t8kL+kQTvpAYToeuiVk3TgDeKOFioZO3Ztz/iZ9pI= 77 | github.com/shirou/gopsutil/v3 v3.24.5/go.mod h1:bsoOS1aStSs9ErQ1WWfxllSeS1K5D+U30r2NfcubMVk= 78 | github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= 79 | github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= 80 | github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU= 81 | github.com/shoenig/test v0.6.4/go.mod h1:byHiCGXqrVaflBLAMq/srcZIHynQPQgeyvkvXnjqq0k= 82 | github.com/sqweek/dialog v0.0.0-20240226140203-065105509627 h1:2JL2wmHXWIAxDofCK+AdkFi1KEg3dgkefCsm7isADzQ= 83 | github.com/sqweek/dialog v0.0.0-20240226140203-065105509627/go.mod h1:/qNPSY91qTz/8TgHEMioAUc6q7+3SOybeKczHMXFcXw= 84 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 85 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 86 | github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= 87 | github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 88 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af h1:6yITBqGTE2lEeTPG04SN9W+iWHCRyHqlVYILiSXziwk= 89 | github.com/tadvi/systray v0.0.0-20190226123456-11a2b8fa57af/go.mod h1:4F09kP5F+am0jAwlQLddpoMDM+iewkxxt6nxUQ5nq5o= 90 | github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU= 91 | github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY= 92 | github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4= 93 | github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4= 94 | github.com/tklauser/numcpus v0.8.0 h1:Mx4Wwe/FjZLeQsK/6kt2EOepwwSl7SmJrK5bV/dXYgY= 95 | github.com/tklauser/numcpus v0.8.0/go.mod h1:ZJZlAY+dmR4eut8epnzf0u/VwodKmryxR8txiloSqBE= 96 | github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso= 97 | github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ= 98 | github.com/wzshiming/anyproxy v0.7.15 h1:gFBjnWbu4tlYL6DaujoCfxVtJgkoSBwJTS7IElhWVF8= 99 | github.com/wzshiming/anyproxy v0.7.15/go.mod h1:66IW4j7SK/aRMJ9mUqnuhAoxXsvvfm/ag0E+KtfPhJk= 100 | github.com/wzshiming/anyproxy v0.7.19 h1:zSnPiFej7MEx+otPkS6pguuiVBGkmBD3+OfpvdvEQWY= 101 | github.com/wzshiming/anyproxy v0.7.19/go.mod h1:2wtEEJIcJCKWulzEOcn6rMoU6qQZdfT2jFYKErXjCBk= 102 | github.com/wzshiming/bridge v0.10.0 h1:gtN+BJfr8kXEp4mxysT5gwG/llFcHan+stIzK0s7jyQ= 103 | github.com/wzshiming/bridge v0.10.0/go.mod h1:T2l873czOCIqazpgpAQUqccHdUPQlJtS333Bngh4tAM= 104 | github.com/wzshiming/bridge v0.12.3 h1:lUtI6qv+BTkDCkhtnexfU4ug4jxCBJRy8TMR/z0XFcA= 105 | github.com/wzshiming/bridge v0.12.3/go.mod h1:upJOc9XFBv3gCxCQluykCzM0MaJbhhVuGo1qMlGHQkE= 106 | github.com/wzshiming/cmux v0.3.3 h1:WlcKUwSN4vpClnHiyX9I4RtZ4xJeAqfrf4ltxSWuPoQ= 107 | github.com/wzshiming/cmux v0.3.3/go.mod h1:lPhqJN2E3frzkxrPdjesxL09z7nTcuZ6i8Is+2G/Xw4= 108 | github.com/wzshiming/cmux v0.4.2 h1:tI73lL5ztVfiqw7R5m5BkxT1+vQ2PBo/oV6qPbNGPiA= 109 | github.com/wzshiming/cmux v0.4.2/go.mod h1:JgE61QfZAjEyNMX0iZo9zIKY6pr9bHVY132yYPwHW5U= 110 | github.com/wzshiming/commandproxy v0.2.0 h1:uPVhgIj2YSncRUo6g9smGR6OMzsIg7lwklcMHPPmEeM= 111 | github.com/wzshiming/commandproxy v0.2.0/go.mod h1:wS6+aJ9KMHciqYX3xmDO0W+QVY0zvngeBvmoIFMfq8A= 112 | github.com/wzshiming/commandproxy v0.2.1 h1:3LbKbNX0JS0CHZH2DpRmhzT+JZLXG6ZgNclL9vpZNCc= 113 | github.com/wzshiming/commandproxy v0.2.1/go.mod h1:wS6+aJ9KMHciqYX3xmDO0W+QVY0zvngeBvmoIFMfq8A= 114 | github.com/wzshiming/emux v0.2.1 h1:pu0oV9PpAJ5cVO8tzkqUXcCqc8xC452vNzQK9cghUis= 115 | github.com/wzshiming/emux v0.2.1/go.mod h1:VQF6NoR4nfm3+OrKZLx47JuxuDeWemHDc0a4qDNtFtg= 116 | github.com/wzshiming/hostmatcher v0.0.3 h1:+JYAq6vUZXDEQ1Ipfdc/D7HmaIMngcc71ftonyCQVQk= 117 | github.com/wzshiming/hostmatcher v0.0.3/go.mod h1:F04RIvIWEvOIrIKOlQlMuR8vQMKAVf2YhpU6l31Wwz4= 118 | github.com/wzshiming/httpproxy v0.5.5 h1:2vEW6QGYDDtA5B97PFWVolRZs3BYEUIXnhc0vXUzrQ8= 119 | github.com/wzshiming/httpproxy v0.5.5/go.mod h1:3bMDE7Ti13Hcdu6LGfYo+WGhriAoT0ldYQgXByohT20= 120 | github.com/wzshiming/httpproxy v0.5.7 h1:eAdbzsnr0JcXVLst9vp4oHL1rTaUgTYQeCerzkOAP3o= 121 | github.com/wzshiming/httpproxy v0.5.7/go.mod h1:vw/jA1IzuGBj+LndQ8h00IkU8OSS0lOZYw0HtjnnGZw= 122 | github.com/wzshiming/permuteproxy v0.0.2 h1:svedMueotlxJk9oJfA0gs8WzRYOdgd0DER9XvKpjwlY= 123 | github.com/wzshiming/permuteproxy v0.0.2/go.mod h1:Ny08A1JbuljB8FeJAOiB7dfvRGCVD8PB9hwrALIvYI8= 124 | github.com/wzshiming/schedialer v0.6.0 h1:U3plhiljDYO0q+3nnK9ntcfSl46HSDauqe2UBm35X7Y= 125 | github.com/wzshiming/schedialer v0.6.0/go.mod h1:TvVxg4QZIBTJzRfmL/G7g6CzynFQKPmtXtSeJ2c4Lus= 126 | github.com/wzshiming/schedialer v0.6.1 h1:4VwtIjVF3uMoWqjbyw3oqYi7WGOEYvDu3L9OYT8sbGY= 127 | github.com/wzshiming/schedialer v0.6.1/go.mod h1:TvVxg4QZIBTJzRfmL/G7g6CzynFQKPmtXtSeJ2c4Lus= 128 | github.com/wzshiming/shadowsocks v0.4.0 h1:Yi+4J/DK15qdKlssNEMoWjtmc8wyY8ByIBbC5Ft29bQ= 129 | github.com/wzshiming/shadowsocks v0.4.0/go.mod h1:xYRRSKR+hTihSDUOE+evavx0wkLVODTWVXEcz5vosoE= 130 | github.com/wzshiming/shadowsocks v0.4.2 h1:f3nVW20I/cpRXxHojRoosCM2DgCAGL8zv7T7QFe0Olw= 131 | github.com/wzshiming/shadowsocks v0.4.2/go.mod h1:4VlBH5YAkDUaU/f3rcuk3m+625Hyz3VajJJI2iRlK8E= 132 | github.com/wzshiming/socks4 v0.3.2 h1:w87nwfgRWteVwIH39nqTur8c+2dcODeLgLrWspcUkSc= 133 | github.com/wzshiming/socks4 v0.3.2/go.mod h1:YEPfhjf/4JezwdTmgXZU+UX+A2KvD05quzhsUBVMNA0= 134 | github.com/wzshiming/socks4 v0.3.3 h1:IsuqRbDrYfJKCrEXYNW3Nxi5l+4xKpoN7Z18b6xhE1s= 135 | github.com/wzshiming/socks4 v0.3.3/go.mod h1:YEPfhjf/4JezwdTmgXZU+UX+A2KvD05quzhsUBVMNA0= 136 | github.com/wzshiming/socks5 v0.5.1 h1:TRekapqSWrE4QYfGiZ4Ok04wQECuAyQsSUdljvbWO5w= 137 | github.com/wzshiming/socks5 v0.5.1/go.mod h1:BvCAqlzocQN5xwLjBZDBbvWlrx8sCYSSbHEOf2wZgT0= 138 | github.com/wzshiming/socks5 v0.5.2 h1:LtoowVNwAmkIQSkP1r1Wg435xUmC+tfRxorNW30KtnM= 139 | github.com/wzshiming/socks5 v0.5.2/go.mod h1:BvCAqlzocQN5xwLjBZDBbvWlrx8sCYSSbHEOf2wZgT0= 140 | github.com/wzshiming/sshd v0.2.2 h1:jvKTwG3lCvhObcUiB98MGcca9VGH4+uU89V96YBr7LY= 141 | github.com/wzshiming/sshd v0.2.2/go.mod h1:KnH7PobIFZ89iTnhLhFl3KUQNqxFTrO7dyDQ6jctVO8= 142 | github.com/wzshiming/sshd v0.2.4 h1:GGhZx8qA1GjjY76C2JCwJZHAJ+Kd1Qrm6ykQQueKidg= 143 | github.com/wzshiming/sshd v0.2.4/go.mod h1:dT2CiVtyiXxEdbTTQ46tAzeT9opon98T263ZtV6xU84= 144 | github.com/wzshiming/sshproxy v0.4.3 h1:RTm/yr0fo9Tx1VpjP1zkVh8ZWWdD2BNwdG/armjbzX8= 145 | github.com/wzshiming/sshproxy v0.4.3/go.mod h1:XYf7TaHNWK/wkML1bYT61ofSHZbRw5ncIQd7Lk+FLCM= 146 | github.com/wzshiming/sshproxy v0.5.2 h1:vV6nX2xVZNUP68gkuw6rOTOPzyV3pJmXNFtZNX8UAc4= 147 | github.com/wzshiming/sshproxy v0.5.2/go.mod h1:VvDPPaMcGav87evkR25DxF7xGx20E9fMhxYfRoZpiCM= 148 | github.com/wzshiming/trie v0.1.1 h1:02AaBSZGhs6Aqljp8fz4xq/Mg8omFBPIlrUS0pJ11ks= 149 | github.com/wzshiming/trie v0.1.1/go.mod h1:c9thxXTh4KcGkejt4sUsO4c5GUmWpxeWzOJ7AZJaI+8= 150 | github.com/wzshiming/trie v0.3.1 h1:YpuoqmEQFJiW0mns/mM6Qk4kdWrXc8kc28/KR1vn0m8= 151 | github.com/wzshiming/trie v0.3.1/go.mod h1:c9thxXTh4KcGkejt4sUsO4c5GUmWpxeWzOJ7AZJaI+8= 152 | github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY= 153 | github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0= 154 | github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= 155 | go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 156 | go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 157 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 158 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= 159 | golang.org/x/crypto v0.6.0/go.mod h1:OFC/31mSvZgRz0V1QTNCzfAI1aIRzbiufJtkMIlEp58= 160 | golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc= 161 | golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU= 162 | golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8= 163 | golang.org/x/crypto v0.25.0 h1:ypSNr+bnYL2YhwoMt2zPxHFmbAN1KZs/njMG3hxUp30= 164 | golang.org/x/crypto v0.25.0/go.mod h1:T+wALwcMOSE0kXgUAnPAHqTLW+XHgcELELW8VaDgm/M= 165 | golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk= 166 | golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34= 167 | golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc= 168 | golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8 h1:yixxcjnhBmY0nkL253HFVIm0JsFHwrHdT3Yh6szTnfY= 169 | golang.org/x/exp v0.0.0-20240613232115-7f521ea00fb8/go.mod h1:jj3sYF3dwk5D+ghuXyeI3r5MFf+NT2An6/9dOA95KSI= 170 | golang.org/x/exp v0.0.0-20250305212735-054e65f0b394 h1:nDVHiLt8aIbd/VzvPWN6kSOPE7+F/fNFDSXLVYkE/Iw= 171 | golang.org/x/exp v0.0.0-20250305212735-054e65f0b394/go.mod h1:sIifuuw/Yco/y6yb6+bDNfyeQ/MdPUy/hKEMYQV17cM= 172 | golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= 173 | golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 174 | golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= 175 | golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 176 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 177 | golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 178 | golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= 179 | golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= 180 | golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= 181 | golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg= 182 | golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk= 183 | golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= 184 | golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM= 185 | golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ= 186 | golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE= 187 | golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c= 188 | golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 189 | golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 190 | golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 191 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 192 | golang.org/x/sync v0.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y= 193 | golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 194 | golang.org/x/sync v0.7.0 h1:YsImfSBoP9QPYL0xyKJPq0gcaJdG3rInoqxTWbfQu9M= 195 | golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 196 | golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= 197 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 198 | golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= 199 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 200 | golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 201 | golang.org/x/sys v0.0.0-20201018230417-eeed37f84f13/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 202 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 203 | golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 204 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 205 | golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 206 | golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 207 | golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 208 | golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 209 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 210 | golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 211 | golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 212 | golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 213 | golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 214 | golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= 215 | golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 216 | golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 217 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 218 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 219 | golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE= 220 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 221 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 222 | golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= 223 | golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo= 224 | golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU= 225 | golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= 226 | golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY= 227 | golang.org/x/term v0.22.0 h1:BbsgPEJULsl2fV/AT3v15Mjva5yXKQDyKf+TbDz7QJk= 228 | golang.org/x/term v0.22.0/go.mod h1:F3qCibpT5AMpCRfhfT53vVJwhLtIVHhB9XDjfFvnMI4= 229 | golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM= 230 | golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 231 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 232 | golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 233 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 234 | golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= 235 | golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= 236 | golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE= 237 | golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 238 | golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 239 | golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4= 240 | golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI= 241 | golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ= 242 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 243 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 244 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 245 | golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= 246 | golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= 247 | golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= 248 | golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58= 249 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 250 | golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 251 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028 h1:+cNy6SZtPcJQH3LJVLOSmiC7MMxXNOb3PU/VUEz+EhU= 252 | golang.org/x/xerrors v0.0.0-20231012003039-104605ab7028/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 253 | golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da h1:noIWHXmPHxILtqtCOPIhSt0ABwskkZKjD3bXGnZGpNY= 254 | golang.org/x/xerrors v0.0.0-20240903120638-7835f813f4da/go.mod h1:NDW/Ps6MPRej6fsCIbMTohpP40sJ/P/vI1MoTEGwX90= 255 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 256 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 257 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 258 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 259 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "strings" 7 | 8 | "github.com/sag-enhanced/native-app/src" 9 | "github.com/sag-enhanced/native-app/src/options" 10 | ) 11 | 12 | func main() { 13 | opt := options.NewOptions() 14 | var openCommand string 15 | var buildOverride int 16 | var releaseOverride int 17 | var loopbackPort int 18 | flag.StringVar(&opt.DataDirectory, "data", opt.DataDirectory, "Data directory to use") 19 | flag.StringVar(&opt.Realm, "realm", options.StableRealm, "Run the app in the specified realm") 20 | flag.BoolVar(&opt.Verbose, "verbose", false, "Enable VERY verbose logging") 21 | flag.StringVar(&openCommand, "open", "", "Command to open URLs") 22 | flag.StringVar(&opt.UI, "ui", opt.UI, "UI to use (webview or playwright)") 23 | flag.BoolVar(&opt.SteamDev, "steamdev", false, "Enable Steam Dev mode") 24 | flag.BoolVar(&opt.NoCompress, "nocompress", false, "Disable file compression") 25 | flag.IntVar(&buildOverride, "build", -1, "Override/spoof build number (NOT RECOMMENDED)") 26 | flag.IntVar(&releaseOverride, "release", -1, "Override/spoof release number (NOT RECOMMENDED)") 27 | flag.IntVar(&loopbackPort, "loopback", -1, fmt.Sprintf("Port to use for loopback connections (default: %d) (NOT RECOMMENDED)", opt.LoopbackPort)) 28 | flag.StringVar(&opt.ForceBrowser, "forcebrowser", "", "Force a specific browser to be used (specify full executable path)") 29 | flag.Parse() 30 | 31 | if openCommand != "" { 32 | opt.OpenCommand = strings.Split(openCommand, " ") 33 | } 34 | if buildOverride != -1 { 35 | fmt.Println("WARNING: Build number override is not recommended and may cause issues.") 36 | opt.Build = uint32(buildOverride) 37 | } 38 | if releaseOverride != -1 { 39 | fmt.Println("WARNING: Release number override is not recommended and may cause issues.") 40 | opt.Release = uint32(releaseOverride) 41 | } 42 | if loopbackPort != -1 { 43 | opt.LoopbackPort = uint16(loopbackPort) 44 | } 45 | if opt.Realm != options.StableRealm { 46 | fmt.Println("WARNING: Using experimental realm. This may cause issues.") 47 | } 48 | 49 | if err := app.Run(opt); err != nil { 50 | fmt.Println(err) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/app.go: -------------------------------------------------------------------------------- 1 | package app 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/sag-enhanced/native-app/src/bindings" 7 | "github.com/sag-enhanced/native-app/src/file" 8 | "github.com/sag-enhanced/native-app/src/options" 9 | "github.com/sag-enhanced/native-app/src/ui" 10 | ) 11 | 12 | func Run(options *options.Options) error { 13 | os.MkdirAll(options.DataDirectory, 0755) 14 | 15 | fm, err := file.NewFileManager(options) 16 | if err != nil { 17 | return err 18 | } 19 | ui := ui.NewUI(options) 20 | 21 | bindings := bindings.NewBindings(options, ui, fm) 22 | ui.SetBindHandler(bindings.BindHandler) 23 | ui.Run() 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /src/bindings/app.go: -------------------------------------------------------------------------------- 1 | package bindings 2 | 3 | import ( 4 | "crypto/sha256" 5 | "encoding/hex" 6 | "os" 7 | "runtime" 8 | "time" 9 | 10 | "github.com/denisbrodbeck/machineid" 11 | ) 12 | 13 | var start = time.Now().UnixMilli() 14 | 15 | func (b *Bindings) Build() uint32 { 16 | return b.options.Build 17 | } 18 | func (b *Bindings) Start() int64 { 19 | return start 20 | } 21 | func (b *Bindings) Info() (map[string]any, error) { 22 | id, _ := machineid.ID() 23 | exe := os.Args[0] 24 | var exeHash string 25 | 26 | if content, err := os.ReadFile(exe); err == nil { 27 | digest := sha256.Sum256(content) 28 | exeHash = hex.EncodeToString(digest[:]) 29 | } 30 | 31 | url := "" 32 | if currentUrl != nil { 33 | url = currentUrl.String() 34 | } 35 | 36 | return map[string]any{ 37 | "build": b.options.Build, 38 | "release": b.options.Release, 39 | "path": b.options.DataDirectory, 40 | "os": runtime.GOOS, 41 | "arch": runtime.GOARCH, 42 | "id": id, 43 | "port": b.options.LoopbackPort, 44 | "args": os.Args, 45 | "exe": exe, 46 | "exe_hash": exeHash, 47 | "url": url, 48 | "ui": b.options.UI, 49 | }, nil 50 | } 51 | 52 | func (b *Bindings) Quit() { 53 | b.ui.Quit() 54 | } 55 | -------------------------------------------------------------------------------- /src/bindings/bindings.go: -------------------------------------------------------------------------------- 1 | package bindings 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "reflect" 8 | "strings" 9 | 10 | "github.com/sag-enhanced/native-app/src/file" 11 | "github.com/sag-enhanced/native-app/src/options" 12 | "github.com/sag-enhanced/native-app/src/ui" 13 | ) 14 | 15 | type Bindings struct { 16 | options *options.Options 17 | ui ui.UII 18 | fm *file.FileManager 19 | } 20 | 21 | func NewBindings(options *options.Options, ui ui.UII, fm *file.FileManager) *Bindings { 22 | return &Bindings{options, ui, fm} 23 | } 24 | 25 | // our own RPC engine ontop of the one that webview already provides 26 | // because the webview one is blocking and we want to be able to call 27 | // functions that take a while to complete (eg make a network request) 28 | 29 | func (b *Bindings) BindHandler(method string, callId int, params string) error { 30 | if b.options.Verbose { 31 | fmt.Println("RPC call:", method, callId, params) 32 | } 33 | prefix := fmt.Sprintf("if(saged[%d]){", callId) 34 | suffix := fmt.Sprintf(";delete saged[%d]}", callId) 35 | 36 | methodName := strings.ToUpper(method[:1]) + method[1:] 37 | 38 | bindingType := reflect.TypeOf(b) 39 | binding, ok := bindingType.MethodByName(methodName) 40 | if !ok { 41 | return fmt.Errorf("method not found: %s (%s)", method, methodName) 42 | } 43 | 44 | var raw []json.RawMessage 45 | if err := json.Unmarshal([]byte(params), &raw); err != nil { 46 | return err 47 | } 48 | if len(raw)+1 < binding.Type.NumIn() { 49 | return fmt.Errorf("wrong number of arguments (got %d, expected %d)", len(raw), binding.Type.NumIn()) 50 | } 51 | 52 | args := []reflect.Value{reflect.ValueOf(b)} 53 | for i := range raw { 54 | arg := reflect.New(binding.Type.In(1 + i)) 55 | if err := json.Unmarshal(raw[i], arg.Interface()); err != nil { 56 | return err 57 | } 58 | args = append(args, arg.Elem()) 59 | } 60 | 61 | go func() { 62 | result, err := parseResults(binding.Func.Call(args)) 63 | 64 | if err != nil { 65 | b.ui.Eval(prefix + fmt.Sprintf("saged[%d].b(new Error(%q))", callId, err.Error()) + suffix) 66 | return 67 | } 68 | encoded, err := json.Marshal(result) 69 | if err != nil { 70 | fmt.Println("Failed to marshal result of RPC function", method, err) 71 | b.ui.Eval(prefix + fmt.Sprintf("saged[%d].b(new Error('result marshal failed'));", callId) + suffix) 72 | return 73 | } 74 | b.ui.Eval(prefix + fmt.Sprintf("saged[%d].a(%s)", callId, string(encoded)) + suffix) 75 | }() 76 | return nil 77 | } 78 | 79 | func parseResults(results []reflect.Value) (interface{}, error) { 80 | if len(results) == 0 { 81 | return nil, nil 82 | } 83 | if len(results) == 1 { 84 | if err, ok := results[0].Interface().(error); ok { 85 | return nil, err 86 | } 87 | return results[0].Interface(), nil 88 | } 89 | if len(results) == 2 { 90 | if err, ok := results[1].Interface().(error); ok { 91 | return nil, err 92 | } 93 | return results[0].Interface(), nil 94 | } 95 | return nil, errors.New("too many return values") 96 | } 97 | -------------------------------------------------------------------------------- /src/bindings/browser-ext.go: -------------------------------------------------------------------------------- 1 | package bindings 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "encoding/json" 7 | "errors" 8 | "fmt" 9 | "io" 10 | "net/http" 11 | "os" 12 | "path" 13 | "strings" 14 | 15 | "github.com/sag-enhanced/native-app/src/options" 16 | ) 17 | 18 | func (b *Bindings) Ext(browser string) (*map[string]string, error) { 19 | dir := path.Join(b.options.DataDirectory, "ext", browser) 20 | extensions := map[string]string{} 21 | files, err := os.ReadDir(dir) 22 | // probably directory doesnt exist, so no extensions 23 | if err != nil { 24 | return &extensions, nil 25 | } 26 | 27 | for _, file := range files { 28 | if !file.IsDir() { 29 | continue 30 | } 31 | manifest, err := os.ReadFile(path.Join(dir, file.Name(), "manifest.json")) 32 | if err != nil { 33 | continue 34 | } 35 | var parsedManifest Manifest 36 | err = json.Unmarshal(manifest, &parsedManifest) 37 | if err != nil { 38 | continue 39 | } 40 | 41 | extensions[file.Name()] = parsedManifest.Version 42 | } 43 | 44 | return &extensions, nil 45 | } 46 | 47 | func (b *Bindings) ExtInstall(name string, browser string, download string) error { 48 | if !strings.HasPrefix(download, "https://github.com/") || strings.Contains(download, "..") { 49 | return errors.New("invalid download URL") 50 | } 51 | if path.Clean(name) != name || strings.Contains(name, ",") { 52 | return errors.New("invalid extension name") 53 | } 54 | 55 | return installExtensionFromGithub(name, browser, download, b.options) 56 | } 57 | 58 | func (b *Bindings) ExtGetManifest(name string, browser string) (string, error) { 59 | if path.Clean(name) != name || strings.Contains(name, ",") { 60 | return "", errors.New("invalid extension name") 61 | } 62 | 63 | manifest := path.Join(b.options.DataDirectory, "ext", browser, name, "manifest.json") 64 | 65 | data, err := os.ReadFile(manifest) 66 | if err != nil { 67 | return "", err 68 | } 69 | return string(data), nil 70 | } 71 | 72 | func (b *Bindings) ExtSetManifest(name string, browser string, manifest string) error { 73 | if path.Clean(name) != name || strings.Contains(name, ",") { 74 | return errors.New("invalid extension name") 75 | } 76 | 77 | manifestPath := path.Join(b.options.DataDirectory, "ext", browser, name, "manifest.json") 78 | return os.WriteFile(manifestPath, []byte(manifest), 0644) 79 | } 80 | 81 | func (b *Bindings) ExtUninstall(name string, browser string) error { 82 | if path.Clean(name) != name || strings.Contains(name, ",") { 83 | return errors.New("invalid extension name") 84 | } 85 | 86 | dir := path.Join(b.options.DataDirectory, "ext", browser, name) 87 | return os.RemoveAll(dir) 88 | } 89 | 90 | type Manifest struct { 91 | Version string `json:"version"` 92 | } 93 | 94 | func installExtensionFromGithub(name string, browser string, download string, options *options.Options) error { 95 | resp, err := http.Get(download) 96 | if err != nil { 97 | return err 98 | } 99 | defer resp.Body.Close() 100 | if resp.StatusCode != 200 { 101 | return fmt.Errorf("HTTP request returned %d", resp.StatusCode) 102 | } 103 | 104 | dir := path.Join(options.DataDirectory, "ext", browser, name) 105 | 106 | os.MkdirAll(dir, 0755) 107 | 108 | body, err := io.ReadAll(resp.Body) 109 | if err != nil { 110 | return err 111 | } 112 | 113 | reader, err := zip.NewReader(bytes.NewReader(body), resp.ContentLength) 114 | if err != nil { 115 | return err 116 | } 117 | 118 | for _, file := range reader.File { 119 | if file.FileInfo().IsDir() { 120 | os.MkdirAll(path.Join(dir, file.Name), 0755) 121 | } else { 122 | newFile, err := os.OpenFile(path.Join(dir, file.Name), os.O_WRONLY|os.O_CREATE|os.O_TRUNC, file.Mode()) 123 | if err != nil { 124 | return err 125 | } 126 | zipFile, err := file.Open() 127 | if err != nil { 128 | return err 129 | } 130 | 131 | _, err = io.Copy(newFile, zipFile) 132 | newFile.Close() 133 | zipFile.Close() 134 | if err != nil { 135 | return err 136 | } 137 | } 138 | } 139 | 140 | return nil 141 | } 142 | -------------------------------------------------------------------------------- /src/bindings/browser.go: -------------------------------------------------------------------------------- 1 | package bindings 2 | 3 | import ( 4 | "context" 5 | "crypto/rand" 6 | "errors" 7 | "fmt" 8 | "net/url" 9 | "os" 10 | "path" 11 | "strings" 12 | "sync" 13 | 14 | browserAPI "github.com/sag-enhanced/native-app/src/browser" 15 | ) 16 | 17 | var browserHandles = map[string]context.CancelFunc{} 18 | var browserHandleLock = sync.Mutex{} 19 | 20 | func (b *Bindings) BrowserNew(pageUrl string, browser string, proxy *string, profileId int32) (string, error) { 21 | rawHandle := make([]byte, 16) 22 | var err error 23 | if _, err := rand.Read(rawHandle); err != nil { 24 | return "", err 25 | } 26 | handle := fmt.Sprintf("%x", rawHandle) 27 | if b.options.Verbose { 28 | fmt.Println("Created new browser instance with handle", handle) 29 | } 30 | var parsedProxy *url.URL 31 | if proxy != nil { 32 | parsedProxy, err = url.Parse(*proxy) 33 | if err != nil { 34 | return "", err 35 | } 36 | if parsedProxy.Hostname() != "127.0.0.1" { 37 | return "", errors.New("Only local proxies are allowed.") 38 | } 39 | } 40 | 41 | if _, err := url.Parse(pageUrl); err != nil { 42 | return "", err 43 | } 44 | 45 | cancelCtx, cancel := context.WithCancel(context.Background()) 46 | 47 | go func() { 48 | defer cancel() 49 | defer func() { 50 | browserHandleLock.Lock() 51 | defer browserHandleLock.Unlock() 52 | delete(browserHandles, handle) 53 | 54 | if b.options.Verbose { 55 | fmt.Println("Destroying browser instance with handle", handle) 56 | } 57 | 58 | b.ui.Eval(fmt.Sprintf("sagebd(%q)", handle)) 59 | }() 60 | err := browserAPI.RunBrowser(cancelCtx, b.options, pageUrl, browser, parsedProxy, profileId) 61 | if err != nil { 62 | fmt.Println("Error running browser:", err) 63 | } 64 | }() 65 | 66 | browserHandleLock.Lock() 67 | browserHandles[handle] = cancel 68 | browserHandleLock.Unlock() 69 | return handle, nil 70 | } 71 | 72 | func (b *Bindings) BrowserDestroy(handle string) { 73 | browserHandleLock.Lock() 74 | defer browserHandleLock.Unlock() 75 | cancelCtx, ok := browserHandles[handle] 76 | if !ok { 77 | return 78 | } 79 | delete(browserHandles, handle) 80 | if b.options.Verbose { 81 | fmt.Println("Destroying browser instance with handle", handle) 82 | } 83 | cancelCtx() 84 | } 85 | 86 | func (b *Bindings) BrowserDestroyProfile(browser string, profileId int32) error { 87 | if strings.ContainsAny(browser+fmt.Sprint(profileId), "/\\.;:") { 88 | return errors.New("invalid browser name") 89 | } 90 | profilePath := path.Join(b.options.DataDirectory, "profiles", browser) 91 | if profileId > 0 { 92 | profilePath = path.Join(profilePath, fmt.Sprint(profileId)) 93 | } 94 | return os.RemoveAll(profilePath) 95 | } 96 | -------------------------------------------------------------------------------- /src/bindings/connect.go: -------------------------------------------------------------------------------- 1 | package bindings 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "crypto/rsa" 8 | "crypto/sha256" 9 | "crypto/x509" 10 | "encoding/base64" 11 | "errors" 12 | "fmt" 13 | "net/url" 14 | 15 | "github.com/go-jose/go-jose/v4" 16 | "github.com/go-jose/go-jose/v4/jwt" 17 | "github.com/sag-enhanced/native-app/src/helper" 18 | ) 19 | 20 | // a lot of thought into making this process secure 21 | // if you see any security issues, please let me know ASAP 22 | // -> https://sage.party/contact <- 23 | 24 | var clientIntent string 25 | var clientSecret string 26 | 27 | // trust me, this is required to keep confidentiality in case of id.sage.party compromise 28 | var serverECIntentPublicKey = []byte{48, 89, 48, 19, 6, 7, 42, 134, 72, 206, 61, 2, 1, 6, 8, 42, 134, 72, 206, 61, 3, 1, 7, 3, 66, 0, 4, 217, 237, 95, 239, 38, 161, 158, 33, 241, 194, 181, 97, 179, 197, 202, 84, 167, 104, 239, 1, 199, 72, 252, 31, 50, 139, 245, 233, 28, 141, 138, 135, 95, 135, 252, 168, 38, 54, 127, 229, 60, 245, 77, 211, 192, 133, 193, 86, 170, 177, 100, 113, 34, 51, 32, 151, 208, 81, 28, 28, 213, 245, 103, 180} 29 | 30 | const idProtocol = "https" 31 | const idHostname = "id.sage.party" 32 | 33 | func (b *Bindings) InitConnect(handover string, resource string) error { 34 | if identity == nil { 35 | return errors.New("Identity not loaded") 36 | } 37 | if currentUrl == nil || currentUrl.Host == idHostname { 38 | return fmt.Errorf("this binding must not be called from %s", idHostname) 39 | } 40 | secret := make([]byte, 16) 41 | if _, err := rand.Read(secret); err != nil { 42 | return err 43 | } 44 | 45 | clientIntent = handover 46 | clientSecret = base64.RawStdEncoding.EncodeToString(secret) 47 | 48 | query := url.Values{ 49 | "secret": {clientSecret}, 50 | "handover": {handover}, 51 | "resource": {resource}, 52 | } 53 | b.ui.Navigate(fmt.Sprintf("%s://%s/#%s", idProtocol, idHostname, query.Encode())) 54 | return nil 55 | } 56 | 57 | func (b *Bindings) ApproveConnect(secret string, approveIntent string, password string) (string, error) { 58 | // prevent bruteforce and replay attacks 59 | defer func() { 60 | clientIntent = "" 61 | clientSecret = "" 62 | }() 63 | 64 | if currentUrl == nil || currentUrl.Host != idHostname { 65 | return "", fmt.Errorf("this binding must be called from %s", idHostname) 66 | } 67 | if secret != clientSecret || secret == "" { 68 | return "", errors.New("Invalid secret") 69 | } 70 | 71 | intentPK, err := x509.ParsePKIXPublicKey(serverECIntentPublicKey) 72 | if err != nil { 73 | return "", err 74 | } 75 | 76 | parsed, err := jwt.ParseSigned(approveIntent, []jose.SignatureAlgorithm{jose.ES256}) 77 | if err != nil { 78 | return "", err 79 | } 80 | 81 | var claims jwt.Claims 82 | var serverIntent serverIntent 83 | if err := parsed.Claims(intentPK, &claims, &serverIntent); err != nil { 84 | return "", err 85 | } 86 | 87 | // get the thumbprint of the public key 88 | der := x509.MarshalPKCS1PublicKey(&identity.PrivateKey.PublicKey) 89 | digest := sha256.Sum256(der) 90 | 91 | // we must make sure that we are approving the correct account 92 | // the ID isnt that important here since its validated at the start, but why not 93 | // (I currently dont see a security benefit to this, but it might be useful in the future) 94 | if err := claims.Validate(jwt.Expected{ 95 | Issuer: "v4", 96 | AnyAudience: jwt.Audience{"v4/connect/server-intent"}, 97 | ID: clientSecret, 98 | Subject: base64.RawStdEncoding.EncodeToString(digest[:]), 99 | }); err != nil { 100 | return "", err 101 | } 102 | 103 | salt := make([]byte, 32) 104 | if _, err := rand.Read(salt); err != nil { 105 | return "", err 106 | } 107 | 108 | sealed := "" 109 | if password != "" { 110 | key := helper.DeriveKey(password, salt) 111 | aesCipher, err := aes.NewCipher(key) 112 | 113 | if err != nil { 114 | return "", err 115 | } 116 | 117 | gcm, err := cipher.NewGCM(aesCipher) 118 | if err != nil { 119 | return "", err 120 | } 121 | 122 | plaintext, err := x509.MarshalPKCS8PrivateKey(identity.PrivateKey) 123 | if err != nil { 124 | return "", err 125 | } 126 | 127 | // now the RSA key is secured with the password 128 | ciphertext := gcm.Seal(nil, salt[:gcm.NonceSize()], plaintext, nil) 129 | 130 | data := make([]byte, len(salt)+len(ciphertext)) 131 | copy(data, salt) 132 | copy(data[len(salt):], ciphertext) 133 | 134 | // to prevent a compromise of id.sage.party just immediately decrypting the key 135 | // using the password it knows, we encrypt the key with the server's public key as well 136 | // then the only way to decrypt the key is to have the password and the server's private key or 137 | // to decrypt the key after its been sent to the server 138 | // however that will require email verification (or server/database compromise ig?) 139 | 140 | serverRSAIntentPublicKey, err := base64.RawStdEncoding.DecodeString(serverIntent.PublicKey) 141 | if err != nil { 142 | return "", err 143 | } 144 | rsaKey, err := x509.ParsePKCS1PublicKey(serverRSAIntentPublicKey) 145 | if err != nil { 146 | return "", err 147 | } 148 | 149 | sealedData, err := helper.RSASeal(rsaKey, data) 150 | if err != nil { 151 | return "", err 152 | } 153 | 154 | sealed = base64.RawStdEncoding.EncodeToString(sealedData) 155 | } 156 | 157 | challenge, err := base64.RawStdEncoding.DecodeString(serverIntent.Challenge) 158 | if err != nil { 159 | return "", err 160 | } 161 | 162 | // ID challenge 163 | if challenge[0] != 0x00 || challenge[1] != 0x02 { 164 | return "", errors.New("Invalid challenge") 165 | } 166 | 167 | response, err := identity.Sign(challenge) 168 | if err != nil { 169 | return "", err 170 | } 171 | 172 | return sealed + "." + base64.RawStdEncoding.EncodeToString(response), nil 173 | } 174 | 175 | func (b *Bindings) RecoverConnect(secret string, data string, password string) error { 176 | // we dont use defer to reset the secret, because people can mistype the password 177 | // trying to mitigate password bruteforce is meaningless here, because that can simply 178 | // be done somewhere else 179 | if secret != clientSecret || secret == "" { 180 | clientSecret = "" // prevent bruteforce and replay attacks 181 | return errors.New("Invalid secret") 182 | } 183 | 184 | if currentUrl == nil || currentUrl.Host != idHostname { 185 | return fmt.Errorf("this binding must be called from %s", idHostname) 186 | } 187 | 188 | sealed, err := base64.RawStdEncoding.DecodeString(data) 189 | if err != nil { 190 | return err 191 | } 192 | 193 | key := helper.DeriveKey(password, sealed[:32]) 194 | aesCipher, err := aes.NewCipher(key) 195 | if err != nil { 196 | return err 197 | } 198 | 199 | gcm, err := cipher.NewGCM(aesCipher) 200 | if err != nil { 201 | return err 202 | } 203 | 204 | plaintext, err := gcm.Open(nil, sealed[:gcm.NonceSize()], sealed[32:], nil) 205 | if err != nil { 206 | return err 207 | } 208 | 209 | privateKey, err := x509.ParsePKCS8PrivateKey(plaintext) 210 | if err != nil { 211 | return err 212 | } 213 | 214 | identity.PrivateKey = privateKey.(*rsa.PrivateKey) 215 | return identity.Save(b.fm) 216 | } 217 | 218 | type serverIntent struct { 219 | PublicKey string `json:"spk"` 220 | Challenge string `json:"challenge"` 221 | } 222 | -------------------------------------------------------------------------------- /src/bindings/crypto-helper.go: -------------------------------------------------------------------------------- 1 | package bindings 2 | 3 | import ( 4 | "crypto/x509" 5 | "encoding/base64" 6 | 7 | "github.com/sag-enhanced/native-app/src/helper" 8 | ) 9 | 10 | func (b *Bindings) SealWithPublicKey(data string, publicKey string) (string, error) { 11 | decoded, err := base64.RawStdEncoding.DecodeString(publicKey) 12 | if err != nil { 13 | return "", err 14 | } 15 | pk, err := x509.ParsePKCS1PublicKey(decoded) 16 | if err != nil { 17 | return "", err 18 | } 19 | 20 | plaintext, err := base64.RawStdEncoding.DecodeString(data) 21 | if err != nil { 22 | return "", err 23 | } 24 | sealed, err := helper.RSASeal(pk, plaintext) 25 | if err != nil { 26 | return "", err 27 | } 28 | return base64.RawStdEncoding.EncodeToString(sealed), nil 29 | } 30 | 31 | func (b *Bindings) SealWithKey(data string, key string) (string, error) { 32 | decoded, err := base64.RawStdEncoding.DecodeString(key) 33 | if err != nil { 34 | return "", err 35 | } 36 | plaintext, err := base64.RawStdEncoding.DecodeString(data) 37 | if err != nil { 38 | return "", err 39 | } 40 | sealed, err := helper.AESSeal(decoded, plaintext) 41 | if err != nil { 42 | return "", err 43 | } 44 | return base64.RawStdEncoding.EncodeToString(sealed), nil 45 | } 46 | 47 | func (b *Bindings) UnsealWithKey(data string, key string) (string, error) { 48 | decoded, err := base64.RawStdEncoding.DecodeString(key) 49 | if err != nil { 50 | return "", err 51 | } 52 | plaintext, err := base64.RawStdEncoding.DecodeString(data) 53 | if err != nil { 54 | return "", err 55 | } 56 | unsealed, err := helper.AESUnseal(decoded, plaintext) 57 | if err != nil { 58 | return "", err 59 | } 60 | return base64.RawStdEncoding.EncodeToString(unsealed), nil 61 | } 62 | -------------------------------------------------------------------------------- /src/bindings/crypto-id.go: -------------------------------------------------------------------------------- 1 | package bindings 2 | 3 | import ( 4 | "encoding/base64" 5 | "errors" 6 | 7 | "github.com/sag-enhanced/native-app/src/file" 8 | id "github.com/sag-enhanced/native-app/src/identity" 9 | ) 10 | 11 | var identity *id.Identity 12 | 13 | func (b *Bindings) Id() (string, error) { 14 | id, err := getIdentity(b.fm) 15 | if err != nil { 16 | return "", err 17 | } 18 | return id.Id(), nil 19 | } 20 | 21 | func (b *Bindings) Sign2(message string) (string, error) { 22 | identity, err := getIdentity(b.fm) 23 | if err != nil { 24 | return "", err 25 | } 26 | decoded, err := base64.RawStdEncoding.DecodeString(message) 27 | if err != nil { 28 | return "", err 29 | } 30 | // 0x0001 is header for signature requests 31 | if len(decoded) < 2 || decoded[0] != 0x00 || decoded[1] != 0x01 { 32 | return "", errors.New("Invalid message") 33 | } 34 | signature, err := identity.Sign(decoded) 35 | if err != nil { 36 | return "", err 37 | } 38 | 39 | return base64.RawStdEncoding.EncodeToString(signature), nil 40 | } 41 | 42 | func (b *Bindings) Seal(data string) (string, error) { 43 | identity, err := getIdentity(b.fm) 44 | if err != nil { 45 | return "", err 46 | } 47 | plaintext, err := base64.RawStdEncoding.DecodeString(data) 48 | if err != nil { 49 | return "", err 50 | } 51 | sealed, err := identity.Seal(plaintext) 52 | if err != nil { 53 | return "", err 54 | } 55 | return base64.RawStdEncoding.EncodeToString(sealed), nil 56 | } 57 | 58 | func (b *Bindings) Unseal(data string) (string, error) { 59 | identity, err := getIdentity(b.fm) 60 | if err != nil { 61 | return "", err 62 | } 63 | decoded, err := base64.RawStdEncoding.DecodeString(data) 64 | if err != nil { 65 | return "", err 66 | } 67 | unsealed, err := identity.Unseal(decoded) 68 | if err != nil { 69 | return "", err 70 | } 71 | return base64.RawStdEncoding.EncodeToString(unsealed), nil 72 | 73 | } 74 | func getIdentity(fm *file.FileManager) (*id.Identity, error) { 75 | if identity == nil { 76 | id, err := id.LoadIdentity(fm) 77 | if err != nil { 78 | return nil, err 79 | } 80 | identity = id 81 | } 82 | return identity, nil 83 | } 84 | -------------------------------------------------------------------------------- /src/bindings/crypto-lock.go: -------------------------------------------------------------------------------- 1 | package bindings 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path" 8 | ) 9 | 10 | func (b *Bindings) EncryptionStatus() EncryptionStatus { 11 | return EncryptionStatus{ 12 | Enabled: b.fm.Manifest != nil, 13 | Locked: b.fm.Cipher == nil && b.fm.Manifest != nil, 14 | } 15 | } 16 | 17 | func (b *Bindings) EncryptionUnlock(password string) error { 18 | return b.fm.TryLoadKey(password) 19 | } 20 | 21 | func (b *Bindings) EncryptionLock() { 22 | b.fm.Cipher = nil 23 | } 24 | 25 | func (b *Bindings) EncryptionDisable() error { 26 | if b.fm.Manifest != nil && b.fm.Cipher == nil { 27 | return errors.New("need to decrypt files first") 28 | } 29 | os.Remove(path.Join(b.options.DataDirectory, "manifest.json")) 30 | errs := b.fm.UpdateFiles(true) 31 | b.fm.Cipher = nil 32 | b.fm.Manifest = nil 33 | if len(errs) > 0 { 34 | fmt.Println("Failed to update files", errs) 35 | return errs[0] 36 | } 37 | return nil 38 | } 39 | 40 | func (b *Bindings) EncryptionEnable(passwords []string) error { 41 | if b.fm.Manifest != nil && b.fm.Cipher == nil { 42 | return errors.New("need to decrypt files first") 43 | } 44 | err := b.fm.CreateKey(passwords) 45 | if err != nil { 46 | return err 47 | } 48 | errs := b.fm.UpdateFiles(false) 49 | if len(errs) > 0 { 50 | fmt.Println("Failed to update files", errs) 51 | return errs[0] 52 | } 53 | return nil 54 | } 55 | 56 | type EncryptionStatus struct { 57 | Enabled bool `json:"enabled"` 58 | Locked bool `json:"locked"` 59 | } 60 | -------------------------------------------------------------------------------- /src/bindings/data.go: -------------------------------------------------------------------------------- 1 | package bindings 2 | 3 | import ( 4 | "bytes" 5 | "compress/flate" 6 | "fmt" 7 | "io" 8 | "os" 9 | "path" 10 | ) 11 | 12 | func (b *Bindings) Get(key string) (string, error) { 13 | filenameNew := b.fm.GetFilename(key) 14 | data, err := b.fm.ReadFile(filenameNew) 15 | if err == nil { 16 | return string(data), nil 17 | } 18 | 19 | // fallback to old file (pre b7) 20 | filenameOld := path.Join(b.options.DataDirectory, key+".dat") 21 | data, err = os.ReadFile(filenameOld) 22 | if err != nil { 23 | return "", err 24 | } 25 | reader := flate.NewReader(bytes.NewReader(data)) 26 | decompressed, err := io.ReadAll(reader) 27 | if err != nil { 28 | return "", err 29 | } 30 | err = b.fm.WriteFile(filenameNew, decompressed, false) 31 | if err == nil { 32 | os.Remove(filenameOld) 33 | } else { 34 | fmt.Println("Failed to write decompressed data to new file", filenameNew) 35 | } 36 | return string(decompressed), nil 37 | } 38 | 39 | func (b *Bindings) Set(key string, value string) error { 40 | filename := b.fm.GetFilename(key) 41 | return b.fm.WriteFile(filename, []byte(value), false) 42 | } 43 | -------------------------------------------------------------------------------- /src/bindings/file.go: -------------------------------------------------------------------------------- 1 | package bindings 2 | 3 | import ( 4 | "encoding/base64" 5 | "errors" 6 | "os" 7 | "path" 8 | "strings" 9 | "unicode/utf8" 10 | ) 11 | 12 | func (b *Bindings) FsReadFile(filename string) (string, error) { 13 | path, err := b.fsValidateFilename(filename) 14 | if err != nil { 15 | return "", err 16 | } 17 | 18 | content, err := os.ReadFile(path) 19 | if err != nil { 20 | return "", err 21 | } 22 | 23 | if utf8.Valid(content) { 24 | return string(content), nil 25 | } 26 | return "data:;base64," + base64.StdEncoding.EncodeToString(content), nil 27 | } 28 | 29 | func (b *Bindings) FsWriteFile(filename string, content string) error { 30 | path, err := b.fsValidateFilename(filename) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | if strings.HasPrefix(content, "data:") { 36 | decoded, err := base64.StdEncoding.DecodeString(strings.SplitN(content, ",", 2)[1]) 37 | if err != nil { 38 | return err 39 | } 40 | content = string(decoded) 41 | } 42 | 43 | return os.WriteFile(path, []byte(content), 0644) 44 | } 45 | 46 | func (b *Bindings) FsDeleteFile(filename string) error { 47 | path, err := b.fsValidateFilename(filename) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | return os.Remove(path) 53 | } 54 | 55 | func (b *Bindings) FsListFiles(dirname string) ([]string, error) { 56 | path, err := b.fsValidateFilename(dirname) 57 | if err != nil { 58 | return nil, err 59 | } 60 | 61 | files, err := os.ReadDir(path) 62 | if err != nil { 63 | return nil, err 64 | } 65 | 66 | var result []string 67 | for _, file := range files { 68 | result = append(result, file.Name()) 69 | } 70 | return result, nil 71 | } 72 | 73 | func (b *Bindings) FsMkdir(dirname string) error { 74 | path, err := b.fsValidateFilename(dirname) 75 | if err != nil { 76 | return err 77 | } 78 | 79 | return os.MkdirAll(path, 0755) 80 | } 81 | 82 | func (b *Bindings) fsValidateFilename(filename string) (string, error) { 83 | cleaned := path.Clean(strings.ReplaceAll(filename, "\\", "/")) 84 | if cleaned != filename { 85 | return "", errors.New("Invalid filename") 86 | } 87 | 88 | realName := path.Clean(path.Join(b.options.DataDirectory, "files", filename)) 89 | if !strings.HasPrefix(realName, b.options.DataDirectory) { 90 | return "", errors.New("Invalid filename") 91 | } 92 | return realName, nil 93 | } 94 | -------------------------------------------------------------------------------- /src/bindings/http.go: -------------------------------------------------------------------------------- 1 | package bindings 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "net/http" 10 | "net/http/cookiejar" 11 | "net/url" 12 | "strings" 13 | "sync" 14 | "unicode/utf8" 15 | ) 16 | 17 | var httpClients = make(map[string]http.Client) 18 | var httpHandleLock = sync.Mutex{} 19 | 20 | func (b *Bindings) HttpClient(proxyUrl *string) (string, error) { 21 | rawHandle := make([]byte, 16) 22 | if _, err := rand.Read(rawHandle); err != nil { 23 | return "", err 24 | } 25 | jar, err := cookiejar.New(nil) 26 | if err != nil { 27 | return "", err 28 | } 29 | var proxy func(*http.Request) (*url.URL, error) 30 | if proxyUrl != nil { 31 | parsedProxyUrl, err := url.Parse(*proxyUrl) 32 | if err != nil { 33 | return "", err 34 | } 35 | if parsedProxyUrl.Hostname() != "127.0.0.1" { 36 | return "", errors.New("Only local proxies are allowed.") 37 | } 38 | proxy = http.ProxyURL(parsedProxyUrl) 39 | } 40 | 41 | handle := fmt.Sprintf("%x", rawHandle) 42 | if b.options.Verbose { 43 | fmt.Println("Created new HTTP client with handle", handle) 44 | } 45 | httpHandleLock.Lock() 46 | httpClients[handle] = http.Client{Jar: jar, Transport: &http.Transport{Proxy: proxy}} 47 | httpHandleLock.Unlock() 48 | return handle, nil 49 | } 50 | 51 | func (b *Bindings) HttpRequest(handle string, method string, url string, headers map[string]string, body string) (*HTTPResponse, error) { 52 | httpHandleLock.Lock() 53 | client, ok := httpClients[handle] 54 | httpHandleLock.Unlock() 55 | if !ok { 56 | return nil, errors.New("invalid handle") 57 | } 58 | var reader io.Reader 59 | if strings.HasPrefix(body, "data:") { 60 | reader = base64.NewDecoder(base64.StdEncoding, strings.NewReader(strings.SplitN(body, ",", 2)[1])) 61 | } else { 62 | reader = strings.NewReader(body) 63 | } 64 | req, err := http.NewRequest(method, url, reader) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | // Prevent access to certain hosts for security reasons 70 | if req.URL.Hostname() == "api.sage.party" || strings.HasSuffix(req.URL.Hostname(), ".leodev.cloud") { 71 | return nil, errors.New("This host is not allowed to be accessed.") 72 | } 73 | 74 | for key, value := range headers { 75 | req.Header.Add(key, value) 76 | } 77 | 78 | resp, err := client.Do(req) 79 | if err != nil { 80 | return nil, err 81 | } 82 | defer resp.Body.Close() 83 | 84 | responseBody, err := io.ReadAll(resp.Body) 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | responseHeaders := map[string]string{} 90 | for key, value := range resp.Header { 91 | responseHeaders[key] = value[0] 92 | } 93 | 94 | if b.options.Verbose { 95 | fmt.Println("HTTP request", method, url, "returned", resp.StatusCode) 96 | } 97 | 98 | var stringifiedBody string 99 | if utf8.Valid(responseBody) { 100 | stringifiedBody = string(responseBody) 101 | } else { 102 | stringifiedBody = "data:;base64," + base64.StdEncoding.EncodeToString(responseBody) 103 | } 104 | 105 | return &HTTPResponse{ 106 | StatusCode: resp.StatusCode, 107 | Headers: responseHeaders, 108 | Body: stringifiedBody, 109 | }, nil 110 | } 111 | 112 | func (b *Bindings) HttpCookie(handle string, domain string, name string, value *string) (string, error) { 113 | httpHandleLock.Lock() 114 | client, ok := httpClients[handle] 115 | httpHandleLock.Unlock() 116 | if !ok { 117 | return "", errors.New("invalid handle") 118 | } 119 | if value != nil { 120 | client.Jar.SetCookies(&url.URL{Scheme: "https", Host: domain}, []*http.Cookie{ 121 | {Name: name, Value: *value}, 122 | }) 123 | } 124 | cookies := client.Jar.Cookies(&url.URL{Scheme: "https", Host: domain}) 125 | for _, cookie := range cookies { 126 | if cookie.Name == name { 127 | return cookie.Value, nil 128 | } 129 | } 130 | return "", errors.New("cookie not found") 131 | } 132 | 133 | func (b *Bindings) HttpDestroy(handle string) { 134 | if b.options.Verbose { 135 | fmt.Println("Destroying HTTP client with handle", handle) 136 | } 137 | httpHandleLock.Lock() 138 | delete(httpClients, handle) 139 | httpHandleLock.Unlock() 140 | } 141 | 142 | type HTTPResponse struct { 143 | StatusCode int `json:"status"` 144 | Headers map[string]string `json:"headers"` 145 | Body string `json:"body"` 146 | } 147 | -------------------------------------------------------------------------------- /src/bindings/os-dialog.go: -------------------------------------------------------------------------------- 1 | package bindings 2 | 3 | import ( 4 | "encoding/base64" 5 | "os" 6 | 7 | "github.com/gen2brain/beeep" 8 | "github.com/sqweek/dialog" 9 | ) 10 | 11 | func (b *Bindings) Save(filename string, data string) error { 12 | path, err := dialog.File().Title("Save file").SetStartFile(filename).Filter("All files", "*").Save() 13 | if err != nil { 14 | return err 15 | } 16 | 17 | return os.WriteFile(path, []byte(data), 0644) 18 | } 19 | 20 | func (b *Bindings) Save2(filename string, data string) error { 21 | path, err := dialog.File().Title("Save file").SetStartFile(filename).Filter("All files", "*").Save() 22 | if err != nil { 23 | return err 24 | } 25 | 26 | decoded, err := base64.RawStdEncoding.DecodeString(data) 27 | if err != nil { 28 | return err 29 | } 30 | return os.WriteFile(path, decoded, 0644) 31 | } 32 | 33 | func (b *Bindings) Read(filterText string, filter string) (string, error) { 34 | path, err := dialog.File().Title("Open file").Filter(filterText, filter).Load() 35 | if err != nil { 36 | return "", err 37 | } 38 | 39 | data, err := os.ReadFile(path) 40 | if err != nil { 41 | return "", err 42 | } 43 | return string(data), nil 44 | } 45 | 46 | func (b *Bindings) Alert(message string) { 47 | dialog.Message(message).Title("Alert").Info() 48 | } 49 | 50 | func (b *Bindings) Notify(title string, message string, alert bool) error { 51 | err := beeep.Notify(title, message, "") 52 | if err != nil { 53 | return err 54 | } 55 | if alert { 56 | return beeep.Beep(beeep.DefaultFreq, beeep.DefaultDuration) 57 | } 58 | return nil 59 | } 60 | -------------------------------------------------------------------------------- /src/bindings/os-native.go: -------------------------------------------------------------------------------- 1 | package bindings 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "strings" 7 | 8 | "github.com/sag-enhanced/native-app/src/helper" 9 | ) 10 | 11 | func (b *Bindings) Open(target string) error { 12 | // some sanization checks 13 | if strings.ContainsAny(target, "\n\r'\"` {}$|;") { 14 | return fmt.Errorf("Invalid URL") 15 | } 16 | url, err := url.Parse(target) 17 | // only allow https urls and block any path traversal attempts 18 | if err != nil || url.Scheme != "https" || strings.Contains(url.Path, "..") { 19 | return fmt.Errorf("Invalid URL") 20 | } 21 | fmt.Println("Opening URL", url.String()) 22 | // re-assemble url to string to avoid any funny business 23 | helper.Open(url.String(), b.options) 24 | return nil 25 | } 26 | -------------------------------------------------------------------------------- /src/bindings/os-screenshot.go: -------------------------------------------------------------------------------- 1 | //go:build !darwin || CI 2 | 3 | package bindings 4 | 5 | import ( 6 | "fmt" 7 | 8 | "github.com/kbinani/screenshot" 9 | "github.com/makiuchi-d/gozxing" 10 | "github.com/makiuchi-d/gozxing/multi/qrcode" 11 | ) 12 | 13 | func (b *Bindings) ScreenshotQR() ([]string, error) { 14 | codes := []string{} 15 | 16 | screens := screenshot.NumActiveDisplays() 17 | reader := qrcode.NewQRCodeMultiReader() 18 | for i := 0; i < screens; i++ { 19 | bounds := screenshot.GetDisplayBounds(i) 20 | if b.options.Verbose { 21 | fmt.Println("Capturing screen", i, bounds) 22 | } 23 | img, err := screenshot.CaptureRect(bounds) 24 | if err != nil { 25 | return codes, err 26 | } 27 | 28 | bmp, err := gozxing.NewBinaryBitmapFromImage(img) 29 | if err != nil { 30 | return codes, err 31 | } 32 | 33 | result, err := reader.DecodeMultiple(bmp, nil) 34 | if err == nil { 35 | for _, result := range result { 36 | if b.options.Verbose { 37 | fmt.Println("QR Code found on screen", i, result.GetResultMetadata(), result.GetResultPoints()) 38 | } 39 | codes = append(codes, result.GetText()) 40 | } 41 | } 42 | } 43 | 44 | return codes, nil 45 | } 46 | -------------------------------------------------------------------------------- /src/bindings/proxy.go: -------------------------------------------------------------------------------- 1 | package bindings 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net" 7 | "net/url" 8 | "sync" 9 | "time" 10 | 11 | "github.com/sag-enhanced/native-app/src/options" 12 | _ "github.com/wzshiming/anyproxy/proxies/socks5" 13 | "github.com/wzshiming/bridge/chain" 14 | "github.com/wzshiming/bridge/config" 15 | "github.com/wzshiming/bridge/logger" 16 | _ "github.com/wzshiming/bridge/protocols/connect" 17 | _ "github.com/wzshiming/bridge/protocols/socks4" 18 | _ "github.com/wzshiming/bridge/protocols/socks5" 19 | _ "github.com/wzshiming/bridge/protocols/ssh" 20 | _ "github.com/wzshiming/bridge/protocols/tls" 21 | ) 22 | 23 | var proxyClients = make(map[string]context.CancelFunc) 24 | var proxyHandleLock = sync.Mutex{} 25 | 26 | func (b *Bindings) ProxyNew(proxyUrl string) (string, error) { 27 | ctx, cancel := context.WithCancel(context.Background()) 28 | 29 | parsedProxyUrl, err := url.Parse(proxyUrl) 30 | if err != nil { 31 | cancel() 32 | return "", err 33 | } 34 | 35 | localProxy, err := createProxyProxy(parsedProxyUrl, b.options, ctx) 36 | if err != nil { 37 | cancel() 38 | return "", err 39 | } 40 | if b.options.Verbose { 41 | fmt.Println("Created new proxy with handle", proxyUrl, localProxy) 42 | } 43 | 44 | proxyHandleLock.Lock() 45 | proxyClients[localProxy.String()] = cancel 46 | proxyHandleLock.Unlock() 47 | 48 | return localProxy.String(), nil 49 | } 50 | 51 | func (b *Bindings) ProxyDestroy(handle string) error { 52 | proxyHandleLock.Lock() 53 | cancel, ok := proxyClients[handle] 54 | proxyHandleLock.Unlock() 55 | if !ok { 56 | return fmt.Errorf("invalid handle %s", handle) 57 | } 58 | cancel() 59 | return nil 60 | } 61 | 62 | func createProxyProxy(proxy *url.URL, options *options.Options, stop context.Context) (*url.URL, error) { 63 | freePort, err := getFreePort() 64 | if err != nil { 65 | return nil, err 66 | } 67 | 68 | localProxy := &url.URL{ 69 | Scheme: "socks5", 70 | Host: fmt.Sprintf("127.0.0.1:%d", freePort), 71 | } 72 | if options.Verbose { 73 | fmt.Println("Local proxy", localProxy) 74 | } 75 | 76 | cfg := config.Chain{ 77 | Bind: []config.Node{ 78 | { 79 | LB: []string{localProxy.String()}, 80 | }, 81 | }, 82 | Proxy: []config.Node{ 83 | { 84 | LB: []string{"-"}, 85 | }, 86 | { 87 | LB: []string{proxy.String()}, 88 | }, 89 | }, 90 | IdleTimeout: 120 * time.Second, 91 | } 92 | b := chain.NewBridge(logger.Std, false) 93 | 94 | go func() { 95 | if err := b.BridgeWithConfig(stop, cfg); err != nil { 96 | fmt.Println("Error running proxy", err) 97 | } 98 | }() 99 | 100 | return localProxy, nil 101 | } 102 | 103 | func getFreePort() (int, error) { 104 | l, err := net.Listen("tcp", "localhost:0") 105 | if err != nil { 106 | return 0, err 107 | } 108 | defer l.Close() 109 | return l.Addr().(*net.TCPAddr).Port, nil 110 | } 111 | -------------------------------------------------------------------------------- /src/bindings/server.go: -------------------------------------------------------------------------------- 1 | package bindings 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "sync" 10 | "time" 11 | ) 12 | 13 | var server *http.Server 14 | var serverRequests = make(map[int]chan serverResponse) 15 | var serverRequestLock = sync.Mutex{} 16 | 17 | func (b *Bindings) ServerNew() string { 18 | addr := fmt.Sprintf("127.0.0.1:%d", b.options.LoopbackPort) 19 | if server != nil { 20 | return addr 21 | } 22 | 23 | requestId := 0 24 | server = &http.Server{ 25 | Addr: addr, 26 | Handler: http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 27 | body, err := io.ReadAll(r.Body) 28 | defer r.Body.Close() 29 | if err != nil { 30 | w.WriteHeader(500) 31 | return 32 | } 33 | r.URL.Host = r.Host 34 | r.URL.Scheme = "http" 35 | 36 | request := serverRequest{ 37 | Id: requestId, 38 | Method: r.Method, 39 | Url: r.URL.String(), 40 | Headers: map[string]string{}, 41 | Body: string(body), 42 | } 43 | requestId++ 44 | for key, value := range r.Header { 45 | request.Headers[key] = value[0] 46 | } 47 | 48 | if b.options.Verbose { 49 | fmt.Println("received HTTP request", request.Method, request.Url) 50 | } 51 | 52 | encoded, err := json.Marshal(request) 53 | if err != nil { 54 | w.WriteHeader(500) 55 | return 56 | } 57 | sequence := time.Now().UnixNano() 58 | 59 | responseChannel := make(chan serverResponse) 60 | 61 | serverRequestLock.Lock() 62 | serverRequests[request.Id] = responseChannel 63 | serverRequestLock.Unlock() 64 | 65 | b.ui.Eval(fmt.Sprintf("sages(%s, %d)", encoded, sequence)) 66 | 67 | select { 68 | case response := <-responseChannel: 69 | for key, value := range response.Headers { 70 | w.Header().Set(key, value) 71 | } 72 | w.WriteHeader(response.StatusCode) 73 | w.Write([]byte(response.Body)) 74 | if b.options.Verbose { 75 | fmt.Println("HTTP request", request.Method, request.Url, "returned", response.StatusCode) 76 | } 77 | case <-time.After(10 * time.Second): 78 | w.WriteHeader(502) 79 | if b.options.Verbose { 80 | fmt.Println("HTTP request timed out") 81 | } 82 | } 83 | 84 | serverRequestLock.Lock() 85 | delete(serverRequests, requestId) 86 | serverRequestLock.Unlock() 87 | }), 88 | } 89 | 90 | go server.ListenAndServe() 91 | return addr 92 | } 93 | 94 | func (b *Bindings) ServerRespond(requestId int, statusCode int, headers map[string]string, body string) error { 95 | serverRequestLock.Lock() 96 | responseChannel, ok := serverRequests[requestId] 97 | if ok { 98 | delete(serverRequests, requestId) 99 | } 100 | serverRequestLock.Unlock() 101 | 102 | if !ok { 103 | return fmt.Errorf("invalid request id") 104 | } 105 | 106 | response := serverResponse{ 107 | StatusCode: statusCode, 108 | Headers: headers, 109 | Body: body, 110 | } 111 | responseChannel <- response 112 | 113 | return nil 114 | } 115 | 116 | func (b *Bindings) ServerDestroy() { 117 | if server != nil { 118 | server.Shutdown(context.Background()) 119 | server = nil 120 | } 121 | } 122 | 123 | type serverResponse struct { 124 | StatusCode int `json:"status"` 125 | Headers map[string]string `json:"headers"` 126 | Body string `json:"body"` 127 | } 128 | 129 | type serverRequest struct { 130 | Id int `json:"id"` 131 | Method string `json:"method"` 132 | Url string `json:"url"` 133 | Headers map[string]string `json:"headers"` 134 | Body string `json:"body"` 135 | } 136 | -------------------------------------------------------------------------------- /src/bindings/steam.go: -------------------------------------------------------------------------------- 1 | package bindings 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "strings" 8 | 9 | "github.com/sag-enhanced/native-app/src/steam" 10 | ) 11 | 12 | func (b *Bindings) SteamPatch(js string) error { 13 | exe, err := steam.FindSteamExecutable(b.options) 14 | if err != nil { 15 | return err 16 | } 17 | 18 | if b.options.Verbose { 19 | fmt.Println("Steam executable found at", exe) 20 | } 21 | 22 | data, err := steam.FindSteamDataDir(b.options) 23 | if err != nil { 24 | return err 25 | } 26 | if b.options.Verbose { 27 | fmt.Println("Steam data directory found at", data) 28 | } 29 | 30 | entryFile := path.Join(data, "steamui", "library.js") 31 | content, err := os.ReadFile(entryFile) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | // inject our code into the steam client 37 | lines := strings.Split(string(content), "\n") 38 | if len(lines) > 2 { 39 | lines = lines[:3] 40 | } 41 | if js != "" { 42 | lines = append(lines, js) 43 | } 44 | 45 | return os.WriteFile(entryFile, []byte(strings.Join(lines, "\n")), 0644) 46 | } 47 | 48 | func (b *Bindings) SteamRun() error { 49 | steam.CloseSteam(b.options) 50 | 51 | if b.options.Verbose { 52 | fmt.Println("Starting Steam with injected code...") 53 | } 54 | // -noverifyfiles is required to prevent steam from checking the files 55 | // and redownloading them if they are modified 56 | return steam.RunSteamWithArguments(b.options, "-noverifyfiles") 57 | } 58 | -------------------------------------------------------------------------------- /src/bindings/url.go: -------------------------------------------------------------------------------- 1 | package bindings 2 | 3 | import "net/url" 4 | 5 | var currentUrl *url.URL 6 | 7 | // webview has no builtin way to get the current url, so we neded a tamper proof way to access it 8 | // we use a secret that is only known to the app and the bindings 9 | // this way we cant be tricked into setting the url to something else 10 | // 11 | // we need the current URL to properly restrict certain bindings to only work on certain pages 12 | func (b *Bindings) SetUrl(currentPageUrl string, secret string) error { 13 | // someone is doing something fishy 14 | if b.options.CurrentUrlSecret != secret { 15 | b.ui.Quit() 16 | return nil 17 | } 18 | 19 | var err error 20 | currentUrl, err = url.Parse(currentPageUrl) 21 | return err 22 | } 23 | -------------------------------------------------------------------------------- /src/browser/browser.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/url" 7 | "path" 8 | 9 | "github.com/sag-enhanced/native-app/src/options" 10 | ) 11 | 12 | func RunBrowser(stop context.Context, options *options.Options, browserUrl string, browser string, proxy *url.URL, profileId int32) error { 13 | var err error 14 | 15 | profile := path.Join(options.DataDirectory, "profiles", browser, fmt.Sprint(profileId)) 16 | 17 | args := prepareArguments(profile, proxy) 18 | if extensions, err := getExtensionList(options, browser); err == nil { 19 | args = prepareExtensions(args, extensions) 20 | } 21 | args = append(args, browserUrl) 22 | 23 | exe := options.ForceBrowser 24 | if exe == "" { 25 | var err error 26 | exe, err = findBrowserBinary(browser) 27 | if err != nil { 28 | return err 29 | } 30 | } 31 | 32 | if options.Verbose { 33 | fmt.Println("Running browser with args", exe, args) 34 | } 35 | 36 | proc, err := launchBrowser(exe, args) 37 | if err != nil { 38 | return err 39 | } 40 | defer proc.Kill() 41 | 42 | processDone := make(chan struct{}) 43 | go func() { 44 | proc.Wait() 45 | close(processDone) 46 | }() 47 | 48 | select { 49 | case <-stop.Done(): 50 | case <-processDone: 51 | } 52 | 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /src/browser/ext.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "os" 5 | "path" 6 | 7 | "github.com/sag-enhanced/native-app/src/options" 8 | ) 9 | 10 | func getExtensionList(options *options.Options, browser string) ([]string, error) { 11 | ext := path.Join(options.DataDirectory, "ext", browser) 12 | files, err := os.ReadDir(ext) 13 | extensions := []string{} 14 | if err != nil { 15 | return extensions, nil 16 | } 17 | for _, file := range files { 18 | if file.IsDir() { 19 | extensions = append(extensions, path.Join(ext, file.Name())) 20 | } 21 | } 22 | return extensions, nil 23 | } 24 | -------------------------------------------------------------------------------- /src/browser/find.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path" 7 | "runtime" 8 | 9 | "github.com/playwright-community/playwright-go" 10 | ) 11 | 12 | func findBrowserBinary(browser string) (string, error) { 13 | if browser == "chromium" { 14 | // we use playwright to manage our chromium installation 15 | 16 | playwright.Install(&playwright.RunOptions{ 17 | Browsers: []string{browser}, 18 | Verbose: true, 19 | }) 20 | 21 | pw, err := playwright.Run() 22 | if err != nil { 23 | return "", err 24 | } 25 | defer pw.Stop() 26 | 27 | return pw.Chromium.ExecutablePath(), nil 28 | } 29 | 30 | switch runtime.GOOS { 31 | case "darwin": 32 | name := "Google Chrome" 33 | if browser == "edge" { 34 | name = "Microsoft Edge" 35 | } 36 | exe := path.Join("/Applications", name+".app", "Contents", "MacOS", name) 37 | if _, err := os.Stat(exe); err == nil { 38 | return exe, nil 39 | } 40 | userExe := path.Join(os.Getenv("HOME"), exe) 41 | if _, err := os.Stat(userExe); err == nil { 42 | return userExe, nil 43 | } 44 | case "windows": 45 | name := "Google\\Chrome\\Application\\chrome.exe" 46 | if browser == "edge" { 47 | name = "Microsoft\\Edge\\Application\\msedge.exe" 48 | } 49 | for _, root := range []string{os.Getenv("LOCALAPPDATA"), os.Getenv("PROGRAMFILES"), os.Getenv("PROGRAMFILES(x86)")} { 50 | if root == "" { 51 | continue 52 | } 53 | exe := path.Join(root, name) 54 | if _, err := os.Stat(exe); err == nil { 55 | return exe, nil 56 | } 57 | } 58 | case "linux": 59 | exe := "/opt/google/chrome/chrome" 60 | if browser == "edge" { 61 | exe = "/opt/microsoft/msedge/msedge" 62 | } 63 | if _, err := os.Stat(exe); err == nil { 64 | return exe, nil 65 | } 66 | } 67 | 68 | return "", fmt.Errorf("Browser binary not found") 69 | } 70 | -------------------------------------------------------------------------------- /src/browser/launch.go: -------------------------------------------------------------------------------- 1 | package browser 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "os" 7 | "os/exec" 8 | "runtime" 9 | "strings" 10 | ) 11 | 12 | func prepareArguments(profile string, proxy *url.URL) []string { 13 | args := []string{ 14 | "--user-data-dir=" + profile, 15 | "--no-first-run", 16 | "--disable-search-engine-choice-screen", // disable search engine choice screen 17 | "--new-window", 18 | } 19 | if runtime.GOOS == "darwin" { 20 | // removes the keychain prompt 21 | args = append(args, "--use-mock-keychain") 22 | } 23 | if proxy != nil { 24 | // we cant pass the authentication information to the browser here 25 | args = append(args, fmt.Sprintf("--proxy-server=%s://%s", proxy.Scheme, proxy.Host)) 26 | } 27 | return args 28 | } 29 | 30 | func prepareExtensions(args []string, extensions []string) []string { 31 | if len(extensions) > 0 { 32 | args = append(args, "--load-extension="+strings.Join(extensions, ",")) 33 | } 34 | return args 35 | } 36 | 37 | func launchBrowser(exe string, args []string) (*os.Process, error) { 38 | cmd := exec.Command(exe, args...) 39 | cmd.Stdout = os.Stdout 40 | cmd.Stderr = os.Stderr 41 | err := cmd.Start() 42 | if err != nil { 43 | return nil, err 44 | } 45 | return cmd.Process, nil 46 | } 47 | -------------------------------------------------------------------------------- /src/file/file.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "crypto/cipher" 5 | "encoding/json" 6 | "errors" 7 | "os" 8 | "path" 9 | "strings" 10 | "sync" 11 | 12 | "github.com/sag-enhanced/native-app/src/options" 13 | ) 14 | 15 | type FileHeader byte 16 | 17 | const ( 18 | FileHeaderRaw FileHeader = 0x0 19 | FileHeaderEncrypted FileHeader = 0x1 20 | FileHeaderCompressed FileHeader = 0x2 21 | FileHeaderEncryptedNoPad FileHeader = 0x3 22 | ) 23 | 24 | var fileWriterLock = sync.Mutex{} 25 | 26 | type FileManager struct { 27 | Manifest *EncryptionManifest 28 | Cipher *cipher.Block 29 | Options *options.Options 30 | } 31 | 32 | func NewFileManager(options *options.Options) (*FileManager, error) { 33 | fm := &FileManager{ 34 | Options: options, 35 | } 36 | manifestPath := path.Join(options.DataDirectory, "manifest.json") 37 | manifestContent, err := os.ReadFile(manifestPath) 38 | if err == nil { 39 | var manifest EncryptionManifest 40 | if err := json.Unmarshal(manifestContent, &manifest); err != nil { 41 | return nil, err 42 | } 43 | fm.Manifest = &manifest 44 | if manifest.Version != 1 { 45 | return nil, errors.New("unsupported manifest version") 46 | } 47 | } 48 | return fm, nil 49 | } 50 | 51 | type EncryptionManifest struct { 52 | Version int32 `json:"version"` 53 | Keys []EncryptionKey `json:"keys"` 54 | Salt string `json:"salt"` 55 | } 56 | 57 | type EncryptionKey struct { 58 | Hash string `json:"hash"` 59 | Secret string `json:"secret"` 60 | } 61 | 62 | func (fm *FileManager) ReadFile(filename string) ([]byte, error) { 63 | content, err := os.ReadFile(filename) 64 | if err != nil { 65 | // only load from the backup file if the main file is missing or otherwise unreadable 66 | bkp := filename + ".bkp" 67 | if content, err = os.ReadFile(bkp); err != nil { 68 | // if we have no backup file, try the .tmp file 69 | bkp = filename + ".tmp" 70 | if content, err = os.ReadFile(bkp); err != nil { 71 | return nil, err 72 | } 73 | } 74 | if err = os.Rename(bkp, filename); err != nil { 75 | return nil, err 76 | } 77 | } 78 | return fm.unpack(content) 79 | } 80 | 81 | func (fm *FileManager) WriteFile(filename string, data []byte, ignoreCipher bool) error { 82 | packed, err := fm.pack(data, ignoreCipher) 83 | if err != nil { 84 | return err 85 | } 86 | fileWriterLock.Lock() 87 | defer fileWriterLock.Unlock() 88 | parent := path.Dir(filename) 89 | if _, err := os.Stat(parent); os.IsNotExist(err) { 90 | if err := os.MkdirAll(parent, 0755); err != nil { 91 | return err 92 | } 93 | } 94 | // we write to .tmp first to avoid corrupting the main file 95 | // then move the main file to .tmp and the .tmp to the main file 96 | tmp := filename + ".tmp" 97 | if err := os.WriteFile(tmp, packed, 0644); err != nil { 98 | return err 99 | } 100 | bkp := filename + ".bkp" 101 | if err := os.Rename(filename, bkp); err != nil && !os.IsNotExist(err) { 102 | return err 103 | } 104 | if err := os.Rename(tmp, filename); err != nil { 105 | return err 106 | } 107 | return nil 108 | } 109 | 110 | func (fm *FileManager) UpdateFiles(ignoreCipher bool) []error { 111 | errors := []error{} 112 | fileNames := []string{} 113 | if files, err := os.ReadDir(path.Join(fm.Options.DataDirectory, "data")); err == nil { 114 | for _, file := range files { 115 | fileNames = append(fileNames, path.Join(fm.Options.DataDirectory, "data", file.Name())) 116 | } 117 | } 118 | if files, err := os.ReadDir(fm.Options.DataDirectory); err == nil { 119 | for _, file := range files { 120 | if !file.IsDir() && strings.HasSuffix(file.Name(), ".id") { 121 | fileNames = append(fileNames, path.Join(fm.Options.DataDirectory, file.Name())) 122 | } 123 | } 124 | } 125 | for _, filename := range fileNames { 126 | data, err := fm.ReadFile(filename) 127 | if err != nil { 128 | errors = append(errors, err) 129 | continue 130 | } 131 | err = fm.WriteFile(filename, data, ignoreCipher) 132 | if err != nil { 133 | errors = append(errors, err) 134 | } 135 | } 136 | return errors 137 | } 138 | 139 | func (fm *FileManager) GetFilename(name string) string { 140 | return path.Join(fm.Options.DataDirectory, "data", name+".dat") 141 | } 142 | -------------------------------------------------------------------------------- /src/file/key.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/rand" 6 | "crypto/sha256" 7 | "encoding/hex" 8 | "encoding/json" 9 | "errors" 10 | "os" 11 | "path" 12 | 13 | "github.com/sag-enhanced/native-app/src/helper" 14 | ) 15 | 16 | func (fm *FileManager) TryLoadKey(password string) error { 17 | if fm.Manifest == nil { 18 | return errors.New("no manifest") 19 | } 20 | 21 | salt, err := hex.DecodeString(fm.Manifest.Salt) 22 | if err != nil { 23 | return err 24 | } 25 | key := helper.DeriveKey(password, salt) 26 | 27 | control := sha256.Sum256(key) 28 | encoded := hex.EncodeToString(control[:]) 29 | for _, k := range fm.Manifest.Keys { 30 | // we dont need to use a constant time comparison here because the hash is already public 31 | if k.Hash == encoded { 32 | secret, err := hex.DecodeString(k.Secret) 33 | if err != nil { 34 | return err 35 | } 36 | decryptCipher, err := aes.NewCipher(key) 37 | if err != nil { 38 | return err 39 | } 40 | decrypted := make([]byte, len(secret)) 41 | decryptCipher.Decrypt(decrypted[:16], secret[:16]) 42 | decryptCipher.Decrypt(decrypted[16:], secret[16:]) 43 | 44 | cipher, err := aes.NewCipher(decrypted) 45 | if err != nil { 46 | return err 47 | } 48 | fm.Cipher = &cipher 49 | return nil 50 | } 51 | } 52 | 53 | return errors.New("invalid password") 54 | } 55 | 56 | func (fm *FileManager) CreateKey(passwords []string) error { 57 | salt := make([]byte, 32) 58 | if _, err := rand.Read(salt); err != nil { 59 | return err 60 | } 61 | masterKey := make([]byte, 32) 62 | if _, err := rand.Read(masterKey); err != nil { 63 | return err 64 | } 65 | keys := make([]EncryptionKey, len(passwords)) 66 | for i, password := range passwords { 67 | key := helper.DeriveKey(password, salt) 68 | 69 | cipher, err := aes.NewCipher(key) 70 | if err != nil { 71 | return err 72 | } 73 | encryptedMasterKey := make([]byte, 32) 74 | cipher.Encrypt(encryptedMasterKey[:16], masterKey[:16]) 75 | cipher.Encrypt(encryptedMasterKey[16:], masterKey[16:]) 76 | 77 | hashedKey := sha256.Sum256(key) 78 | keys[i] = EncryptionKey{ 79 | Hash: hex.EncodeToString(hashedKey[:]), 80 | Secret: hex.EncodeToString(encryptedMasterKey), 81 | } 82 | } 83 | manifest := EncryptionManifest{ 84 | Version: 1, 85 | Salt: hex.EncodeToString(salt), 86 | Keys: keys, 87 | } 88 | 89 | manifestPath := path.Join(fm.Options.DataDirectory, "manifest.json") 90 | data, err := json.Marshal(manifest) 91 | if err != nil { 92 | return err 93 | } 94 | err = os.WriteFile(manifestPath, data, 0644) 95 | if err != nil { 96 | return err 97 | } 98 | cipher, err := aes.NewCipher(masterKey) 99 | if err != nil { 100 | return err 101 | } 102 | fm.Manifest = &manifest 103 | fm.Cipher = &cipher 104 | return nil 105 | } 106 | -------------------------------------------------------------------------------- /src/file/pack.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "bytes" 5 | "compress/flate" 6 | "crypto/cipher" 7 | "crypto/rand" 8 | "errors" 9 | "io" 10 | 11 | "github.com/sag-enhanced/native-app/src/helper" 12 | ) 13 | 14 | func (fm *FileManager) unpack(data []byte) ([]byte, error) { 15 | if len(data) == 0 { 16 | return nil, errors.New("empty file (corrupted)") 17 | } 18 | header := FileHeader(data[0]) 19 | if header == FileHeaderRaw { 20 | return data[1:], nil 21 | } else if header == FileHeaderEncrypted || header == FileHeaderEncryptedNoPad { 22 | if fm.Cipher == nil { 23 | return nil, errors.New("encrypted") 24 | } 25 | aesCipher, err := cipher.NewGCM(*fm.Cipher) 26 | if err != nil { 27 | return nil, err 28 | } 29 | content, err := aesCipher.Open(nil, data[1:1+aesCipher.NonceSize()], data[1+aesCipher.NonceSize():], nil) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | if header == FileHeaderEncryptedNoPad { 35 | return fm.unpack(content) 36 | } 37 | // GCM does not need padding, but due to an historical false judgement, we still padded it 38 | // FileHeaderEncrypted is legacy and will not be created anymore 39 | return fm.unpack(helper.Unpad(content)) 40 | } else if header == FileHeaderCompressed { 41 | reader := flate.NewReader(bytes.NewReader(data[1:])) 42 | decompressed, err := io.ReadAll(reader) 43 | if err != nil { 44 | return nil, err 45 | } 46 | return fm.unpack(decompressed) 47 | } 48 | return nil, errors.New("unknown header") 49 | } 50 | 51 | func (fm *FileManager) pack(data []byte, ignoreCipher bool) ([]byte, error) { 52 | data = append([]byte{byte(FileHeaderRaw)}, data...) 53 | data, err := fm.tryCompress(data) 54 | if err != nil { 55 | return nil, err 56 | } 57 | if fm.Cipher != nil && !ignoreCipher { 58 | aesCipher, err := cipher.NewGCM(*fm.Cipher) 59 | if err != nil { 60 | return nil, err 61 | } 62 | nonce := make([]byte, aesCipher.NonceSize()) 63 | if _, err := io.ReadFull(rand.Reader, nonce); err != nil { 64 | return nil, err 65 | } 66 | encrypted := aesCipher.Seal(nil, nonce, data, nil) 67 | data = make([]byte, 1+len(nonce)+len(encrypted)) 68 | data[0] = byte(FileHeaderEncryptedNoPad) 69 | copy(data[1:], nonce) 70 | copy(data[1+len(nonce):], encrypted) 71 | } 72 | data, err = fm.tryCompress(data) 73 | if err != nil { 74 | return nil, err 75 | } 76 | return data, nil 77 | } 78 | 79 | func (fm *FileManager) tryCompress(data []byte) ([]byte, error) { 80 | // compression was disabled 81 | if fm.Options.NoCompress { 82 | return data, nil 83 | } 84 | var buf bytes.Buffer 85 | buf.Write([]byte{byte(FileHeaderCompressed)}) 86 | writer, err := flate.NewWriter(&buf, flate.BestCompression) 87 | if err != nil { 88 | return nil, err 89 | } 90 | _, err = writer.Write(data) 91 | if err != nil { 92 | return nil, err 93 | } 94 | if err := writer.Close(); err != nil { 95 | return nil, err 96 | } 97 | if buf.Len() >= len(data) { 98 | return data, nil 99 | } 100 | return buf.Bytes(), nil 101 | } 102 | -------------------------------------------------------------------------------- /src/helper/crypto.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import ( 4 | "bytes" 5 | ) 6 | 7 | func Pad(data []byte, blockSize int) []byte { 8 | padding := blockSize - (len(data) % blockSize) 9 | return append(data, bytes.Repeat([]byte{byte(padding)}, padding)...) 10 | } 11 | 12 | func Unpad(data []byte) []byte { 13 | padding := int(data[len(data)-1]) 14 | return data[:len(data)-padding] 15 | } 16 | -------------------------------------------------------------------------------- /src/helper/crypto_aes.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "errors" 8 | ) 9 | 10 | func AESSeal(key []byte, data []byte) ([]byte, error) { 11 | if len(key) != 32 { 12 | return nil, errors.New("key must be 32 bytes") 13 | } 14 | aesCipher, err := aes.NewCipher(key) 15 | if err != nil { 16 | return nil, err 17 | } 18 | cipher, err := cipher.NewGCM(aesCipher) 19 | if err != nil { 20 | return nil, err 21 | } 22 | iv := make([]byte, cipher.NonceSize()) 23 | if _, err := rand.Read(iv); err != nil { 24 | return nil, err 25 | } 26 | 27 | sealed := cipher.Seal(nil, iv, data, nil) 28 | 29 | result := make([]byte, len(iv)+len(sealed)) 30 | copy(result, iv) 31 | copy(result[len(iv):], sealed) 32 | 33 | return result, nil 34 | } 35 | 36 | func AESUnseal(key []byte, data []byte) ([]byte, error) { 37 | if len(key) != 32 { 38 | return nil, errors.New("key must be 32 bytes") 39 | } 40 | aesCipher, err := aes.NewCipher(key) 41 | if err != nil { 42 | return nil, err 43 | } 44 | cipher, err := cipher.NewGCM(aesCipher) 45 | if err != nil { 46 | return nil, err 47 | } 48 | if len(data) < cipher.NonceSize() { 49 | return nil, errors.New("data is too short") 50 | } 51 | iv := data[:cipher.NonceSize()] 52 | sealed := data[cipher.NonceSize():] 53 | 54 | return cipher.Open(nil, iv, sealed, nil) 55 | } 56 | -------------------------------------------------------------------------------- /src/helper/crypto_rsa.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "crypto/rsa" 8 | "crypto/sha256" 9 | ) 10 | 11 | func RSASeal(publicKey *rsa.PublicKey, data []byte) ([]byte, error) { 12 | key := make([]byte, 32) 13 | if _, err := rand.Read(key); err != nil { 14 | return nil, err 15 | } 16 | aesCipher, err := aes.NewCipher(key) 17 | if err != nil { 18 | return nil, err 19 | } 20 | cipher, err := cipher.NewGCM(aesCipher) 21 | if err != nil { 22 | return nil, err 23 | } 24 | iv := make([]byte, cipher.NonceSize()) 25 | if _, err := rand.Read(iv); err != nil { 26 | return nil, err 27 | } 28 | 29 | // we dont need to actually pad here because its GCM, but its too late to change it now 30 | sealed := cipher.Seal(nil, iv, Pad(data, aes.BlockSize), nil) 31 | sealedKey, err := rsa.EncryptOAEP(sha256.New(), rand.Reader, publicKey, key, nil) 32 | if err != nil { 33 | return nil, err 34 | } 35 | 36 | result := make([]byte, len(iv)+len(sealedKey)+len(sealed)) 37 | copy(result, sealedKey) 38 | copy(result[len(sealedKey):], iv) 39 | copy(result[len(sealedKey)+len(iv):], sealed) 40 | 41 | return result, nil 42 | } 43 | 44 | func RSAUnseal(privateKey *rsa.PrivateKey, data []byte) ([]byte, error) { 45 | sealedKeyLen := privateKey.PublicKey.Size() 46 | if len(data) < sealedKeyLen { 47 | return nil, rsa.ErrDecryption 48 | } 49 | key, err := rsa.DecryptOAEP(sha256.New(), rand.Reader, privateKey, data[:sealedKeyLen], nil) 50 | if err != nil { 51 | return nil, err 52 | } 53 | aesCipher, err := aes.NewCipher(key) 54 | if err != nil { 55 | return nil, err 56 | } 57 | cipher, err := cipher.NewGCM(aesCipher) 58 | if err != nil { 59 | return nil, err 60 | } 61 | 62 | iv := data[sealedKeyLen : sealedKeyLen+cipher.NonceSize()] 63 | sealed := data[sealedKeyLen+cipher.NonceSize():] 64 | 65 | plain, err := cipher.Open(nil, iv, sealed, nil) 66 | if err != nil { 67 | return nil, err 68 | } 69 | 70 | return Unpad(plain), nil 71 | } 72 | -------------------------------------------------------------------------------- /src/helper/open.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import ( 4 | "os/exec" 5 | 6 | "github.com/sag-enhanced/native-app/src/options" 7 | ) 8 | 9 | func Open(url string, options *options.Options) { 10 | args := append(options.OpenCommand, url) 11 | exec.Command(args[0], args[1:]...).Run() 12 | } 13 | -------------------------------------------------------------------------------- /src/helper/password.go: -------------------------------------------------------------------------------- 1 | package helper 2 | 3 | import "golang.org/x/crypto/argon2" 4 | 5 | func DeriveKey(password string, salt []byte) []byte { 6 | memory := uint32(64 * 1024) 7 | time := uint32(3) 8 | threads := uint8(4) 9 | bytes := uint32(32) 10 | 11 | return argon2.IDKey([]byte(password), salt, time, memory, threads, bytes) 12 | } 13 | -------------------------------------------------------------------------------- /src/identity/id.go: -------------------------------------------------------------------------------- 1 | package identity 2 | 3 | import ( 4 | "crypto" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | "crypto/sha256" 8 | "crypto/x509" 9 | "encoding/base64" 10 | "errors" 11 | "os" 12 | "path" 13 | 14 | "github.com/sag-enhanced/native-app/src/file" 15 | "github.com/sag-enhanced/native-app/src/helper" 16 | ) 17 | 18 | type Identity struct { 19 | PrivateKey *rsa.PrivateKey 20 | } 21 | 22 | func (identity *Identity) Sign(data []byte) ([]byte, error) { 23 | hash := sha256.Sum256(data) 24 | return rsa.SignPSS(rand.Reader, identity.PrivateKey, crypto.SHA256, hash[:], nil) 25 | } 26 | 27 | func (identity *Identity) Id() string { 28 | data := x509.MarshalPKCS1PublicKey(&identity.PrivateKey.PublicKey) 29 | return base64.RawStdEncoding.EncodeToString(data) 30 | } 31 | 32 | func (identity *Identity) Seal(data []byte) ([]byte, error) { 33 | return helper.RSASeal(&identity.PrivateKey.PublicKey, data) 34 | } 35 | 36 | func (identity *Identity) Unseal(data []byte) ([]byte, error) { 37 | return helper.RSAUnseal(identity.PrivateKey, data) 38 | } 39 | 40 | func (identity *Identity) Save(fm *file.FileManager) error { 41 | data, err := x509.MarshalPKCS8PrivateKey(identity.PrivateKey) 42 | if err != nil { 43 | return err 44 | } 45 | idFile := path.Join(fm.Options.DataDirectory, "sage2.id") 46 | return fm.WriteFile(idFile, data, false) 47 | } 48 | 49 | func LoadIdentity(fm *file.FileManager) (*Identity, error) { 50 | idFileNew := path.Join(fm.Options.DataDirectory, "sage2.id") 51 | data, err := fm.ReadFile(idFileNew) 52 | if err != nil { 53 | // migration for old id file (pre b7) 54 | idFileOld := path.Join(fm.Options.DataDirectory, "sage.id") 55 | data, err = os.ReadFile(idFileOld) 56 | if err == nil { 57 | err = fm.WriteFile(idFileNew, data, false) 58 | if err == nil { 59 | os.Remove(idFileOld) 60 | } 61 | } 62 | } 63 | if err == nil { 64 | private, err := x509.ParsePKCS8PrivateKey(data) 65 | if err != nil { 66 | return nil, err 67 | } 68 | if private, ok := private.(*rsa.PrivateKey); ok { 69 | return &Identity{PrivateKey: private}, nil 70 | } 71 | return nil, errors.New("invalid private key") 72 | } 73 | private, err := rsa.GenerateKey(rand.Reader, 4096) 74 | if err != nil { 75 | return nil, err 76 | } 77 | 78 | id := &Identity{PrivateKey: private} 79 | if err := id.Save(fm); err != nil { 80 | return nil, err 81 | } 82 | 83 | return id, nil 84 | } 85 | -------------------------------------------------------------------------------- /src/options/open.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import "runtime" 4 | 5 | func GetDefaultOpenCommand() []string { 6 | if runtime.GOOS == "windows" { 7 | return []string{"rundll32", "url.dll,FileProtocolHandler"} 8 | } else if runtime.GOOS == "darwin" { 9 | return []string{"open"} 10 | } 11 | return []string{"xdg-open"} 12 | } 13 | -------------------------------------------------------------------------------- /src/options/options.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | "crypto/rand" 5 | "encoding/base64" 6 | ) 7 | 8 | type Options struct { 9 | Build uint32 10 | Release uint32 11 | LoopbackPort uint16 12 | 13 | Verbose bool 14 | Realm Realm 15 | OpenCommand []string 16 | UI UI 17 | SteamDev bool 18 | DataDirectory string 19 | NoCompress bool 20 | ForceBrowser string 21 | 22 | CurrentUrlSecret string 23 | } 24 | 25 | func NewOptions() *Options { 26 | secret := make([]byte, 32) 27 | rand.Read(secret) // let's pray this doesn't fail 28 | 29 | return &Options{ 30 | Build: 14, 31 | Release: 4, 32 | LoopbackPort: 8666, 33 | 34 | UI: GetPreferredUI(), 35 | OpenCommand: GetDefaultOpenCommand(), 36 | DataDirectory: GetDefaultStoragePath(), 37 | 38 | CurrentUrlSecret: base64.RawURLEncoding.EncodeToString(secret), 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/options/realm.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | type Realm = string 4 | 5 | const ( 6 | StableRealm Realm = "stable" 7 | BetaRealm Realm = "beta" 8 | DevRealm Realm = "dev" 9 | LocalRealm Realm = "local" 10 | ) 11 | 12 | func (options *Options) GetRealmOrigin() string { 13 | switch options.Realm { 14 | case StableRealm: 15 | return "https://app.sage.party" 16 | case BetaRealm: 17 | return "https://app-beta.sage.party" 18 | case DevRealm: 19 | return "https://app-dev.sage.party" 20 | case LocalRealm: 21 | return "http://localhost:5173" 22 | } 23 | return "https://" + options.Realm 24 | } 25 | -------------------------------------------------------------------------------- /src/options/storage.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | import ( 4 | "os" 5 | "runtime" 6 | ) 7 | 8 | func GetDefaultStoragePath() string { 9 | if runtime.GOOS == "windows" { 10 | return os.ExpandEnv("${APPDATA}/sage") 11 | } else if runtime.GOOS == "darwin" { 12 | return os.ExpandEnv("${HOME}/Library/Application Support/sage") 13 | } 14 | return os.ExpandEnv("${HOME}/.config/sage") 15 | } 16 | -------------------------------------------------------------------------------- /src/options/ui.go: -------------------------------------------------------------------------------- 1 | package options 2 | 3 | type UI = string 4 | 5 | const ( 6 | PlaywrightUI UI = "playwright" 7 | WebviewUI UI = "webview" 8 | ) 9 | 10 | func GetPreferredUI() UI { 11 | if isWebviewAvailable() { 12 | return WebviewUI 13 | } 14 | return PlaywrightUI 15 | } 16 | -------------------------------------------------------------------------------- /src/options/webview_default.go: -------------------------------------------------------------------------------- 1 | //go:build !windows && !linux 2 | 3 | package options 4 | 5 | func isWebviewAvailable() bool { 6 | return true 7 | } 8 | -------------------------------------------------------------------------------- /src/options/webview_linux.go: -------------------------------------------------------------------------------- 1 | //go:build linux 2 | 3 | package options 4 | 5 | import "os" 6 | 7 | // sage requires webkit2gtk-4.1-dev to be installed, otherwise it'll segfault when trying to use webview 8 | func isWebviewAvailable() bool { 9 | if _, err := os.Stat("/usr/include/webkit2gtk-4.1"); err == nil { 10 | return true 11 | } 12 | return false 13 | } 14 | -------------------------------------------------------------------------------- /src/options/webview_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package options 4 | 5 | import "golang.org/x/sys/windows/registry" 6 | 7 | func isWebviewAvailable() bool { 8 | // https://learn.microsoft.com/en-us/microsoft-edge/webview2/concepts/distribution?tabs=dotnetcsharp#detect-if-a-webview2-runtime-is-already-installed 9 | for _, edge := range []edgeLocation{ 10 | {registry.LOCAL_MACHINE, `SOFTWARE\WOW6432Node\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}`}, 11 | {registry.LOCAL_MACHINE, `SOFTWARE\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}`}, 12 | {registry.CURRENT_USER, `Software\Microsoft\EdgeUpdate\Clients\{F3017226-FE2A-4295-8BDF-00C3A9A7E4C5}`}, 13 | } { 14 | key, err := registry.OpenKey(edge.registry, edge.key, registry.QUERY_VALUE) 15 | if err != nil { 16 | continue 17 | } 18 | defer key.Close() 19 | 20 | s, _, err := key.GetStringValue(`pv`) 21 | if err == nil && s != "" && s != "0.0.0.0" { 22 | return true 23 | } 24 | } 25 | 26 | return false 27 | } 28 | 29 | type edgeLocation struct { 30 | registry registry.Key 31 | key string 32 | } 33 | -------------------------------------------------------------------------------- /src/steam/close.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/sag-enhanced/native-app/src/options" 8 | "github.com/shirou/gopsutil/v3/process" 9 | ) 10 | 11 | func CloseSteam(options *options.Options) error { 12 | killed := int32(0) 13 | for { 14 | var proc *process.Process 15 | var err error 16 | if proc, err = findSteamProcess(); err != nil { 17 | break 18 | } 19 | if proc.Pid != killed { 20 | // new process found (this can happen if we close steam while its still bootstrapping) 21 | if options.Verbose { 22 | fmt.Println("Steam running, shutting it down...") 23 | } 24 | 25 | RunSteamWithArguments(options, "-shutdown") 26 | killed = proc.Pid 27 | } 28 | if options.Verbose { 29 | fmt.Println("Waiting for Steam to shut down...", proc.Pid) 30 | } 31 | time.Sleep(1 * time.Second) 32 | } 33 | return nil 34 | } 35 | -------------------------------------------------------------------------------- /src/steam/find.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "os" 7 | "path" 8 | "path/filepath" 9 | "runtime" 10 | "time" 11 | 12 | "github.com/sag-enhanced/native-app/src/helper" 13 | "github.com/sag-enhanced/native-app/src/options" 14 | "github.com/shirou/gopsutil/v3/process" 15 | ) 16 | 17 | // looking where the steam executable is from currently running processes 18 | // seemed like the most reliable way to find it on all platforms 19 | func FindSteamExecutable(options *options.Options) (string, error) { 20 | cache := path.Join(options.DataDirectory, "steam_executable.txt") 21 | if _, err := os.Stat(cache); err == nil { 22 | data, err := os.ReadFile(cache) 23 | if err != nil { 24 | return "", err 25 | } 26 | return string(data), nil 27 | } 28 | 29 | fmt.Println("Searching for Steam executable...") 30 | process, err := findSteamProcess() 31 | if err != nil { 32 | fmt.Println("Steam process not found; starting it...") 33 | // we are opening steam now 34 | // opening the console just for why not 35 | // the code that is calling this will close steam immediately afterwards anyway 36 | helper.Open("steam://open/console", options) 37 | for { 38 | if process, err = findSteamProcess(); err != nil { 39 | break 40 | } 41 | fmt.Println("Waiting for Steam process...") 42 | time.Sleep(1 * time.Second) 43 | } 44 | } 45 | fmt.Println("Steam process found: ", process.Pid) 46 | exe, err := process.Exe() 47 | if err != nil { 48 | return "", err 49 | } 50 | 51 | os.WriteFile(cache, []byte(exe), 0644) 52 | return exe, nil 53 | } 54 | 55 | func FindSteamDataDir(options *options.Options) (string, error) { 56 | if runtime.GOOS == "darwin" { 57 | // the application in /Applications is just the bootstrapper, the real executable 58 | // is installed per user right here: 59 | return path.Join(os.Getenv("HOME"), "Library/Application Support/Steam/Steam.AppBundle/Steam/Contents/MacOS"), nil 60 | } 61 | executable, err := FindSteamExecutable(options) 62 | if err != nil { 63 | return "", err 64 | } 65 | parent := filepath.Dir(executable) 66 | for len(parent) > 1 { 67 | _, err := os.Stat(filepath.Join(parent, "steamui")) 68 | if err == nil || !os.IsNotExist(err) { 69 | break 70 | } 71 | parent = filepath.Dir(parent) 72 | } 73 | if len(parent) <= 1 { 74 | return "", errors.New("Steam data directory not found") 75 | } 76 | return parent, nil 77 | } 78 | 79 | func findSteamProcess() (*process.Process, error) { 80 | processList, err := process.Processes() 81 | if err != nil { 82 | return nil, err 83 | } 84 | for _, p := range processList { 85 | name, err := p.Name() 86 | if err != nil { 87 | continue 88 | } 89 | if name == "steam_osx" || name == "steam.exe" || name == "steam" { 90 | return p, nil 91 | } 92 | } 93 | return nil, errors.New("Steam process not found") 94 | } 95 | -------------------------------------------------------------------------------- /src/steam/run.go: -------------------------------------------------------------------------------- 1 | package steam 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "os/exec" 7 | "runtime" 8 | 9 | "github.com/sag-enhanced/native-app/src/options" 10 | ) 11 | 12 | func RunSteamWithArguments(options *options.Options, args ...string) error { 13 | var executable string 14 | var err error 15 | 16 | if runtime.GOOS == "linux" { 17 | // steam is a shell script on linux, so we need to run it with bash 18 | executable = "/bin/bash" 19 | args = append([]string{"-c", "steam"}, args...) 20 | } else { 21 | executable, err = FindSteamExecutable(options) 22 | if err != nil { 23 | return err 24 | } 25 | } 26 | if options.SteamDev { 27 | args = append(args, "-dev") 28 | } 29 | 30 | if options.Verbose { 31 | fmt.Println("Running Steam with arguments:", executable, args) 32 | } 33 | cmd := exec.Command(executable, args...) 34 | // steam is dying without having stdout attached 35 | cmd.Stdout = os.Stdout 36 | cmd.Stderr = os.Stderr 37 | 38 | if runtime.GOOS == "windows" { 39 | // so windows apparently doesnt have the bootstrapper and it will directly start steam 40 | // and if we ran .Run() it would block the process until steam is closed, which is not what we want 41 | cmd.Start() 42 | } else { 43 | // this will run the bootstrapper and then block until its done and starts the actual steam process 44 | cmd.Run() 45 | } 46 | return nil 47 | } 48 | -------------------------------------------------------------------------------- /src/ui/scripts.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/sag-enhanced/native-app/src/options" 7 | ) 8 | 9 | func getScripts(options *options.Options) []string { 10 | scripts := []string{} 11 | 12 | // arbitrary redirect protection 13 | origin := options.GetRealmOrigin() 14 | js := fmt.Sprintf("if([%q,'https://id.sage.party'].indexOf(location.origin)===-1)location.href=%q", origin, origin) 15 | scripts = append(scripts, js) 16 | 17 | // expose current URL 18 | js = fmt.Sprintf("window.saged=window.saged||[];window.sage('setUrl',window.saged.push([()=>{},console.error.bind(console)]),JSON.stringify([location.href, %q]))", options.CurrentUrlSecret) 19 | scripts = append(scripts, js) 20 | 21 | return scripts 22 | } 23 | -------------------------------------------------------------------------------- /src/ui/ui.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import "github.com/sag-enhanced/native-app/src/options" 4 | 5 | type UII interface { 6 | Run() 7 | Eval(code string) 8 | SetBindHandler(handler bindHandler) 9 | Navigate(url string) 10 | Quit() 11 | } 12 | 13 | type bindHandler func(method string, callId int, params string) error 14 | 15 | func NewUI(opt *options.Options) UII { 16 | if opt.UI == options.PlaywrightUI { 17 | return createPlaywrightUII(opt) 18 | } else { 19 | return createWebviewUII(opt) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/ui/ui_playwright.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | "net/url" 6 | "time" 7 | 8 | "github.com/playwright-community/playwright-go" 9 | "github.com/sag-enhanced/native-app/src/options" 10 | ) 11 | 12 | type PlaywrightUII struct { 13 | page playwright.Page 14 | mainThread chan func() 15 | options *options.Options 16 | bindHandler bindHandler 17 | } 18 | 19 | func createPlaywrightUII(options *options.Options) *PlaywrightUII { 20 | return &PlaywrightUII{options: options} 21 | } 22 | 23 | func (pwui *PlaywrightUII) Run() { 24 | options := playwright.BrowserTypeLaunchOptions{ 25 | Headless: playwright.Bool(false), 26 | Args: []string{ 27 | "--disable-blink-features=AutomationControlled", 28 | }, 29 | IgnoreDefaultArgs: []string{ 30 | // disables "Chrome is being controlled by automated test software" banner 31 | "--enable-automation", 32 | }, 33 | } 34 | 35 | playwright.Install(&playwright.RunOptions{ 36 | Browsers: []string{"chromium"}, 37 | }) 38 | 39 | pw, err := playwright.Run() 40 | 41 | if err != nil { 42 | fmt.Println("Error while starting playwright: ", err) 43 | return 44 | } 45 | defer pw.Stop() 46 | 47 | browser, err := pw.Chromium.Launch(options) 48 | if err != nil { 49 | fmt.Println("Error while launching browser: ", err) 50 | return 51 | } 52 | defer browser.Close() 53 | 54 | pwui.page, err = browser.NewPage(playwright.BrowserNewPageOptions{ 55 | NoViewport: playwright.Bool(true), 56 | }) 57 | if err != nil { 58 | fmt.Println("Error while creating new page: ", err) 59 | return 60 | } 61 | defer pwui.page.Close() 62 | 63 | pwui.mainThread = make(chan func()) 64 | pwui.initBinding() 65 | 66 | scripts := getScripts(pwui.options) 67 | for _, script := range scripts { 68 | pwui.page.AddInitScript(playwright.Script{ 69 | Content: playwright.String(script), 70 | }) 71 | } 72 | 73 | pwui.page.OnClose(func(_ playwright.Page) { 74 | // this will wake-up the main thread which will then realize its time to exit 75 | pwui.mainThread <- func() {} 76 | }) 77 | 78 | pwui.page.Goto(pwui.options.GetRealmOrigin()) 79 | 80 | for !pwui.page.IsClosed() { 81 | select { 82 | case fn := <-pwui.mainThread: 83 | fn() 84 | } 85 | } 86 | } 87 | 88 | func (pwui *PlaywrightUII) Navigate(url string) { 89 | if pwui.options.Verbose { 90 | fmt.Println("Navigate:", url) 91 | } 92 | pwui.mainThread <- func() { 93 | pwui.page.Goto(url) 94 | } 95 | } 96 | 97 | func (pwui *PlaywrightUII) Eval(code string) { 98 | if pwui.options.Verbose { 99 | fmt.Println("Eval:", code) 100 | } 101 | result := make(chan bool, 1) 102 | pwui.mainThread <- func() { 103 | pwui.page.Evaluate(code) 104 | result <- true 105 | } 106 | 107 | if pwui.options.Verbose { 108 | go func() { 109 | select { 110 | case <-result: 111 | case <-time.After(5 * time.Second): 112 | fmt.Println("Eval timed out. Something is returning a promise that doesn't resolve. Please contact support.") 113 | } 114 | }() 115 | } 116 | } 117 | 118 | func (pwui *PlaywrightUII) Quit() { 119 | pwui.mainThread <- func() { 120 | pwui.page.Close() 121 | } 122 | } 123 | 124 | func (pwui *PlaywrightUII) initBinding() { 125 | pwui.page.ExposeBinding("sage", func(source *playwright.BindingSource, args ...any) any { 126 | if len(args) != 3 { 127 | return fmt.Errorf("sage() expects 3 arguments") 128 | } 129 | caller, err := url.Parse(source.Frame.URL()) 130 | if err != nil { 131 | return fmt.Errorf("failed to parse caller URL: %w", err) 132 | } 133 | 134 | callerOrigin := fmt.Sprintf("%s://%s", caller.Scheme, caller.Host) 135 | if callerOrigin != pwui.options.GetRealmOrigin() && callerOrigin != "https://id.sage.party" { 136 | return fmt.Errorf("sage() is not allowed to be called from %q", callerOrigin) 137 | } 138 | 139 | method := args[0].(string) 140 | callId := args[1].(int) 141 | params := args[2].(string) 142 | 143 | return pwui.bindHandler(method, callId, params) 144 | }) 145 | } 146 | 147 | func (pwui *PlaywrightUII) SetBindHandler(handler bindHandler) { 148 | pwui.bindHandler = handler 149 | } 150 | -------------------------------------------------------------------------------- /src/ui/ui_webview.go: -------------------------------------------------------------------------------- 1 | package ui 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/sag-enhanced/native-app/src/options" 7 | webview_go "github.com/sag-enhanced/webview_go" 8 | ) 9 | 10 | type WebviewUII struct { 11 | webview webview_go.WebView 12 | options *options.Options 13 | bindHandler bindHandler 14 | } 15 | 16 | func createWebviewUII(options *options.Options) *WebviewUII { 17 | return &WebviewUII{ 18 | options: options, 19 | } 20 | } 21 | 22 | func (wui *WebviewUII) Run() { 23 | wui.webview = webview_go.New(true) 24 | defer wui.webview.Destroy() 25 | 26 | wui.webview.SetTitle(fmt.Sprintf("SAG Enhanced (b%d)", wui.options.Build)) 27 | wui.webview.SetSize(800, 600, webview_go.HintNone) 28 | 29 | wui.webview.Bind("sage", wui.bindHandler) 30 | 31 | for _, script := range getScripts(wui.options) { 32 | wui.webview.Init(script) 33 | } 34 | 35 | wui.webview.Navigate(wui.options.GetRealmOrigin()) 36 | wui.webview.Run() 37 | } 38 | 39 | func (wui *WebviewUII) Navigate(url string) { 40 | if wui.options.Verbose { 41 | fmt.Println("Navigate:", url) 42 | } 43 | wui.webview.Dispatch(func() { 44 | wui.webview.Navigate(url) 45 | }) 46 | } 47 | 48 | func (wui *WebviewUII) Eval(code string) { 49 | if wui.options.Verbose { 50 | fmt.Println("Eval:", code) 51 | } 52 | // there seems to be a rare bug in webview where sometimes the eval doesn't work 53 | // so we try it a few times (the code is idempotent so it's safe to retry) 54 | // 3 tries should be enough 55 | // for i := 0; i < 3; i++ { 56 | wui.webview.Dispatch(func() { 57 | wui.webview.Eval(code) 58 | }) 59 | // } 60 | } 61 | 62 | func (wui *WebviewUII) Quit() { 63 | wui.webview.Dispatch(func() { 64 | wui.webview.Terminate() 65 | }) 66 | } 67 | 68 | func (wui *WebviewUII) SetBindHandler(handler bindHandler) { 69 | wui.bindHandler = handler 70 | } 71 | --------------------------------------------------------------------------------