├── .github └── workflows │ └── test.yml ├── .gitignore ├── LICENSE ├── README.md ├── accelerator.go ├── accelerator_test.go ├── astilectron.go ├── astilectron_test.go ├── dialog.go ├── dispatcher.go ├── dispatcher_test.go ├── display.go ├── display_pool.go ├── display_pool_test.go ├── display_test.go ├── dock.go ├── dock_test.go ├── event.go ├── event_test.go ├── example ├── index.html └── main.go ├── executer.go ├── global_shortcuts.go ├── global_shortcuts_test.go ├── go.mod ├── go.sum ├── helper.go ├── helper_test.go ├── identifier.go ├── identifier_test.go ├── menu.go ├── menu_item.go ├── menu_item_test.go ├── menu_test.go ├── notification.go ├── notification_test.go ├── object.go ├── object_test.go ├── paths.go ├── paths_test.go ├── power.go ├── provisioner.go ├── provisioner_test.go ├── reader.go ├── reader_test.go ├── rectangle.go ├── session.go ├── session_test.go ├── sub_menu.go ├── sub_menu_test.go ├── testdata └── provisioner │ ├── astilectron │ ├── astilectron.zip │ ├── astilectron │ │ └── main.js │ └── disembedder.zip │ ├── electron │ ├── darwin │ │ ├── Electron.app │ │ │ └── Contents │ │ │ │ ├── Frameworks │ │ │ │ └── Electron Helper.app │ │ │ │ │ └── Contents │ │ │ │ │ ├── Info.plist │ │ │ │ │ └── MacOS │ │ │ │ │ └── Electron Helper │ │ │ │ ├── Info.plist │ │ │ │ └── MacOS │ │ │ │ └── Electron │ │ └── electron.zip │ ├── linux │ │ ├── electron │ │ └── electron.zip │ └── windows │ │ ├── electron.exe │ │ └── electron.zip │ └── icon.icns ├── tray.go ├── tray_test.go ├── window.go ├── window_test.go ├── writer.go └── writer_test.go /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: [ "master" ] 6 | pull_request: 7 | branches: [ "master" ] 8 | 9 | jobs: 10 | 11 | build: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v3 15 | 16 | - name: Set up Go 17 | uses: actions/setup-go@v4 18 | with: 19 | go-version: '1.20' 20 | 21 | - name: Install dependencies 22 | run: go mod download 23 | 24 | - name: Run tests 25 | run: go test -race -covermode atomic -coverprofile=covprofile ./... 26 | 27 | - if: github.event_name != 'pull_request' 28 | name: Send coverage 29 | env: 30 | COVERALLS_TOKEN: ${{ secrets.COVERALLS_TOKEN }} 31 | run: | 32 | go install github.com/mattn/goveralls@latest 33 | goveralls -coverprofile=covprofile -service=github 34 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | Thumbs.db 3 | .idea/ 4 | cover* 5 | example/vendor 6 | testdata/tmp/* 7 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Quentin RENARD 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![GoReportCard](http://goreportcard.com/badge/github.com/asticode/go-astilectron)](http://goreportcard.com/report/github.com/asticode/go-astilectron) 2 | [![GoDoc](https://godoc.org/github.com/asticode/go-astilectron?status.svg)](https://godoc.org/github.com/asticode/go-astilectron) 3 | [![Test](https://github.com/asticode/go-astilectron/actions/workflows/test.yml/badge.svg)](https://github.com/asticode/go-astilectron/actions/workflows/test.yml) 4 | [![Coveralls](https://coveralls.io/repos/github/asticode/go-astilectron/badge.svg?branch=master)](https://coveralls.io/github/asticode/go-astilectron) 5 | 6 | Thanks to `go-astilectron` build cross platform GUI apps with GO and HTML/JS/CSS. It is the official GO bindings of [astilectron](https://github.com/asticode/astilectron) and is powered by [Electron](https://github.com/electron/electron). 7 | 8 | # Warning 9 | 10 | This project is not maintained anymore. 11 | 12 | # Demo 13 | 14 | To see a minimal Astilectron app, checkout out the [demo](https://github.com/asticode/go-astilectron-demo). 15 | 16 | It uses the [bootstrap](https://github.com/asticode/go-astilectron-bootstrap) and the [bundler](https://github.com/asticode/go-astilectron-bundler). 17 | 18 | If you're looking for a minimalistic example, run `go run example/main.go -v`. 19 | 20 | # Real-life examples 21 | 22 | Here's a list of awesome projects using `go-astilectron` (if you're using `go-astilectron` and want your project to be listed here please submit a PR): 23 | 24 | - [go-astivid](https://github.com/asticode/go-astivid) Video tools written in GO 25 | - [GroupMatcher](https://github.com/veecue/GroupMatcher) Program to allocate persons to groups while trying to fulfill all the given wishes as good as possible 26 | - [Stellite GUI Miner](https://github.com/stellitecoin/GUI-miner) An easy to use GUI cryptocurrency miner for Stellite 27 | 28 | # Bootstrap 29 | 30 | For convenience purposes, a [bootstrap](https://github.com/asticode/go-astilectron-bootstrap) has been implemented. 31 | 32 | The bootstrap allows you to quickly create a one-window application. 33 | 34 | There's no obligation to use it, but it's strongly recommended. 35 | 36 | If you decide to use it, read thoroughly the documentation as you'll have to structure your project in a specific way. 37 | 38 | # Bundler 39 | 40 | Still for convenience purposes, a [bundler](https://github.com/asticode/go-astilectron-bundler) has been implemented. 41 | 42 | The bundler allows you to bundle your app for every os/arch combinations and get a nice set of files to send your users. 43 | 44 | # Quick start 45 | 46 | WARNING: the code below doesn't handle errors for readibility purposes. However you SHOULD! 47 | 48 | ## Import `go-astilectron` 49 | 50 | To import `go-astilectron` run: 51 | 52 | $ go get -u github.com/asticode/go-astilectron 53 | 54 | ## Start `go-astilectron` 55 | 56 | ```go 57 | // Initialize astilectron 58 | var a, _ = astilectron.New(log.New(os.Stderr, "", 0), astilectron.Options{ 59 | AppName: "", 60 | AppIconDefaultPath: "", // If path is relative, it must be relative to the data directory 61 | AppIconDarwinPath: "", // Same here 62 | BaseDirectoryPath: "", 63 | VersionAstilectron: "", 64 | VersionElectron: "", 65 | }) 66 | defer a.Close() 67 | 68 | // Start astilectron 69 | a.Start() 70 | 71 | // Blocking pattern 72 | a.Wait() 73 | ``` 74 | 75 | For everything to work properly we need to fetch 2 dependencies : [astilectron](https://github.com/asticode/astilectron) and [Electron](https://github.com/electron/electron). `.Start()` takes care of it by downloading the sources and setting them up properly. 76 | 77 | In case you want to embed the sources in the binary to keep a unique binary you can use the **NewDisembedderProvisioner** function to get the proper **Provisioner** and attach it to `go-astilectron` with `.SetProvisioner(p Provisioner)`. Or you can use the [bootstrap](https://github.com/asticode/go-astilectron-bootstrap) and the [bundler](https://github.com/asticode/go-astilectron-bundler). Check out the [demo](https://github.com/asticode/go-astilectron-demo) to see how to use them. 78 | 79 | Beware when trying to add your own app icon as you'll need 2 icons : one compatible with MacOSX (.icns) and one compatible with the rest (.png for instance). 80 | 81 | If no BaseDirectoryPath is provided, it defaults to the executable's directory path. 82 | 83 | The majority of methods are asynchronous which means that when executing them `go-astilectron` will block until it receives a specific Electron event or until the overall context is cancelled. This is the case of `.Start()` which will block until it receives the `app.event.ready` `astilectron` event or until the overall context is cancelled. 84 | 85 | ### HTML paths 86 | NB! All paths in HTML (and Javascript) must be relative, otherwise the files will not be found. 87 | To make this happen in React for example, just set the homepage property of your package.json to "./". 88 | 89 | ``` { "homepage": "./" }``` 90 | 91 | ## Create a window 92 | 93 | ```go 94 | // Create a new window 95 | var w, _ = a.NewWindow("http://127.0.0.1:4000", &astilectron.WindowOptions{ 96 | Center: astikit.BoolPtr(true), 97 | Height: astikit.IntPtr(600), 98 | Width: astikit.IntPtr(600), 99 | }) 100 | w.Create() 101 | ``` 102 | 103 | When creating a window you need to indicate a URL as well as options such as position, size, etc. 104 | 105 | This is pretty straightforward except the `astilectron.Ptr*` methods so let me explain: GO doesn't do optional fields when json encoding unless you use pointers whereas Electron does handle optional fields. Therefore I added helper methods to convert int, bool and string into pointers and used pointers in structs sent to Electron. 106 | 107 | ## Open the dev tools 108 | 109 | When developing in JS, it's very convenient to debug your code using the browser window's dev tools: 110 | 111 | ````go 112 | // Open dev tools 113 | w.OpenDevTools() 114 | 115 | // Close dev tools 116 | w.CloseDevTools() 117 | ```` 118 | 119 | ## Add listeners 120 | 121 | ```go 122 | // Add a listener on Astilectron 123 | a.On(astilectron.EventNameAppCrash, func(e astilectron.Event) (deleteListener bool) { 124 | log.Println("App has crashed") 125 | return 126 | }) 127 | 128 | // Add a listener on the window 129 | w.On(astilectron.EventNameWindowEventResize, func(e astilectron.Event) (deleteListener bool) { 130 | log.Println("Window resized") 131 | return 132 | }) 133 | ``` 134 | 135 | Nothing much to say here either except that you can add listeners to Astilectron as well. 136 | 137 | ## Play with the window 138 | 139 | ```go 140 | // Play with the window 141 | w.Resize(200, 200) 142 | time.Sleep(time.Second) 143 | w.Maximize() 144 | ``` 145 | 146 | Check out the [Window doc](https://godoc.org/github.com/asticode/go-astilectron#Window) for a list of all exported methods 147 | 148 | ## Send messages from GO to Javascript 149 | 150 | ### Javascript 151 | 152 | ```javascript 153 | // This will wait for the astilectron namespace to be ready 154 | document.addEventListener('astilectron-ready', function() { 155 | // This will listen to messages sent by GO 156 | astilectron.onMessage(function(message) { 157 | // Process message 158 | if (message === "hello") { 159 | return "world"; 160 | } 161 | }); 162 | }) 163 | ``` 164 | 165 | ### GO 166 | 167 | ```go 168 | // This will send a message and execute a callback 169 | // Callbacks are optional 170 | w.SendMessage("hello", func(m *astilectron.EventMessage) { 171 | // Unmarshal 172 | var s string 173 | m.Unmarshal(&s) 174 | 175 | // Process message 176 | log.Printf("received %s\n", s) 177 | }) 178 | ``` 179 | 180 | This will print `received world` in the GO output 181 | 182 | ## Send messages from Javascript to GO 183 | 184 | ### GO 185 | 186 | ```go 187 | // This will listen to messages sent by Javascript 188 | w.OnMessage(func(m *astilectron.EventMessage) interface{} { 189 | // Unmarshal 190 | var s string 191 | m.Unmarshal(&s) 192 | 193 | // Process message 194 | if s == "hello" { 195 | return "world" 196 | } 197 | return nil 198 | }) 199 | ``` 200 | 201 | ### Javascript 202 | 203 | ```javascript 204 | // This will wait for the astilectron namespace to be ready 205 | document.addEventListener('astilectron-ready', function() { 206 | // This will send a message to GO 207 | astilectron.sendMessage("hello", function(message) { 208 | console.log("received " + message) 209 | }); 210 | }) 211 | ``` 212 | 213 | This will print "received world" in the Javascript output 214 | 215 | ## Play with the window's session 216 | 217 | ```go 218 | // Clear window's HTTP cache 219 | w.Session.ClearCache() 220 | ``` 221 | 222 | ## Handle several screens/displays 223 | 224 | ```go 225 | // If several displays, move the window to the second display 226 | var displays = a.Displays() 227 | if len(displays) > 1 { 228 | time.Sleep(time.Second) 229 | w.MoveInDisplay(displays[1], 50, 50) 230 | } 231 | ``` 232 | 233 | ## Menus 234 | 235 | ```go 236 | // Init a new app menu 237 | // You can do the same thing with a window 238 | var m = a.NewMenu([]*astilectron.MenuItemOptions{ 239 | { 240 | Label: astikit.StrPtr("Separator"), 241 | SubMenu: []*astilectron.MenuItemOptions{ 242 | {Label: astikit.StrPtr("Normal 1")}, 243 | { 244 | Label: astikit.StrPtr("Normal 2"), 245 | OnClick: func(e astilectron.Event) (deleteListener bool) { 246 | log.Println("Normal 2 item has been clicked") 247 | return 248 | }, 249 | }, 250 | {Type: astilectron.MenuItemTypeSeparator}, 251 | {Label: astikit.StrPtr("Normal 3")}, 252 | }, 253 | }, 254 | { 255 | Label: astikit.StrPtr("Checkbox"), 256 | SubMenu: []*astilectron.MenuItemOptions{ 257 | {Checked: astikit.BoolPtr(true), Label: astikit.StrPtr("Checkbox 1"), Type: astilectron.MenuItemTypeCheckbox}, 258 | {Label: astikit.StrPtr("Checkbox 2"), Type: astilectron.MenuItemTypeCheckbox}, 259 | {Label: astikit.StrPtr("Checkbox 3"), Type: astilectron.MenuItemTypeCheckbox}, 260 | }, 261 | }, 262 | { 263 | Label: astikit.StrPtr("Radio"), 264 | SubMenu: []*astilectron.MenuItemOptions{ 265 | {Checked: astikit.BoolPtr(true), Label: astikit.StrPtr("Radio 1"), Type: astilectron.MenuItemTypeRadio}, 266 | {Label: astikit.StrPtr("Radio 2"), Type: astilectron.MenuItemTypeRadio}, 267 | {Label: astikit.StrPtr("Radio 3"), Type: astilectron.MenuItemTypeRadio}, 268 | }, 269 | }, 270 | { 271 | Label: astikit.StrPtr("Roles"), 272 | SubMenu: []*astilectron.MenuItemOptions{ 273 | {Label: astikit.StrPtr("Minimize"), Role: astilectron.MenuItemRoleMinimize}, 274 | {Label: astikit.StrPtr("Close"), Role: astilectron.MenuItemRoleClose}, 275 | }, 276 | }, 277 | }) 278 | 279 | // Retrieve a menu item 280 | // This will retrieve the "Checkbox 1" item 281 | mi, _ := m.Item(1, 0) 282 | 283 | // Add listener manually 284 | // An OnClick listener has already been added in the options directly for another menu item 285 | mi.On(astilectron.EventNameMenuItemEventClicked, func(e astilectron.Event) bool { 286 | log.Printf("Menu item has been clicked. 'Checked' status is now %t\n", *e.MenuItemOptions.Checked) 287 | return false 288 | }) 289 | 290 | // Create the menu 291 | m.Create() 292 | 293 | // Manipulate a menu item 294 | mi.SetChecked(true) 295 | 296 | // Init a new menu item 297 | var ni = m.NewItem(&astilectron.MenuItemOptions{ 298 | Label: astikit.StrPtr("Inserted"), 299 | SubMenu: []*astilectron.MenuItemOptions{ 300 | {Label: astikit.StrPtr("Inserted 1")}, 301 | {Label: astikit.StrPtr("Inserted 2")}, 302 | }, 303 | }) 304 | 305 | // Insert the menu item at position "1" 306 | m.Insert(1, ni) 307 | 308 | // Fetch a sub menu 309 | s, _ := m.SubMenu(0) 310 | 311 | // Init a new menu item 312 | ni = s.NewItem(&astilectron.MenuItemOptions{ 313 | Label: astikit.StrPtr("Appended"), 314 | SubMenu: []*astilectron.MenuItemOptions{ 315 | {Label: astikit.StrPtr("Appended 1")}, 316 | {Label: astikit.StrPtr("Appended 2")}, 317 | }, 318 | }) 319 | 320 | // Append menu item dynamically 321 | s.Append(ni) 322 | 323 | // Pop up sub menu as a context menu 324 | s.Popup(&astilectron.MenuPopupOptions{PositionOptions: astilectron.PositionOptions{X: astikit.IntPtr(50), Y: astikit.IntPtr(50)}}) 325 | 326 | // Close popup 327 | s.ClosePopup() 328 | 329 | // Destroy the menu 330 | m.Destroy() 331 | ``` 332 | 333 | A few things to know: 334 | 335 | * when assigning a role to a menu item, `go-astilectron` won't be able to capture its click event 336 | * on MacOS there's no such thing as a window menu, only app menus therefore my advice is to stick to one global app menu instead of creating separate window menus 337 | * on MacOS MenuItem without SubMenu is not displayed 338 | 339 | ## Tray 340 | 341 | ```go 342 | // New tray 343 | var t = a.NewTray(&astilectron.TrayOptions{ 344 | Image: astikit.StrPtr("/path/to/image.png"), 345 | Tooltip: astikit.StrPtr("Tray's tooltip"), 346 | }) 347 | 348 | // Create tray 349 | t.Create() 350 | 351 | // New tray menu 352 | var m = t.NewMenu([]*astilectron.MenuItemOptions{ 353 | { 354 | Label: astikit.StrPtr("Root 1"), 355 | SubMenu: []*astilectron.MenuItemOptions{ 356 | {Label: astikit.StrPtr("Item 1")}, 357 | {Label: astikit.StrPtr("Item 2")}, 358 | {Type: astilectron.MenuItemTypeSeparator}, 359 | {Label: astikit.StrPtr("Item 3")}, 360 | }, 361 | }, 362 | { 363 | Label: astikit.StrPtr("Root 2"), 364 | SubMenu: []*astilectron.MenuItemOptions{ 365 | {Label: astikit.StrPtr("Item 1")}, 366 | {Label: astikit.StrPtr("Item 2")}, 367 | }, 368 | }, 369 | }) 370 | 371 | // Create the menu 372 | m.Create() 373 | 374 | // Change tray's image 375 | time.Sleep(time.Second) 376 | t.SetImage("/path/to/image-2.png") 377 | ``` 378 | 379 | ## Notifications 380 | 381 | ```go 382 | // Create the notification 383 | var n = a.NewNotification(&astilectron.NotificationOptions{ 384 | Body: "My Body", 385 | HasReply: astikit.BoolPtr(true), // Only MacOSX 386 | Icon: "/path/to/icon", 387 | ReplyPlaceholder: "type your reply here", // Only MacOSX 388 | Title: "My title", 389 | }) 390 | 391 | // Add listeners 392 | n.On(astilectron.EventNameNotificationEventClicked, func(e astilectron.Event) (deleteListener bool) { 393 | log.Println("the notification has been clicked!") 394 | return 395 | }) 396 | // Only for MacOSX 397 | n.On(astilectron.EventNameNotificationEventReplied, func(e astilectron.Event) (deleteListener bool) { 398 | log.Printf("the user has replied to the notification: %s\n", e.Reply) 399 | return 400 | }) 401 | 402 | // Create notification 403 | n.Create() 404 | 405 | // Show notification 406 | n.Show() 407 | ``` 408 | 409 | ## Dock (MacOSX only) 410 | 411 | ```go 412 | // Get the dock 413 | var d = a.Dock() 414 | 415 | // Hide and show the dock 416 | d.Hide() 417 | d.Show() 418 | 419 | // Make the Dock bounce 420 | id, _ := d.Bounce(astilectron.DockBounceTypeCritical) 421 | 422 | // Cancel the bounce 423 | d.CancelBounce(id) 424 | 425 | // Update badge and icon 426 | d.SetBadge("test") 427 | d.SetIcon("/path/to/icon") 428 | 429 | // New dock menu 430 | var m = d.NewMenu([]*astilectron.MenuItemOptions{ 431 | { 432 | Label: astikit.StrPtr("Root 1"), 433 | SubMenu: []*astilectron.MenuItemOptions{ 434 | {Label: astikit.StrPtr("Item 1")}, 435 | {Label: astikit.StrPtr("Item 2")}, 436 | {Type: astilectron.MenuItemTypeSeparator}, 437 | {Label: astikit.StrPtr("Item 3")}, 438 | }, 439 | }, 440 | { 441 | Label: astikit.StrPtr("Root 2"), 442 | SubMenu: []*astilectron.MenuItemOptions{ 443 | {Label: astikit.StrPtr("Item 1")}, 444 | {Label: astikit.StrPtr("Item 2")}, 445 | }, 446 | }, 447 | }) 448 | 449 | // Create the menu 450 | m.Create() 451 | ``` 452 | 453 | ## Global Shortcuts 454 | 455 | Registering a global shortcut. 456 | 457 | ```go 458 | // Register a new global shortcut 459 | isRegistered, _ := a.GlobalShortcuts().Register("CmdOrCtrl+x", func() { 460 | fmt.Println("CmdOrCtrl+x is pressed") 461 | }) 462 | fmt.Println("CmdOrCtrl+x is registered:", isRegistered) // true 463 | 464 | // Check if a global shortcut is registered 465 | isRegistered, _ = a.GlobalShortcuts().IsRegistered("Shift+Y") // false 466 | 467 | // Unregister a global shortcut 468 | a.GlobalShortcuts().Unregister("CmdOrCtrl+x") 469 | 470 | // Unregister all global shortcuts 471 | a.GlobalShortcuts().UnregisterAll() 472 | ``` 473 | 474 | ## Dialogs 475 | 476 | Add the following line at the top of your javascript file : 477 | 478 | ```javascript 479 | const { dialog } = require('electron').remote 480 | ``` 481 | 482 | Use the available [methods](https://github.com/electron/electron/blob/v7.1.10/docs/api/dialog.md). 483 | 484 | ## Basic auth 485 | 486 | ```go 487 | // Listen to login events 488 | w.OnLogin(func(i astilectron.Event) (username, password string, err error) { 489 | // Process the request and auth info 490 | if i.Request.Method == "GET" && i.AuthInfo.Scheme == "http://" { 491 | username = "username" 492 | password = "password" 493 | } 494 | return 495 | }) 496 | ``` 497 | 498 | # Features and roadmap 499 | 500 | - [x] custom branding (custom app name, app icon, etc.) 501 | - [x] window basic methods (create, show, close, resize, minimize, maximize, ...) 502 | - [x] window basic events (close, blur, focus, unresponsive, crashed, ...) 503 | - [x] remote messaging (messages between GO and Javascript) 504 | - [x] single binary distribution 505 | - [x] multi screens/displays 506 | - [x] menu methods and events (create, insert, append, popup, clicked, ...) 507 | - [x] bootstrap 508 | - [x] dialogs (open or save file, alerts, ...) 509 | - [x] tray 510 | - [x] bundler 511 | - [x] session 512 | - [x] accelerators (shortcuts) 513 | - [x] dock 514 | - [x] notifications 515 | - [ ] loader 516 | - [ ] file methods (drag & drop, ...) 517 | - [ ] clipboard methods 518 | - [ ] power monitor events (suspend, resume, ...) 519 | - [ ] desktop capturer (audio and video) 520 | - [ ] window advanced options (add missing ones) 521 | - [ ] window advanced methods (add missing ones) 522 | - [ ] window advanced events (add missing ones) 523 | - [ ] child windows 524 | 525 | # Cheers to 526 | 527 | [go-thrust](https://github.com/miketheprogrammer/go-thrust) which is awesome but unfortunately not maintained anymore. It inspired this project. 528 | -------------------------------------------------------------------------------- /accelerator.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // Accelerator separator 8 | const acceleratorSeparator = "+" 9 | 10 | // Accelerator represents an accelerator 11 | // https://github.com/electron/electron/blob/v1.8.1/docs/api/accelerator.md 12 | type Accelerator []string 13 | 14 | // NewAccelerator creates a new accelerator 15 | func NewAccelerator(items ...string) (a *Accelerator) { 16 | a = &Accelerator{} 17 | for _, i := range items { 18 | *a = append(*a, i) 19 | } 20 | return 21 | } 22 | 23 | // MarshalText implements the encoding.TextMarshaler interface 24 | func (a *Accelerator) MarshalText() ([]byte, error) { 25 | return []byte(strings.Join(*a, acceleratorSeparator)), nil 26 | } 27 | 28 | // UnmarshalText implements the encoding.TextUnmarshaler interface 29 | func (a *Accelerator) UnmarshalText(b []byte) error { 30 | *a = strings.Split(string(b), acceleratorSeparator) 31 | return nil 32 | } 33 | -------------------------------------------------------------------------------- /accelerator_test.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestAccelerator(t *testing.T) { 10 | var tb = []byte("1+2+3") 11 | var a Accelerator 12 | err := a.UnmarshalText(tb) 13 | assert.NoError(t, err) 14 | assert.Equal(t, Accelerator{"1", "2", "3"}, a) 15 | b, err := a.MarshalText() 16 | assert.NoError(t, err) 17 | assert.Equal(t, tb, b) 18 | } 19 | -------------------------------------------------------------------------------- /astilectron.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "fmt" 5 | "github.com/asticode/go-astikit" 6 | "net" 7 | "os/exec" 8 | "runtime" 9 | "time" 10 | ) 11 | 12 | // Versions 13 | const ( 14 | DefaultAcceptTCPTimeout = 30 * time.Second 15 | DefaultVersionAstilectron = "0.58.0" 16 | DefaultVersionElectron = "11.4.3" 17 | ) 18 | 19 | // Misc vars 20 | var ( 21 | validOSes = map[string]bool{ 22 | "darwin": true, 23 | "linux": true, 24 | "windows": true, 25 | } 26 | ) 27 | 28 | // App event names 29 | const ( 30 | EventNameAppClose = "app.close" 31 | EventNameAppCmdQuit = "app.cmd.quit" // Sends an event to Electron to properly quit the app 32 | EventNameAppCmdStop = "app.cmd.stop" // Cancel the context which results in exiting abruptly Electron's app 33 | EventNameAppCrash = "app.crash" 34 | EventNameAppErrorAccept = "app.error.accept" 35 | EventNameAppEventReady = "app.event.ready" 36 | EventNameAppEventSecondInstance = "app.event.second.instance" 37 | EventNameAppNoAccept = "app.no.accept" 38 | EventNameAppTooManyAccept = "app.too.many.accept" 39 | ) 40 | 41 | // Astilectron represents an object capable of interacting with Astilectron 42 | type Astilectron struct { 43 | dispatcher *dispatcher 44 | displayPool *displayPool 45 | dock *Dock 46 | executer Executer 47 | globalShortcuts *GlobalShortcuts 48 | identifier *identifier 49 | l astikit.SeverityLogger 50 | listener net.Listener 51 | options Options 52 | paths *Paths 53 | provisioner Provisioner 54 | reader *reader 55 | stderrWriter *astikit.WriterAdapter 56 | stdoutWriter *astikit.WriterAdapter 57 | supported *Supported 58 | worker *astikit.Worker 59 | writer *writer 60 | } 61 | 62 | // Options represents Astilectron options 63 | type Options struct { 64 | AcceptTCPTimeout time.Duration 65 | AppName string 66 | AppIconDarwinPath string // Darwin systems requires a specific .icns file 67 | AppIconDefaultPath string 68 | CustomElectronPath string 69 | BaseDirectoryPath string 70 | DataDirectoryPath string 71 | ElectronSwitches []string 72 | SingleInstance bool 73 | SkipSetup bool // If true, the user must handle provisioning and executing astilectron. 74 | TCPPort *int // The port to listen on. 75 | VersionAstilectron string 76 | VersionElectron string 77 | } 78 | 79 | // Supported represents Astilectron supported features 80 | type Supported struct { 81 | Notification *bool `json:"notification"` 82 | } 83 | 84 | // New creates a new Astilectron instance 85 | func New(l astikit.StdLogger, o Options) (a *Astilectron, err error) { 86 | // Validate the OS 87 | if !IsValidOS(runtime.GOOS) { 88 | err = fmt.Errorf("OS %s is invalid", runtime.GOOS) 89 | return 90 | } 91 | 92 | if o.VersionAstilectron == "" { 93 | o.VersionAstilectron = DefaultVersionAstilectron 94 | } 95 | if o.VersionElectron == "" { 96 | o.VersionElectron = DefaultVersionElectron 97 | } 98 | 99 | // Init 100 | a = &Astilectron{ 101 | dispatcher: newDispatcher(), 102 | displayPool: newDisplayPool(), 103 | executer: DefaultExecuter, 104 | identifier: newIdentifier(), 105 | l: astikit.AdaptStdLogger(l), 106 | options: o, 107 | provisioner: newDefaultProvisioner(l), 108 | worker: astikit.NewWorker(astikit.WorkerOptions{Logger: l}), 109 | } 110 | 111 | // Set paths 112 | if a.paths, err = newPaths(runtime.GOOS, runtime.GOARCH, o); err != nil { 113 | err = fmt.Errorf("creating new paths failed: %w", err) 114 | return 115 | } 116 | 117 | // Add default listeners 118 | a.On(EventNameAppCmdStop, func(e Event) (deleteListener bool) { 119 | a.Stop() 120 | return 121 | }) 122 | a.On(EventNameDisplayEventAdded, func(e Event) (deleteListener bool) { 123 | a.displayPool.update(e.Displays) 124 | return 125 | }) 126 | a.On(EventNameDisplayEventMetricsChanged, func(e Event) (deleteListener bool) { 127 | a.displayPool.update(e.Displays) 128 | return 129 | }) 130 | a.On(EventNameDisplayEventRemoved, func(e Event) (deleteListener bool) { 131 | a.displayPool.update(e.Displays) 132 | return 133 | }) 134 | a.On(EventNameAppCmdQuit, func(e Event) (deleteListener bool) { 135 | a.Stop() 136 | return 137 | }) 138 | return 139 | } 140 | 141 | // IsValidOS validates the OS 142 | func IsValidOS(os string) (ok bool) { 143 | _, ok = validOSes[os] 144 | return 145 | } 146 | 147 | // SetProvisioner sets the provisioner 148 | func (a *Astilectron) SetProvisioner(p Provisioner) *Astilectron { 149 | a.provisioner = p 150 | return a 151 | } 152 | 153 | // SetExecuter sets the executer 154 | func (a *Astilectron) SetExecuter(e Executer) *Astilectron { 155 | a.executer = e 156 | return a 157 | } 158 | 159 | // GlobalShortcuts gets the global shortcuts 160 | func (a *Astilectron) GlobalShortcuts() *GlobalShortcuts { 161 | return a.globalShortcuts 162 | } 163 | 164 | // On implements the Listenable interface 165 | func (a *Astilectron) On(eventName string, l Listener) { 166 | a.dispatcher.addListener(targetIDApp, eventName, l) 167 | } 168 | 169 | // Start starts Astilectron 170 | func (a *Astilectron) Start() (err error) { 171 | // Log 172 | a.l.Debug("Starting...") 173 | 174 | // Provision 175 | if !a.options.SkipSetup { 176 | if err = a.provision(); err != nil { 177 | return fmt.Errorf("provisioning failed: %w", err) 178 | } 179 | } 180 | 181 | // Unfortunately communicating with Electron through stdin/stdout doesn't work on Windows so all communications 182 | // will be done through TCP 183 | if err = a.listenTCP(); err != nil { 184 | return fmt.Errorf("listening failed: %w", err) 185 | } 186 | 187 | // Execute 188 | if !a.options.SkipSetup { 189 | if err = a.execute(); err != nil { 190 | return fmt.Errorf("executing failed: %w", err) 191 | } 192 | } else { 193 | synchronousFunc(a.worker.Context(), a, nil, "app.event.ready") 194 | } 195 | return nil 196 | } 197 | 198 | // provision provisions Astilectron 199 | func (a *Astilectron) provision() error { 200 | a.l.Debug("Provisioning...") 201 | return a.provisioner.Provision(a.worker.Context(), a.options.AppName, runtime.GOOS, runtime.GOARCH, a.options.VersionAstilectron, a.options.VersionElectron, *a.paths) 202 | } 203 | 204 | // listenTCP creates a TCP server for astilectron to connect to 205 | // and listens to the first TCP connection coming its way (this should be Astilectron). 206 | func (a *Astilectron) listenTCP() (err error) { 207 | // Log 208 | a.l.Debug("Listening...") 209 | 210 | addr := "127.0.0.1:" 211 | if a.options.TCPPort != nil { 212 | addr += fmt.Sprint(*a.options.TCPPort) 213 | } 214 | // Listen 215 | if a.listener, err = net.Listen("tcp", addr); err != nil { 216 | return fmt.Errorf("tcp net.Listen failed: %w", err) 217 | } 218 | 219 | // Check a connection has been accepted quickly enough 220 | var chanAccepted = make(chan bool) 221 | go a.watchNoAccept(a.options.AcceptTCPTimeout, chanAccepted) 222 | 223 | // Accept connections 224 | go a.acceptTCP(chanAccepted) 225 | return 226 | } 227 | 228 | // watchNoAccept checks whether a TCP connection is accepted quickly enough 229 | func (a *Astilectron) watchNoAccept(timeout time.Duration, chanAccepted chan bool) { 230 | // check timeout 231 | if timeout == 0 { 232 | timeout = DefaultAcceptTCPTimeout 233 | } 234 | var t = time.NewTimer(timeout) 235 | defer t.Stop() 236 | for { 237 | select { 238 | case <-chanAccepted: 239 | return 240 | case <-t.C: 241 | a.l.Errorf("No TCP connection has been accepted in the past %s", timeout) 242 | a.dispatcher.dispatch(Event{Name: EventNameAppNoAccept, TargetID: targetIDApp}) 243 | a.dispatcher.dispatch(Event{Name: EventNameAppCmdStop, TargetID: targetIDApp}) 244 | return 245 | } 246 | } 247 | } 248 | 249 | // watchAcceptTCP accepts TCP connections 250 | func (a *Astilectron) acceptTCP(chanAccepted chan bool) { 251 | for i := 0; i <= 1; i++ { 252 | // Accept 253 | var conn net.Conn 254 | var err error 255 | if conn, err = a.listener.Accept(); err != nil { 256 | a.l.Errorf("%s while TCP accepting", err) 257 | a.dispatcher.dispatch(Event{Name: EventNameAppErrorAccept, TargetID: targetIDApp}) 258 | a.dispatcher.dispatch(Event{Name: EventNameAppCmdStop, TargetID: targetIDApp}) 259 | return 260 | } 261 | 262 | // We only accept the first connection which should be Astilectron, close the next one and stop 263 | // the app 264 | if i > 0 { 265 | a.l.Errorf("Too many TCP connections") 266 | a.dispatcher.dispatch(Event{Name: EventNameAppTooManyAccept, TargetID: targetIDApp}) 267 | a.dispatcher.dispatch(Event{Name: EventNameAppCmdStop, TargetID: targetIDApp}) 268 | conn.Close() 269 | return 270 | } 271 | 272 | // Let the timer know a connection has been accepted 273 | chanAccepted <- true 274 | 275 | // Create reader and writer 276 | a.writer = newWriter(conn, a.l) 277 | a.reader = newReader(a.worker.Context(), a.l, a.dispatcher, conn) 278 | go a.reader.read() 279 | } 280 | } 281 | 282 | // execute executes Astilectron in Electron 283 | func (a *Astilectron) execute() (err error) { 284 | // Log 285 | a.l.Debug("Executing...") 286 | 287 | // Create command 288 | var singleInstance string 289 | if a.options.SingleInstance { 290 | singleInstance = "true" 291 | } else { 292 | singleInstance = "false" 293 | } 294 | var cmd = exec.CommandContext(a.worker.Context(), a.paths.AppExecutable(), append([]string{a.paths.AstilectronApplication(), a.listener.Addr().String(), singleInstance}, a.options.ElectronSwitches...)...) 295 | a.stderrWriter = astikit.NewWriterAdapter(astikit.WriterAdapterOptions{ 296 | Callback: func(i []byte) { a.l.Debugf("Stderr says: %s", i) }, 297 | Split: []byte("\n"), 298 | }) 299 | a.stdoutWriter = astikit.NewWriterAdapter(astikit.WriterAdapterOptions{ 300 | Callback: func(i []byte) { a.l.Debugf("Stdout says: %s", i) }, 301 | Split: []byte("\n"), 302 | }) 303 | cmd.Stderr = a.stderrWriter 304 | cmd.Stdout = a.stdoutWriter 305 | 306 | // Execute command 307 | if err = a.executeCmd(cmd); err != nil { 308 | return fmt.Errorf("executing cmd failed: %w", err) 309 | } 310 | return 311 | } 312 | 313 | // executeCmd executes the command 314 | func (a *Astilectron) executeCmd(cmd *exec.Cmd) (err error) { 315 | // Execute 316 | var e Event 317 | if e, err = synchronousFunc(a.worker.Context(), a, func() error { return a.executer(a.l, a, cmd) }, EventNameAppEventReady); err != nil { 318 | err = fmt.Errorf("executer failed: %w", err) 319 | return 320 | } 321 | 322 | // Update display pool 323 | if e.Displays != nil { 324 | a.displayPool.update(e.Displays) 325 | } 326 | 327 | // Create dock 328 | a.dock = newDock(a.worker.Context(), a.dispatcher, a.identifier, a.writer) 329 | 330 | // Create global shortcuts 331 | a.globalShortcuts = newGlobalShortcuts(a.worker.Context(), a.dispatcher, a.identifier, a.writer) 332 | 333 | // Update supported features 334 | a.supported = e.Supported 335 | return 336 | } 337 | 338 | // watchCmd watches the cmd execution 339 | func (a *Astilectron) watchCmd(cmd *exec.Cmd) { 340 | a.worker.NewTask().Do(func() { 341 | // Wait 342 | if err := cmd.Wait(); err != nil { 343 | a.l.Errorf("'%v' exited with code: %v", cmd.Path, cmd.ProcessState.ExitCode()) 344 | } 345 | 346 | // Check the context to determine whether it was a crash 347 | if a.worker.Context().Err() == nil { 348 | a.l.Debug("App has crashed") 349 | a.dispatcher.dispatch(Event{Name: EventNameAppCrash, TargetID: targetIDApp}) 350 | } else { 351 | a.l.Debug("App has closed") 352 | a.dispatcher.dispatch(Event{Name: EventNameAppClose, TargetID: targetIDApp}) 353 | } 354 | a.dispatcher.dispatch(Event{Name: EventNameAppCmdStop, TargetID: targetIDApp}) 355 | }) 356 | } 357 | 358 | // Close closes Astilectron properly 359 | func (a *Astilectron) Close() { 360 | a.l.Debug("Closing...") 361 | a.worker.Stop() 362 | if a.listener != nil { 363 | a.listener.Close() 364 | } 365 | if a.reader != nil { 366 | a.reader.close() 367 | } 368 | if a.stderrWriter != nil { 369 | a.stderrWriter.Close() 370 | } 371 | if a.stdoutWriter != nil { 372 | a.stdoutWriter.Close() 373 | } 374 | if a.writer != nil { 375 | a.writer.close() 376 | } 377 | } 378 | 379 | // HandleSignals handles signals 380 | func (a *Astilectron) HandleSignals(hs ...astikit.SignalHandler) { 381 | a.worker.HandleSignals(hs...) 382 | } 383 | 384 | // Stop orders Astilectron to stop 385 | func (a *Astilectron) Stop() { 386 | a.l.Debug("Stopping...") 387 | a.worker.Stop() 388 | } 389 | 390 | // Wait is a blocking pattern 391 | func (a *Astilectron) Wait() { 392 | a.worker.Wait() 393 | } 394 | 395 | // Quit quits the app 396 | func (a *Astilectron) Quit() error { 397 | return a.writer.write(Event{Name: EventNameAppCmdQuit}) 398 | } 399 | 400 | // Paths returns the paths 401 | func (a *Astilectron) Paths() Paths { 402 | return *a.paths 403 | } 404 | 405 | // Displays returns the displays 406 | func (a *Astilectron) Displays() []*Display { 407 | return a.displayPool.all() 408 | } 409 | 410 | // Dock returns the dock 411 | func (a *Astilectron) Dock() *Dock { 412 | return a.dock 413 | } 414 | 415 | // PrimaryDisplay returns the primary display 416 | func (a *Astilectron) PrimaryDisplay() *Display { 417 | return a.displayPool.primary() 418 | } 419 | 420 | // NewMenu creates a new app menu 421 | func (a *Astilectron) NewMenu(i []*MenuItemOptions) *Menu { 422 | return newMenu(a.worker.Context(), targetIDApp, i, a.dispatcher, a.identifier, a.writer) 423 | } 424 | 425 | // NewWindow creates a new window 426 | func (a *Astilectron) NewWindow(url string, o *WindowOptions) (*Window, error) { 427 | return newWindow(a.worker.Context(), a.l, a.options, a.Paths(), url, o, a.dispatcher, a.identifier, a.writer) 428 | } 429 | 430 | // NewWindowInDisplay creates a new window in a specific display 431 | // This overrides the center attribute 432 | func (a *Astilectron) NewWindowInDisplay(d *Display, url string, o *WindowOptions) (*Window, error) { 433 | if o.X != nil { 434 | *o.X += d.Bounds().X 435 | } else { 436 | o.X = astikit.IntPtr(d.Bounds().X) 437 | } 438 | if o.Y != nil { 439 | *o.Y += d.Bounds().Y 440 | } else { 441 | o.Y = astikit.IntPtr(d.Bounds().Y) 442 | } 443 | return newWindow(a.worker.Context(), a.l, a.options, a.Paths(), url, o, a.dispatcher, a.identifier, a.writer) 444 | } 445 | 446 | // NewTray creates a new tray 447 | func (a *Astilectron) NewTray(o *TrayOptions) *Tray { 448 | return newTray(a.worker.Context(), o, a.dispatcher, a.identifier, a.writer) 449 | } 450 | 451 | // NewNotification creates a new notification 452 | func (a *Astilectron) NewNotification(o *NotificationOptions) *Notification { 453 | return newNotification(a.worker.Context(), o, a.supported != nil && a.supported.Notification != nil && *a.supported.Notification, a.dispatcher, a.identifier, a.writer) 454 | } 455 | -------------------------------------------------------------------------------- /astilectron_test.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "errors" 5 | "net" 6 | "os" 7 | "sync" 8 | "testing" 9 | "time" 10 | 11 | "github.com/stretchr/testify/assert" 12 | ) 13 | 14 | type logger struct{} 15 | 16 | func (l *logger) Debug(v ...interface{}) {} 17 | func (l *logger) Debugf(format string, v ...interface{}) {} 18 | func (l *logger) Error(v ...interface{}) {} 19 | func (l *logger) Errorf(format string, v ...interface{}) {} 20 | func (l *logger) Info(v ...interface{}) {} 21 | func (l *logger) Infof(format string, v ...interface{}) {} 22 | func (l *logger) Warn(v ...interface{}) {} 23 | func (l *logger) Warnf(format string, v ...interface{}) {} 24 | func (l *logger) Fatal(v ...interface{}) {} 25 | func (l *logger) Fatalf(format string, v ...interface{}) {} 26 | 27 | func TestAstilectron_Provision(t *testing.T) { 28 | // Init 29 | var o = Options{ 30 | BaseDirectoryPath: mockedTempPath(), 31 | VersionAstilectron: "0.35.1", 32 | } 33 | defer os.RemoveAll(o.BaseDirectoryPath) 34 | a, err := New(nil, o) 35 | assert.NoError(t, err) 36 | a.SetProvisioner(NewDisembedderProvisioner(mockedDisembedder, "astilectron", "electron/linux", nil)) 37 | 38 | // Test provision is successful 39 | err = a.provision() 40 | assert.NoError(t, err) 41 | } 42 | 43 | func TestAstilectron_WatchNoAccept(t *testing.T) { 44 | // Init 45 | a, err := New(nil, Options{}) 46 | assert.NoError(t, err) 47 | var isStopped bool 48 | var wg = &sync.WaitGroup{} 49 | a.On(EventNameAppCmdStop, func(e Event) bool { 50 | isStopped = true 51 | wg.Done() 52 | return false 53 | }) 54 | c := make(chan bool) 55 | 56 | // Test success 57 | go func() { 58 | time.Sleep(50 * time.Microsecond) 59 | c <- true 60 | }() 61 | a.watchNoAccept(time.Second, c) 62 | assert.False(t, isStopped) 63 | 64 | // Test failure 65 | wg.Add(1) 66 | a.watchNoAccept(time.Nanosecond, c) 67 | wg.Wait() 68 | assert.True(t, isStopped) 69 | } 70 | 71 | // mockedListener implements the net.Listener interface 72 | type mockedListener struct { 73 | c chan bool 74 | e chan bool 75 | } 76 | 77 | func (l mockedListener) Accept() (net.Conn, error) { 78 | for { 79 | select { 80 | case <-l.c: 81 | return mockedConn{}, nil 82 | case <-l.e: 83 | return nil, errors.New("invalid") 84 | } 85 | } 86 | } 87 | func (l mockedListener) Close() error { return nil } 88 | func (l mockedListener) Addr() net.Addr { return nil } 89 | 90 | // mockedConn implements the net.Conn interface 91 | type mockedConn struct{} 92 | 93 | func (c mockedConn) Read(b []byte) (n int, err error) { return } 94 | func (c mockedConn) Write(b []byte) (n int, err error) { return } 95 | func (c mockedConn) Close() error { return nil } 96 | func (c mockedConn) LocalAddr() net.Addr { return nil } 97 | func (c mockedConn) RemoteAddr() net.Addr { return nil } 98 | func (c mockedConn) SetDeadline(t time.Time) error { return nil } 99 | func (c mockedConn) SetReadDeadline(t time.Time) error { return nil } 100 | func (c mockedConn) SetWriteDeadline(t time.Time) error { return nil } 101 | 102 | func TestAstilectron_AcceptTCP(t *testing.T) { 103 | // Init 104 | a, err := New(nil, Options{}) 105 | assert.NoError(t, err) 106 | defer a.Close() 107 | var l = &mockedListener{c: make(chan bool), e: make(chan bool)} 108 | a.listener = l 109 | var isStopped bool 110 | var wg = &sync.WaitGroup{} 111 | a.On(EventNameAppCmdStop, func(e Event) bool { 112 | isStopped = true 113 | wg.Done() 114 | return false 115 | }) 116 | c := make(chan bool) 117 | var isAccepted bool 118 | go func() { 119 | <-c 120 | isAccepted = true 121 | wg.Done() 122 | }() 123 | go a.acceptTCP(c) 124 | 125 | // Test accepted 126 | wg.Add(1) 127 | l.c <- true 128 | wg.Wait() 129 | assert.True(t, isAccepted) 130 | assert.False(t, isStopped) 131 | 132 | // Test refused 133 | isAccepted = false 134 | wg.Add(1) 135 | l.c <- true 136 | wg.Wait() 137 | assert.False(t, isAccepted) 138 | assert.True(t, isStopped) 139 | 140 | // Test error accept 141 | go a.acceptTCP(c) 142 | isStopped = false 143 | wg.Add(1) 144 | l.e <- true 145 | wg.Wait() 146 | assert.False(t, isAccepted) 147 | assert.True(t, isStopped) 148 | } 149 | 150 | func TestIsValidOS(t *testing.T) { 151 | assert.True(t, IsValidOS("darwin")) 152 | assert.True(t, IsValidOS("linux")) 153 | assert.True(t, IsValidOS("windows")) 154 | assert.False(t, IsValidOS("invalid")) 155 | } 156 | 157 | func TestAstilectron_Wait(t *testing.T) { 158 | a, err := New(nil, Options{}) 159 | assert.NoError(t, err) 160 | a.HandleSignals() 161 | go func() { 162 | time.Sleep(20 * time.Microsecond) 163 | p, err := os.FindProcess(os.Getpid()) 164 | assert.NoError(t, err) 165 | p.Signal(os.Interrupt) 166 | }() 167 | a.Wait() 168 | } 169 | 170 | func TestAstilectron_NewMenu(t *testing.T) { 171 | a, err := New(nil, Options{}) 172 | assert.NoError(t, err) 173 | m := a.NewMenu([]*MenuItemOptions{}) 174 | assert.Equal(t, targetIDApp, m.rootID) 175 | } 176 | 177 | func TestAstilectron_Actions(t *testing.T) { 178 | // Init 179 | a, err := New(nil, Options{}) 180 | assert.NoError(t, err) 181 | defer a.Close() 182 | wrt := &mockedWriter{} 183 | a.writer = newWriter(wrt, &logger{}) 184 | 185 | // Actions 186 | err = a.Quit() 187 | assert.NoError(t, err) 188 | assert.Equal(t, []string{"{\"name\":\"app.cmd.quit\"}\n"}, wrt.w) 189 | } 190 | -------------------------------------------------------------------------------- /dialog.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | // Message box types 4 | const ( 5 | MessageBoxTypeError = "error" 6 | MessageBoxTypeInfo = "info" 7 | MessageBoxTypeNone = "none" 8 | MessageBoxTypeQuestion = "question" 9 | MessageBoxTypeWarning = "warning" 10 | ) 11 | 12 | // MessageBoxOptions represents message box options 13 | // We must use pointers since GO doesn't handle optional fields whereas NodeJS does. Use astikit.BoolPtr, astikit.IntPtr or astikit.StrPtr 14 | // to fill the struct 15 | // https://github.com/electron/electron/blob/v1.8.1/docs/api/dialog.md#dialogshowmessageboxbrowserwindow-options-callback 16 | type MessageBoxOptions struct { 17 | Buttons []string `json:"buttons,omitempty"` 18 | CancelID *int `json:"cancelId,omitempty"` 19 | CheckboxChecked *bool `json:"checkboxChecked,omitempty"` 20 | CheckboxLabel string `json:"checkboxLabel,omitempty"` 21 | ConfirmID *int `json:"confirmId,omitempty"` 22 | DefaultID *int `json:"defaultId,omitempty"` 23 | Detail string `json:"detail,omitempty"` 24 | Icon string `json:"icon,omitempty"` 25 | Message string `json:"message,omitempty"` 26 | NoLink *bool `json:"noLink,omitempty"` 27 | Title string `json:"title,omitempty"` 28 | Type string `json:"type,omitempty"` 29 | } 30 | -------------------------------------------------------------------------------- /dispatcher.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import "sync" 4 | 5 | // Listener represents a listener executed when an event is dispatched 6 | type Listener func(e Event) (deleteListener bool) 7 | 8 | // listenable represents an object that can listen 9 | type listenable interface { 10 | On(eventName string, l Listener) 11 | } 12 | 13 | // dispatcher represents an object capable of dispatching events 14 | type dispatcher struct { 15 | id int 16 | // Indexed by target ID then by event name then be listener id 17 | // We use a map[int]Listener so that deletion is as smooth as possible 18 | // It means it doesn't store listeners in order 19 | l map[string]map[string]map[int]Listener 20 | m sync.Mutex 21 | } 22 | 23 | // newDispatcher creates a new dispatcher 24 | func newDispatcher() *dispatcher { 25 | return &dispatcher{ 26 | l: make(map[string]map[string]map[int]Listener), 27 | } 28 | } 29 | 30 | // addListener adds a listener 31 | func (d *dispatcher) addListener(targetID, eventName string, l Listener) { 32 | d.m.Lock() 33 | defer d.m.Unlock() 34 | if _, ok := d.l[targetID]; !ok { 35 | d.l[targetID] = make(map[string]map[int]Listener) 36 | } 37 | if _, ok := d.l[targetID][eventName]; !ok { 38 | d.l[targetID][eventName] = make(map[int]Listener) 39 | } 40 | d.id++ 41 | d.l[targetID][eventName][d.id] = l 42 | } 43 | 44 | // delListener delete a specific listener 45 | func (d *dispatcher) delListener(targetID, eventName string, id int) { 46 | d.m.Lock() 47 | defer d.m.Unlock() 48 | if _, ok := d.l[targetID]; !ok { 49 | return 50 | } 51 | if _, ok := d.l[targetID][eventName]; !ok { 52 | return 53 | } 54 | delete(d.l[targetID][eventName], id) 55 | } 56 | 57 | // Dispatch dispatches an event 58 | func (d *dispatcher) dispatch(e Event) { 59 | // needed so dispatches of events triggered in the listeners can be received without blocking 60 | go func() { 61 | for id, l := range d.listeners(e.TargetID, e.Name) { 62 | if l(e) { 63 | d.delListener(e.TargetID, e.Name, id) 64 | } 65 | } 66 | }() 67 | } 68 | 69 | // listeners returns the listeners for a target ID and an event name 70 | func (d *dispatcher) listeners(targetID, eventName string) (l map[int]Listener) { 71 | d.m.Lock() 72 | defer d.m.Unlock() 73 | l = map[int]Listener{} 74 | if _, ok := d.l[targetID]; !ok { 75 | return 76 | } 77 | if _, ok := d.l[targetID][eventName]; !ok { 78 | return 79 | } 80 | for k, v := range d.l[targetID][eventName] { 81 | l[k] = v 82 | } 83 | return 84 | } 85 | -------------------------------------------------------------------------------- /dispatcher_test.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestDispatcher(t *testing.T) { 11 | // Init 12 | var d = newDispatcher() 13 | var wg = sync.WaitGroup{} 14 | var dispatched = []int{} 15 | var m sync.Mutex 16 | 17 | // Test adding listener 18 | d.addListener("1", "1", func(e Event) (deleteListener bool) { 19 | m.Lock() 20 | dispatched = append(dispatched, 1) 21 | m.Unlock() 22 | wg.Done() 23 | return 24 | }) 25 | d.addListener("1", "1", func(e Event) (deleteListener bool) { 26 | m.Lock() 27 | dispatched = append(dispatched, 2) 28 | m.Unlock() 29 | wg.Done() 30 | return true 31 | }) 32 | d.addListener("1", "1", func(e Event) (deleteListener bool) { 33 | m.Lock() 34 | dispatched = append(dispatched, 3) 35 | m.Unlock() 36 | wg.Done() 37 | return true 38 | }) 39 | d.addListener("1", "2", func(e Event) (deleteListener bool) { 40 | m.Lock() 41 | dispatched = append(dispatched, 4) 42 | m.Unlock() 43 | wg.Done() 44 | return 45 | }) 46 | assert.Len(t, d.l["1"]["1"], 3) 47 | 48 | // Test dispatch 49 | wg.Add(4) 50 | d.dispatch(Event{Name: "2", TargetID: "1"}) 51 | d.dispatch(Event{Name: "1", TargetID: "1"}) 52 | wg.Wait() 53 | for _, v := range []int{1, 2, 3, 4} { 54 | assert.Contains(t, dispatched, v) 55 | } 56 | assert.Len(t, d.listeners("1", "1"), 1) 57 | } 58 | -------------------------------------------------------------------------------- /display.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | // Display event names 4 | const ( 5 | EventNameDisplayEventAdded = "display.event.added" 6 | EventNameDisplayEventMetricsChanged = "display.event.metrics.changed" 7 | EventNameDisplayEventRemoved = "display.event.removed" 8 | ) 9 | 10 | // Display represents a display 11 | // https://github.com/electron/electron/blob/v1.8.1/docs/api/structures/display.md 12 | type Display struct { 13 | o *DisplayOptions 14 | primary bool 15 | } 16 | 17 | // DisplayOptions represents display options 18 | // https://github.com/electron/electron/blob/v1.8.1/docs/api/structures/display.md 19 | type DisplayOptions struct { 20 | Bounds *RectangleOptions `json:"bounds,omitempty"` 21 | ID *int64 `json:"id,omitempty"` 22 | Rotation *int `json:"rotation,omitempty"` // 0, 90, 180 or 270 23 | ScaleFactor *float64 `json:"scaleFactor,omitempty"` 24 | Size *SizeOptions `json:"size,omitempty"` 25 | TouchSupport *string `json:"touchSupport,omitempty"` // available, unavailable or unknown 26 | WorkArea *RectangleOptions `json:"workArea,omitempty"` 27 | WorkAreaSize *SizeOptions `json:"workAreaSize,omitempty"` 28 | } 29 | 30 | // newDisplay creates a displays 31 | func newDisplay(o *DisplayOptions, primary bool) *Display { return &Display{o: o, primary: primary} } 32 | 33 | // Bounds returns the display bounds 34 | func (d Display) Bounds() Rectangle { 35 | return Rectangle{ 36 | Position: Position{X: *d.o.Bounds.X, Y: *d.o.Bounds.Y}, 37 | Size: Size{Height: *d.o.Bounds.Height, Width: *d.o.Bounds.Width}, 38 | } 39 | } 40 | 41 | // ID returns the display's ID 42 | func (d Display) ID() int64 { 43 | return *d.o.ID 44 | } 45 | 46 | // IsPrimary checks whether the display is the primary display 47 | func (d Display) IsPrimary() bool { 48 | return d.primary 49 | } 50 | 51 | // IsTouchAvailable checks whether touch is available on this display 52 | func (d Display) IsTouchAvailable() bool { 53 | return *d.o.TouchSupport == "available" 54 | } 55 | 56 | // Rotation returns the display rotation 57 | func (d Display) Rotation() int { 58 | return *d.o.Rotation 59 | } 60 | 61 | // ScaleFactor returns the display scale factor 62 | func (d Display) ScaleFactor() float64 { 63 | return *d.o.ScaleFactor 64 | } 65 | 66 | // Size returns the display size 67 | func (d Display) Size() Size { 68 | return Size{Height: *d.o.Size.Height, Width: *d.o.Size.Width} 69 | } 70 | 71 | // WorkArea returns the display work area 72 | func (d Display) WorkArea() Rectangle { 73 | return Rectangle{ 74 | Position: Position{X: *d.o.WorkArea.X, Y: *d.o.WorkArea.Y}, 75 | Size: Size{Height: *d.o.WorkArea.Height, Width: *d.o.WorkArea.Width}, 76 | } 77 | } 78 | 79 | // WorkAreaSize returns the display work area size 80 | func (d Display) WorkAreaSize() Size { 81 | return Size{Height: *d.o.WorkAreaSize.Height, Width: *d.o.WorkAreaSize.Width} 82 | } 83 | -------------------------------------------------------------------------------- /display_pool.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import "sync" 4 | 5 | // displayPool represents a display pool 6 | type displayPool struct { 7 | d map[int64]*Display 8 | m *sync.Mutex 9 | } 10 | 11 | // newDisplayPool creates a new display pool 12 | func newDisplayPool() *displayPool { 13 | return &displayPool{ 14 | d: make(map[int64]*Display), 15 | m: &sync.Mutex{}, 16 | } 17 | } 18 | 19 | // all returns all the displays 20 | func (p *displayPool) all() (ds []*Display) { 21 | p.m.Lock() 22 | defer p.m.Unlock() 23 | ds = []*Display{} 24 | for _, d := range p.d { 25 | ds = append(ds, d) 26 | } 27 | return 28 | } 29 | 30 | // primary returns the primary display 31 | // It defaults to the last display 32 | func (p *displayPool) primary() (d *Display) { 33 | p.m.Lock() 34 | defer p.m.Unlock() 35 | for _, d = range p.d { 36 | if d.primary { 37 | return 38 | } 39 | } 40 | return 41 | } 42 | 43 | // update updates the pool based on event displays 44 | func (p *displayPool) update(e *EventDisplays) { 45 | p.m.Lock() 46 | defer p.m.Unlock() 47 | var ids = make(map[int64]bool) 48 | for _, o := range e.All { 49 | ids[*o.ID] = true 50 | var primary bool 51 | if *o.ID == *e.Primary.ID { 52 | primary = true 53 | } 54 | if d, ok := p.d[*o.ID]; ok { 55 | d.primary = primary 56 | *d.o = *o 57 | } else { 58 | p.d[*o.ID] = newDisplay(o, primary) 59 | } 60 | } 61 | for id := range p.d { 62 | if _, ok := ids[id]; !ok { 63 | delete(p.d, id) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /display_pool_test.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/asticode/go-astikit" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestDisplayPool(t *testing.T) { 11 | // Init 12 | var dp = newDisplayPool() 13 | 14 | // Test update 15 | dp.update(&EventDisplays{ 16 | All: []*DisplayOptions{ 17 | {ID: astikit.Int64Ptr(1), Rotation: astikit.IntPtr(1)}, 18 | {ID: astikit.Int64Ptr(2)}, 19 | }, 20 | Primary: &DisplayOptions{ID: astikit.Int64Ptr(2)}, 21 | }) 22 | assert.Len(t, dp.all(), 2) 23 | assert.Equal(t, int64(2), *dp.primary().o.ID) 24 | 25 | // Test removing one display 26 | dp.update(&EventDisplays{ 27 | All: []*DisplayOptions{ 28 | {ID: astikit.Int64Ptr(1), Rotation: astikit.IntPtr(2)}, 29 | }, 30 | Primary: &DisplayOptions{ID: astikit.Int64Ptr(1)}, 31 | }) 32 | assert.Len(t, dp.all(), 1) 33 | assert.Equal(t, 2, dp.all()[0].Rotation()) 34 | assert.Equal(t, int64(1), *dp.primary().o.ID) 35 | 36 | // Test adding a new one 37 | dp.update(&EventDisplays{ 38 | All: []*DisplayOptions{ 39 | {ID: astikit.Int64Ptr(1)}, 40 | {ID: astikit.Int64Ptr(3)}, 41 | }, 42 | Primary: &DisplayOptions{ID: astikit.Int64Ptr(1)}, 43 | }) 44 | assert.Len(t, dp.all(), 2) 45 | assert.Equal(t, int64(1), *dp.primary().o.ID) 46 | } 47 | -------------------------------------------------------------------------------- /display_test.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/asticode/go-astikit" 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | // TestDisplay tests display 11 | func TestDisplay(t *testing.T) { 12 | var o = &DisplayOptions{ 13 | Bounds: &RectangleOptions{PositionOptions: PositionOptions{X: astikit.IntPtr(1), Y: astikit.IntPtr(2)}, SizeOptions: SizeOptions{Height: astikit.IntPtr(3), Width: astikit.IntPtr(4)}}, 14 | ID: astikit.Int64Ptr(1234), 15 | Rotation: astikit.IntPtr(5), 16 | ScaleFactor: astikit.Float64Ptr(6), 17 | Size: &SizeOptions{Height: astikit.IntPtr(7), Width: astikit.IntPtr(8)}, 18 | TouchSupport: astikit.StrPtr("available"), 19 | WorkArea: &RectangleOptions{PositionOptions: PositionOptions{X: astikit.IntPtr(9), Y: astikit.IntPtr(10)}, SizeOptions: SizeOptions{Height: astikit.IntPtr(11), Width: astikit.IntPtr(12)}}, 20 | WorkAreaSize: &SizeOptions{Height: astikit.IntPtr(13), Width: astikit.IntPtr(14)}, 21 | } 22 | var d = newDisplay(o, true) 23 | assert.Equal(t, Rectangle{Position: Position{X: 1, Y: 2}, Size: Size{Height: 3, Width: 4}}, d.Bounds()) 24 | assert.Equal(t, int64(1234), d.ID()) 25 | assert.True(t, d.IsPrimary()) 26 | assert.Equal(t, 5, d.Rotation()) 27 | assert.Equal(t, float64(6), d.ScaleFactor()) 28 | assert.Equal(t, Size{Height: 7, Width: 8}, d.Size()) 29 | assert.True(t, d.IsTouchAvailable()) 30 | assert.Equal(t, Rectangle{Position: Position{X: 9, Y: 10}, Size: Size{Height: 11, Width: 12}}, d.WorkArea()) 31 | assert.Equal(t, Size{Height: 13, Width: 14}, d.WorkAreaSize()) 32 | o.TouchSupport = astikit.StrPtr("unavailable") 33 | d = newDisplay(o, false) 34 | assert.False(t, d.IsPrimary()) 35 | assert.False(t, d.IsTouchAvailable()) 36 | } 37 | -------------------------------------------------------------------------------- /dock.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/asticode/go-astikit" 7 | ) 8 | 9 | // Dock event names 10 | const ( 11 | eventNameDockCmdBounce = "dock.cmd.bounce" 12 | eventNameDockCmdBounceDownloads = "dock.cmd.bounce.downloads" 13 | eventNameDockCmdCancelBounce = "dock.cmd.cancel.bounce" 14 | eventNameDockCmdHide = "dock.cmd.hide" 15 | eventNameDockCmdSetBadge = "dock.cmd.set.badge" 16 | eventNameDockCmdSetIcon = "dock.cmd.set.icon" 17 | eventNameDockCmdShow = "dock.cmd.show" 18 | eventNameDockEventBadgeSet = "dock.event.badge.set" 19 | eventNameDockEventBouncing = "dock.event.bouncing" 20 | eventNameDockEventBouncingCancelled = "dock.event.bouncing.cancelled" 21 | eventNameDockEventDownloadsBouncing = "dock.event.download.bouncing" 22 | eventNameDockEventHidden = "dock.event.hidden" 23 | eventNameDockEventIconSet = "dock.event.icon.set" 24 | eventNameDockEventShown = "dock.event.shown" 25 | ) 26 | 27 | // Dock bounce types 28 | const ( 29 | DockBounceTypeCritical = "critical" 30 | DockBounceTypeInformational = "informational" 31 | ) 32 | 33 | // Dock represents a dock 34 | // https://github.com/electron/electron/blob/v1.8.1/docs/api/app.md#appdockbouncetype-macos 35 | type Dock struct { 36 | *object 37 | } 38 | 39 | func newDock(ctx context.Context, d *dispatcher, i *identifier, wrt *writer) *Dock { 40 | return &Dock{object: newObject(ctx, d, i, wrt, targetIDDock)} 41 | } 42 | 43 | // Bounce bounces the dock 44 | func (d *Dock) Bounce(bounceType string) (id int, err error) { 45 | if err = d.ctx.Err(); err != nil { 46 | return 47 | } 48 | var e Event 49 | if e, err = synchronousEvent(d.ctx, d, d.w, Event{Name: eventNameDockCmdBounce, TargetID: d.id, BounceType: bounceType}, eventNameDockEventBouncing); err != nil { 50 | return 51 | } 52 | if e.ID != nil { 53 | id = *e.ID 54 | } 55 | return 56 | } 57 | 58 | // BounceDownloads bounces the downloads part of the dock 59 | func (d *Dock) BounceDownloads(filePath string) (err error) { 60 | if err = d.ctx.Err(); err != nil { 61 | return 62 | } 63 | _, err = synchronousEvent(d.ctx, d, d.w, Event{Name: eventNameDockCmdBounceDownloads, TargetID: d.id, FilePath: filePath}, eventNameDockEventDownloadsBouncing) 64 | return 65 | } 66 | 67 | // CancelBounce cancels the dock bounce 68 | func (d *Dock) CancelBounce(id int) (err error) { 69 | if err = d.ctx.Err(); err != nil { 70 | return 71 | } 72 | _, err = synchronousEvent(d.ctx, d, d.w, Event{Name: eventNameDockCmdCancelBounce, TargetID: d.id, ID: astikit.IntPtr(id)}, eventNameDockEventBouncingCancelled) 73 | return 74 | } 75 | 76 | // Hide hides the dock 77 | func (d *Dock) Hide() (err error) { 78 | if err = d.ctx.Err(); err != nil { 79 | return 80 | } 81 | _, err = synchronousEvent(d.ctx, d, d.w, Event{Name: eventNameDockCmdHide, TargetID: d.id}, eventNameDockEventHidden) 82 | return 83 | } 84 | 85 | // NewMenu creates a new dock menu 86 | func (d *Dock) NewMenu(i []*MenuItemOptions) *Menu { 87 | return newMenu(d.ctx, d.id, i, d.d, d.i, d.w) 88 | } 89 | 90 | // SetBadge sets the badge of the dock 91 | func (d *Dock) SetBadge(badge string) (err error) { 92 | if err = d.ctx.Err(); err != nil { 93 | return 94 | } 95 | _, err = synchronousEvent(d.ctx, d, d.w, Event{Name: eventNameDockCmdSetBadge, TargetID: d.id, Badge: &badge}, eventNameDockEventBadgeSet) 96 | return 97 | } 98 | 99 | // SetIcon sets the icon of the dock 100 | func (d *Dock) SetIcon(image string) (err error) { 101 | if err = d.ctx.Err(); err != nil { 102 | return 103 | } 104 | _, err = synchronousEvent(d.ctx, d, d.w, Event{Name: eventNameDockCmdSetIcon, TargetID: d.id, Image: image}, eventNameDockEventIconSet) 105 | return 106 | } 107 | 108 | // Show shows the dock 109 | func (d *Dock) Show() (err error) { 110 | if err = d.ctx.Err(); err != nil { 111 | return 112 | } 113 | _, err = synchronousEvent(d.ctx, d, d.w, Event{Name: eventNameDockCmdShow, TargetID: d.id}, eventNameDockEventShown) 114 | return 115 | } 116 | -------------------------------------------------------------------------------- /dock_test.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestDock_Actions(t *testing.T) { 11 | // Init 12 | var d = newDispatcher() 13 | var i = newIdentifier() 14 | var wrt = &mockedWriter{} 15 | var w = newWriter(wrt, &logger{}) 16 | var dck = newDock(context.Background(), d, i, w) 17 | 18 | // Actions 19 | testObjectAction(t, func() error { 20 | _, err := dck.Bounce(DockBounceTypeCritical) 21 | return err 22 | }, dck.object, wrt, "{\"name\":\""+eventNameDockCmdBounce+"\",\"targetID\":\""+dck.id+"\",\"bounceType\":\"critical\"}\n", eventNameDockEventBouncing, nil) 23 | testObjectAction(t, func() error { return dck.BounceDownloads("/path/to/file") }, dck.object, wrt, "{\"name\":\""+eventNameDockCmdBounceDownloads+"\",\"targetID\":\""+dck.id+"\",\"filePath\":\"/path/to/file\"}\n", eventNameDockEventDownloadsBouncing, nil) 24 | testObjectAction(t, func() error { return dck.CancelBounce(1) }, dck.object, wrt, "{\"name\":\""+eventNameDockCmdCancelBounce+"\",\"targetID\":\""+dck.id+"\",\"id\":1}\n", eventNameDockEventBouncingCancelled, nil) 25 | testObjectAction(t, func() error { return dck.Hide() }, dck.object, wrt, "{\"name\":\""+eventNameDockCmdHide+"\",\"targetID\":\""+dck.id+"\"}\n", eventNameDockEventHidden, nil) 26 | testObjectAction(t, func() error { return dck.SetBadge("badge") }, dck.object, wrt, "{\"name\":\""+eventNameDockCmdSetBadge+"\",\"targetID\":\""+dck.id+"\",\"badge\":\"badge\"}\n", eventNameDockEventBadgeSet, nil) 27 | testObjectAction(t, func() error { return dck.SetIcon("/path/to/icon") }, dck.object, wrt, "{\"name\":\""+eventNameDockCmdSetIcon+"\",\"targetID\":\""+dck.id+"\",\"image\":\"/path/to/icon\"}\n", eventNameDockEventIconSet, nil) 28 | testObjectAction(t, func() error { return dck.Show() }, dck.object, wrt, "{\"name\":\""+eventNameDockCmdShow+"\",\"targetID\":\""+dck.id+"\"}\n", eventNameDockEventShown, nil) 29 | } 30 | 31 | func TestDock_NewMenu(t *testing.T) { 32 | var d = newDispatcher() 33 | var i = newIdentifier() 34 | var wrt = &mockedWriter{} 35 | var w = newWriter(wrt, &logger{}) 36 | var dck = newDock(context.Background(), d, i, w) 37 | m := dck.NewMenu([]*MenuItemOptions{}) 38 | assert.Equal(t, dck.id, m.rootID) 39 | } 40 | -------------------------------------------------------------------------------- /event.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | ) 7 | 8 | // Target IDs 9 | const ( 10 | targetIDApp = "app" 11 | targetIDDock = "dock" 12 | ) 13 | 14 | // Event represents an event 15 | type Event struct { 16 | // This is the base of the event 17 | Name string `json:"name"` 18 | TargetID string `json:"targetID,omitempty"` 19 | 20 | // This is a list of all possible payloads. 21 | // A choice was made not to use interfaces since it's a pain in the ass asserting each an every payload afterwards 22 | // We use pointers so that omitempty works 23 | AuthInfo *EventAuthInfo `json:"authInfo,omitempty"` 24 | Badge *string `json:"badge,omitempty"` 25 | BounceType string `json:"bounceType,omitempty"` 26 | Bounds *RectangleOptions `json:"bounds,omitempty"` 27 | CallbackID string `json:"callbackId,omitempty"` 28 | Code string `json:"code,omitempty"` 29 | Displays *EventDisplays `json:"displays,omitempty"` 30 | Enable *bool `json:"enable,omitempty"` 31 | FilePath string `json:"filePath,omitempty"` 32 | GlobalShortcuts *EventGlobalShortcuts `json:"globalShortcuts,omitempty"` 33 | ID *int `json:"id,omitempty"` 34 | Image string `json:"image,omitempty"` 35 | Index *int `json:"index,omitempty"` 36 | Menu *EventMenu `json:"menu,omitempty"` 37 | MenuItem *EventMenuItem `json:"menuItem,omitempty"` 38 | MenuItemOptions *MenuItemOptions `json:"menuItemOptions,omitempty"` 39 | MenuItemPosition *int `json:"menuItemPosition,omitempty"` 40 | MenuPopupOptions *MenuPopupOptions `json:"menuPopupOptions,omitempty"` 41 | Message *EventMessage `json:"message,omitempty"` 42 | NotificationOptions *NotificationOptions `json:"notificationOptions,omitempty"` 43 | Password string `json:"password,omitempty"` 44 | Path string `json:"path,omitempty"` 45 | Reply string `json:"reply,omitempty"` 46 | Request *EventRequest `json:"request,omitempty"` 47 | SecondInstance *EventSecondInstance `json:"secondInstance,omitempty"` 48 | SessionID string `json:"sessionId,omitempty"` 49 | Supported *Supported `json:"supported,omitempty"` 50 | TrayOptions *TrayOptions `json:"trayOptions,omitempty"` 51 | URL string `json:"url,omitempty"` 52 | URLNew string `json:"newUrl,omitempty"` 53 | URLOld string `json:"oldUrl,omitempty"` 54 | Username string `json:"username,omitempty"` 55 | WindowID string `json:"windowId,omitempty"` 56 | WindowOptions *WindowOptions `json:"windowOptions,omitempty"` 57 | } 58 | 59 | // EventAuthInfo represents an event auth info 60 | type EventAuthInfo struct { 61 | Host string `json:"host,omitempty"` 62 | IsProxy *bool `json:"isProxy,omitempty"` 63 | Port *int `json:"port,omitempty"` 64 | Realm string `json:"realm,omitempty"` 65 | Scheme string `json:"scheme,omitempty"` 66 | } 67 | 68 | // EventDisplays represents events displays 69 | type EventDisplays struct { 70 | All []*DisplayOptions `json:"all,omitempty"` 71 | Primary *DisplayOptions `json:"primary,omitempty"` 72 | } 73 | 74 | // EventGlobalShortcuts represents event global shortcuts 75 | type EventGlobalShortcuts struct { 76 | Accelerator string `json:"accelerator,omitempty"` 77 | IsRegistered bool `json:"isRegistered,omitempty"` 78 | } 79 | 80 | // EventMessage represents an event message 81 | type EventMessage struct { 82 | i interface{} 83 | } 84 | 85 | // newEventMessage creates a new event message 86 | func newEventMessage(i interface{}) *EventMessage { 87 | return &EventMessage{i: i} 88 | } 89 | 90 | // MarshalJSON implements the JSONMarshaler interface 91 | func (p *EventMessage) MarshalJSON() ([]byte, error) { 92 | return json.Marshal(p.i) 93 | } 94 | 95 | // Unmarshal unmarshals the payload into the given interface 96 | func (p *EventMessage) Unmarshal(i interface{}) error { 97 | if b, ok := p.i.([]byte); ok { 98 | return json.Unmarshal(b, i) 99 | } 100 | return errors.New("event message should []byte") 101 | } 102 | 103 | // UnmarshalJSON implements the JSONUnmarshaler interface 104 | func (p *EventMessage) UnmarshalJSON(i []byte) error { 105 | p.i = i 106 | return nil 107 | } 108 | 109 | // EventMenu represents an event menu 110 | type EventMenu struct { 111 | *EventSubMenu 112 | } 113 | 114 | // EventMenuItem represents an event menu item 115 | type EventMenuItem struct { 116 | ID string `json:"id"` 117 | Options *MenuItemOptions `json:"options,omitempty"` 118 | RootID string `json:"rootId"` 119 | SubMenu *EventSubMenu `json:"submenu,omitempty"` 120 | } 121 | 122 | // EventRequest represents an event request 123 | type EventRequest struct { 124 | Method string `json:"method,omitempty"` 125 | Referrer string `json:"referrer,omitempty"` 126 | URL string `json:"url,omitempty"` 127 | } 128 | 129 | // EventSecondInstance represents data related to a second instance of the app being started 130 | type EventSecondInstance struct { 131 | CommandLine []string `json:"commandLine,omitempty"` 132 | WorkingDirectory string `json:"workingDirectory,omitempty"` 133 | } 134 | 135 | // EventSubMenu represents a sub menu event 136 | type EventSubMenu struct { 137 | ID string `json:"id"` 138 | Items []*EventMenuItem `json:"items,omitempty"` 139 | RootID string `json:"rootId"` 140 | } 141 | -------------------------------------------------------------------------------- /event_test.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "encoding/json" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func TestEventMessage(t *testing.T) { 11 | // Init 12 | var em = newEventMessage(false) 13 | 14 | // Test marshal 15 | var b, err = json.Marshal(em) 16 | assert.NoError(t, err) 17 | assert.Equal(t, "false", string(b)) 18 | 19 | // Test unmarshal 20 | err = json.Unmarshal([]byte("true"), em) 21 | assert.NoError(t, err) 22 | assert.Equal(t, []byte("true"), em.i) 23 | var v bool 24 | err = em.Unmarshal(&v) 25 | assert.NoError(t, err) 26 | assert.Equal(t, true, v) 27 | } 28 | -------------------------------------------------------------------------------- /example/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Hello World! 8 | 9 | -------------------------------------------------------------------------------- /example/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | 7 | "github.com/asticode/go-astikit" 8 | "github.com/asticode/go-astilectron" 9 | ) 10 | 11 | func main() { 12 | // Set logger 13 | l := log.New(log.Writer(), log.Prefix(), log.Flags()) 14 | 15 | // Create astilectron 16 | a, err := astilectron.New(l, astilectron.Options{ 17 | AppName: "Test", 18 | BaseDirectoryPath: "example", 19 | }) 20 | if err != nil { 21 | l.Fatal(fmt.Errorf("main: creating astilectron failed: %w", err)) 22 | } 23 | defer a.Close() 24 | 25 | // Handle signals 26 | a.HandleSignals() 27 | 28 | // Start 29 | if err = a.Start(); err != nil { 30 | l.Fatal(fmt.Errorf("main: starting astilectron failed: %w", err)) 31 | } 32 | 33 | // New window 34 | var w *astilectron.Window 35 | if w, err = a.NewWindow("example/index.html", &astilectron.WindowOptions{ 36 | Center: astikit.BoolPtr(true), 37 | Height: astikit.IntPtr(700), 38 | Width: astikit.IntPtr(700), 39 | }); err != nil { 40 | l.Fatal(fmt.Errorf("main: new window failed: %w", err)) 41 | } 42 | 43 | // Create windows 44 | if err = w.Create(); err != nil { 45 | l.Fatal(fmt.Errorf("main: creating window failed: %w", err)) 46 | } 47 | 48 | // Blocking pattern 49 | a.Wait() 50 | } 51 | -------------------------------------------------------------------------------- /executer.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "fmt" 5 | "os/exec" 6 | "strings" 7 | 8 | "github.com/asticode/go-astikit" 9 | ) 10 | 11 | // Executer represents an object capable of executing Astilectron run command 12 | type Executer func(l astikit.SeverityLogger, a *Astilectron, cmd *exec.Cmd) (err error) 13 | 14 | // DefaultExecuter represents the default executer 15 | func DefaultExecuter(l astikit.SeverityLogger, a *Astilectron, cmd *exec.Cmd) (err error) { 16 | // Start command 17 | l.Debugf("Starting cmd %s", strings.Join(cmd.Args, " ")) 18 | if err = cmd.Start(); err != nil { 19 | err = fmt.Errorf("starting cmd %s failed: %w", strings.Join(cmd.Args, " "), err) 20 | return 21 | } 22 | 23 | // Watch command 24 | go a.watchCmd(cmd) 25 | return 26 | } 27 | -------------------------------------------------------------------------------- /global_shortcuts.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "context" 5 | "sync" 6 | ) 7 | 8 | const ( 9 | EventNameGlobalShortcutsCmdRegister = "global.shortcuts.cmd.register" 10 | EventNameGlobalShortcutsCmdIsRegistered = "global.shortcuts.cmd.is.registered" 11 | EventNameGlobalShortcutsCmdUnregister = "global.shortcuts.cmd.unregister" 12 | EventNameGlobalShortcutsCmdUnregisterAll = "global.shortcuts.cmd.unregister.all" 13 | EventNameGlobalShortcutsEventRegistered = "global.shortcuts.event.registered" 14 | EventNameGlobalShortcutsEventIsRegistered = "global.shortcuts.event.is.registered" 15 | EventNameGlobalShortcutsEventUnregistered = "global.shortcuts.event.unregistered" 16 | EventNameGlobalShortcutsEventUnregisteredAll = "global.shortcuts.event.unregistered.all" 17 | EventNameGlobalShortcutEventTriggered = "global.shortcuts.event.triggered" 18 | ) 19 | 20 | type globalShortcutsCallback func() 21 | 22 | // GlobalShortcuts represents global shortcuts 23 | type GlobalShortcuts struct { 24 | *object 25 | m *sync.Mutex 26 | callbacks map[string]globalShortcutsCallback 27 | } 28 | 29 | func newGlobalShortcuts(ctx context.Context, d *dispatcher, i *identifier, w *writer) (gs *GlobalShortcuts) { 30 | gs = &GlobalShortcuts{ 31 | object: newObject(ctx, d, i, w, i.new()), 32 | m: new(sync.Mutex), 33 | callbacks: make(map[string]globalShortcutsCallback), 34 | } 35 | gs.On(EventNameGlobalShortcutEventTriggered, func(e Event) (deleteListener bool) { // Register the listener for the triggered event 36 | gs.m.Lock() 37 | callback, ok := gs.callbacks[e.GlobalShortcuts.Accelerator] 38 | gs.m.Unlock() 39 | if ok { 40 | (callback)() 41 | } 42 | return 43 | }) 44 | return 45 | } 46 | 47 | // Register registers a global shortcut 48 | func (gs *GlobalShortcuts) Register(accelerator string, callback globalShortcutsCallback) (isRegistered bool, err error) { 49 | if err = gs.ctx.Err(); err != nil { 50 | return 51 | } 52 | 53 | // Send an event to astilectron to register the global shortcut 54 | result, err := synchronousEvent(gs.ctx, gs, gs.w, Event{Name: EventNameGlobalShortcutsCmdRegister, TargetID: gs.id, GlobalShortcuts: &EventGlobalShortcuts{Accelerator: accelerator}}, EventNameGlobalShortcutsEventRegistered) 55 | if err != nil { 56 | return 57 | } 58 | 59 | // If registered successfully, add the callback to the map 60 | if result.GlobalShortcuts != nil { 61 | if result.GlobalShortcuts.IsRegistered { 62 | gs.m.Lock() 63 | gs.callbacks[accelerator] = callback 64 | gs.m.Unlock() 65 | } 66 | isRegistered = result.GlobalShortcuts.IsRegistered 67 | } 68 | return 69 | } 70 | 71 | // IsRegistered checks whether a global shortcut is registered 72 | func (gs *GlobalShortcuts) IsRegistered(accelerator string) (isRegistered bool, err error) { 73 | if err = gs.ctx.Err(); err != nil { 74 | return 75 | } 76 | 77 | // Send an event to astilectron to check if global shortcut is registered 78 | result, err := synchronousEvent(gs.ctx, gs, gs.w, Event{Name: EventNameGlobalShortcutsCmdIsRegistered, TargetID: gs.id, GlobalShortcuts: &EventGlobalShortcuts{Accelerator: accelerator}}, EventNameGlobalShortcutsEventIsRegistered) 79 | if err != nil { 80 | return 81 | } 82 | 83 | if result.GlobalShortcuts != nil { 84 | isRegistered = result.GlobalShortcuts.IsRegistered 85 | } 86 | return 87 | } 88 | 89 | // Unregister unregisters a global shortcut 90 | func (gs *GlobalShortcuts) Unregister(accelerator string) (err error) { 91 | if err = gs.ctx.Err(); err != nil { 92 | return 93 | } 94 | 95 | // Send an event to astilectron to unregister the global shortcut 96 | _, err = synchronousEvent(gs.ctx, gs, gs.w, Event{Name: EventNameGlobalShortcutsCmdUnregister, TargetID: gs.id, GlobalShortcuts: &EventGlobalShortcuts{Accelerator: accelerator}}, EventNameGlobalShortcutsEventUnregistered) 97 | if err != nil { 98 | return 99 | } 100 | gs.m.Lock() 101 | delete(gs.callbacks, accelerator) 102 | gs.m.Unlock() 103 | return 104 | } 105 | 106 | // UnregisterAll unregisters all global shortcuts 107 | func (gs *GlobalShortcuts) UnregisterAll() (err error) { 108 | if err = gs.ctx.Err(); err != nil { 109 | return 110 | } 111 | 112 | // Send an event to astilectron to unregister all global shortcuts 113 | _, err = synchronousEvent(gs.ctx, gs, gs.w, Event{Name: EventNameGlobalShortcutsCmdUnregisterAll, TargetID: gs.id}, EventNameGlobalShortcutsEventUnregisteredAll) 114 | if err != nil { 115 | return 116 | } 117 | 118 | gs.m.Lock() 119 | gs.callbacks = make(map[string]globalShortcutsCallback) // Clear the map 120 | gs.m.Unlock() 121 | 122 | return 123 | } 124 | -------------------------------------------------------------------------------- /global_shortcuts_test.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "testing" 7 | ) 8 | 9 | func TestGlobalShortcut_Actions(t *testing.T) { 10 | var d = newDispatcher() 11 | var i = newIdentifier() 12 | var wrt = &mockedWriter{} 13 | var w = newWriter(wrt, &logger{}) 14 | 15 | var gs = newGlobalShortcuts(context.Background(), d, i, w) 16 | 17 | // Register 18 | testObjectAction(t, func() error { 19 | _, e := gs.Register("Ctrl+X", func() {}) 20 | return e 21 | }, gs.object, wrt, fmt.Sprintf(`{"name":"%s","targetID":"%s","globalShortcuts":{"accelerator":"Ctrl+X"}}%s`, EventNameGlobalShortcutsCmdRegister, gs.id, "\n"), 22 | EventNameGlobalShortcutsEventRegistered, &Event{GlobalShortcuts: &EventGlobalShortcuts{Accelerator: "Ctrl+X", IsRegistered: true}}) 23 | 24 | // IsRegistered 25 | testObjectAction(t, func() error { 26 | _, e := gs.IsRegistered("Ctrl+Y") 27 | return e 28 | }, gs.object, wrt, fmt.Sprintf(`{"name":"%s","targetID":"%s","globalShortcuts":{"accelerator":"Ctrl+Y"}}%s`, EventNameGlobalShortcutsCmdIsRegistered, gs.id, "\n"), 29 | EventNameGlobalShortcutsEventIsRegistered, &Event{GlobalShortcuts: &EventGlobalShortcuts{Accelerator: "Ctrl+Y", IsRegistered: false}}) 30 | 31 | // Unregister 32 | testObjectAction(t, func() error { 33 | return gs.Unregister("Ctrl+Z") 34 | }, gs.object, wrt, fmt.Sprintf(`{"name":"%s","targetID":"%s","globalShortcuts":{"accelerator":"Ctrl+Z"}}%s`, EventNameGlobalShortcutsCmdUnregister, gs.id, "\n"), 35 | EventNameGlobalShortcutsEventUnregistered, nil) 36 | 37 | // UnregisterAll 38 | testObjectAction(t, func() error { 39 | return gs.UnregisterAll() 40 | }, gs.object, wrt, fmt.Sprintf(`{"name":"%s","targetID":"%s"}%s`, EventNameGlobalShortcutsCmdUnregisterAll, gs.id, "\n"), 41 | EventNameGlobalShortcutsEventUnregisteredAll, nil) 42 | } 43 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/asticode/go-astilectron 2 | 3 | go 1.13 4 | 5 | require ( 6 | github.com/asticode/go-astikit v0.29.1 7 | github.com/stretchr/testify v1.4.0 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/asticode/go-astikit v0.29.1 h1:w27sLYXK84mDwArf/Vw1BiD5dfD5PBDB+iHoIcpYq0w= 2 | github.com/asticode/go-astikit v0.29.1/go.mod h1:h4ly7idim1tNhaVkdVBeXQZEE3L0xblP7fCWbgwipF0= 3 | github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= 4 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 5 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 6 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 7 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 8 | github.com/stretchr/testify v1.4.0 h1:2E4SXV/wtOkTonXsotYi4li6zVWxYlZuYNCXe9XRJyk= 9 | github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= 10 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= 11 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 12 | gopkg.in/yaml.v2 v2.2.2 h1:ZCJp+EgiOT7lHqUV2J862kp8Qj64Jo6az82+3Td9dZw= 13 | gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= 14 | -------------------------------------------------------------------------------- /helper.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/asticode/go-astikit" 11 | ) 12 | 13 | // Download is a cancellable function that downloads a src into a dst using a specific *http.Client and cleans up on 14 | // failed downloads 15 | func Download(ctx context.Context, l astikit.SeverityLogger, d *astikit.HTTPDownloader, src, dst string) (err error) { 16 | // Log 17 | l.Debugf("Downloading %s into %s", src, dst) 18 | 19 | // Destination already exists 20 | if _, err = os.Stat(dst); err == nil { 21 | l.Debugf("%s already exists, skipping download...", dst) 22 | return 23 | } else if !os.IsNotExist(err) { 24 | return fmt.Errorf("stating %s failed: %w", dst, err) 25 | } 26 | err = nil 27 | 28 | // Clean up on error 29 | defer func(err *error) { 30 | if *err != nil || ctx.Err() != nil { 31 | l.Debugf("Removing %s...", dst) 32 | os.Remove(dst) 33 | } 34 | }(&err) 35 | 36 | // Make sure the dst directory exists 37 | if err = os.MkdirAll(filepath.Dir(dst), 0775); err != nil { 38 | return fmt.Errorf("mkdirall %s failed: %w", filepath.Dir(dst), err) 39 | } 40 | 41 | // Download 42 | if err = d.DownloadInFile(ctx, dst, astikit.HTTPDownloaderSrc{URL: src}); err != nil { 43 | return fmt.Errorf("DownloadInFile failed: %w", err) 44 | } 45 | return 46 | } 47 | 48 | // Disembed is a cancellable disembed of an src to a dst using a custom Disembedder 49 | func Disembed(ctx context.Context, l astikit.SeverityLogger, d Disembedder, src, dst string) (err error) { 50 | // Log 51 | l.Debugf("Disembedding %s into %s...", src, dst) 52 | 53 | // No need to disembed 54 | if _, err = os.Stat(dst); err != nil && !os.IsNotExist(err) { 55 | return fmt.Errorf("stating %s failed: %w", dst, err) 56 | } else if err == nil { 57 | l.Debugf("%s already exists, skipping disembed...", dst) 58 | return 59 | } 60 | err = nil 61 | 62 | // Clean up on error 63 | defer func(err *error) { 64 | if *err != nil || ctx.Err() != nil { 65 | l.Debugf("Removing %s...", dst) 66 | os.Remove(dst) 67 | } 68 | }(&err) 69 | 70 | // Make sure directory exists 71 | var dirPath = filepath.Dir(dst) 72 | l.Debugf("Creating %s", dirPath) 73 | if err = os.MkdirAll(dirPath, 0755); err != nil { 74 | return fmt.Errorf("mkdirall %s failed: %w", dirPath, err) 75 | } 76 | 77 | // Create dst 78 | var f *os.File 79 | l.Debugf("Creating %s", dst) 80 | if f, err = os.Create(dst); err != nil { 81 | return fmt.Errorf("creating %s failed: %w", dst, err) 82 | } 83 | defer f.Close() 84 | 85 | // Disembed 86 | var b []byte 87 | l.Debugf("Disembedding %s", src) 88 | if b, err = d(src); err != nil { 89 | return fmt.Errorf("disembedding %s failed: %w", src, err) 90 | } 91 | 92 | // Copy 93 | l.Debugf("Copying disembedded data to %s", dst) 94 | if _, err = astikit.Copy(ctx, f, bytes.NewReader(b)); err != nil { 95 | return fmt.Errorf("copying disembedded data into %s failed: %w", dst, err) 96 | } 97 | return 98 | } 99 | 100 | // Unzip unzips a src into a dst. 101 | // Possible src formats are /path/to/zip.zip or /path/to/zip.zip/internal/path. 102 | func Unzip(ctx context.Context, l astikit.SeverityLogger, src, dst string) (err error) { 103 | // Clean up on error 104 | defer func(err *error) { 105 | if *err != nil || ctx.Err() != nil { 106 | l.Debugf("Removing %s...", dst) 107 | os.RemoveAll(dst) 108 | } 109 | }(&err) 110 | 111 | // Unzipping 112 | l.Debugf("Unzipping %s into %s", src, dst) 113 | if err = astikit.Unzip(ctx, dst, src); err != nil { 114 | err = fmt.Errorf("unzipping %s into %s failed: %w", src, dst, err) 115 | return 116 | } 117 | return 118 | } 119 | 120 | // synchronousFunc executes a function, blocks until it has received a specific event or the context has been 121 | // cancelled and returns the corresponding event 122 | func synchronousFunc(parentCtx context.Context, l listenable, fn func() error, eventNameDone string) (e Event, err error) { 123 | ctx, cancel := context.WithCancel(parentCtx) 124 | defer cancel() 125 | l.On(eventNameDone, func(i Event) (deleteListener bool) { 126 | if ctx.Err() == nil { 127 | e = i 128 | } 129 | cancel() 130 | return true 131 | }) 132 | if fn != nil { 133 | if err = fn(); err != nil { 134 | return 135 | } 136 | } 137 | <-ctx.Done() 138 | return 139 | } 140 | 141 | // synchronousEvent sends an event, blocks until it has received a specific event or the context has been cancelled 142 | // and returns the corresponding event 143 | func synchronousEvent(ctx context.Context, l listenable, w *writer, i Event, eventNameDone string) (Event, error) { 144 | return synchronousFunc(ctx, l, func() (err error) { 145 | if err = w.write(i); err != nil { 146 | err = fmt.Errorf("writing %+v event failed: %w", i, err) 147 | return 148 | } 149 | return 150 | }, eventNameDone) 151 | } 152 | -------------------------------------------------------------------------------- /helper_test.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "io/ioutil" 8 | "net/http" 9 | "net/http/httptest" 10 | "os" 11 | "sync" 12 | "testing" 13 | 14 | "github.com/asticode/go-astikit" 15 | "github.com/stretchr/testify/assert" 16 | ) 17 | 18 | // mockedHandler is a mocked handler 19 | type mockedHandler struct { 20 | e bool 21 | } 22 | 23 | func (h *mockedHandler) readFile(rw http.ResponseWriter, path string) { 24 | var b, err = ioutil.ReadFile(path) 25 | if err != nil { 26 | rw.WriteHeader(http.StatusInternalServerError) 27 | return 28 | } 29 | rw.Write(b) 30 | } 31 | 32 | // ServeHTTP implements the http.Handler interface 33 | func (h *mockedHandler) ServeHTTP(rw http.ResponseWriter, r *http.Request) { 34 | if h.e { 35 | rw.WriteHeader(http.StatusInternalServerError) 36 | return 37 | } 38 | switch r.URL.Path { 39 | case "/provisioner/astilectron": 40 | h.readFile(rw, "testdata/provisioner/astilectron/astilectron.zip") 41 | case "/provisioner/electron/darwin": 42 | h.readFile(rw, "testdata/provisioner/electron/darwin/electron.zip") 43 | case "/provisioner/electron/linux": 44 | h.readFile(rw, "testdata/provisioner/electron/linux/electron.zip") 45 | case "/provisioner/electron/windows": 46 | h.readFile(rw, "testdata/provisioner/electron/windows/electron.zip") 47 | default: 48 | rw.Write([]byte("body")) 49 | } 50 | } 51 | 52 | var tempPathCount int 53 | 54 | func mockedTempPath() string { 55 | tempPathCount++ 56 | return fmt.Sprintf("testdata/tmp/%d", tempPathCount) 57 | } 58 | 59 | func TestDownload(t *testing.T) { 60 | // Init 61 | var mh = &mockedHandler{e: true} 62 | var s = httptest.NewServer(mh) 63 | var dst = mockedTempPath() 64 | var d = astikit.NewHTTPDownloader(astikit.HTTPDownloaderOptions{}) 65 | 66 | // Test failed download 67 | err := Download(context.Background(), &logger{}, d, s.URL, dst) 68 | assert.Error(t, err) 69 | _, err = os.Stat(dst) 70 | assert.True(t, os.IsNotExist(err)) 71 | 72 | // Test successful download 73 | mh.e = false 74 | err = Download(context.Background(), &logger{}, d, s.URL, dst) 75 | assert.NoError(t, err) 76 | defer os.Remove(dst) 77 | b, err := ioutil.ReadFile(dst) 78 | assert.NoError(t, err) 79 | assert.Equal(t, "body", string(b)) 80 | } 81 | 82 | // mockedDisembedder is a mocked disembedder 83 | func mockedDisembedder(src string) ([]byte, error) { 84 | switch src { 85 | case "astilectron": 86 | return ioutil.ReadFile("testdata/provisioner/astilectron/disembedder.zip") 87 | case "electron/linux": 88 | return ioutil.ReadFile("testdata/provisioner/electron/linux/electron.zip") 89 | case "test": 90 | return []byte("body"), nil 91 | default: 92 | return []byte{}, errors.New("invalid") 93 | } 94 | } 95 | 96 | func TestDisembed(t *testing.T) { 97 | // Init 98 | var dst = mockedTempPath() 99 | 100 | // Test failed disembed 101 | err := Disembed(context.Background(), &logger{}, mockedDisembedder, "invalid", dst) 102 | assert.EqualError(t, err, "disembedding invalid failed: invalid") 103 | 104 | // Test successful disembed 105 | err = Disembed(context.Background(), &logger{}, mockedDisembedder, "test", dst) 106 | assert.NoError(t, err) 107 | defer os.Remove(dst) 108 | b, err := ioutil.ReadFile(dst) 109 | assert.NoError(t, err) 110 | assert.Equal(t, "body", string(b)) 111 | } 112 | 113 | func TestPtr(t *testing.T) { 114 | assert.Equal(t, true, *astikit.BoolPtr(true)) 115 | assert.Equal(t, 1, *astikit.IntPtr(1)) 116 | assert.Equal(t, "1", *astikit.StrPtr("1")) 117 | } 118 | 119 | // mockedListenable is a mocked listenable 120 | type mockedListenable struct { 121 | d *dispatcher 122 | id string 123 | } 124 | 125 | // On implements the listenable interface 126 | func (m *mockedListenable) On(eventName string, l Listener) { 127 | m.d.addListener(m.id, eventName, l) 128 | } 129 | 130 | func TestSynchronousFunc(t *testing.T) { 131 | // Init 132 | var d = newDispatcher() 133 | var l = &mockedListenable{d: d, id: "1"} 134 | var done bool 135 | var m sync.Mutex 136 | l.On("done", func(e Event) bool { 137 | m.Lock() 138 | defer m.Unlock() 139 | done = true 140 | return false 141 | }) 142 | 143 | // Test canceller cancel 144 | ctx, cancel := context.WithCancel(context.Background()) 145 | synchronousFunc(ctx, l, func() error { 146 | cancel() 147 | return nil 148 | }, "done") 149 | assert.False(t, done) 150 | 151 | // Test done event 152 | var ed = Event{Name: "done", TargetID: "1"} 153 | e, err := synchronousFunc(context.Background(), l, func() error { 154 | d.dispatch(ed) 155 | return nil 156 | }, "done") 157 | assert.NoError(t, err) 158 | m.Lock() 159 | assert.True(t, done) 160 | m.Unlock() 161 | assert.Equal(t, ed, e) 162 | 163 | // Test error 164 | _, err = synchronousFunc(context.Background(), l, func() error { return errors.New("invalid") }, "done") 165 | assert.Error(t, err) 166 | } 167 | 168 | func TestSynchronousEvent(t *testing.T) { 169 | // Init 170 | var d = newDispatcher() 171 | var ed = Event{Name: "done", TargetID: "1"} 172 | var mw = &mockedWriter{fn: func() { d.dispatch(ed) }} 173 | var w = newWriter(mw, &logger{}) 174 | var l = &mockedListenable{d: d, id: "1"} 175 | var done bool 176 | var m sync.Mutex 177 | l.On("done", func(e Event) bool { 178 | m.Lock() 179 | defer m.Unlock() 180 | done = true 181 | return false 182 | }) 183 | var ei = Event{Name: "order", TargetID: "1"} 184 | 185 | // Test successful synchronous event 186 | var e, err = synchronousEvent(context.Background(), l, w, ei, "done") 187 | assert.NoError(t, err) 188 | m.Lock() 189 | assert.True(t, done) 190 | m.Unlock() 191 | assert.Equal(t, ed, e) 192 | assert.Equal(t, []string{"{\"name\":\"order\",\"targetID\":\"1\"}\n"}, mw.w) 193 | } 194 | -------------------------------------------------------------------------------- /identifier.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "strconv" 5 | "sync" 6 | ) 7 | 8 | // identifier is in charge of delivering a unique identifier 9 | type identifier struct { 10 | i int 11 | m *sync.Mutex 12 | } 13 | 14 | // newIdentifier creates a new identifier 15 | func newIdentifier() *identifier { 16 | return &identifier{m: &sync.Mutex{}} 17 | } 18 | 19 | // new returns a new unique identifier 20 | func (i *identifier) new() string { 21 | i.m.Lock() 22 | defer i.m.Unlock() 23 | i.i++ 24 | return strconv.Itoa(i.i) 25 | } 26 | -------------------------------------------------------------------------------- /identifier_test.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "testing" 5 | 6 | "github.com/stretchr/testify/assert" 7 | ) 8 | 9 | func TestIdentifier(t *testing.T) { 10 | var i = newIdentifier() 11 | assert.Equal(t, "1", i.new()) 12 | assert.Equal(t, "2", i.new()) 13 | assert.Equal(t, "3", i.new()) 14 | } 15 | -------------------------------------------------------------------------------- /menu.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Menu event names 8 | const ( 9 | EventNameMenuCmdCreate = "menu.cmd.create" 10 | EventNameMenuCmdDestroy = "menu.cmd.destroy" 11 | EventNameMenuEventCreated = "menu.event.created" 12 | EventNameMenuEventDestroyed = "menu.event.destroyed" 13 | ) 14 | 15 | // Menu represents a menu 16 | // https://github.com/electron/electron/blob/v1.8.1/docs/api/menu.md 17 | type Menu struct { 18 | *subMenu 19 | } 20 | 21 | // newMenu creates a new menu 22 | func newMenu(ctx context.Context, rootID string, items []*MenuItemOptions, d *dispatcher, i *identifier, w *writer) (m *Menu) { 23 | // Init 24 | m = &Menu{newSubMenu(ctx, rootID, items, d, i, w)} 25 | 26 | // Make sure the menu's context is cancelled once the destroyed event is received 27 | m.On(EventNameMenuEventDestroyed, func(e Event) (deleteListener bool) { 28 | m.cancel() 29 | return true 30 | }) 31 | return 32 | } 33 | 34 | // toEvent returns the menu in the proper event format 35 | func (m *Menu) toEvent() *EventMenu { 36 | return &EventMenu{m.subMenu.toEvent()} 37 | } 38 | 39 | // Create creates the menu 40 | func (m *Menu) Create() (err error) { 41 | if err = m.ctx.Err(); err != nil { 42 | return 43 | } 44 | _, err = synchronousEvent(m.ctx, m, m.w, Event{Name: EventNameMenuCmdCreate, TargetID: m.id, Menu: m.toEvent()}, EventNameMenuEventCreated) 45 | return 46 | } 47 | 48 | // Destroy destroys the menu 49 | func (m *Menu) Destroy() (err error) { 50 | if err = m.ctx.Err(); err != nil { 51 | return 52 | } 53 | _, err = synchronousEvent(m.ctx, m, m.w, Event{Name: EventNameMenuCmdDestroy, TargetID: m.id, Menu: m.toEvent()}, EventNameMenuEventDestroyed) 54 | return 55 | } 56 | -------------------------------------------------------------------------------- /menu_item.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/asticode/go-astikit" 7 | ) 8 | 9 | // Menu item event names 10 | const ( 11 | EventNameMenuItemCmdSetChecked = "menu.item.cmd.set.checked" 12 | EventNameMenuItemCmdSetEnabled = "menu.item.cmd.set.enabled" 13 | EventNameMenuItemCmdSetLabel = "menu.item.cmd.set.label" 14 | EventNameMenuItemCmdSetVisible = "menu.item.cmd.set.visible" 15 | EventNameMenuItemEventCheckedSet = "menu.item.event.checked.set" 16 | EventNameMenuItemEventClicked = "menu.item.event.clicked" 17 | EventNameMenuItemEventEnabledSet = "menu.item.event.enabled.set" 18 | EventNameMenuItemEventLabelSet = "menu.item.event.label.set" 19 | EventNameMenuItemEventVisibleSet = "menu.item.event.visible.set" 20 | ) 21 | 22 | // Menu item roles 23 | var ( 24 | // All 25 | MenuItemRoleClose = astikit.StrPtr("close") 26 | MenuItemRoleCopy = astikit.StrPtr("copy") 27 | MenuItemRoleCut = astikit.StrPtr("cut") 28 | MenuItemRoleDelete = astikit.StrPtr("delete") 29 | MenuItemRoleEditMenu = astikit.StrPtr("editMenu") 30 | MenuItemRoleForceReload = astikit.StrPtr("forcereload") 31 | MenuItemRoleMinimize = astikit.StrPtr("minimize") 32 | MenuItemRolePaste = astikit.StrPtr("paste") 33 | MenuItemRolePasteAndMatchStyle = astikit.StrPtr("pasteandmatchstyle") 34 | MenuItemRoleQuit = astikit.StrPtr("quit") 35 | MenuItemRoleRedo = astikit.StrPtr("redo") 36 | MenuItemRoleReload = astikit.StrPtr("reload") 37 | MenuItemRoleResetZoom = astikit.StrPtr("resetzoom") 38 | MenuItemRoleSelectAll = astikit.StrPtr("selectall") 39 | MenuItemRoleToggleDevTools = astikit.StrPtr("toggledevtools") 40 | MenuItemRoleToggleFullScreen = astikit.StrPtr("togglefullscreen") 41 | MenuItemRoleUndo = astikit.StrPtr("undo") 42 | MenuItemRoleWindowMenu = astikit.StrPtr("windowMenu") 43 | MenuItemRoleZoomOut = astikit.StrPtr("zoomout") 44 | MenuItemRoleZoomIn = astikit.StrPtr("zoomin") 45 | 46 | // MacOSX 47 | MenuItemRoleAbout = astikit.StrPtr("about") 48 | MenuItemRoleHide = astikit.StrPtr("hide") 49 | MenuItemRoleHideOthers = astikit.StrPtr("hideothers") 50 | MenuItemRoleUnhide = astikit.StrPtr("unhide") 51 | MenuItemRoleStartSpeaking = astikit.StrPtr("startspeaking") 52 | MenuItemRoleStopSpeaking = astikit.StrPtr("stopspeaking") 53 | MenuItemRoleFront = astikit.StrPtr("front") 54 | MenuItemRoleZoom = astikit.StrPtr("zoom") 55 | MenuItemRoleWindow = astikit.StrPtr("window") 56 | MenuItemRoleHelp = astikit.StrPtr("help") 57 | MenuItemRoleServices = astikit.StrPtr("services") 58 | ) 59 | 60 | // Menu item types 61 | var ( 62 | MenuItemTypeNormal = astikit.StrPtr("normal") 63 | MenuItemTypeSeparator = astikit.StrPtr("separator") 64 | MenuItemTypeCheckbox = astikit.StrPtr("checkbox") 65 | MenuItemTypeRadio = astikit.StrPtr("radio") 66 | ) 67 | 68 | // MenuItem represents a menu item 69 | type MenuItem struct { 70 | *object 71 | o *MenuItemOptions 72 | // We must store the root ID since everytime we update a sub menu we need to set the root menu all over again in electron 73 | rootID string 74 | s *SubMenu 75 | } 76 | 77 | // MenuItemOptions represents menu item options 78 | // We must use pointers since GO doesn't handle optional fields whereas NodeJS does. Use astikit.BoolPtr, astikit.IntPtr or astikit.StrPtr 79 | // to fill the struct 80 | // https://github.com/electron/electron/blob/v1.8.1/docs/api/menu-item.md 81 | type MenuItemOptions struct { 82 | Accelerator *Accelerator `json:"accelerator,omitempty"` 83 | Checked *bool `json:"checked,omitempty"` 84 | Enabled *bool `json:"enabled,omitempty"` 85 | Icon *string `json:"icon,omitempty"` 86 | Label *string `json:"label,omitempty"` 87 | OnClick Listener `json:"-"` 88 | Position *string `json:"position,omitempty"` 89 | Role *string `json:"role,omitempty"` 90 | SubLabel *string `json:"sublabel,omitempty"` 91 | SubMenu []*MenuItemOptions `json:"-"` 92 | Type *string `json:"type,omitempty"` 93 | Visible *bool `json:"visible,omitempty"` 94 | } 95 | 96 | // newMenu creates a new menu item 97 | func newMenuItem(ctx context.Context, rootID string, o *MenuItemOptions, d *dispatcher, i *identifier, w *writer) (m *MenuItem) { 98 | m = &MenuItem{ 99 | o: o, 100 | object: newObject(ctx, d, i, w, i.new()), 101 | rootID: rootID, 102 | } 103 | if o.OnClick != nil { 104 | m.On(EventNameMenuItemEventClicked, o.OnClick) 105 | } 106 | if len(o.SubMenu) > 0 { 107 | m.s = &SubMenu{newSubMenu(ctx, rootID, o.SubMenu, d, i, w)} 108 | } 109 | return 110 | } 111 | 112 | // toEvent returns the menu item in the proper event format 113 | func (i *MenuItem) toEvent() (e *EventMenuItem) { 114 | e = &EventMenuItem{ 115 | ID: i.id, 116 | Options: i.o, 117 | RootID: i.rootID, 118 | } 119 | if i.s != nil { 120 | e.SubMenu = i.s.toEvent() 121 | } 122 | return 123 | } 124 | 125 | // SubMenu returns the menu item sub menu 126 | func (i *MenuItem) SubMenu() *SubMenu { 127 | return i.s 128 | } 129 | 130 | // SetChecked sets the checked attribute 131 | func (i *MenuItem) SetChecked(checked bool) (err error) { 132 | if err = i.ctx.Err(); err != nil { 133 | return 134 | } 135 | i.o.Checked = astikit.BoolPtr(checked) 136 | _, err = synchronousEvent(i.ctx, i, i.w, Event{Name: EventNameMenuItemCmdSetChecked, TargetID: i.id, MenuItemOptions: &MenuItemOptions{Checked: i.o.Checked}}, EventNameMenuItemEventCheckedSet) 137 | return 138 | } 139 | 140 | // SetEnabled sets the enabled attribute 141 | func (i *MenuItem) SetEnabled(enabled bool) (err error) { 142 | if err = i.ctx.Err(); err != nil { 143 | return 144 | } 145 | i.o.Enabled = astikit.BoolPtr(enabled) 146 | _, err = synchronousEvent(i.ctx, i, i.w, Event{Name: EventNameMenuItemCmdSetEnabled, TargetID: i.id, MenuItemOptions: &MenuItemOptions{Enabled: i.o.Enabled}}, EventNameMenuItemEventEnabledSet) 147 | return 148 | } 149 | 150 | // SetLabel sets the label attribute 151 | func (i *MenuItem) SetLabel(label string) (err error) { 152 | if err = i.ctx.Err(); err != nil { 153 | return 154 | } 155 | i.o.Label = astikit.StrPtr(label) 156 | _, err = synchronousEvent(i.ctx, i, i.w, Event{Name: EventNameMenuItemCmdSetLabel, TargetID: i.id, MenuItemOptions: &MenuItemOptions{Label: i.o.Label}}, EventNameMenuItemEventLabelSet) 157 | return 158 | } 159 | 160 | // SetVisible sets the visible attribute 161 | func (i *MenuItem) SetVisible(visible bool) (err error) { 162 | if err = i.ctx.Err(); err != nil { 163 | return 164 | } 165 | i.o.Visible = astikit.BoolPtr(visible) 166 | _, err = synchronousEvent(i.ctx, i, i.w, Event{Name: EventNameMenuItemCmdSetVisible, TargetID: i.id, MenuItemOptions: &MenuItemOptions{Visible: i.o.Visible}}, EventNameMenuItemEventVisibleSet) 167 | return 168 | } 169 | -------------------------------------------------------------------------------- /menu_item_test.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/asticode/go-astikit" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestMenuItem_ToEvent(t *testing.T) { 12 | var o = &MenuItemOptions{Label: astikit.StrPtr("1"), SubMenu: []*MenuItemOptions{{Label: astikit.StrPtr("2")}, {Label: astikit.StrPtr("3")}}} 13 | var mi = newMenuItem(context.Background(), targetIDApp, o, nil, newIdentifier(), nil) 14 | e := mi.toEvent() 15 | assert.Equal(t, &EventMenuItem{ID: "1", RootID: targetIDApp, Options: o, SubMenu: &EventSubMenu{ID: "2", Items: []*EventMenuItem{{ID: "3", Options: &MenuItemOptions{Label: astikit.StrPtr("2")}, RootID: targetIDApp}, {ID: "4", Options: &MenuItemOptions{Label: astikit.StrPtr("3")}, RootID: targetIDApp}}, RootID: targetIDApp}}, e) 16 | assert.Len(t, mi.SubMenu().items, 2) 17 | } 18 | 19 | func TestMenuItem_Actions(t *testing.T) { 20 | // Init 21 | var d = newDispatcher() 22 | var i = newIdentifier() 23 | var wrt = &mockedWriter{} 24 | var w = newWriter(wrt, &logger{}) 25 | var mi = newMenuItem(context.Background(), targetIDApp, &MenuItemOptions{Label: astikit.StrPtr("label")}, d, i, w) 26 | 27 | // Actions 28 | testObjectAction(t, func() error { return mi.SetChecked(true) }, mi.object, wrt, "{\"name\":\""+EventNameMenuItemCmdSetChecked+"\",\"targetID\":\""+mi.id+"\",\"menuItemOptions\":{\"checked\":true}}\n", EventNameMenuItemEventCheckedSet, nil) 29 | testObjectAction(t, func() error { return mi.SetEnabled(true) }, mi.object, wrt, "{\"name\":\""+EventNameMenuItemCmdSetEnabled+"\",\"targetID\":\""+mi.id+"\",\"menuItemOptions\":{\"enabled\":true}}\n", EventNameMenuItemEventEnabledSet, nil) 30 | testObjectAction(t, func() error { return mi.SetLabel("test") }, mi.object, wrt, "{\"name\":\""+EventNameMenuItemCmdSetLabel+"\",\"targetID\":\""+mi.id+"\",\"menuItemOptions\":{\"label\":\"test\"}}\n", EventNameMenuItemEventLabelSet, nil) 31 | testObjectAction(t, func() error { return mi.SetVisible(true) }, mi.object, wrt, "{\"name\":\""+EventNameMenuItemCmdSetVisible+"\",\"targetID\":\""+mi.id+"\",\"menuItemOptions\":{\"visible\":true}}\n", EventNameMenuItemEventVisibleSet, nil) 32 | 33 | } 34 | -------------------------------------------------------------------------------- /menu_test.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/asticode/go-astikit" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestMenu_ToEvent(t *testing.T) { 12 | var m = newMenu(context.Background(), targetIDApp, []*MenuItemOptions{{Label: astikit.StrPtr("1")}, {Label: astikit.StrPtr("2")}}, newDispatcher(), newIdentifier(), nil) 13 | e := m.toEvent() 14 | assert.Equal(t, &EventMenu{EventSubMenu: &EventSubMenu{ID: "1", Items: []*EventMenuItem{{ID: "2", Options: &MenuItemOptions{Label: astikit.StrPtr("1")}, RootID: targetIDApp}, {ID: "3", Options: &MenuItemOptions{Label: astikit.StrPtr("2")}, RootID: targetIDApp}}, RootID: targetIDApp}}, e) 15 | } 16 | 17 | func TestMenu_Actions(t *testing.T) { 18 | // Init 19 | var d = newDispatcher() 20 | var i = newIdentifier() 21 | var wrt = &mockedWriter{} 22 | var w = newWriter(wrt, &logger{}) 23 | var m = newMenu(context.Background(), targetIDApp, []*MenuItemOptions{{Label: astikit.StrPtr("1")}, {Label: astikit.StrPtr("2")}}, d, i, w) 24 | 25 | // Actions 26 | testObjectAction(t, func() error { return m.Create() }, m.object, wrt, "{\"name\":\""+EventNameMenuCmdCreate+"\",\"targetID\":\""+m.id+"\",\"menu\":{\"id\":\"1\",\"items\":[{\"id\":\"2\",\"options\":{\"label\":\"1\"},\"rootId\":\""+targetIDApp+"\"},{\"id\":\"3\",\"options\":{\"label\":\"2\"},\"rootId\":\""+targetIDApp+"\"}],\"rootId\":\""+targetIDApp+"\"}}\n", EventNameMenuEventCreated, nil) 27 | testObjectAction(t, func() error { return m.Destroy() }, m.object, wrt, "{\"name\":\""+EventNameMenuCmdDestroy+"\",\"targetID\":\""+m.id+"\",\"menu\":{\"id\":\"1\",\"items\":[{\"id\":\"2\",\"options\":{\"label\":\"1\"},\"rootId\":\""+targetIDApp+"\"},{\"id\":\"3\",\"options\":{\"label\":\"2\"},\"rootId\":\""+targetIDApp+"\"}],\"rootId\":\""+targetIDApp+"\"}}\n", EventNameMenuEventDestroyed, nil) 28 | assert.True(t, m.ctx.Err() != nil) 29 | } 30 | -------------------------------------------------------------------------------- /notification.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import "context" 4 | 5 | // Notification event names 6 | const ( 7 | eventNameNotificationCmdCreate = "notification.cmd.create" 8 | eventNameNotificationCmdShow = "notification.cmd.show" 9 | EventNameNotificationEventClicked = "notification.event.clicked" 10 | EventNameNotificationEventClosed = "notification.event.closed" 11 | EventNameNotificationEventCreated = "notification.event.created" 12 | EventNameNotificationEventReplied = "notification.event.replied" 13 | EventNameNotificationEventShown = "notification.event.shown" 14 | ) 15 | 16 | // Notification represents a notification 17 | // https://github.com/electron/electron/blob/v1.8.1/docs/api/notification.md 18 | type Notification struct { 19 | isSupported bool 20 | o *NotificationOptions 21 | *object 22 | } 23 | 24 | // NotificationOptions represents notification options 25 | type NotificationOptions struct { 26 | Body string `json:"body,omitempty"` 27 | HasReply *bool `json:"hasReply,omitempty"` 28 | Icon string `json:"icon,omitempty"` 29 | ReplyPlaceholder string `json:"replyPlaceholder,omitempty"` 30 | Silent *bool `json:"silent,omitempty"` 31 | Sound string `json:"sound,omitempty"` 32 | Subtitle string `json:"subtitle,omitempty"` 33 | Title string `json:"title,omitempty"` 34 | } 35 | 36 | func newNotification(ctx context.Context, o *NotificationOptions, isSupported bool, d *dispatcher, i *identifier, wrt *writer) *Notification { 37 | return &Notification{ 38 | isSupported: isSupported, 39 | o: o, 40 | object: newObject(ctx, d, i, wrt, i.new()), 41 | } 42 | } 43 | 44 | // Create creates the notification 45 | func (n *Notification) Create() (err error) { 46 | if !n.isSupported { 47 | return 48 | } 49 | if err = n.ctx.Err(); err != nil { 50 | return 51 | } 52 | _, err = synchronousEvent(n.ctx, n, n.w, Event{Name: eventNameNotificationCmdCreate, TargetID: n.id, NotificationOptions: n.o}, EventNameNotificationEventCreated) 53 | return 54 | } 55 | 56 | // Show shows the notification 57 | func (n *Notification) Show() (err error) { 58 | if !n.isSupported { 59 | return 60 | } 61 | if err = n.ctx.Err(); err != nil { 62 | return 63 | } 64 | _, err = synchronousEvent(n.ctx, n, n.w, Event{Name: eventNameNotificationCmdShow, TargetID: n.id}, EventNameNotificationEventShown) 65 | return 66 | } 67 | -------------------------------------------------------------------------------- /notification_test.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/asticode/go-astikit" 8 | ) 9 | 10 | func TestNotification_Actions(t *testing.T) { 11 | // Init 12 | var d = newDispatcher() 13 | var i = newIdentifier() 14 | var wrt = &mockedWriter{} 15 | var w = newWriter(wrt, &logger{}) 16 | var n = newNotification(context.Background(), &NotificationOptions{ 17 | Body: "body", 18 | HasReply: astikit.BoolPtr(true), 19 | Icon: "/path/to/icon", 20 | ReplyPlaceholder: "placeholder", 21 | Silent: astikit.BoolPtr(true), 22 | Sound: "sound", 23 | Subtitle: "subtitle", 24 | Title: "title", 25 | }, true, d, i, w) 26 | 27 | // Actions 28 | testObjectAction(t, func() error { return n.Create() }, n.object, wrt, "{\"name\":\""+eventNameNotificationCmdCreate+"\",\"targetID\":\""+n.id+"\",\"notificationOptions\":{\"body\":\"body\",\"hasReply\":true,\"icon\":\"/path/to/icon\",\"replyPlaceholder\":\"placeholder\",\"silent\":true,\"sound\":\"sound\",\"subtitle\":\"subtitle\",\"title\":\"title\"}}\n", EventNameNotificationEventCreated, nil) 29 | testObjectAction(t, func() error { return n.Show() }, n.object, wrt, "{\"name\":\""+eventNameNotificationCmdShow+"\",\"targetID\":\""+n.id+"\"}\n", EventNameNotificationEventShown, nil) 30 | } 31 | -------------------------------------------------------------------------------- /object.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // object represents a base object 8 | type object struct { 9 | cancel context.CancelFunc 10 | ctx context.Context 11 | d *dispatcher 12 | i *identifier 13 | id string 14 | w *writer 15 | } 16 | 17 | // newObject returns a new base object 18 | func newObject(ctx context.Context, d *dispatcher, i *identifier, w *writer, id string) (o *object) { 19 | o = &object{ 20 | d: d, 21 | i: i, 22 | id: id, 23 | w: w, 24 | } 25 | o.ctx, o.cancel = context.WithCancel(ctx) 26 | return 27 | } 28 | 29 | // On implements the Listenable interface 30 | func (o *object) On(eventName string, l Listener) { 31 | o.d.addListener(o.id, eventName, l) 32 | } 33 | -------------------------------------------------------------------------------- /object_test.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | func testObjectAction(t *testing.T, fn func() error, o *object, wrt *mockedWriter, sentEvent, eventNameDone string, receivedEvent *Event) { 11 | wrt.w = []string{} 12 | o.cancel() 13 | err := fn() 14 | assert.EqualError(t, err, context.Canceled.Error()) 15 | o.ctx, o.cancel = context.WithCancel(context.Background()) 16 | if eventNameDone != "" { 17 | wrt.fn = func() { 18 | var event Event 19 | if receivedEvent != nil { 20 | event = *receivedEvent 21 | } 22 | event.Name = eventNameDone 23 | event.TargetID = o.id 24 | 25 | o.d.dispatch(event) 26 | } 27 | } 28 | err = fn() 29 | assert.NoError(t, err) 30 | assert.Equal(t, []string{sentEvent}, wrt.w) 31 | } 32 | -------------------------------------------------------------------------------- /paths.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "path/filepath" 7 | "strings" 8 | ) 9 | 10 | // Paths represents the set of paths needed by Astilectron 11 | type Paths struct { 12 | appExecutable string 13 | appIconDarwinSrc string 14 | appIconDefaultSrc string 15 | astilectronApplication string 16 | astilectronDirectory string 17 | astilectronDownloadSrc string 18 | astilectronDownloadDst string 19 | astilectronUnzipSrc string 20 | baseDirectory string 21 | dataDirectory string 22 | electronDirectory string 23 | electronDownloadSrc string 24 | electronDownloadDst string 25 | electronUnzipSrc string 26 | provisionStatus string 27 | vendorDirectory string 28 | } 29 | 30 | // newPaths creates new paths 31 | func newPaths(os, arch string, o Options) (p *Paths, err error) { 32 | 33 | // Init base directory path 34 | p = &Paths{} 35 | if err = p.initBaseDirectory(o.BaseDirectoryPath); err != nil { 36 | err = fmt.Errorf("initializing base directory failed: %w", err) 37 | return 38 | } 39 | 40 | // Init data directory path 41 | if err = p.initDataDirectory(o.DataDirectoryPath, o.AppName); err != nil { 42 | err = fmt.Errorf("initializing data directory failed: %w", err) 43 | return 44 | } 45 | 46 | // Init other paths 47 | //!\\ Order matters 48 | p.appIconDarwinSrc = o.AppIconDarwinPath 49 | if len(p.appIconDarwinSrc) > 0 && !filepath.IsAbs(p.appIconDarwinSrc) { 50 | p.appIconDarwinSrc = filepath.Join(p.dataDirectory, p.appIconDarwinSrc) 51 | } 52 | p.appIconDefaultSrc = o.AppIconDefaultPath 53 | if len(p.appIconDefaultSrc) > 0 && !filepath.IsAbs(p.appIconDefaultSrc) { 54 | p.appIconDefaultSrc = filepath.Join(p.dataDirectory, p.appIconDefaultSrc) 55 | } 56 | p.vendorDirectory = filepath.Join(p.dataDirectory, "vendor") 57 | p.provisionStatus = filepath.Join(p.vendorDirectory, "status.json") 58 | p.astilectronDirectory = filepath.Join(p.vendorDirectory, "astilectron") 59 | p.astilectronApplication = filepath.Join(p.astilectronDirectory, "main.js") 60 | p.astilectronDownloadSrc = AstilectronDownloadSrc(o.VersionAstilectron) 61 | p.astilectronDownloadDst = filepath.Join(p.vendorDirectory, fmt.Sprintf("astilectron-v%s.zip", o.VersionAstilectron)) 62 | p.astilectronUnzipSrc = filepath.Join(p.astilectronDownloadDst, fmt.Sprintf("astilectron-%s", o.VersionAstilectron)) 63 | if o.CustomElectronPath == "" { 64 | p.electronDirectory = filepath.Join(p.vendorDirectory, fmt.Sprintf("electron-%s-%s", os, arch)) 65 | p.electronDownloadSrc = ElectronDownloadSrc(os, arch, o.VersionElectron) 66 | p.electronDownloadDst = filepath.Join(p.vendorDirectory, fmt.Sprintf("electron-%s-%s-v%s.zip", os, arch, o.VersionElectron)) 67 | p.electronUnzipSrc = p.electronDownloadDst 68 | p.initAppExecutable(os, o.AppName) 69 | } else { 70 | p.appExecutable = o.CustomElectronPath 71 | } 72 | return 73 | } 74 | 75 | // initBaseDirectory initializes the base directory path 76 | func (p *Paths) initBaseDirectory(baseDirectoryPath string) (err error) { 77 | // No path specified in the options 78 | p.baseDirectory = baseDirectoryPath 79 | if len(p.baseDirectory) == 0 { 80 | // Retrieve executable path 81 | var ep string 82 | if ep, err = os.Executable(); err != nil { 83 | err = fmt.Errorf("retrieving executable path failed: %w", err) 84 | return 85 | } 86 | p.baseDirectory = filepath.Dir(ep) 87 | } 88 | 89 | // We need the absolute path 90 | if p.baseDirectory, err = filepath.Abs(p.baseDirectory); err != nil { 91 | err = fmt.Errorf("computing absolute path failed: %w", err) 92 | return 93 | } 94 | return 95 | } 96 | 97 | func (p *Paths) initDataDirectory(dataDirectoryPath, appName string) (err error) { 98 | // Path is specified in the options 99 | if len(dataDirectoryPath) > 0 { 100 | // We need the absolute path 101 | if p.dataDirectory, err = filepath.Abs(dataDirectoryPath); err != nil { 102 | err = fmt.Errorf("computing absolute path of %s failed: %w", dataDirectoryPath, err) 103 | return 104 | } 105 | return 106 | } 107 | 108 | // If the APPDATA env exists, we use it 109 | if v := os.Getenv("APPDATA"); len(v) > 0 { 110 | p.dataDirectory = filepath.Join(v, appName) 111 | return 112 | } 113 | 114 | // Default to base directory path 115 | p.dataDirectory = p.baseDirectory 116 | return 117 | } 118 | 119 | // AstilectronDownloadSrc returns the download URL of the (currently platform-independent) astilectron zip file 120 | func AstilectronDownloadSrc(versionAstilectron string) string { 121 | return fmt.Sprintf("https://github.com/asticode/astilectron/archive/v%s.zip", versionAstilectron) 122 | } 123 | 124 | // ElectronDownloadSrc returns the download URL of the platform-dependant electron zipfile 125 | func ElectronDownloadSrc(os, arch, versionElectron string) string { 126 | // Get OS name 127 | var o string 128 | switch strings.ToLower(os) { 129 | case "darwin": 130 | o = "darwin" 131 | case "linux": 132 | o = "linux" 133 | case "windows": 134 | o = "win32" 135 | } 136 | 137 | // Get arch name 138 | var a = "ia32" 139 | if strings.ToLower(arch) == "amd64" { 140 | a = "x64" 141 | } else if strings.ToLower(arch) == "arm" && o == "linux" { 142 | a = "armv7l" 143 | } else if strings.ToLower(arch) == "arm64" { 144 | a = "arm64" 145 | } 146 | 147 | // Return url 148 | return fmt.Sprintf("https://github.com/electron/electron/releases/download/v%s/electron-v%s-%s-%s.zip", versionElectron, versionElectron, o, a) 149 | } 150 | 151 | // initAppExecutable initializes the app executable path 152 | func (p *Paths) initAppExecutable(os, appName string) { 153 | switch os { 154 | case "darwin": 155 | if appName == "" { 156 | appName = "Electron" 157 | } 158 | p.appExecutable = filepath.Join(p.electronDirectory, appName+".app", "Contents", "MacOS", appName) 159 | case "linux": 160 | p.appExecutable = filepath.Join(p.electronDirectory, "electron") 161 | case "windows": 162 | p.appExecutable = filepath.Join(p.electronDirectory, "electron.exe") 163 | } 164 | } 165 | 166 | // AppExecutable returns the app executable path 167 | func (p Paths) AppExecutable() string { 168 | return p.appExecutable 169 | } 170 | 171 | // AppIconDarwinSrc returns the darwin app icon path 172 | func (p Paths) AppIconDarwinSrc() string { 173 | return p.appIconDarwinSrc 174 | } 175 | 176 | // AppIconDefaultSrc returns the default app icon path 177 | func (p Paths) AppIconDefaultSrc() string { 178 | return p.appIconDefaultSrc 179 | } 180 | 181 | // BaseDirectory returns the base directory path 182 | func (p Paths) BaseDirectory() string { 183 | return p.baseDirectory 184 | } 185 | 186 | // AstilectronApplication returns the astilectron application path 187 | func (p Paths) AstilectronApplication() string { 188 | return p.astilectronApplication 189 | } 190 | 191 | // AstilectronDirectory returns the astilectron directory path 192 | func (p Paths) AstilectronDirectory() string { 193 | return p.astilectronDirectory 194 | } 195 | 196 | // AstilectronDownloadDst returns the astilectron download destination path 197 | func (p Paths) AstilectronDownloadDst() string { 198 | return p.astilectronDownloadDst 199 | } 200 | 201 | // AstilectronDownloadSrc returns the astilectron download source path 202 | func (p Paths) AstilectronDownloadSrc() string { 203 | return p.astilectronDownloadSrc 204 | } 205 | 206 | // AstilectronUnzipSrc returns the astilectron unzip source path 207 | func (p Paths) AstilectronUnzipSrc() string { 208 | return p.astilectronUnzipSrc 209 | } 210 | 211 | // DataDirectory returns the data directory path 212 | func (p Paths) DataDirectory() string { 213 | return p.dataDirectory 214 | } 215 | 216 | // ElectronDirectory returns the electron directory path 217 | func (p Paths) ElectronDirectory() string { 218 | return p.electronDirectory 219 | } 220 | 221 | // ElectronDownloadDst returns the electron download destination path 222 | func (p Paths) ElectronDownloadDst() string { 223 | return p.electronDownloadDst 224 | } 225 | 226 | // ElectronDownloadSrc returns the electron download source path 227 | func (p Paths) ElectronDownloadSrc() string { 228 | return p.electronDownloadSrc 229 | } 230 | 231 | // ElectronUnzipSrc returns the electron unzip source path 232 | func (p Paths) ElectronUnzipSrc() string { 233 | return p.electronUnzipSrc 234 | } 235 | 236 | // ProvisionStatus returns the provision status path 237 | func (p Paths) ProvisionStatus() string { 238 | return p.provisionStatus 239 | } 240 | 241 | // VendorDirectory returns the vendor directory path 242 | func (p Paths) VendorDirectory() string { 243 | return p.vendorDirectory 244 | } 245 | -------------------------------------------------------------------------------- /paths_test.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "testing" 7 | 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestPaths(t *testing.T) { 12 | const k = "APPDATA" 13 | 14 | ad := os.Getenv(k) 15 | os.Setenv(k, "") 16 | ep, err := os.Executable() 17 | ep = filepath.Dir(ep) 18 | assert.NoError(t, err) 19 | 20 | o := Options{VersionAstilectron: DefaultVersionAstilectron, VersionElectron: DefaultVersionElectron} 21 | p, err := newPaths("linux", "amd64", o) 22 | assert.NoError(t, err) 23 | assert.Equal(t, ep+"/vendor/electron-linux-amd64/electron", p.AppExecutable()) 24 | assert.Equal(t, "", p.AppIconDarwinSrc()) 25 | assert.Equal(t, ep, p.BaseDirectory()) 26 | assert.Equal(t, ep, p.DataDirectory()) 27 | assert.Equal(t, ep+"/vendor/astilectron/main.js", p.AstilectronApplication()) 28 | assert.Equal(t, ep+"/vendor/astilectron", p.AstilectronDirectory()) 29 | assert.Equal(t, ep+"/vendor/astilectron-v"+o.VersionAstilectron+".zip", p.AstilectronDownloadDst()) 30 | assert.Equal(t, "https://github.com/asticode/astilectron/archive/v"+o.VersionAstilectron+".zip", p.AstilectronDownloadSrc()) 31 | assert.Equal(t, ep+"/vendor/astilectron-v"+o.VersionAstilectron+".zip/astilectron-"+o.VersionAstilectron, p.AstilectronUnzipSrc()) 32 | assert.Equal(t, ep+"/vendor/electron-linux-amd64", p.ElectronDirectory()) 33 | assert.Equal(t, ep+"/vendor/electron-linux-amd64-v"+o.VersionElectron+".zip", p.ElectronDownloadDst()) 34 | assert.Equal(t, "https://github.com/electron/electron/releases/download/v"+o.VersionElectron+"/electron-v"+o.VersionElectron+"-linux-x64.zip", p.ElectronDownloadSrc()) 35 | assert.Equal(t, ep+"/vendor/electron-linux-amd64-v"+o.VersionElectron+".zip", p.ElectronUnzipSrc()) 36 | assert.Equal(t, ep+"/vendor/status.json", p.ProvisionStatus()) 37 | assert.Equal(t, ep+"/vendor", p.VendorDirectory()) 38 | p, err = newPaths("linux", "", o) 39 | assert.NoError(t, err) 40 | assert.Equal(t, "https://github.com/electron/electron/releases/download/v"+o.VersionElectron+"/electron-v"+o.VersionElectron+"-linux-ia32.zip", p.ElectronDownloadSrc()) 41 | p, err = newPaths("linux", "arm", o) 42 | assert.NoError(t, err) 43 | assert.Equal(t, "https://github.com/electron/electron/releases/download/v"+o.VersionElectron+"/electron-v"+o.VersionElectron+"-linux-armv7l.zip", p.ElectronDownloadSrc()) 44 | p, err = newPaths("linux", "arm64", o) 45 | assert.NoError(t, err) 46 | assert.Equal(t, "https://github.com/electron/electron/releases/download/v"+o.VersionElectron+"/electron-v"+o.VersionElectron+"-linux-arm64.zip", p.ElectronDownloadSrc()) 47 | p, err = newPaths("darwin", "", Options{BaseDirectoryPath: "/path/to/base/directory", AppIconDarwinPath: "/path/to/darwin/icon", AppIconDefaultPath: "icon", VersionAstilectron: DefaultVersionAstilectron, VersionElectron: DefaultVersionElectron}) 48 | assert.NoError(t, err) 49 | assert.Equal(t, "/path/to/base/directory/vendor/electron-darwin-/Electron.app/Contents/MacOS/Electron", p.AppExecutable()) 50 | assert.Equal(t, "/path/to/darwin/icon", p.AppIconDarwinSrc()) 51 | assert.Equal(t, "/path/to/base/directory/icon", p.AppIconDefaultSrc()) 52 | assert.Equal(t, "https://github.com/electron/electron/releases/download/v"+o.VersionElectron+"/electron-v"+o.VersionElectron+"-darwin-ia32.zip", p.ElectronDownloadSrc()) 53 | p, err = newPaths("darwin", "amd64", Options{AppName: "Test app", BaseDirectoryPath: "/path/to/base/directory", DataDirectoryPath: "/path/to/data/directory", VersionAstilectron: DefaultVersionAstilectron, VersionElectron: DefaultVersionElectron}) 54 | assert.NoError(t, err) 55 | assert.Equal(t, "/path/to/data/directory", p.DataDirectory()) 56 | assert.Equal(t, "/path/to/data/directory/vendor/electron-darwin-amd64/Test app.app/Contents/MacOS/Test app", p.AppExecutable()) 57 | assert.Equal(t, "/path/to/data/directory/vendor/electron-darwin-amd64-v"+o.VersionElectron+".zip", p.ElectronDownloadDst()) 58 | assert.Equal(t, "/path/to/data/directory/vendor/electron-darwin-amd64-v"+o.VersionElectron+".zip", p.ElectronUnzipSrc()) 59 | assert.Equal(t, "https://github.com/electron/electron/releases/download/v"+o.VersionElectron+"/electron-v"+o.VersionElectron+"-darwin-x64.zip", p.ElectronDownloadSrc()) 60 | p, err = newPaths("darwin", "arm64", o) 61 | assert.NoError(t, err) 62 | assert.Equal(t, "https://github.com/electron/electron/releases/download/v"+o.VersionElectron+"/electron-v"+o.VersionElectron+"-darwin-arm64.zip", p.ElectronDownloadSrc()) 63 | const pad = "/path/to/appdata" 64 | os.Setenv(k, pad) 65 | p, err = newPaths("windows", "amd64", o) 66 | assert.NoError(t, err) 67 | assert.Equal(t, pad, p.DataDirectory()) 68 | assert.Equal(t, pad+"/vendor", p.VendorDirectory()) 69 | assert.Equal(t, pad+"/vendor/electron-windows-amd64/electron.exe", p.AppExecutable()) 70 | assert.Equal(t, "https://github.com/electron/electron/releases/download/v"+o.VersionElectron+"/electron-v"+o.VersionElectron+"-win32-x64.zip", p.ElectronDownloadSrc()) 71 | assert.Equal(t, pad+"/vendor/electron-windows-amd64-v"+o.VersionElectron+".zip", p.ElectronDownloadDst()) 72 | assert.Equal(t, pad+"/vendor/electron-windows-amd64-v"+o.VersionElectron+".zip", p.ElectronUnzipSrc()) 73 | p, err = newPaths("windows", "", o) 74 | assert.NoError(t, err) 75 | assert.Equal(t, "https://github.com/electron/electron/releases/download/v"+o.VersionElectron+"/electron-v"+o.VersionElectron+"-win32-ia32.zip", p.ElectronDownloadSrc()) 76 | p, err = newPaths("windows", "arm64", o) 77 | assert.NoError(t, err) 78 | assert.Equal(t, "https://github.com/electron/electron/releases/download/v"+o.VersionElectron+"/electron-v"+o.VersionElectron+"-win32-arm64.zip", p.ElectronDownloadSrc()) 79 | os.Setenv(k, ad) 80 | } 81 | -------------------------------------------------------------------------------- /power.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | // Power event names 4 | const ( 5 | EventNamePowerSuspend = "power.event.suspend" 6 | EventNamePowerResume = "power.event.resume" 7 | EventNamePowerOnAC = "power.event.on.ac" 8 | EventNamePowerOnBattery = "power.event.on.battery" 9 | EventNamePowerShutdown = "power.event.shutdown" 10 | EventNamePowerLockScreen = "power.event.lock.screen" 11 | EventNamePowerUnlockScreen = "power.event.unlock.screen" 12 | EventNamePowerUserDidBecomeActive = "power.event.user.did.become.active" 13 | EventNamePowerUserDidResignActive = "power.event.user.did.resign.active" 14 | ) 15 | -------------------------------------------------------------------------------- /provisioner.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "io/ioutil" 8 | "os" 9 | "path/filepath" 10 | "regexp" 11 | 12 | "github.com/asticode/go-astikit" 13 | ) 14 | 15 | // Var 16 | var ( 17 | regexpDarwinInfoPList = regexp.MustCompile("Electron") 18 | ) 19 | 20 | // Provisioner represents an object capable of provisioning Astilectron 21 | type Provisioner interface { 22 | Provision(ctx context.Context, appName, os, arch, versionAstilectron, versionElectron string, p Paths) error 23 | } 24 | 25 | // mover is a function that moves a package 26 | type mover func(ctx context.Context, p Paths) (func() error, error) 27 | 28 | // defaultProvisioner represents the default provisioner 29 | type defaultProvisioner struct { 30 | l astikit.SeverityLogger 31 | moverAstilectron mover 32 | moverElectron mover 33 | } 34 | 35 | func newDefaultProvisioner(l astikit.StdLogger) (dp *defaultProvisioner) { 36 | d := astikit.NewHTTPDownloader(astikit.HTTPDownloaderOptions{ 37 | Sender: astikit.HTTPSenderOptions{ 38 | Logger: l, 39 | }, 40 | }) 41 | dp = &defaultProvisioner{l: astikit.AdaptStdLogger(l)} 42 | dp.moverAstilectron = func(ctx context.Context, p Paths) (closeFunc func() error, err error) { 43 | if err = Download(ctx, dp.l, d, p.AstilectronDownloadSrc(), p.AstilectronDownloadDst()); err != nil { 44 | return nil, fmt.Errorf("downloading %s into %s failed: %w", p.AstilectronDownloadSrc(), p.AstilectronDownloadDst(), err) 45 | } 46 | return func() (err error) { 47 | dp.l.Debugf("removing %s", p.AstilectronDownloadDst()) 48 | if err = os.Remove(p.AstilectronDownloadDst()); err != nil { 49 | return fmt.Errorf("removing %s failed: %w", p.AstilectronDownloadDst(), err) 50 | } 51 | return nil 52 | }, err 53 | } 54 | dp.moverElectron = func(ctx context.Context, p Paths) (closeFunc func() error, err error) { 55 | if err = Download(ctx, dp.l, d, p.ElectronDownloadSrc(), p.ElectronDownloadDst()); err != nil { 56 | return nil, fmt.Errorf("downloading %s into %s failed: %w", p.ElectronDownloadSrc(), p.ElectronDownloadDst(), err) 57 | } 58 | return func() (err error) { 59 | dp.l.Debugf("removing %s", p.ElectronDownloadDst()) 60 | if err = os.Remove(p.ElectronDownloadDst()); err != nil { 61 | return fmt.Errorf("removing %s failed: %w", p.ElectronDownloadDst(), err) 62 | } 63 | return nil 64 | }, err 65 | } 66 | return 67 | } 68 | 69 | // provisionStatusElectronKey returns the electron's provision status key 70 | func provisionStatusElectronKey(os, arch string) string { 71 | return fmt.Sprintf("%s-%s", os, arch) 72 | } 73 | 74 | // Provision implements the provisioner interface 75 | // TODO Package app using electron instead of downloading Electron + Astilectron separately 76 | func (p *defaultProvisioner) Provision(ctx context.Context, appName, os, arch, versionAstilectron, versionElectron string, paths Paths) (err error) { 77 | // Retrieve provision status 78 | var s ProvisionStatus 79 | if s, err = p.ProvisionStatus(paths); err != nil { 80 | err = fmt.Errorf("retrieving provisioning status failed: %w", err) 81 | return 82 | } 83 | defer p.updateProvisionStatus(paths, &s) 84 | 85 | // Provision astilectron 86 | if err = p.provisionAstilectron(ctx, paths, s, versionAstilectron); err != nil { 87 | err = fmt.Errorf("provisioning astilectron failed: %w", err) 88 | return 89 | } 90 | s.Astilectron = &ProvisionStatusPackage{Version: versionAstilectron} 91 | 92 | // Provision electron 93 | if err = p.provisionElectron(ctx, paths, s, appName, os, arch, versionElectron); err != nil { 94 | err = fmt.Errorf("provisioning electron failed: %w", err) 95 | return 96 | } 97 | s.Electron[provisionStatusElectronKey(os, arch)] = &ProvisionStatusPackage{Version: versionElectron} 98 | return 99 | } 100 | 101 | // ProvisionStatus represents the provision status 102 | type ProvisionStatus struct { 103 | Astilectron *ProvisionStatusPackage `json:"astilectron,omitempty"` 104 | Electron map[string]*ProvisionStatusPackage `json:"electron,omitempty"` 105 | } 106 | 107 | // ProvisionStatusPackage represents the provision status of a package 108 | type ProvisionStatusPackage struct { 109 | Version string `json:"version"` 110 | } 111 | 112 | // ProvisionStatus returns the provision status 113 | func (p *defaultProvisioner) ProvisionStatus(paths Paths) (s ProvisionStatus, err error) { 114 | // Open the file 115 | var f *os.File 116 | s.Electron = make(map[string]*ProvisionStatusPackage) 117 | if f, err = os.Open(paths.ProvisionStatus()); err != nil { 118 | if !os.IsNotExist(err) { 119 | err = fmt.Errorf("opening file %s failed: %w", paths.ProvisionStatus(), err) 120 | } else { 121 | err = nil 122 | } 123 | return 124 | } 125 | defer f.Close() 126 | 127 | // Unmarshal 128 | if errLocal := json.NewDecoder(f).Decode(&s); errLocal != nil { 129 | // For backward compatibility purposes, if there's an unmarshal error we delete the status file and make the 130 | // assumption that provisioning has to be done all over again 131 | p.l.Error(fmt.Errorf("json decoding from %s failed: %w", paths.ProvisionStatus(), errLocal)) 132 | p.l.Debugf("Removing %s", f.Name()) 133 | if errLocal = os.RemoveAll(f.Name()); errLocal != nil { 134 | p.l.Error(fmt.Errorf("removing %s failed: %w", f.Name(), errLocal)) 135 | } 136 | return 137 | } 138 | return 139 | } 140 | 141 | // ProvisionStatus updates the provision status 142 | func (p *defaultProvisioner) updateProvisionStatus(paths Paths, s *ProvisionStatus) (err error) { 143 | // Create the file 144 | var f *os.File 145 | if f, err = os.Create(paths.ProvisionStatus()); err != nil { 146 | err = fmt.Errorf("creating file %s failed: %w", paths.ProvisionStatus(), err) 147 | return 148 | } 149 | defer f.Close() 150 | 151 | // Marshal 152 | if err = json.NewEncoder(f).Encode(s); err != nil { 153 | err = fmt.Errorf("json encoding into %s failed: %w", paths.ProvisionStatus(), err) 154 | return 155 | } 156 | return 157 | } 158 | 159 | // provisionAstilectron provisions astilectron 160 | func (p *defaultProvisioner) provisionAstilectron(ctx context.Context, paths Paths, s ProvisionStatus, versionAstilectron string) error { 161 | return p.provisionPackage(ctx, paths, s.Astilectron, p.moverAstilectron, "Astilectron", versionAstilectron, paths.AstilectronUnzipSrc(), paths.AstilectronDirectory(), nil) 162 | } 163 | 164 | // provisionElectron provisions electron 165 | func (p *defaultProvisioner) provisionElectron(ctx context.Context, paths Paths, s ProvisionStatus, appName, os, arch, versionElectron string) error { 166 | if paths.ElectronUnzipSrc() == "" { 167 | return nil 168 | } 169 | return p.provisionPackage(ctx, paths, s.Electron[provisionStatusElectronKey(os, arch)], p.moverElectron, "Electron", versionElectron, paths.ElectronUnzipSrc(), paths.ElectronDirectory(), func() (err error) { 170 | switch os { 171 | case "darwin": 172 | if err = p.provisionElectronFinishDarwin(appName, paths); err != nil { 173 | return fmt.Errorf("finishing provisioning electron for darwin systems failed: %w", err) 174 | } 175 | default: 176 | p.l.Debug("System doesn't require finshing provisioning electron, moving on...") 177 | } 178 | return 179 | }) 180 | } 181 | 182 | // provisionPackage provisions a package 183 | func (p *defaultProvisioner) provisionPackage(ctx context.Context, paths Paths, s *ProvisionStatusPackage, m mover, name, version, pathUnzipSrc, pathDirectory string, finish func() error) (err error) { 184 | // Package has already been provisioned 185 | if s != nil && s.Version == version { 186 | p.l.Debugf("%s has already been provisioned to version %s, moving on...", name, version) 187 | return 188 | } 189 | p.l.Debugf("Provisioning %s...", name) 190 | 191 | // Remove previous install 192 | p.l.Debugf("Removing directory %s", pathDirectory) 193 | if err = os.RemoveAll(pathDirectory); err != nil && !os.IsNotExist(err) { 194 | return fmt.Errorf("removing %s failed: %w", pathDirectory, err) 195 | } 196 | 197 | // Move 198 | var closeFunc func() error 199 | if closeFunc, err = m(ctx, paths); err != nil { 200 | return fmt.Errorf("moving %s failed: %w", name, err) 201 | } 202 | 203 | // Make sure to close 204 | defer func() { 205 | if closeFunc == nil { 206 | return 207 | } 208 | if err := closeFunc(); err != nil { 209 | // Only log the error 210 | p.l.Error(fmt.Errorf("closing failed: %w", err)) 211 | return 212 | } 213 | }() 214 | 215 | // Create directory 216 | p.l.Debugf("Creating directory %s", pathDirectory) 217 | if err = os.MkdirAll(pathDirectory, 0755); err != nil { 218 | return fmt.Errorf("mkdirall %s failed: %w", pathDirectory, err) 219 | } 220 | 221 | // Unzip 222 | if err = Unzip(ctx, p.l, pathUnzipSrc, pathDirectory); err != nil { 223 | return fmt.Errorf("unzipping %s into %s failed: %w", pathUnzipSrc, pathDirectory, err) 224 | } 225 | 226 | // Finish 227 | if finish != nil { 228 | if err = finish(); err != nil { 229 | return fmt.Errorf("finishing failed: %w", err) 230 | } 231 | } 232 | return 233 | } 234 | 235 | // provisionElectronFinishDarwin finishes provisioning electron for Darwin systems 236 | // https://github.com/electron/electron/blob/v1.8.1/docs/tutorial/application-distribution.md#macos 237 | func (p *defaultProvisioner) provisionElectronFinishDarwin(appName string, paths Paths) (err error) { 238 | // Log 239 | p.l.Debug("Finishing provisioning electron for darwin system") 240 | 241 | // Custom app icon 242 | if paths.AppIconDarwinSrc() != "" { 243 | if err = p.provisionElectronFinishDarwinCopy(paths); err != nil { 244 | return fmt.Errorf("copying for darwin system finish failed: %w", err) 245 | } 246 | } 247 | 248 | // Custom app name 249 | if appName != "" { 250 | // Replace 251 | if err = p.provisionElectronFinishDarwinReplace(appName, paths); err != nil { 252 | return fmt.Errorf("replacing for darwin system finish failed: %w", err) 253 | } 254 | 255 | // Rename 256 | if err = p.provisionElectronFinishDarwinRename(appName, paths); err != nil { 257 | return fmt.Errorf("renaming for darwin system finish failed: %w", err) 258 | } 259 | } 260 | return 261 | } 262 | 263 | // provisionElectronFinishDarwinCopy copies the proper darwin files 264 | func (p *defaultProvisioner) provisionElectronFinishDarwinCopy(paths Paths) (err error) { 265 | // Icon 266 | var src, dst = paths.AppIconDarwinSrc(), filepath.Join(paths.ElectronDirectory(), "Electron.app", "Contents", "Resources", "electron.icns") 267 | if src != "" { 268 | p.l.Debugf("Copying %s to %s", src, dst) 269 | if err = astikit.CopyFile(context.Background(), dst, src, astikit.LocalCopyFileFunc); err != nil { 270 | return fmt.Errorf("copying %s to %s failed: %w", src, dst, err) 271 | } 272 | } 273 | return 274 | } 275 | 276 | // provisionElectronFinishDarwinReplace makes the proper replacements in the proper darwin files 277 | func (p *defaultProvisioner) provisionElectronFinishDarwinReplace(appName string, paths Paths) (err error) { 278 | for _, path := range []string{ 279 | filepath.Join(paths.electronDirectory, "Electron.app", "Contents", "Info.plist"), 280 | filepath.Join(paths.electronDirectory, "Electron.app", "Contents", "Frameworks", "Electron Helper.app", "Contents", "Info.plist"), 281 | filepath.Join(paths.electronDirectory, "Electron.app", "Contents", "Frameworks", "Electron Helper (Renderer).app", "Contents", "Info.plist"), 282 | filepath.Join(paths.electronDirectory, "Electron.app", "Contents", "Frameworks", "Electron Helper (Plugin).app", "Contents", "Info.plist"), 283 | filepath.Join(paths.electronDirectory, "Electron.app", "Contents", "Frameworks", "Electron Helper (GPU).app", "Contents", "Info.plist"), 284 | } { 285 | // Log 286 | p.l.Debugf("Replacing in %s", path) 287 | 288 | if _, err := os.Stat(path); os.IsNotExist(err) { 289 | continue 290 | } 291 | 292 | // Read file 293 | var b []byte 294 | if b, err = ioutil.ReadFile(path); err != nil { 295 | return fmt.Errorf("reading %s failed: %w", path, err) 296 | } 297 | 298 | // Open and truncate file 299 | var f *os.File 300 | if f, err = os.Create(path); err != nil { 301 | return fmt.Errorf("creating %s failed: %w", path, err) 302 | } 303 | defer f.Close() 304 | 305 | // Replace 306 | b = regexpDarwinInfoPList.ReplaceAll(b, []byte(""+appName)) 307 | 308 | // Write 309 | if _, err = f.Write(b); err != nil { 310 | return fmt.Errorf("writing to %s failed: %w", path, err) 311 | } 312 | } 313 | return 314 | } 315 | 316 | // rename represents a rename 317 | type rename struct { 318 | src, dst string 319 | } 320 | 321 | // provisionElectronFinishDarwinRename renames the proper darwin folders 322 | func (p *defaultProvisioner) provisionElectronFinishDarwinRename(appName string, paths Paths) (err error) { 323 | var appDirectory = filepath.Join(paths.electronDirectory, appName+".app") 324 | var frameworksDirectory = filepath.Join(appDirectory, "Contents", "Frameworks") 325 | var helper = filepath.Join(frameworksDirectory, appName+" Helper.app") 326 | var helperRenderer = filepath.Join(frameworksDirectory, appName+" Helper (Renderer).app") 327 | var helperPlugin = filepath.Join(frameworksDirectory, appName+" Helper (Plugin).app") 328 | var helperGPU = filepath.Join(frameworksDirectory, appName+" Helper (GPU).app") 329 | for _, r := range []rename{ 330 | {src: filepath.Join(paths.electronDirectory, "Electron.app"), dst: appDirectory}, 331 | {src: filepath.Join(appDirectory, "Contents", "MacOS", "Electron"), dst: paths.AppExecutable()}, 332 | {src: filepath.Join(frameworksDirectory, "Electron Helper.app"), dst: filepath.Join(helper)}, 333 | {src: filepath.Join(frameworksDirectory, "Electron Helper (Renderer).app"), dst: filepath.Join(helperRenderer)}, 334 | {src: filepath.Join(frameworksDirectory, "Electron Helper (Plugin).app"), dst: filepath.Join(helperPlugin)}, 335 | {src: filepath.Join(frameworksDirectory, "Electron Helper (GPU).app"), dst: filepath.Join(helperGPU)}, 336 | {src: filepath.Join(helper, "Contents", "MacOS", "Electron Helper"), dst: filepath.Join(helper, "Contents", "MacOS", appName+" Helper")}, 337 | {src: filepath.Join(helperRenderer, "Contents", "MacOS", "Electron Helper (Renderer)"), dst: filepath.Join(helperRenderer, "Contents", "MacOS", appName+" Helper (Renderer)")}, 338 | {src: filepath.Join(helperPlugin, "Contents", "MacOS", "Electron Helper (Plugin)"), dst: filepath.Join(helperPlugin, "Contents", "MacOS", appName+" Helper (Plugin)")}, 339 | {src: filepath.Join(helperGPU, "Contents", "MacOS", "Electron Helper (GPU)"), dst: filepath.Join(helperGPU, "Contents", "MacOS", appName+" Helper (GPU)")}, 340 | } { 341 | p.l.Debugf("Renaming %s into %s", r.src, r.dst) 342 | if _, err := os.Stat(r.src); os.IsNotExist(err) { 343 | continue 344 | } 345 | if err = os.Rename(r.src, r.dst); err != nil { 346 | return fmt.Errorf("renaming %s into %s failed: %w", r.src, r.dst, err) 347 | } 348 | } 349 | return 350 | } 351 | 352 | // Disembedder is a functions that allows to disembed data from a path 353 | type Disembedder func(src string) ([]byte, error) 354 | 355 | // NewDisembedderProvisioner creates a provisioner that can provision based on embedded data 356 | func NewDisembedderProvisioner(d Disembedder, pathAstilectron, pathElectron string, l astikit.StdLogger) Provisioner { 357 | dp := &defaultProvisioner{l: astikit.AdaptStdLogger(l)} 358 | dp.moverAstilectron = func(ctx context.Context, p Paths) (closeFunc func() error, err error) { 359 | if err = Disembed(ctx, dp.l, d, pathAstilectron, p.AstilectronDownloadDst()); err != nil { 360 | return nil, fmt.Errorf("disembedding %s into %s failed: %w", pathAstilectron, p.AstilectronDownloadDst(), err) 361 | } 362 | return func() (err error) { 363 | dp.l.Debugf("removing %s", p.AstilectronDownloadDst()) 364 | if err = os.Remove(p.AstilectronDownloadDst()); err != nil { 365 | return fmt.Errorf("removing %s failed: %w", p.AstilectronDownloadDst(), err) 366 | } 367 | return nil 368 | }, err 369 | } 370 | dp.moverElectron = func(ctx context.Context, p Paths) (closeFunc func() error, err error) { 371 | if err = Disembed(ctx, dp.l, d, pathElectron, p.ElectronDownloadDst()); err != nil { 372 | return nil, fmt.Errorf("disembedding %s into %s failed: %w", pathElectron, p.ElectronDownloadDst(), err) 373 | } 374 | return func() (err error) { 375 | dp.l.Debugf("removing %s", p.ElectronDownloadDst()) 376 | if err = os.Remove(p.ElectronDownloadDst()); err != nil { 377 | return fmt.Errorf("removing %s failed: %w", p.ElectronDownloadDst(), err) 378 | } 379 | return nil 380 | }, err 381 | } 382 | return dp 383 | } 384 | -------------------------------------------------------------------------------- /provisioner_test.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "context" 5 | "github.com/asticode/go-astikit" 6 | "github.com/stretchr/testify/assert" 7 | "io/ioutil" 8 | "net/http/httptest" 9 | "os" 10 | "path/filepath" 11 | "testing" 12 | ) 13 | 14 | func testProvisionerSuccessful(t *testing.T, p Paths, osName, arch, versionAstilectron, versionElectron string) { 15 | _, err := os.Stat(p.AstilectronApplication()) 16 | assert.NoError(t, err) 17 | _, err = os.Stat(p.AppExecutable()) 18 | assert.NoError(t, err) 19 | b, err := ioutil.ReadFile(p.ProvisionStatus()) 20 | assert.NoError(t, err) 21 | assert.Equal(t, "{\"astilectron\":{\"version\":\""+versionAstilectron+"\"},\"electron\":{\""+provisionStatusElectronKey(osName, arch)+"\":{\"version\":\""+versionElectron+"\"}}}\n", string(b)) 22 | } 23 | 24 | func TestDefaultProvisioner(t *testing.T) { 25 | // Init 26 | var o = Options{BaseDirectoryPath: mockedTempPath()} 27 | defer os.RemoveAll(o.BaseDirectoryPath) 28 | var mh = &mockedHandler{} 29 | var s = httptest.NewServer(mh) 30 | 31 | // Test linux 32 | p, err := newPaths("linux", "amd64", o) 33 | assert.NoError(t, err) 34 | p.astilectronUnzipSrc = filepath.Join(p.astilectronDownloadDst, "astilectron") 35 | p.astilectronDownloadSrc = s.URL + "/provisioner/astilectron" 36 | p.electronDownloadSrc = s.URL + "/provisioner/electron/linux" 37 | err = newDefaultProvisioner(nil).Provision(context.Background(), "", "linux", "amd64", DefaultVersionAstilectron, DefaultVersionElectron, *p) 38 | assert.NoError(t, err) 39 | testProvisionerSuccessful(t, *p, "linux", "amd64", DefaultVersionAstilectron, DefaultVersionElectron) 40 | 41 | // Test nothing happens if provision status is up to date 42 | mh.e = true 43 | os.Remove(p.AstilectronDownloadDst()) 44 | os.Remove(p.ElectronDownloadDst()) 45 | err = newDefaultProvisioner(nil).Provision(context.Background(), "", "linux", "amd64", DefaultVersionAstilectron, DefaultVersionElectron, *p) 46 | assert.NoError(t, err) 47 | testProvisionerSuccessful(t, *p, "linux", "amd64", DefaultVersionAstilectron, DefaultVersionElectron) 48 | 49 | // Test windows 50 | mh.e = false 51 | os.RemoveAll(o.BaseDirectoryPath) 52 | p, err = newPaths("windows", "amd64", o) 53 | assert.NoError(t, err) 54 | p.astilectronUnzipSrc = filepath.Join(p.astilectronDownloadDst, "astilectron") 55 | p.astilectronDownloadSrc = s.URL + "/provisioner/astilectron" 56 | p.electronDownloadSrc = s.URL + "/provisioner/electron/windows" 57 | err = newDefaultProvisioner(nil).Provision(context.Background(), "", "windows", "amd64", DefaultVersionAstilectron, DefaultVersionElectron, *p) 58 | assert.NoError(t, err) 59 | testProvisionerSuccessful(t, *p, "windows", "amd64", DefaultVersionAstilectron, DefaultVersionElectron) 60 | 61 | // Test darwin without custom app name + icon 62 | os.RemoveAll(o.BaseDirectoryPath) 63 | p, err = newPaths("darwin", "amd64", o) 64 | assert.NoError(t, err) 65 | p.astilectronUnzipSrc = filepath.Join(p.astilectronDownloadDst, "astilectron") 66 | p.astilectronDownloadSrc = s.URL + "/provisioner/astilectron" 67 | p.electronDownloadSrc = s.URL + "/provisioner/electron/darwin" 68 | err = newDefaultProvisioner(nil).Provision(context.Background(), "", "darwin", "amd64", DefaultVersionAstilectron, DefaultVersionElectron, *p) 69 | assert.NoError(t, err) 70 | testProvisionerSuccessful(t, *p, "darwin", "amd64", DefaultVersionAstilectron, DefaultVersionElectron) 71 | 72 | // Test darwin with custom app name + icon 73 | os.RemoveAll(o.BaseDirectoryPath) 74 | o.AppName = "Test app" 75 | wd, err := os.Getwd() 76 | assert.NoError(t, err) 77 | o.AppIconDarwinPath = filepath.Join(wd, "testdata", "provisioner", "icon.icns") 78 | p, err = newPaths("darwin", "amd64", o) 79 | assert.NoError(t, err) 80 | p.astilectronUnzipSrc = filepath.Join(p.astilectronDownloadDst, "astilectron") 81 | p.astilectronDownloadSrc = s.URL + "/provisioner/astilectron" 82 | p.electronDownloadSrc = s.URL + "/provisioner/electron/darwin" 83 | err = newDefaultProvisioner(nil).Provision(context.Background(), o.AppName, "darwin", "amd64", DefaultVersionAstilectron, DefaultVersionElectron, *p) 84 | assert.NoError(t, err) 85 | testProvisionerSuccessful(t, *p, "darwin", "amd64", DefaultVersionAstilectron, DefaultVersionElectron) 86 | // Rename 87 | _, err = os.Stat(filepath.Join(p.ElectronDirectory(), o.AppName+".app")) 88 | assert.NoError(t, err) 89 | _, err = os.Stat(filepath.Join(p.ElectronDirectory(), o.AppName+".app", "Contents", "MacOS", o.AppName)) 90 | assert.NoError(t, err) 91 | _, err = os.Stat(filepath.Join(p.ElectronDirectory(), o.AppName+".app", "Contents", "Frameworks", o.AppName+" Helper.app")) 92 | assert.NoError(t, err) 93 | _, err = os.Stat(filepath.Join(p.ElectronDirectory(), o.AppName+".app", "Contents", "Frameworks", o.AppName+" Helper.app", "Contents", "MacOS", o.AppName+" Helper")) 94 | assert.NoError(t, err) 95 | // Icon 96 | b, err := ioutil.ReadFile(filepath.Join(p.ElectronDirectory(), o.AppName+".app", "Contents", "Resources", "electron.icns")) 97 | assert.NoError(t, err) 98 | assert.Equal(t, "body", string(b)) 99 | // Replace 100 | b, err = ioutil.ReadFile(filepath.Join(p.ElectronDirectory(), o.AppName+".app", "Contents", "Info.plist")) 101 | assert.NoError(t, err) 102 | assert.Equal(t, ""+o.AppName+" Test", string(b)) 103 | b, err = ioutil.ReadFile(filepath.Join(p.ElectronDirectory(), o.AppName+".app", "Contents", "Frameworks", o.AppName+" Helper.app", "Contents", "Info.plist")) 104 | assert.NoError(t, err) 105 | assert.Equal(t, ""+o.AppName+" Test", string(b)) 106 | } 107 | 108 | func TestNewDisembedderProvisioner(t *testing.T) { 109 | // Init 110 | var o = Options{BaseDirectoryPath: mockedTempPath()} 111 | defer os.RemoveAll(o.BaseDirectoryPath) 112 | p, err := newPaths("linux", "amd64", o) 113 | assert.NoError(t, err) 114 | p.astilectronUnzipSrc = filepath.Join(p.astilectronDownloadDst, "astilectron-0.35.1") 115 | pvb := NewDisembedderProvisioner(mockedDisembedder, "astilectron", "electron/linux", nil) 116 | 117 | // Test provision 118 | err = pvb.Provision(context.Background(), "", "linux", "amd64", DefaultVersionAstilectron, DefaultVersionElectron, *p) 119 | assert.NoError(t, err) 120 | testProvisionerSuccessful(t, *p, "linux", "amd64", DefaultVersionAstilectron, DefaultVersionElectron) 121 | } 122 | 123 | func TestRemoveDownloadDst(t *testing.T) { 124 | var o = Options{ 125 | DataDirectoryPath: mockedTempPath(), 126 | } 127 | 128 | // Make sure the test directory doesn't exist. 129 | if err := os.RemoveAll(o.DataDirectoryPath); err != nil && !os.IsNotExist(err) { 130 | t.Fatalf("main: removing %s failed: %s", o.DataDirectoryPath, err) 131 | } 132 | defer os.RemoveAll(o.DataDirectoryPath) 133 | 134 | a, err := New(astikit.AdaptTestLogger(t), o) 135 | if err != nil { 136 | t.Fatalf("main: creating astilectron failed: %s", err) 137 | } 138 | 139 | p := a.Paths() 140 | 141 | if err = a.provision(); err != nil { 142 | t.Fatalf("main: provisionning failed: %s", err) 143 | } 144 | 145 | // Check UnZip successful 146 | if _, err := os.Stat(p.AstilectronDirectory()); os.IsNotExist(err) { 147 | t.Fatalf("%v", err) 148 | } 149 | 150 | if _, err := os.Stat(p.ElectronDirectory()); os.IsNotExist(err) { 151 | t.Fatalf("%v", err) 152 | } 153 | 154 | // Check Zip doesn't exist 155 | if _, err := os.Stat(p.AstilectronDownloadDst()); !os.IsNotExist(err) { 156 | t.Fatalf("%v", err) 157 | } 158 | 159 | if _, err := os.Stat(p.ElectronDownloadDst()); !os.IsNotExist(err) { 160 | t.Fatalf("%v", err) 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /reader.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "bufio" 5 | "bytes" 6 | "context" 7 | "encoding/json" 8 | "io" 9 | "strings" 10 | 11 | "github.com/asticode/go-astikit" 12 | ) 13 | 14 | // reader represents an object capable of reading in the TCP server 15 | type reader struct { 16 | ctx context.Context 17 | d *dispatcher 18 | l astikit.SeverityLogger 19 | r io.ReadCloser 20 | } 21 | 22 | // newReader creates a new reader 23 | func newReader(ctx context.Context, l astikit.SeverityLogger, d *dispatcher, r io.ReadCloser) *reader { 24 | return &reader{ 25 | ctx: ctx, 26 | d: d, 27 | l: l, 28 | r: r, 29 | } 30 | } 31 | 32 | // close closes the reader properly 33 | func (r *reader) close() error { 34 | return r.r.Close() 35 | } 36 | 37 | // isEOFErr checks whether the error is an EOF error 38 | // wsarecv is the error sent on Windows when the client closes its connection 39 | func (r *reader) isEOFErr(err error) bool { 40 | return err == io.EOF || strings.Contains(strings.ToLower(err.Error()), "wsarecv:") 41 | } 42 | 43 | // read reads from stdout 44 | func (r *reader) read() { 45 | var reader = bufio.NewReader(r.r) 46 | for { 47 | // Check context error 48 | if r.ctx.Err() != nil { 49 | return 50 | } 51 | 52 | // Read next line 53 | var b []byte 54 | var err error 55 | if b, err = reader.ReadBytes('\n'); err != nil { 56 | if !r.isEOFErr(err) { 57 | r.l.Errorf("%s while reading", err) 58 | continue 59 | } 60 | return 61 | } 62 | b = bytes.TrimSpace(b) 63 | r.l.Debugf("Astilectron says: %s", b) 64 | 65 | // Unmarshal 66 | var e Event 67 | if err = json.Unmarshal(b, &e); err != nil { 68 | r.l.Errorf("%s while unmarshaling %s", err, b) 69 | continue 70 | } 71 | 72 | // Dispatch 73 | r.d.dispatch(e) 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /reader_test.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "errors" 7 | "io" 8 | "io/ioutil" 9 | "sync" 10 | "testing" 11 | 12 | "github.com/stretchr/testify/assert" 13 | ) 14 | 15 | // mockedReader represents a mocked reader 16 | type mockedReader struct { 17 | *bytes.Buffer 18 | c bool 19 | } 20 | 21 | // Close implements the io.Close interface 22 | func (r *mockedReader) Close() error { 23 | r.c = true 24 | return nil 25 | } 26 | 27 | func TestReader_IsEOFErr(t *testing.T) { 28 | var r = newReader(context.Background(), &logger{}, &dispatcher{}, ioutil.NopCloser(&bytes.Buffer{})) 29 | assert.True(t, r.isEOFErr(io.EOF)) 30 | assert.True(t, r.isEOFErr(errors.New("read tcp 127.0.0.1:56093->127.0.0.1:56092: wsarecv: An existing connection was forcibly closed by the remote host."))) 31 | assert.False(t, r.isEOFErr(errors.New("random error"))) 32 | } 33 | 34 | func TestReader(t *testing.T) { 35 | // Init 36 | var mr = &mockedReader{Buffer: bytes.NewBuffer([]byte("{\"name\":\"1\",\"targetId\":\"1\"}\n{\n{\"name\":\"2\",\"targetId\":\"2\"}\n"))} 37 | var d = newDispatcher() 38 | var wg = &sync.WaitGroup{} 39 | var dispatched = []int{} 40 | var dispatchedMutex = sync.Mutex{} 41 | d.addListener("1", "1", func(e Event) (deleteListener bool) { 42 | dispatchedMutex.Lock() 43 | dispatched = append(dispatched, 1) 44 | dispatchedMutex.Unlock() 45 | wg.Done() 46 | return 47 | }) 48 | d.addListener("2", "2", func(e Event) (deleteListener bool) { 49 | dispatchedMutex.Lock() 50 | dispatched = append(dispatched, 2) 51 | dispatchedMutex.Unlock() 52 | wg.Done() 53 | return 54 | }) 55 | wg.Add(2) 56 | var r = newReader(context.Background(), &logger{}, d, mr) 57 | 58 | // Test read 59 | go r.read() 60 | wg.Wait() 61 | assert.Contains(t, dispatched, 1) 62 | assert.Contains(t, dispatched, 2) 63 | 64 | // Test close 65 | r.close() 66 | assert.True(t, mr.c) 67 | } 68 | -------------------------------------------------------------------------------- /rectangle.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | // Position represents a position 4 | type Position struct { 5 | X, Y int 6 | } 7 | 8 | // PositionOptions represents position options 9 | type PositionOptions struct { 10 | X *int `json:"x,omitempty"` 11 | Y *int `json:"y,omitempty"` 12 | } 13 | 14 | // Size represents a size 15 | type Size struct { 16 | Height, Width int 17 | } 18 | 19 | // SizeOptions represents size options 20 | type SizeOptions struct { 21 | Height *int `json:"height,omitempty"` 22 | Width *int `json:"width,omitempty"` 23 | } 24 | 25 | // Rectangle represents a rectangle 26 | type Rectangle struct { 27 | Position 28 | Size 29 | } 30 | 31 | // RectangleOptions represents rectangle options 32 | type RectangleOptions struct { 33 | PositionOptions 34 | SizeOptions 35 | } 36 | -------------------------------------------------------------------------------- /session.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Session event names 8 | const ( 9 | EventNameSessionCmdClearCache = "session.cmd.clear.cache" 10 | EventNameSessionEventClearedCache = "session.event.cleared.cache" 11 | EventNameSessionCmdFlushStorage = "session.cmd.flush.storage" 12 | EventNameSessionEventFlushedStorage = "session.event.flushed.storage" 13 | EventNameSessionCmdLoadExtension = "session.cmd.load.extension" 14 | EventNameSessionEventLoadedExtension = "session.event.loaded.extension" 15 | EventNameSessionEventWillDownload = "session.event.will.download" 16 | ) 17 | 18 | // Session represents a session 19 | // TODO Add missing session methods 20 | // TODO Add missing session events 21 | // https://github.com/electron/electron/blob/v1.8.1/docs/api/session.md 22 | type Session struct { 23 | *object 24 | } 25 | 26 | // newSession creates a new session 27 | func newSession(ctx context.Context, d *dispatcher, i *identifier, w *writer) *Session { 28 | return &Session{object: newObject(ctx, d, i, w, i.new())} 29 | } 30 | 31 | // ClearCache clears the Session's HTTP cache 32 | func (s *Session) ClearCache() (err error) { 33 | if err = s.ctx.Err(); err != nil { 34 | return 35 | } 36 | _, err = synchronousEvent(s.ctx, s, s.w, Event{Name: EventNameSessionCmdClearCache, TargetID: s.id}, EventNameSessionEventClearedCache) 37 | return 38 | } 39 | 40 | // FlushStorage writes any unwritten DOMStorage data to disk 41 | func (s *Session) FlushStorage() (err error) { 42 | if err = s.ctx.Err(); err != nil { 43 | return 44 | } 45 | _, err = synchronousEvent(s.ctx, s, s.w, Event{Name: EventNameSessionCmdFlushStorage, TargetID: s.id}, EventNameSessionEventFlushedStorage) 46 | return 47 | } 48 | 49 | // Loads a chrome extension 50 | func (s *Session) LoadExtension(path string) (err error) { 51 | if err = s.ctx.Err(); err != nil { 52 | return 53 | } 54 | _, err = synchronousEvent(s.ctx, s, s.w, Event{Name: EventNameSessionCmdLoadExtension, Path: path, TargetID: s.id}, EventNameSessionEventLoadedExtension) 55 | return 56 | } 57 | -------------------------------------------------------------------------------- /session_test.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | ) 7 | 8 | func TestSession_Actions(t *testing.T) { 9 | // Init 10 | var d = newDispatcher() 11 | var i = newIdentifier() 12 | var wrt = &mockedWriter{} 13 | var w = newWriter(wrt, &logger{}) 14 | var s = newSession(context.Background(), d, i, w) 15 | 16 | // Actions 17 | testObjectAction(t, func() error { return s.ClearCache() }, s.object, wrt, "{\"name\":\"session.cmd.clear.cache\",\"targetID\":\"1\"}\n", EventNameSessionEventClearedCache, nil) 18 | testObjectAction(t, func() error { return s.FlushStorage() }, s.object, wrt, "{\"name\":\"session.cmd.flush.storage\",\"targetID\":\"1\"}\n", EventNameSessionEventFlushedStorage, nil) 19 | } 20 | -------------------------------------------------------------------------------- /sub_menu.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/asticode/go-astikit" 10 | ) 11 | 12 | // Sub menu event names 13 | const ( 14 | EventNameSubMenuCmdAppend = "sub.menu.cmd.append" 15 | EventNameSubMenuCmdClosePopup = "sub.menu.cmd.close.popup" 16 | EventNameSubMenuCmdInsert = "sub.menu.cmd.insert" 17 | EventNameSubMenuCmdPopup = "sub.menu.cmd.popup" 18 | EventNameSubMenuEventAppended = "sub.menu.event.appended" 19 | EventNameSubMenuEventClosedPopup = "sub.menu.event.closed.popup" 20 | EventNameSubMenuEventInserted = "sub.menu.event.inserted" 21 | EventNameSubMenuEventPoppedUp = "sub.menu.event.popped.up" 22 | ) 23 | 24 | // SubMenu represents an exported sub menu 25 | type SubMenu struct { 26 | *subMenu 27 | } 28 | 29 | // subMenu represents an internal sub menu 30 | // We use this internal subMenu in SubMenu and Menu since all functions of subMenu should be in SubMenu and Menu but 31 | // some functions of Menu shouldn't be in SubMenu and vice versa 32 | type subMenu struct { 33 | *object 34 | items []*MenuItem 35 | // We must store the root ID since everytime we update a sub menu we need to set the root menu all over again in electron 36 | rootID string 37 | } 38 | 39 | // newSubMenu creates a new sub menu 40 | func newSubMenu(ctx context.Context, rootID string, items []*MenuItemOptions, d *dispatcher, i *identifier, w *writer) *subMenu { 41 | // Init 42 | var m = &subMenu{ 43 | object: newObject(ctx, d, i, w, i.new()), 44 | rootID: rootID, 45 | } 46 | 47 | // Parse items 48 | for _, o := range items { 49 | m.items = append(m.items, newMenuItem(m.ctx, rootID, o, d, i, w)) 50 | } 51 | return m 52 | } 53 | 54 | // toEvent returns the sub menu in the proper event format 55 | func (m *subMenu) toEvent() (e *EventSubMenu) { 56 | e = &EventSubMenu{ 57 | ID: m.id, 58 | RootID: m.rootID, 59 | } 60 | for _, i := range m.items { 61 | e.Items = append(e.Items, i.toEvent()) 62 | } 63 | return 64 | } 65 | 66 | // NewItem returns a new menu item 67 | func (m *subMenu) NewItem(o *MenuItemOptions) *MenuItem { 68 | return newMenuItem(m.ctx, m.rootID, o, m.d, m.i, m.w) 69 | } 70 | 71 | // SubMenu returns the sub menu at the specified indexes 72 | func (m *subMenu) SubMenu(indexes ...int) (s *SubMenu, err error) { 73 | var is = m 74 | var processedIndexes = []string{} 75 | for _, index := range indexes { 76 | if index >= len(is.items) { 77 | return nil, fmt.Errorf("submenu at %s has %d items, invalid index %d", strings.Join(processedIndexes, ":"), len(is.items), index) 78 | } 79 | s = is.items[index].s 80 | processedIndexes = append(processedIndexes, strconv.Itoa(index)) 81 | if s == nil { 82 | return nil, fmt.Errorf("no submenu at %s", strings.Join(processedIndexes, ":")) 83 | } 84 | is = s.subMenu 85 | } 86 | return 87 | } 88 | 89 | // Item returns the item at the specified indexes 90 | func (m *subMenu) Item(indexes ...int) (mi *MenuItem, err error) { 91 | var is = m 92 | if len(indexes) > 1 { 93 | var s *SubMenu 94 | if s, err = m.SubMenu(indexes[:len(indexes)-1]...); err != nil { 95 | return 96 | } 97 | is = s.subMenu 98 | } 99 | var index = indexes[len(indexes)-1] 100 | if index >= len(is.items) { 101 | return nil, fmt.Errorf("submenu has %d items, invalid index %d", len(is.items), index) 102 | } 103 | mi = is.items[index] 104 | return 105 | } 106 | 107 | // Append appends a menu item into the sub menu 108 | func (m *subMenu) Append(i *MenuItem) (err error) { 109 | if err = m.ctx.Err(); err != nil { 110 | return 111 | } 112 | if _, err = synchronousEvent(m.ctx, m, m.w, Event{Name: EventNameSubMenuCmdAppend, TargetID: m.id, MenuItem: i.toEvent()}, EventNameSubMenuEventAppended); err != nil { 113 | return 114 | } 115 | m.items = append(m.items, i) 116 | return 117 | } 118 | 119 | // Insert inserts a menu item to the position of the sub menu 120 | func (m *subMenu) Insert(pos int, i *MenuItem) (err error) { 121 | if err = m.ctx.Err(); err != nil { 122 | return 123 | } 124 | if pos > len(m.items) { 125 | err = fmt.Errorf("submenu has %d items, position %d is invalid", len(m.items), pos) 126 | return 127 | } 128 | if _, err = synchronousEvent(m.ctx, m, m.w, Event{Name: EventNameSubMenuCmdInsert, TargetID: m.id, MenuItem: i.toEvent(), MenuItemPosition: astikit.IntPtr(pos)}, EventNameSubMenuEventInserted); err != nil { 129 | return 130 | } 131 | m.items = append(m.items[:pos], append([]*MenuItem{i}, m.items[pos:]...)...) 132 | return 133 | } 134 | 135 | // MenuPopupOptions represents menu pop options 136 | type MenuPopupOptions struct { 137 | PositionOptions 138 | PositioningItem *int `json:"positioningItem,omitempty"` 139 | } 140 | 141 | // Popup pops up the menu as a context menu in the focused window 142 | func (m *subMenu) Popup(o *MenuPopupOptions) error { 143 | return m.PopupInWindow(nil, o) 144 | } 145 | 146 | // PopupInWindow pops up the menu as a context menu in the specified window 147 | func (m *subMenu) PopupInWindow(w *Window, o *MenuPopupOptions) (err error) { 148 | if err = m.ctx.Err(); err != nil { 149 | return 150 | } 151 | var e = Event{Name: EventNameSubMenuCmdPopup, TargetID: m.id, MenuPopupOptions: o} 152 | if w != nil { 153 | e.WindowID = w.id 154 | } 155 | _, err = synchronousEvent(m.ctx, m, m.w, e, EventNameSubMenuEventPoppedUp) 156 | return 157 | } 158 | 159 | // ClosePopup close the context menu in the focused window 160 | func (m *subMenu) ClosePopup() error { 161 | return m.ClosePopupInWindow(nil) 162 | } 163 | 164 | // ClosePopupInWindow close the context menu in the specified window 165 | func (m *subMenu) ClosePopupInWindow(w *Window) (err error) { 166 | if err = m.ctx.Err(); err != nil { 167 | return 168 | } 169 | var e = Event{Name: EventNameSubMenuCmdClosePopup, TargetID: m.id} 170 | if w != nil { 171 | e.WindowID = w.id 172 | } 173 | _, err = synchronousEvent(m.ctx, m, m.w, e, EventNameSubMenuEventClosedPopup) 174 | return 175 | } 176 | -------------------------------------------------------------------------------- /sub_menu_test.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/asticode/go-astikit" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestSubMenu_ToEvent(t *testing.T) { 12 | // App sub menu 13 | var s = newSubMenu(context.Background(), targetIDApp, []*MenuItemOptions{{Label: astikit.StrPtr("1")}, {Label: astikit.StrPtr("2")}}, newDispatcher(), newIdentifier(), nil) 14 | e := s.toEvent() 15 | assert.Equal(t, &EventSubMenu{ID: "1", Items: []*EventMenuItem{{ID: "2", Options: &MenuItemOptions{Label: astikit.StrPtr("1")}, RootID: targetIDApp}, {ID: "3", Options: &MenuItemOptions{Label: astikit.StrPtr("2")}, RootID: targetIDApp}}, RootID: targetIDApp}, e) 16 | 17 | // Window sub menu 18 | var i = newIdentifier() 19 | w, err := newWindow(context.Background(), nil, Options{}, Paths{}, "http://test.com", &WindowOptions{}, newDispatcher(), i, nil) 20 | assert.NoError(t, err) 21 | s = newSubMenu(context.Background(), w.id, []*MenuItemOptions{{Label: astikit.StrPtr("1")}, {Label: astikit.StrPtr("2")}}, newDispatcher(), i, nil) 22 | e = s.toEvent() 23 | assert.Equal(t, &EventSubMenu{ID: "3", Items: []*EventMenuItem{{ID: "4", Options: &MenuItemOptions{Label: astikit.StrPtr("1")}, RootID: "1"}, {ID: "5", Options: &MenuItemOptions{Label: astikit.StrPtr("2")}, RootID: "1"}}, RootID: "1"}, e) 24 | } 25 | 26 | func TestSubMenu_SubMenu(t *testing.T) { 27 | var o = []*MenuItemOptions{ 28 | {}, 29 | {SubMenu: []*MenuItemOptions{ 30 | {}, 31 | {SubMenu: []*MenuItemOptions{ 32 | {}, 33 | {}, 34 | {}, 35 | }}, 36 | {}, 37 | }}, 38 | {}, 39 | } 40 | var m = newMenu(context.Background(), targetIDApp, o, newDispatcher(), newIdentifier(), nil) 41 | _, err := m.SubMenu(0, 1) 42 | assert.EqualError(t, err, "no submenu at 0") 43 | s, err := m.SubMenu(1) 44 | assert.NoError(t, err) 45 | assert.Len(t, s.items, 3) 46 | _, err = m.SubMenu(1, 0) 47 | assert.EqualError(t, err, "no submenu at 1:0") 48 | s, err = m.SubMenu(1, 1) 49 | assert.NoError(t, err) 50 | assert.Len(t, s.items, 3) 51 | _, err = m.SubMenu(1, 3) 52 | assert.EqualError(t, err, "submenu at 1 has 3 items, invalid index 3") 53 | } 54 | 55 | func TestSubMenu_Item(t *testing.T) { 56 | var o = []*MenuItemOptions{ 57 | {Label: astikit.StrPtr("1")}, 58 | {Label: astikit.StrPtr("2"), SubMenu: []*MenuItemOptions{ 59 | {Label: astikit.StrPtr("2-1")}, 60 | {Label: astikit.StrPtr("2-2"), SubMenu: []*MenuItemOptions{ 61 | {Label: astikit.StrPtr("2-2-1")}, 62 | {Label: astikit.StrPtr("2-2-2")}, 63 | {Label: astikit.StrPtr("2-2-3")}, 64 | }}, 65 | {Label: astikit.StrPtr("2-3")}, 66 | }}, 67 | {Label: astikit.StrPtr("3")}, 68 | } 69 | var m = newMenu(context.Background(), targetIDApp, o, newDispatcher(), newIdentifier(), nil) 70 | _, err := m.Item(3) 71 | assert.EqualError(t, err, "submenu has 3 items, invalid index 3") 72 | i, err := m.Item(0) 73 | assert.NoError(t, err) 74 | assert.Equal(t, "1", *i.o.Label) 75 | _, err = m.Item(1, 3) 76 | assert.EqualError(t, err, "submenu has 3 items, invalid index 3") 77 | i, err = m.Item(1, 2) 78 | assert.NoError(t, err) 79 | assert.Equal(t, "2-3", *i.o.Label) 80 | i, err = m.Item(1, 1, 0) 81 | assert.NoError(t, err) 82 | assert.Equal(t, "2-2-1", *i.o.Label) 83 | } 84 | 85 | func TestSubMenu_Actions(t *testing.T) { 86 | // Init 87 | var d = newDispatcher() 88 | var i = newIdentifier() 89 | var wrt = &mockedWriter{} 90 | var w = newWriter(wrt, &logger{}) 91 | var s = newSubMenu(context.Background(), targetIDApp, []*MenuItemOptions{{Label: astikit.StrPtr("0")}}, d, i, w) 92 | 93 | // Actions 94 | var mi = s.NewItem(&MenuItemOptions{Label: astikit.StrPtr("1")}) 95 | testObjectAction(t, func() error { return s.Append(mi) }, s.object, wrt, "{\"name\":\""+EventNameSubMenuCmdAppend+"\",\"targetID\":\""+s.id+"\",\"menuItem\":{\"id\":\"3\",\"options\":{\"label\":\"1\"},\"rootId\":\""+targetIDApp+"\"}}\n", EventNameSubMenuEventAppended, nil) 96 | assert.Len(t, s.items, 2) 97 | assert.Equal(t, "1", *s.items[1].o.Label) 98 | mi = s.NewItem(&MenuItemOptions{Label: astikit.StrPtr("2")}) 99 | err := s.Insert(3, mi) 100 | assert.EqualError(t, err, "submenu has 2 items, position 3 is invalid") 101 | testObjectAction(t, func() error { return s.Insert(1, mi) }, s.object, wrt, "{\"name\":\""+EventNameSubMenuCmdInsert+"\",\"targetID\":\""+s.id+"\",\"menuItem\":{\"id\":\"4\",\"options\":{\"label\":\"2\"},\"rootId\":\""+targetIDApp+"\"},\"menuItemPosition\":1}\n", EventNameSubMenuEventInserted, nil) 102 | assert.Len(t, s.items, 3) 103 | assert.Equal(t, "2", *s.items[1].o.Label) 104 | testObjectAction(t, func() error { 105 | return s.Popup(&MenuPopupOptions{PositionOptions: PositionOptions{X: astikit.IntPtr(1), Y: astikit.IntPtr(2)}}) 106 | }, s.object, wrt, "{\"name\":\""+EventNameSubMenuCmdPopup+"\",\"targetID\":\""+s.id+"\",\"menuPopupOptions\":{\"x\":1,\"y\":2}}\n", EventNameSubMenuEventPoppedUp, nil) 107 | testObjectAction(t, func() error { 108 | return s.PopupInWindow(&Window{object: &object{id: "2"}}, &MenuPopupOptions{PositionOptions: PositionOptions{X: astikit.IntPtr(1), Y: astikit.IntPtr(2)}}) 109 | }, s.object, wrt, "{\"name\":\""+EventNameSubMenuCmdPopup+"\",\"targetID\":\""+s.id+"\",\"menuPopupOptions\":{\"x\":1,\"y\":2},\"windowId\":\"2\"}\n", EventNameSubMenuEventPoppedUp, nil) 110 | testObjectAction(t, func() error { return s.ClosePopup() }, s.object, wrt, "{\"name\":\""+EventNameSubMenuCmdClosePopup+"\",\"targetID\":\""+s.id+"\"}\n", EventNameSubMenuEventClosedPopup, nil) 111 | testObjectAction(t, func() error { return s.ClosePopupInWindow(&Window{object: &object{id: "2"}}) }, s.object, wrt, "{\"name\":\""+EventNameSubMenuCmdClosePopup+"\",\"targetID\":\""+s.id+"\",\"windowId\":\"2\"}\n", EventNameSubMenuEventClosedPopup, nil) 112 | } 113 | -------------------------------------------------------------------------------- /testdata/provisioner/astilectron/astilectron.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asticode/go-astilectron/927a6da863781826ad63db7a27703b68214fcf74/testdata/provisioner/astilectron/astilectron.zip -------------------------------------------------------------------------------- /testdata/provisioner/astilectron/astilectron/main.js: -------------------------------------------------------------------------------- 1 | package astilectron 2 | -------------------------------------------------------------------------------- /testdata/provisioner/astilectron/disembedder.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asticode/go-astilectron/927a6da863781826ad63db7a27703b68214fcf74/testdata/provisioner/astilectron/disembedder.zip -------------------------------------------------------------------------------- /testdata/provisioner/electron/darwin/Electron.app/Contents/Frameworks/Electron Helper.app/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | Electron Test -------------------------------------------------------------------------------- /testdata/provisioner/electron/darwin/Electron.app/Contents/Frameworks/Electron Helper.app/Contents/MacOS/Electron Helper: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asticode/go-astilectron/927a6da863781826ad63db7a27703b68214fcf74/testdata/provisioner/electron/darwin/Electron.app/Contents/Frameworks/Electron Helper.app/Contents/MacOS/Electron Helper -------------------------------------------------------------------------------- /testdata/provisioner/electron/darwin/Electron.app/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | Electron Test -------------------------------------------------------------------------------- /testdata/provisioner/electron/darwin/Electron.app/Contents/MacOS/Electron: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asticode/go-astilectron/927a6da863781826ad63db7a27703b68214fcf74/testdata/provisioner/electron/darwin/Electron.app/Contents/MacOS/Electron -------------------------------------------------------------------------------- /testdata/provisioner/electron/darwin/electron.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asticode/go-astilectron/927a6da863781826ad63db7a27703b68214fcf74/testdata/provisioner/electron/darwin/electron.zip -------------------------------------------------------------------------------- /testdata/provisioner/electron/linux/electron: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asticode/go-astilectron/927a6da863781826ad63db7a27703b68214fcf74/testdata/provisioner/electron/linux/electron -------------------------------------------------------------------------------- /testdata/provisioner/electron/linux/electron.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asticode/go-astilectron/927a6da863781826ad63db7a27703b68214fcf74/testdata/provisioner/electron/linux/electron.zip -------------------------------------------------------------------------------- /testdata/provisioner/electron/windows/electron.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asticode/go-astilectron/927a6da863781826ad63db7a27703b68214fcf74/testdata/provisioner/electron/windows/electron.exe -------------------------------------------------------------------------------- /testdata/provisioner/electron/windows/electron.zip: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/asticode/go-astilectron/927a6da863781826ad63db7a27703b68214fcf74/testdata/provisioner/electron/windows/electron.zip -------------------------------------------------------------------------------- /testdata/provisioner/icon.icns: -------------------------------------------------------------------------------- 1 | body -------------------------------------------------------------------------------- /tray.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/asticode/go-astikit" 7 | ) 8 | 9 | // Tray event names 10 | const ( 11 | EventNameTrayCmdCreate = "tray.cmd.create" 12 | EventNameTrayCmdDestroy = "tray.cmd.destroy" 13 | EventNameTrayCmdSetImage = "tray.cmd.set.image" 14 | EventNameTrayCmdPopupContextMenu = "tray.cmd.popup.context.menu" 15 | EventNameTrayEventClicked = "tray.event.clicked" 16 | EventNameTrayEventCreated = "tray.event.created" 17 | EventNameTrayEventDestroyed = "tray.event.destroyed" 18 | EventNameTrayEventDoubleClicked = "tray.event.double.clicked" 19 | EventNameTrayEventImageSet = "tray.event.image.set" 20 | EventNameTrayEventRightClicked = "tray.event.right.clicked" 21 | EventNameTrayEventContextMenuPoppedUp = "tray.event.context.menu.popped.up" 22 | ) 23 | 24 | // Tray represents a tray 25 | type Tray struct { 26 | *object 27 | o *TrayOptions 28 | } 29 | 30 | // TrayOptions represents tray options 31 | // We must use pointers since GO doesn't handle optional fields whereas NodeJS does. Use astikit.BoolPtr, astikit.IntPtr or astikit.StrPtr 32 | // to fill the struct 33 | // https://github.com/electron/electron/blob/v1.8.1/docs/api/tray.md 34 | type TrayOptions struct { 35 | Image *string `json:"image,omitempty"` 36 | Tooltip *string `json:"tooltip,omitempty"` 37 | } 38 | 39 | // TrayPopUpOptions represents Tray PopUpContextMenu options 40 | type TrayPopUpOptions struct { 41 | Menu *Menu 42 | Position *PositionOptions 43 | } 44 | 45 | // newTray creates a new tray 46 | func newTray(ctx context.Context, o *TrayOptions, d *dispatcher, i *identifier, wrt *writer) (t *Tray) { 47 | // Init 48 | t = &Tray{ 49 | o: o, 50 | object: newObject(ctx, d, i, wrt, i.new()), 51 | } 52 | 53 | // Make sure the tray's context is cancelled once the destroyed event is received 54 | t.On(EventNameTrayEventDestroyed, func(e Event) (deleteListener bool) { 55 | t.cancel() 56 | return true 57 | }) 58 | return 59 | } 60 | 61 | // Create creates the tray 62 | func (t *Tray) Create() (err error) { 63 | if err = t.ctx.Err(); err != nil { 64 | return 65 | } 66 | var e = Event{Name: EventNameTrayCmdCreate, TargetID: t.id, TrayOptions: t.o} 67 | _, err = synchronousEvent(t.ctx, t, t.w, e, EventNameTrayEventCreated) 68 | return 69 | } 70 | 71 | // Destroy destroys the tray 72 | func (t *Tray) Destroy() (err error) { 73 | if err = t.ctx.Err(); err != nil { 74 | return 75 | } 76 | _, err = synchronousEvent(t.ctx, t, t.w, Event{Name: EventNameTrayCmdDestroy, TargetID: t.id}, EventNameTrayEventDestroyed) 77 | return 78 | } 79 | 80 | // NewMenu creates a new tray menu 81 | func (t *Tray) NewMenu(i []*MenuItemOptions) *Menu { 82 | return newMenu(t.ctx, t.id, i, t.d, t.i, t.w) 83 | } 84 | 85 | // SetImage sets the tray image 86 | func (t *Tray) SetImage(image string) (err error) { 87 | if err = t.ctx.Err(); err != nil { 88 | return 89 | } 90 | t.o.Image = astikit.StrPtr(image) 91 | _, err = synchronousEvent(t.ctx, t, t.w, Event{Name: EventNameTrayCmdSetImage, Image: image, TargetID: t.id}, EventNameTrayEventImageSet) 92 | return 93 | } 94 | 95 | // PopUpContextMenu pops up the context menu of the tray icon. 96 | // When menu is passed, the menu will be shown instead of the tray icon's context menu. 97 | // The position is only available on Windows, and it is (0, 0) by default. 98 | // https://www.electronjs.org/docs/latest/api/tray#traypopupcontextmenumenu-position-macos-windows 99 | func (t *Tray) PopUpContextMenu(p *TrayPopUpOptions) (err error) { 100 | var em *EventMenu 101 | var mp *MenuPopupOptions 102 | if err = t.ctx.Err(); err != nil { 103 | return 104 | } 105 | if p.Menu != nil { 106 | em = p.Menu.toEvent() 107 | } 108 | if p.Position != nil { 109 | mp = &MenuPopupOptions{PositionOptions: *p.Position} 110 | } 111 | var e = Event{Name: EventNameTrayCmdPopupContextMenu, TargetID: t.id, Menu: em, MenuPopupOptions: mp} 112 | _, err = synchronousEvent(t.ctx, t, t.w, e, EventNameTrayEventContextMenuPoppedUp) 113 | return 114 | } 115 | -------------------------------------------------------------------------------- /tray_test.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "context" 5 | "testing" 6 | 7 | "github.com/asticode/go-astikit" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestTray_Actions(t *testing.T) { 12 | // Init 13 | var d = newDispatcher() 14 | var i = newIdentifier() 15 | var wrt = &mockedWriter{} 16 | var w = newWriter(wrt, &logger{}) 17 | var tr = newTray(context.Background(), &TrayOptions{ 18 | Image: astikit.StrPtr("/path/to/image"), 19 | Tooltip: astikit.StrPtr("tooltip"), 20 | }, d, i, w) 21 | var m = tr.NewMenu([]*MenuItemOptions{ 22 | { 23 | Label: astikit.StrPtr("Root 1"), 24 | SubMenu: []*MenuItemOptions{ 25 | {Label: astikit.StrPtr("Item 1")}, 26 | {Label: astikit.StrPtr("Item 2")}, 27 | {Type: MenuItemTypeSeparator}, 28 | {Label: astikit.StrPtr("Item 3")}, 29 | }, 30 | }}) 31 | var p = &PositionOptions{ 32 | X: astikit.IntPtr(250), 33 | Y: astikit.IntPtr(250), 34 | } 35 | 36 | // Actions 37 | testObjectAction(t, func() error { return tr.Create() }, tr.object, wrt, "{\"name\":\""+EventNameTrayCmdCreate+"\",\"targetID\":\""+tr.id+"\",\"trayOptions\":{\"image\":\"/path/to/image\",\"tooltip\":\"tooltip\"}}\n", EventNameTrayEventCreated, nil) 38 | testObjectAction(t, func() error { return tr.SetImage("test") }, tr.object, wrt, "{\"name\":\""+EventNameTrayCmdSetImage+"\",\"targetID\":\""+tr.id+"\",\"image\":\"test\"}\n", EventNameTrayEventImageSet, nil) 39 | testObjectAction(t, func() error { return tr.PopUpContextMenu(&TrayPopUpOptions{}) }, tr.object, wrt, "{\"name\":\""+EventNameTrayCmdPopupContextMenu+"\",\"targetID\":\""+tr.id+"\"}\n", EventNameTrayEventContextMenuPoppedUp, nil) 40 | testObjectAction(t, func() error { return tr.PopUpContextMenu(&TrayPopUpOptions{Position: p}) }, tr.object, wrt, "{\"name\":\""+EventNameTrayCmdPopupContextMenu+"\",\"targetID\":\""+tr.id+"\",\"menuPopupOptions\":{\"x\":250,\"y\":250}}\n", EventNameTrayEventContextMenuPoppedUp, nil) 41 | testObjectAction(t, func() error { return tr.PopUpContextMenu(&TrayPopUpOptions{Menu: m}) }, tr.object, wrt, "{\"name\":\""+EventNameTrayCmdPopupContextMenu+"\",\"targetID\":\""+tr.id+"\",\"menu\":{\"id\":\""+m.id+"\",\"items\":[{\"id\":\"3\",\"options\":{\"label\":\"Root 1\"},\"rootId\":\""+m.rootID+"\",\"submenu\":{\"id\":\"4\",\"items\":[{\"id\":\"5\",\"options\":{\"label\":\"Item 1\"},\"rootId\":\""+m.rootID+"\"},{\"id\":\"6\",\"options\":{\"label\":\"Item 2\"},\"rootId\":\""+m.rootID+"\"},{\"id\":\"7\",\"options\":{\"type\":\"separator\"},\"rootId\":\""+m.rootID+"\"},{\"id\":\"8\",\"options\":{\"label\":\"Item 3\"},\"rootId\":\""+m.rootID+"\"}],\"rootId\":\""+m.rootID+"\"}}],\"rootId\":\""+m.rootID+"\"}}\n", EventNameTrayEventContextMenuPoppedUp, nil) 42 | testObjectAction(t, func() error { return tr.PopUpContextMenu(&TrayPopUpOptions{Menu: m, Position: p}) }, tr.object, wrt, "{\"name\":\""+EventNameTrayCmdPopupContextMenu+"\",\"targetID\":\""+tr.id+"\",\"menu\":{\"id\":\""+m.id+"\",\"items\":[{\"id\":\"3\",\"options\":{\"label\":\"Root 1\"},\"rootId\":\""+m.rootID+"\",\"submenu\":{\"id\":\"4\",\"items\":[{\"id\":\"5\",\"options\":{\"label\":\"Item 1\"},\"rootId\":\""+m.rootID+"\"},{\"id\":\"6\",\"options\":{\"label\":\"Item 2\"},\"rootId\":\""+m.rootID+"\"},{\"id\":\"7\",\"options\":{\"type\":\"separator\"},\"rootId\":\""+m.rootID+"\"},{\"id\":\"8\",\"options\":{\"label\":\"Item 3\"},\"rootId\":\""+m.rootID+"\"}],\"rootId\":\""+m.rootID+"\"}}],\"rootId\":\""+m.rootID+"\"},\"menuPopupOptions\":{\"x\":250,\"y\":250}}\n", EventNameTrayEventContextMenuPoppedUp, nil) 43 | testObjectAction(t, func() error { return tr.Destroy() }, tr.object, wrt, "{\"name\":\""+EventNameTrayCmdDestroy+"\",\"targetID\":\""+tr.id+"\"}\n", EventNameTrayEventDestroyed, nil) 44 | assert.True(t, tr.ctx.Err() != nil) 45 | } 46 | 47 | func TestTray_NewMenu(t *testing.T) { 48 | a, err := New(nil, Options{}) 49 | assert.NoError(t, err) 50 | tr := a.NewTray(&TrayOptions{}) 51 | m := tr.NewMenu([]*MenuItemOptions{}) 52 | assert.Equal(t, tr.id, m.rootID) 53 | } 54 | -------------------------------------------------------------------------------- /window.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | stdUrl "net/url" 7 | "path/filepath" 8 | "sync" 9 | 10 | "github.com/asticode/go-astikit" 11 | ) 12 | 13 | // Window event names 14 | const ( 15 | EventNameWebContentsEventLogin = "web.contents.event.login" 16 | EventNameWebContentsEventLoginCallback = "web.contents.event.login.callback" 17 | EventNameWindowCmdBlur = "window.cmd.blur" 18 | EventNameWindowCmdCenter = "window.cmd.center" 19 | EventNameWindowCmdClose = "window.cmd.close" 20 | EventNameWindowCmdCreate = "window.cmd.create" 21 | EventNameWindowCmdDestroy = "window.cmd.destroy" 22 | EventNameWindowCmdFocus = "window.cmd.focus" 23 | EventNameWindowCmdHide = "window.cmd.hide" 24 | EventNameWindowCmdLog = "window.cmd.log" 25 | EventNameWindowCmdMaximize = "window.cmd.maximize" 26 | eventNameWindowCmdMessage = "window.cmd.message" 27 | eventNameWindowCmdMessageCallback = "window.cmd.message.callback" 28 | EventNameWindowCmdMinimize = "window.cmd.minimize" 29 | EventNameWindowCmdMove = "window.cmd.move" 30 | EventNameWindowCmdMoveTop = "window.cmd.move.top" 31 | EventNameWindowCmdResize = "window.cmd.resize" 32 | EventNameWindowCmdResizeContent = "window.cmd.resize.content" 33 | EventNameWindowCmdSetBounds = "window.cmd.set.bounds" 34 | EventNameWindowCmdRestore = "window.cmd.restore" 35 | EventNameWindowCmdSetContentProtection = "window.cmd.set.content.protection" 36 | EventNameWindowCmdShow = "window.cmd.show" 37 | EventNameWindowCmdUnmaximize = "window.cmd.unmaximize" 38 | EventNameWindowCmdUpdateCustomOptions = "window.cmd.update.custom.options" 39 | EventNameWindowCmdWebContentsCloseDevTools = "window.cmd.web.contents.close.dev.tools" 40 | EventNameWindowCmdWebContentsOpenDevTools = "window.cmd.web.contents.open.dev.tools" 41 | EventNameWindowCmdWebContentsExecuteJavaScript = "window.cmd.web.contents.execute.javascript" 42 | EventNameWindowCmdSetAlwaysOnTop = "window.cmd.set.always.on.top" 43 | EventNameWindowCmdSetFullScreen = "window.cmd.set.full.screen" 44 | EventNameWindowEventBlur = "window.event.blur" 45 | EventNameWindowEventClosed = "window.event.closed" 46 | EventNameWindowEventContentProtectionSet = "window.event.content.protection.set" 47 | EventNameWindowEventDidFinishLoad = "window.event.did.finish.load" 48 | EventNameWindowEventEnterFullScreen = "window.event.enter.full.screen" 49 | EventNameWindowEventFocus = "window.event.focus" 50 | EventNameWindowEventHide = "window.event.hide" 51 | EventNameWindowEventLeaveFullScreen = "window.event.leave.full.screen" 52 | EventNameWindowEventMaximize = "window.event.maximize" 53 | eventNameWindowEventMessage = "window.event.message" 54 | eventNameWindowEventMessageCallback = "window.event.message.callback" 55 | EventNameWindowEventMinimize = "window.event.minimize" 56 | EventNameWindowEventMove = "window.event.move" 57 | EventNameWindowEventMoved = "window.event.moved" 58 | EventNameWindowEventMovedTop = "window.event.moved.top" 59 | EventNameWindowEventReadyToShow = "window.event.ready.to.show" 60 | EventNameWindowEventResize = "window.event.resize" 61 | EventNameWindowEventResizeContent = "window.event.resize.content" 62 | EventNameWindowEventRestore = "window.event.restore" 63 | EventNameWindowEventShow = "window.event.show" 64 | EventNameWindowEventUnmaximize = "window.event.unmaximize" 65 | EventNameWindowEventUnresponsive = "window.event.unresponsive" 66 | EventNameWindowEventDidGetRedirectRequest = "window.event.did.get.redirect.request" 67 | EventNameWindowEventWebContentsExecutedJavaScript = "window.event.web.contents.executed.javascript" 68 | EventNameWindowEventWillMove = "window.event.will.move" 69 | EventNameWindowEventWillNavigate = "window.event.will.navigate" 70 | EventNameWindowEventUpdatedCustomOptions = "window.event.updated.custom.options" 71 | EventNameWindowEventAlwaysOnTopChanged = "window.event.always.on.top.changed" 72 | ) 73 | 74 | // Title bar styles 75 | var ( 76 | TitleBarStyleDefault = astikit.StrPtr("default") 77 | TitleBarStyleHidden = astikit.StrPtr("hidden") 78 | TitleBarStyleHiddenInset = astikit.StrPtr("hidden-inset") 79 | ) 80 | 81 | // Window represents a window 82 | // TODO Add missing window options 83 | // TODO Add missing window methods 84 | // TODO Add missing window events 85 | type Window struct { 86 | *object 87 | callbackIdentifier *identifier 88 | l astikit.SeverityLogger 89 | m sync.Mutex // Locks o 90 | o *WindowOptions 91 | onMessageOnce sync.Once 92 | Session *Session 93 | url *stdUrl.URL 94 | } 95 | 96 | // WindowOptions represents window options 97 | // We must use pointers since GO doesn't handle optional fields whereas NodeJS does. Use astikit.BoolPtr, astikit.IntPtr or astikit.StrPtr 98 | // to fill the struct 99 | // https://github.com/electron/electron/blob/v1.8.1/docs/api/browser-window.md 100 | type WindowOptions struct { 101 | AcceptFirstMouse *bool `json:"acceptFirstMouse,omitempty"` 102 | AlwaysOnTop *bool `json:"alwaysOnTop,omitempty"` 103 | AutoHideMenuBar *bool `json:"autoHideMenuBar,omitempty"` 104 | BackgroundColor *string `json:"backgroundColor,omitempty"` 105 | Center *bool `json:"center,omitempty"` 106 | Closable *bool `json:"closable,omitempty"` 107 | DisableAutoHideCursor *bool `json:"disableAutoHideCursor,omitempty"` 108 | EnableLargerThanScreen *bool `json:"enableLargerThanScreen,omitempty"` 109 | Focusable *bool `json:"focusable,omitempty"` 110 | Frame *bool `json:"frame,omitempty"` 111 | Fullscreen *bool `json:"fullscreen,omitempty"` 112 | Fullscreenable *bool `json:"fullscreenable,omitempty"` 113 | HasShadow *bool `json:"hasShadow,omitempty"` 114 | Height *int `json:"height,omitempty"` 115 | Icon *string `json:"icon,omitempty"` 116 | Kiosk *bool `json:"kiosk,omitempty"` 117 | MaxHeight *int `json:"maxHeight,omitempty"` 118 | Maximizable *bool `json:"maximizable,omitempty"` 119 | MaxWidth *int `json:"maxWidth,omitempty"` 120 | MinHeight *int `json:"minHeight,omitempty"` 121 | Minimizable *bool `json:"minimizable,omitempty"` 122 | MinWidth *int `json:"minWidth,omitempty"` 123 | Modal *bool `json:"modal,omitempty"` 124 | Movable *bool `json:"movable,omitempty"` 125 | Resizable *bool `json:"resizable,omitempty"` 126 | Show *bool `json:"show,omitempty"` 127 | SkipTaskbar *bool `json:"skipTaskbar,omitempty"` 128 | Title *string `json:"title,omitempty"` 129 | TitleBarStyle *string `json:"titleBarStyle,omitempty"` 130 | TrafficLightPosition *TrafficLightPosition `json:"trafficLightPosition,omitempty"` 131 | Transparent *bool `json:"transparent,omitempty"` 132 | UseContentSize *bool `json:"useContentSize,omitempty"` 133 | WebPreferences *WebPreferences `json:"webPreferences,omitempty"` 134 | Width *int `json:"width,omitempty"` 135 | X *int `json:"x,omitempty"` 136 | Y *int `json:"y,omitempty"` 137 | 138 | // Additional options 139 | AppDetails *WindowAppDetails `json:"appDetails,omitempty"` 140 | Custom *WindowCustomOptions `json:"custom,omitempty"` 141 | Load *WindowLoadOptions `json:"load,omitempty"` 142 | Proxy *WindowProxyOptions `json:"proxy,omitempty"` 143 | } 144 | 145 | // WindowAppDetails represents window app details 146 | // https://github.com/electron/electron/blob/v4.0.1/docs/api/browser-window.md#winsetappdetailsoptions-windows 147 | type WindowAppDetails struct { 148 | AppID *string `json:"appId,omitempty"` 149 | AppIconPath *string `json:"appIconPath,omitempty"` 150 | RelaunchCommand *string `json:"relaunchCommand,omitempty"` 151 | AppIconIndex *int `json:"appIconIndex,omitempty"` 152 | RelaunchDisplayName *string `json:"relaunchDisplayName,omitempty"` 153 | } 154 | 155 | // WindowCustomOptions represents window custom options 156 | type WindowCustomOptions struct { 157 | HideOnClose *bool `json:"hideOnClose,omitempty"` 158 | MessageBoxOnClose *MessageBoxOptions `json:"messageBoxOnClose,omitempty"` 159 | MinimizeOnClose *bool `json:"minimizeOnClose,omitempty"` 160 | Script string `json:"script,omitempty"` 161 | } 162 | 163 | // WindowLoadOptions represents window load options 164 | // https://github.com/electron/electron/blob/v1.8.1/docs/api/browser-window.md#winloadurlurl-options 165 | type WindowLoadOptions struct { 166 | ExtraHeaders string `json:"extraHeaders,omitempty"` 167 | HTTPReferer string `json:"httpReferrer,omitempty"` 168 | UserAgent string `json:"userAgent,omitempty"` 169 | } 170 | 171 | // WindowProxyOptions represents window proxy options 172 | // https://github.com/electron/electron/blob/v1.8.1/docs/api/session.md#sessetproxyconfig-callback 173 | type WindowProxyOptions struct { 174 | BypassRules string `json:"proxyBypassRules,omitempty"` 175 | PACScript string `json:"pacScript,omitempty"` 176 | Rules string `json:"proxyRules,omitempty"` 177 | } 178 | 179 | // WebPreferences represents web preferences in window options. 180 | // We must use pointers since GO doesn't handle optional fields whereas NodeJS does. 181 | // Use astikit.BoolPtr, astikit.IntPtr or astikit.StrPtr to fill the struct 182 | type WebPreferences struct { 183 | AllowRunningInsecureContent *bool `json:"allowRunningInsecureContent,omitempty"` 184 | BackgroundThrottling *bool `json:"backgroundThrottling,omitempty"` 185 | BlinkFeatures *string `json:"blinkFeatures,omitempty"` 186 | // This attribute needs to be false at all time 187 | // ContextIsolation *bool `json:"contextIsolation,omitempty"` 188 | DefaultEncoding *string `json:"defaultEncoding,omitempty"` 189 | DefaultFontFamily map[string]interface{} `json:"defaultFontFamily,omitempty"` 190 | DefaultFontSize *int `json:"defaultFontSize,omitempty"` 191 | DefaultMonospaceFontSize *int `json:"defaultMonospaceFontSize,omitempty"` 192 | DevTools *bool `json:"devTools,omitempty"` 193 | DisableBlinkFeatures *string `json:"disableBlinkFeatures,omitempty"` 194 | EnableRemoteModule *bool `json:"enableRemoteModule,omitempty"` 195 | ExperimentalCanvasFeatures *bool `json:"experimentalCanvasFeatures,omitempty"` 196 | ExperimentalFeatures *bool `json:"experimentalFeatures,omitempty"` 197 | Images *bool `json:"images,omitempty"` 198 | Javascript *bool `json:"javascript,omitempty"` 199 | MinimumFontSize *int `json:"minimumFontSize,omitempty"` 200 | // This attribute needs to be true at all time 201 | // NodeIntegration *bool `json:"nodeIntegration,omitempty"` 202 | NodeIntegrationInWorker *bool `json:"nodeIntegrationInWorker,omitempty"` 203 | Offscreen *bool `json:"offscreen,omitempty"` 204 | Partition *string `json:"partition,omitempty"` 205 | Plugins *bool `json:"plugins,omitempty"` 206 | Preload *string `json:"preload,omitempty"` 207 | Sandbox *bool `json:"sandbox,omitempty"` 208 | ScrollBounce *bool `json:"scrollBounce,omitempty"` 209 | Session map[string]interface{} `json:"session,omitempty"` 210 | TextAreasAreResizable *bool `json:"textAreasAreResizable,omitempty"` 211 | Webaudio *bool `json:"webaudio,omitempty"` 212 | Webgl *bool `json:"webgl,omitempty"` 213 | WebSecurity *bool `json:"webSecurity,omitempty"` 214 | WebviewTag *bool `json:"webviewTag,omitempty"` 215 | ZoomFactor *float64 `json:"zoomFactor,omitempty"` 216 | } 217 | 218 | // TrafficLightPosition represents traffic light positions (macOS only) 219 | // https://www.electronjs.org/docs/latest/tutorial/window-customization#create-frameless-windows 220 | type TrafficLightPosition struct { 221 | X *int `json:"x,omitempty"` 222 | Y *int `json:"y,omitempty"` 223 | } 224 | 225 | // newWindow creates a new window 226 | func newWindow(ctx context.Context, l astikit.SeverityLogger, o Options, p Paths, url string, wo *WindowOptions, d *dispatcher, i *identifier, wrt *writer) (w *Window, err error) { 227 | // Init 228 | w = &Window{ 229 | callbackIdentifier: newIdentifier(), 230 | l: l, 231 | o: wo, 232 | object: newObject(ctx, d, i, wrt, i.new()), 233 | } 234 | w.Session = newSession(w.ctx, d, i, wrt) 235 | 236 | // Check app details 237 | if wo.Icon == nil && p.AppIconDefaultSrc() != "" { 238 | wo.Icon = astikit.StrPtr(p.AppIconDefaultSrc()) 239 | } 240 | if wo.Title == nil && o.AppName != "" { 241 | wo.Title = astikit.StrPtr(o.AppName) 242 | } 243 | 244 | // Make sure the window's context is cancelled once the closed event is received 245 | w.On(EventNameWindowEventClosed, func(e Event) (deleteListener bool) { 246 | w.cancel() 247 | return true 248 | }) 249 | 250 | // Fullscreen state 251 | w.On(EventNameWindowEventEnterFullScreen, func(e Event) (deleteListener bool) { 252 | w.m.Lock() 253 | defer w.m.Unlock() 254 | w.o.Fullscreen = astikit.BoolPtr(true) 255 | return 256 | }) 257 | w.On(EventNameWindowEventLeaveFullScreen, func(e Event) (deleteListener bool) { 258 | w.m.Lock() 259 | defer w.m.Unlock() 260 | w.o.Fullscreen = astikit.BoolPtr(false) 261 | return 262 | }) 263 | 264 | // Show 265 | w.On(EventNameWindowEventHide, func(e Event) (deleteListener bool) { 266 | w.m.Lock() 267 | defer w.m.Unlock() 268 | w.o.Show = astikit.BoolPtr(false) 269 | return 270 | }) 271 | w.On(EventNameWindowEventShow, func(e Event) (deleteListener bool) { 272 | w.m.Lock() 273 | defer w.m.Unlock() 274 | w.o.Show = astikit.BoolPtr(true) 275 | return 276 | }) 277 | 278 | // Bounds change handling, updates the internal WindowOption's bounds 279 | updateBoundsFunc := func(w *Window) func(e Event) (deleteListener bool) { 280 | return func(e Event) (deleteListener bool) { 281 | w.m.Lock() 282 | defer w.m.Unlock() 283 | if w.o != nil && e.Bounds != nil { 284 | w.o.X = e.Bounds.X 285 | w.o.Y = e.Bounds.Y 286 | w.o.Width = e.Bounds.Width 287 | w.o.Height = e.Bounds.Height 288 | } 289 | return 290 | } 291 | } 292 | 293 | w.On(EventNameWindowEventDidFinishLoad, updateBoundsFunc(w)) 294 | w.On(EventNameWindowEventMaximize, updateBoundsFunc(w)) 295 | w.On(EventNameWindowEventMove, updateBoundsFunc(w)) 296 | w.On(EventNameWindowEventMoved, updateBoundsFunc(w)) 297 | w.On(EventNameWindowEventResize, updateBoundsFunc(w)) 298 | w.On(EventNameWindowEventResizeContent, updateBoundsFunc(w)) 299 | w.On(EventNameWindowEventUnmaximize, updateBoundsFunc(w)) 300 | w.On(EventNameWindowEventWillMove, updateBoundsFunc(w)) 301 | 302 | // Basic parse 303 | if w.url, err = stdUrl.Parse(url); err != nil { 304 | err = fmt.Errorf("std parsing of url %s failed: %w", url, err) 305 | return 306 | } 307 | 308 | // File 309 | if w.url.Scheme == "" { 310 | // Get absolute path 311 | if url, err = filepath.Abs(url); err != nil { 312 | err = fmt.Errorf("getting absolute path of %s failed: %w", url, err) 313 | return 314 | } 315 | 316 | // Set url 317 | w.url = &stdUrl.URL{Path: filepath.ToSlash(url), Scheme: "file"} 318 | } 319 | 320 | return 321 | } 322 | 323 | // NewMenu creates a new window menu 324 | func (w *Window) NewMenu(i []*MenuItemOptions) *Menu { 325 | return newMenu(w.ctx, w.id, i, w.d, w.i, w.w) 326 | } 327 | 328 | // Blur blurs the window 329 | func (w *Window) Blur() (err error) { 330 | if err = w.ctx.Err(); err != nil { 331 | return 332 | } 333 | _, err = synchronousEvent(w.ctx, w, w.w, Event{Name: EventNameWindowCmdBlur, TargetID: w.id}, EventNameWindowEventBlur) 334 | return 335 | } 336 | 337 | // Bounds return the window bounds 338 | func (w *Window) Bounds() (rect Rectangle, err error) { 339 | if err = w.ctx.Err(); err != nil { 340 | return 341 | } 342 | w.m.Lock() 343 | defer w.m.Unlock() 344 | 345 | if w.o.Width != nil && w.o.Height != nil { 346 | rect.Size.Width = *w.o.Width 347 | rect.Size.Height = *w.o.Height 348 | } 349 | 350 | if w.o.X != nil && w.o.Y != nil { 351 | rect.Position.X = *w.o.X 352 | rect.Position.Y = *w.o.Y 353 | } 354 | return 355 | } 356 | 357 | // Center centers the window 358 | func (w *Window) Center() (err error) { 359 | if err = w.ctx.Err(); err != nil { 360 | return 361 | } 362 | _, err = synchronousEvent(w.ctx, w, w.w, Event{Name: EventNameWindowCmdCenter, TargetID: w.id}, EventNameWindowEventMove) 363 | return 364 | } 365 | 366 | // Close closes the window 367 | func (w *Window) Close() (err error) { 368 | if err = w.ctx.Err(); err != nil { 369 | return 370 | } 371 | _, err = synchronousEvent(w.ctx, w, w.w, Event{Name: EventNameWindowCmdClose, TargetID: w.id}, EventNameWindowEventClosed) 372 | return 373 | } 374 | 375 | // CloseDevTools closes the dev tools 376 | func (w *Window) CloseDevTools() (err error) { 377 | if err = w.ctx.Err(); err != nil { 378 | return 379 | } 380 | return w.w.write(Event{Name: EventNameWindowCmdWebContentsCloseDevTools, TargetID: w.id}) 381 | } 382 | 383 | // Create creates the window 384 | // We wait for EventNameWindowEventDidFinishLoad since we need the web content to be fully loaded before being able to 385 | // send messages to it 386 | func (w *Window) Create() (err error) { 387 | if err = w.ctx.Err(); err != nil { 388 | return 389 | } 390 | _, err = synchronousEvent(w.ctx, w, w.w, Event{Name: EventNameWindowCmdCreate, SessionID: w.Session.id, TargetID: w.id, URL: w.url.String(), WindowOptions: w.o}, EventNameWindowEventDidFinishLoad) 391 | return 392 | } 393 | 394 | // Destroy destroys the window 395 | func (w *Window) Destroy() (err error) { 396 | if err = w.ctx.Err(); err != nil { 397 | return 398 | } 399 | _, err = synchronousEvent(w.ctx, w, w.w, Event{Name: EventNameWindowCmdDestroy, TargetID: w.id}, EventNameWindowEventClosed) 400 | return 401 | } 402 | 403 | // ExecuteJavaScript executes some js 404 | func (w *Window) ExecuteJavaScript(code string) (err error) { 405 | if err = w.ctx.Err(); err != nil { 406 | return 407 | } 408 | _, err = synchronousEvent(w.ctx, w, w.w, Event{Name: EventNameWindowCmdWebContentsExecuteJavaScript, TargetID: w.id, Code: code}, EventNameWindowEventWebContentsExecutedJavaScript) 409 | return 410 | } 411 | 412 | // Focus focuses on the window 413 | func (w *Window) Focus() (err error) { 414 | if err = w.ctx.Err(); err != nil { 415 | return 416 | } 417 | _, err = synchronousEvent(w.ctx, w, w.w, Event{Name: EventNameWindowCmdFocus, TargetID: w.id}, EventNameWindowEventFocus) 418 | return 419 | } 420 | 421 | // Hide hides the window 422 | func (w *Window) Hide() (err error) { 423 | if err = w.ctx.Err(); err != nil { 424 | return 425 | } 426 | _, err = synchronousEvent(w.ctx, w, w.w, Event{Name: EventNameWindowCmdHide, TargetID: w.id}, EventNameWindowEventHide) 427 | return 428 | } 429 | 430 | // IsFullScreen returns whether the window is in full screen mode 431 | func (w *Window) IsFullScreen() bool { 432 | if w.ctx.Err() != nil { 433 | return false 434 | } 435 | w.m.Lock() 436 | defer w.m.Unlock() 437 | return w.o.Fullscreen != nil && *w.o.Fullscreen 438 | } 439 | 440 | // IsShown returns whether the window is shown 441 | func (w *Window) IsShown() bool { 442 | if w.ctx.Err() != nil { 443 | return false 444 | } 445 | w.m.Lock() 446 | defer w.m.Unlock() 447 | return w.o.Show != nil && *w.o.Show 448 | } 449 | 450 | // Log logs a message in the JS console of the window 451 | func (w *Window) Log(message string) (err error) { 452 | if err = w.ctx.Err(); err != nil { 453 | return 454 | } 455 | return w.w.write(Event{Message: newEventMessage(message), Name: EventNameWindowCmdLog, TargetID: w.id}) 456 | } 457 | 458 | // Maximize maximizes the window 459 | func (w *Window) Maximize() (err error) { 460 | if err = w.ctx.Err(); err != nil { 461 | return 462 | } 463 | _, err = synchronousEvent(w.ctx, w, w.w, Event{Name: EventNameWindowCmdMaximize, TargetID: w.id}, EventNameWindowEventMaximize) 464 | return 465 | } 466 | 467 | // Minimize minimizes the window 468 | func (w *Window) Minimize() (err error) { 469 | if err = w.ctx.Err(); err != nil { 470 | return 471 | } 472 | _, err = synchronousEvent(w.ctx, w, w.w, Event{Name: EventNameWindowCmdMinimize, TargetID: w.id}, EventNameWindowEventMinimize) 473 | return 474 | } 475 | 476 | // Move moves the window 477 | func (w *Window) Move(x, y int) (err error) { 478 | if err = w.ctx.Err(); err != nil { 479 | return 480 | } 481 | _, err = synchronousEvent(w.ctx, w, w.w, Event{Name: EventNameWindowCmdMove, TargetID: w.id, WindowOptions: &WindowOptions{X: astikit.IntPtr(x), Y: astikit.IntPtr(y)}}, EventNameWindowEventMove) 482 | return 483 | } 484 | 485 | // MoveTop moves window to top (z-order) regardless of focus 486 | func (w *Window) MoveTop() (err error) { 487 | if err = w.ctx.Err(); err != nil { 488 | return 489 | } 490 | _, err = synchronousEvent(w.ctx, w, w.w, Event{Name: EventNameWindowCmdMoveTop, TargetID: w.id}, EventNameWindowEventMovedTop) 491 | return 492 | } 493 | 494 | // MoveInDisplay moves the window in the proper display 495 | func (w *Window) MoveInDisplay(d *Display, x, y int) error { 496 | return w.Move(d.Bounds().X+x, d.Bounds().Y+y) 497 | } 498 | 499 | func (w *Window) OnLogin(fn func(i Event) (username, password string, err error)) { 500 | w.On(EventNameWebContentsEventLogin, func(i Event) (deleteListener bool) { 501 | // Get username and password 502 | username, password, err := fn(i) 503 | if err != nil { 504 | w.l.Error(fmt.Errorf("getting username and password failed: %w", err)) 505 | return 506 | } 507 | 508 | // No auth 509 | if len(username) == 0 && len(password) == 0 { 510 | return 511 | } 512 | 513 | // Send message back 514 | if err = w.w.write(Event{CallbackID: i.CallbackID, Name: EventNameWebContentsEventLoginCallback, Password: password, TargetID: w.id, Username: username}); err != nil { 515 | w.l.Error(fmt.Errorf("writing login callback message failed: %w", err)) 516 | return 517 | } 518 | return 519 | }) 520 | } 521 | 522 | // ListenerMessage represents a message listener executed when receiving a message from the JS 523 | type ListenerMessage func(m *EventMessage) (v interface{}) 524 | 525 | // OnMessage adds a specific listener executed when receiving a message from the JS 526 | // This method can be called only once 527 | func (w *Window) OnMessage(l ListenerMessage) { 528 | w.onMessageOnce.Do(func() { 529 | w.On(eventNameWindowEventMessage, func(i Event) (deleteListener bool) { 530 | v := l(i.Message) 531 | if len(i.CallbackID) > 0 { 532 | o := Event{CallbackID: i.CallbackID, Name: eventNameWindowCmdMessageCallback, TargetID: w.id} 533 | if v != nil { 534 | o.Message = newEventMessage(v) 535 | } 536 | if err := w.w.write(o); err != nil { 537 | w.l.Error(fmt.Errorf("writing callback message failed: %w", err)) 538 | } 539 | } 540 | return 541 | }) 542 | }) 543 | } 544 | 545 | // OpenDevTools opens the dev tools 546 | func (w *Window) OpenDevTools() (err error) { 547 | if err = w.ctx.Err(); err != nil { 548 | return 549 | } 550 | return w.w.write(Event{Name: EventNameWindowCmdWebContentsOpenDevTools, TargetID: w.id}) 551 | } 552 | 553 | // SetAlwaysOnTop sets whether the window should show always on top of other windows. 554 | func (w *Window) SetAlwaysOnTop(flag bool) (err error) { 555 | if err = w.ctx.Err(); err != nil { 556 | return 557 | } 558 | w.m.Lock() 559 | w.o.AlwaysOnTop = astikit.BoolPtr(flag) 560 | w.m.Unlock() 561 | _, err = synchronousEvent(w.ctx, w, w.w, Event{Name: EventNameWindowCmdSetAlwaysOnTop, TargetID: w.id, Enable: astikit.BoolPtr(flag)}, EventNameWindowEventAlwaysOnTopChanged) 562 | return 563 | } 564 | 565 | // Resize resizes the window 566 | func (w *Window) Resize(width, height int) (err error) { 567 | if err = w.ctx.Err(); err != nil { 568 | return 569 | } 570 | _, err = synchronousEvent(w.ctx, w, w.w, Event{Name: EventNameWindowCmdResize, TargetID: w.id, WindowOptions: &WindowOptions{Height: astikit.IntPtr(height), Width: astikit.IntPtr(width)}}, EventNameWindowEventResize) 571 | return 572 | } 573 | 574 | // ResizeContent resizes the content viewport 575 | func (w *Window) ResizeContent(width, height int) (err error) { 576 | if err = w.ctx.Err(); err != nil { 577 | return 578 | } 579 | _, err = synchronousEvent(w.ctx, w, w.w, Event{Name: EventNameWindowCmdResizeContent, TargetID: w.id, WindowOptions: &WindowOptions{Height: astikit.IntPtr(height), Width: astikit.IntPtr(width)}}, EventNameWindowEventResizeContent) 580 | return 581 | } 582 | 583 | // SetBounds set bounds of the window 584 | func (w *Window) SetBounds(r RectangleOptions) (err error) { 585 | if err = w.ctx.Err(); err != nil { 586 | return 587 | } 588 | _, err = synchronousEvent(w.ctx, w, w.w, Event{Name: EventNameWindowCmdSetBounds, TargetID: w.id, Bounds: &r}, EventNameWindowEventResize) 589 | return 590 | } 591 | 592 | // Enable content protection on the window 593 | func (w *Window) SetContentProtection(enable bool) (err error) { 594 | if err = w.ctx.Err(); err != nil { 595 | return 596 | } 597 | _, err = synchronousEvent(w.ctx, w, w.w, Event{Name: EventNameWindowCmdSetContentProtection, TargetID: w.id, Enable: astikit.BoolPtr(enable)}, EventNameWindowEventContentProtectionSet) 598 | return 599 | } 600 | 601 | // SetFullScreen sets the fullscreen flag of the window 602 | func (w *Window) SetFullScreen(enable bool) (err error) { 603 | if err = w.ctx.Err(); err != nil { 604 | return 605 | } 606 | 607 | var eventNameDone string 608 | if enable { 609 | eventNameDone = EventNameWindowEventEnterFullScreen 610 | } else { 611 | eventNameDone = EventNameWindowEventLeaveFullScreen 612 | } 613 | 614 | _, err = synchronousEvent(w.ctx, w, w.w, Event{Name: EventNameWindowCmdSetFullScreen, TargetID: w.id, Enable: astikit.BoolPtr(enable)}, eventNameDone) 615 | return 616 | } 617 | 618 | // Restore restores the window 619 | func (w *Window) Restore() (err error) { 620 | if err = w.ctx.Err(); err != nil { 621 | return 622 | } 623 | _, err = synchronousEvent(w.ctx, w, w.w, Event{Name: EventNameWindowCmdRestore, TargetID: w.id}, EventNameWindowEventRestore) 624 | return 625 | } 626 | 627 | // CallbackMessage represents a message callback 628 | type CallbackMessage func(m *EventMessage) 629 | 630 | // SendMessage sends a message to the JS window and execute optional callbacks upon receiving a response from the JS 631 | // Use astilectron.onMessage method to capture those messages in JS 632 | func (w *Window) SendMessage(message interface{}, callbacks ...CallbackMessage) (err error) { 633 | if err = w.ctx.Err(); err != nil { 634 | return 635 | } 636 | var e = Event{Message: newEventMessage(message), Name: eventNameWindowCmdMessage, TargetID: w.id} 637 | if len(callbacks) > 0 { 638 | e.CallbackID = w.callbackIdentifier.new() 639 | w.On(eventNameWindowEventMessageCallback, func(i Event) (deleteListener bool) { 640 | if i.CallbackID == e.CallbackID { 641 | for _, c := range callbacks { 642 | c(i.Message) 643 | } 644 | deleteListener = true 645 | } 646 | return 647 | }) 648 | } 649 | return w.w.write(e) 650 | } 651 | 652 | // Show shows the window 653 | func (w *Window) Show() (err error) { 654 | if err = w.ctx.Err(); err != nil { 655 | return 656 | } 657 | _, err = synchronousEvent(w.ctx, w, w.w, Event{Name: EventNameWindowCmdShow, TargetID: w.id}, EventNameWindowEventShow) 658 | return 659 | } 660 | 661 | // Unmaximize unmaximize the window 662 | func (w *Window) Unmaximize() (err error) { 663 | if err = w.ctx.Err(); err != nil { 664 | return 665 | } 666 | _, err = synchronousEvent(w.ctx, w, w.w, Event{Name: EventNameWindowCmdUnmaximize, TargetID: w.id}, EventNameWindowEventUnmaximize) 667 | return 668 | } 669 | 670 | // UpdateCustomOptions updates the window custom options 671 | func (w *Window) UpdateCustomOptions(o WindowCustomOptions) (err error) { 672 | if err = w.ctx.Err(); err != nil { 673 | return 674 | } 675 | w.m.Lock() 676 | w.o.Custom = &o 677 | w.m.Unlock() 678 | _, err = synchronousEvent(w.ctx, w, w.w, Event{WindowOptions: w.o, Name: EventNameWindowCmdUpdateCustomOptions, TargetID: w.id}, EventNameWindowEventUpdatedCustomOptions) 679 | return 680 | } 681 | -------------------------------------------------------------------------------- /window_test.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | 7 | "github.com/asticode/go-astikit" 8 | "github.com/stretchr/testify/assert" 9 | ) 10 | 11 | func TestNewWindow(t *testing.T) { 12 | // Init 13 | a, err := New(nil, Options{AppName: "app name", AppIconDefaultPath: "/path/to/default/icon"}) 14 | assert.NoError(t, err) 15 | w, err := a.NewWindow("http://test.com", &WindowOptions{}) 16 | assert.NoError(t, err) 17 | 18 | // Test app name + icon 19 | assert.Equal(t, "app name", *w.o.Title) 20 | assert.Equal(t, "/path/to/default/icon", *w.o.Icon) 21 | 22 | // Test in display 23 | w, err = a.NewWindowInDisplay(newDisplay(&DisplayOptions{Bounds: &RectangleOptions{PositionOptions: PositionOptions{X: astikit.IntPtr(1), Y: astikit.IntPtr(2)}, SizeOptions: SizeOptions{Height: astikit.IntPtr(5), Width: astikit.IntPtr(6)}}}, true), "http://test.com", &WindowOptions{X: astikit.IntPtr(3), Y: astikit.IntPtr(4)}) 24 | assert.NoError(t, err) 25 | assert.Equal(t, 4, *w.o.X) 26 | assert.Equal(t, 6, *w.o.Y) 27 | w, err = a.NewWindowInDisplay(newDisplay(&DisplayOptions{Bounds: &RectangleOptions{PositionOptions: PositionOptions{X: astikit.IntPtr(1), Y: astikit.IntPtr(2)}, SizeOptions: SizeOptions{Height: astikit.IntPtr(5), Width: astikit.IntPtr(6)}}}, true), "http://test.com", &WindowOptions{}) 28 | assert.NoError(t, err) 29 | assert.Equal(t, 1, *w.o.X) 30 | assert.Equal(t, 2, *w.o.Y) 31 | } 32 | 33 | func TestWindow_Actions(t *testing.T) { 34 | // Init 35 | a, err := New(nil, Options{}) 36 | assert.NoError(t, err) 37 | defer a.Close() 38 | wrt := &mockedWriter{} 39 | a.writer = newWriter(wrt, &logger{}) 40 | w, err := a.NewWindow("http://test.com", &WindowOptions{}) 41 | assert.NoError(t, err) 42 | assert.Equal(t, false, w.IsShown()) 43 | 44 | // Actions 45 | testObjectAction(t, func() error { return w.Blur() }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdBlur+"\",\"targetID\":\""+w.id+"\"}\n", EventNameWindowEventBlur, nil) 46 | testObjectAction(t, func() error { return w.Center() }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdCenter+"\",\"targetID\":\""+w.id+"\"}\n", EventNameWindowEventMove, &Event{Bounds: &RectangleOptions{ 47 | PositionOptions: PositionOptions{X: astikit.IntPtr(3), Y: astikit.IntPtr(4)}, 48 | SizeOptions: SizeOptions{Height: astikit.IntPtr(1), Width: astikit.IntPtr(1)}, 49 | }}) 50 | bounds, err := w.Bounds() 51 | assert.NoError(t, err) 52 | assert.Equal(t, Rectangle{Position: Position{X: 3, Y: 4}, Size: Size{Height: 1, Width: 1}}, bounds) 53 | testObjectAction(t, func() error { return w.Close() }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdClose+"\",\"targetID\":\""+w.id+"\"}\n", EventNameWindowEventClosed, nil) 54 | assert.True(t, w.ctx.Err() != nil) 55 | w, err = a.NewWindow("http://test.com", &WindowOptions{Center: astikit.BoolPtr(true)}) 56 | assert.NoError(t, err) 57 | testObjectAction(t, func() error { return w.CloseDevTools() }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdWebContentsCloseDevTools+"\",\"targetID\":\""+w.id+"\"}\n", "", nil) 58 | testObjectAction(t, func() error { return w.Create() }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdCreate+"\",\"targetID\":\""+w.id+"\",\"sessionId\":\"4\",\"url\":\"http://test.com\",\"windowOptions\":{\"center\":true}}\n", EventNameWindowEventDidFinishLoad, &Event{Bounds: &RectangleOptions{ 59 | PositionOptions: PositionOptions{X: astikit.IntPtr(3), Y: astikit.IntPtr(4)}, 60 | SizeOptions: SizeOptions{Height: astikit.IntPtr(1), Width: astikit.IntPtr(1)}, 61 | }}) 62 | bounds, err = w.Bounds() 63 | assert.NoError(t, err) 64 | assert.Equal(t, Rectangle{Position: Position{X: 3, Y: 4}, Size: Size{Height: 1, Width: 1}}, bounds) 65 | testObjectAction(t, func() error { return w.Destroy() }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdDestroy+"\",\"targetID\":\""+w.id+"\"}\n", EventNameWindowEventClosed, nil) 66 | assert.True(t, w.ctx.Err() != nil) 67 | w, err = a.NewWindow("http://test.com", &WindowOptions{}) 68 | assert.NoError(t, err) 69 | testObjectAction(t, func() error { return w.Focus() }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdFocus+"\",\"targetID\":\""+w.id+"\"}\n", EventNameWindowEventFocus, nil) 70 | testObjectAction(t, func() error { return w.Hide() }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdHide+"\",\"targetID\":\""+w.id+"\"}\n", EventNameWindowEventHide, nil) 71 | assert.Equal(t, false, w.IsShown()) 72 | testObjectAction(t, func() error { return w.Log("message") }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdLog+"\",\"targetID\":\""+w.id+"\",\"message\":\"message\"}\n", "", nil) 73 | testObjectAction(t, func() error { return w.Maximize() }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdMaximize+"\",\"targetID\":\""+w.id+"\"}\n", EventNameWindowEventMaximize, &Event{Bounds: &RectangleOptions{ 74 | PositionOptions: PositionOptions{X: astikit.IntPtr(0), Y: astikit.IntPtr(0)}, 75 | SizeOptions: SizeOptions{Height: astikit.IntPtr(100), Width: astikit.IntPtr(200)}, 76 | }}) 77 | bounds, err = w.Bounds() 78 | assert.NoError(t, err) 79 | assert.Equal(t, Rectangle{Position: Position{X: 0, Y: 0}, Size: Size{Height: 100, Width: 200}}, bounds) 80 | testObjectAction(t, func() error { return w.Minimize() }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdMinimize+"\",\"targetID\":\""+w.id+"\"}\n", EventNameWindowEventMinimize, nil) 81 | testObjectAction(t, func() error { return w.OpenDevTools() }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdWebContentsOpenDevTools+"\",\"targetID\":\""+w.id+"\"}\n", "", nil) 82 | testObjectAction(t, func() error { return w.Move(3, 4) }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdMove+"\",\"targetID\":\""+w.id+"\",\"windowOptions\":{\"x\":3,\"y\":4}}\n", EventNameWindowEventMove, &Event{Bounds: &RectangleOptions{ 83 | PositionOptions: PositionOptions{X: astikit.IntPtr(5), Y: astikit.IntPtr(6)}, 84 | SizeOptions: SizeOptions{Height: astikit.IntPtr(2), Width: astikit.IntPtr(2)}, 85 | }}) 86 | bounds, err = w.Bounds() 87 | assert.NoError(t, err) 88 | assert.Equal(t, Rectangle{Position: Position{X: 5, Y: 6}, Size: Size{Height: 2, Width: 2}}, bounds) 89 | var d = newDisplay(&DisplayOptions{Bounds: &RectangleOptions{PositionOptions: PositionOptions{X: astikit.IntPtr(1), Y: astikit.IntPtr(2)}, SizeOptions: SizeOptions{Height: astikit.IntPtr(1), Width: astikit.IntPtr(2)}}}, true) 90 | testObjectAction(t, func() error { return w.MoveInDisplay(d, 3, 4) }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdMove+"\",\"targetID\":\""+w.id+"\",\"windowOptions\":{\"x\":4,\"y\":6}}\n", EventNameWindowEventMove, &Event{Bounds: &RectangleOptions{ 91 | PositionOptions: PositionOptions{X: astikit.IntPtr(5), Y: astikit.IntPtr(6)}, 92 | SizeOptions: SizeOptions{Height: astikit.IntPtr(2), Width: astikit.IntPtr(2)}, 93 | }}) 94 | bounds, err = w.Bounds() 95 | assert.NoError(t, err) 96 | assert.Equal(t, Rectangle{Position: Position{X: 5, Y: 6}, Size: Size{Height: 2, Width: 2}}, bounds) 97 | testObjectAction(t, func() error { return w.Resize(1, 2) }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdResize+"\",\"targetID\":\""+w.id+"\",\"windowOptions\":{\"height\":2,\"width\":1}}\n", EventNameWindowEventResize, &Event{Bounds: &RectangleOptions{ 98 | PositionOptions: PositionOptions{X: astikit.IntPtr(5), Y: astikit.IntPtr(6)}, 99 | SizeOptions: SizeOptions{Height: astikit.IntPtr(4), Width: astikit.IntPtr(4)}, 100 | }}) 101 | bounds, err = w.Bounds() 102 | assert.NoError(t, err) 103 | assert.Equal(t, Rectangle{Position: Position{X: 5, Y: 6}, Size: Size{Height: 4, Width: 4}}, bounds) 104 | testObjectAction(t, func() error { return w.ResizeContent(1, 2) }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdResizeContent+"\",\"targetID\":\""+w.id+"\",\"windowOptions\":{\"height\":2,\"width\":1}}\n", EventNameWindowEventResizeContent, &Event{Bounds: &RectangleOptions{ 105 | PositionOptions: PositionOptions{X: astikit.IntPtr(4), Y: astikit.IntPtr(6)}, 106 | SizeOptions: SizeOptions{Height: astikit.IntPtr(2), Width: astikit.IntPtr(1)}, 107 | }}) 108 | bounds, err = w.Bounds() 109 | assert.NoError(t, err) 110 | assert.Equal(t, Rectangle{Position: Position{X: 4, Y: 6}, Size: Size{Height: 2, Width: 1}}, bounds) 111 | testObjectAction(t, func() error { return w.Restore() }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdRestore+"\",\"targetID\":\""+w.id+"\"}\n", EventNameWindowEventRestore, nil) 112 | testObjectAction(t, func() error { return w.SetAlwaysOnTop(true) }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdSetAlwaysOnTop+"\",\"targetID\":\""+w.id+"\",\"enable\":true}\n", EventNameWindowEventAlwaysOnTopChanged, nil) 113 | testObjectAction(t, func() error { return w.Show() }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdShow+"\",\"targetID\":\""+w.id+"\"}\n", EventNameWindowEventShow, nil) 114 | assert.Equal(t, true, w.IsShown()) 115 | testObjectAction(t, func() error { return w.UpdateCustomOptions(WindowCustomOptions{HideOnClose: astikit.BoolPtr(true)}) }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdUpdateCustomOptions+"\",\"targetID\":\""+w.id+"\",\"windowOptions\":{\"alwaysOnTop\":true,\"height\":2,\"show\":true,\"width\":1,\"x\":4,\"y\":6,\"custom\":{\"hideOnClose\":true}}}\n", EventNameWindowEventUpdatedCustomOptions, nil) 116 | testObjectAction(t, func() error { return w.Unmaximize() }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdUnmaximize+"\",\"targetID\":\""+w.id+"\"}\n", EventNameWindowEventUnmaximize, nil) 117 | testObjectAction(t, func() error { return w.SetFullScreen(true) }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdSetFullScreen+"\",\"targetID\":\""+w.id+"\",\"enable\":true}\n", EventNameWindowEventEnterFullScreen, &Event{WindowOptions: &WindowOptions{Fullscreen: astikit.BoolPtr(true)}}) 118 | assert.Equal(t, true, w.IsFullScreen()) 119 | testObjectAction(t, func() error { return w.SetFullScreen(false) }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdSetFullScreen+"\",\"targetID\":\""+w.id+"\",\"enable\":false}\n", EventNameWindowEventLeaveFullScreen, &Event{WindowOptions: &WindowOptions{Fullscreen: astikit.BoolPtr(false)}}) 120 | assert.Equal(t, false, w.IsFullScreen()) 121 | testObjectAction(t, func() error { return w.ExecuteJavaScript("console.log('test');") }, w.object, wrt, "{\"name\":\""+EventNameWindowCmdWebContentsExecuteJavaScript+"\",\"targetID\":\""+w.id+"\",\"code\":\"console.log('test');\"}\n", EventNameWindowEventWebContentsExecutedJavaScript, nil) 122 | } 123 | 124 | func TestWindow_OnLogin(t *testing.T) { 125 | a, err := New(nil, Options{}) 126 | assert.NoError(t, err) 127 | defer a.Close() 128 | wrt := &mockedWriter{wg: &sync.WaitGroup{}} 129 | a.writer = newWriter(wrt, &logger{}) 130 | w, err := a.NewWindow("http://test.com", &WindowOptions{}) 131 | assert.NoError(t, err) 132 | w.OnLogin(func(i Event) (username, password string, err error) { 133 | return "username", "password", nil 134 | }) 135 | wrt.wg.Add(1) 136 | a.dispatcher.dispatch(Event{CallbackID: "1", Name: EventNameWebContentsEventLogin, TargetID: w.id}) 137 | wrt.wg.Wait() 138 | assert.Equal(t, []string{"{\"name\":\"web.contents.event.login.callback\",\"targetID\":\"1\",\"callbackId\":\"1\",\"password\":\"password\",\"username\":\"username\"}\n"}, wrt.w) 139 | } 140 | 141 | func TestWindow_OnMessage(t *testing.T) { 142 | a, err := New(nil, Options{}) 143 | assert.NoError(t, err) 144 | defer a.Close() 145 | wrt := &mockedWriter{wg: &sync.WaitGroup{}} 146 | a.writer = newWriter(wrt, &logger{}) 147 | w, err := a.NewWindow("http://test.com", &WindowOptions{}) 148 | assert.NoError(t, err) 149 | w.OnMessage(func(m *EventMessage) interface{} { 150 | return "test" 151 | }) 152 | wrt.wg.Add(1) 153 | a.dispatcher.dispatch(Event{CallbackID: "1", Name: eventNameWindowEventMessage, TargetID: w.id}) 154 | wrt.wg.Wait() 155 | assert.Equal(t, []string{"{\"name\":\"window.cmd.message.callback\",\"targetID\":\"1\",\"callbackId\":\"1\",\"message\":\"test\"}\n"}, wrt.w) 156 | } 157 | 158 | func TestWindow_SendMessage(t *testing.T) { 159 | a, err := New(nil, Options{}) 160 | assert.NoError(t, err) 161 | defer a.Close() 162 | wrt := &mockedWriter{} 163 | a.writer = newWriter(wrt, &logger{}) 164 | w, err := a.NewWindow("http://test.com", &WindowOptions{}) 165 | assert.NoError(t, err) 166 | wrt.fn = func() { 167 | a.dispatcher.dispatch(Event{CallbackID: "1", Message: newEventMessage([]byte("\"bar\"")), Name: eventNameWindowEventMessageCallback, TargetID: w.id}) 168 | wrt.fn = nil 169 | } 170 | var wg sync.WaitGroup 171 | wg.Add(1) 172 | var s string 173 | w.SendMessage("foo", func(m *EventMessage) { 174 | m.Unmarshal(&s) 175 | wg.Done() 176 | }) 177 | wg.Wait() 178 | assert.Equal(t, []string{"{\"name\":\"window.cmd.message\",\"targetID\":\"1\",\"callbackId\":\"1\",\"message\":\"foo\"}\n"}, wrt.w) 179 | assert.Equal(t, "bar", s) 180 | } 181 | 182 | func TestWindow_NewMenu(t *testing.T) { 183 | a, err := New(nil, Options{}) 184 | assert.NoError(t, err) 185 | w, err := a.NewWindow("http://test.com", &WindowOptions{}) 186 | assert.NoError(t, err) 187 | m := w.NewMenu([]*MenuItemOptions{}) 188 | assert.Equal(t, w.id, m.rootID) 189 | } 190 | -------------------------------------------------------------------------------- /writer.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | 8 | "github.com/asticode/go-astikit" 9 | ) 10 | 11 | // writer represents an object capable of writing in the TCP server 12 | type writer struct { 13 | l astikit.SeverityLogger 14 | w io.WriteCloser 15 | } 16 | 17 | // newWriter creates a new writer 18 | func newWriter(w io.WriteCloser, l astikit.SeverityLogger) *writer { 19 | return &writer{ 20 | l: l, 21 | w: w, 22 | } 23 | } 24 | 25 | // close closes the writer properly 26 | func (w *writer) close() error { 27 | return w.w.Close() 28 | } 29 | 30 | // write writes to the stdin 31 | func (w *writer) write(e Event) (err error) { 32 | // Marshal 33 | var b []byte 34 | if b, err = json.Marshal(e); err != nil { 35 | return fmt.Errorf("marshaling %+v failed: %w", e, err) 36 | } 37 | 38 | // Write 39 | w.l.Debugf("Sending to Astilectron: %s", b) 40 | if _, err = w.w.Write(append(b, '\n')); err != nil { 41 | return fmt.Errorf("writing %s failed: %w", b, err) 42 | } 43 | return 44 | } 45 | -------------------------------------------------------------------------------- /writer_test.go: -------------------------------------------------------------------------------- 1 | package astilectron 2 | 3 | import ( 4 | "sync" 5 | "testing" 6 | 7 | "github.com/stretchr/testify/assert" 8 | ) 9 | 10 | // mockedWriter represents a mocked writer 11 | type mockedWriter struct { 12 | c bool 13 | fn func() 14 | w []string 15 | wg *sync.WaitGroup 16 | } 17 | 18 | // Close implements the io.Closer interface 19 | func (w *mockedWriter) Close() error { 20 | w.c = true 21 | return nil 22 | } 23 | 24 | // Write implements io.Writer interface 25 | func (w *mockedWriter) Write(p []byte) (int, error) { 26 | w.w = append(w.w, string(p)) 27 | if w.fn != nil { 28 | w.fn() 29 | } 30 | if w.wg != nil { 31 | w.wg.Done() 32 | } 33 | return len(p), nil 34 | } 35 | 36 | // TestWriter tests the writer 37 | func TestWriter(t *testing.T) { 38 | // Init 39 | var mw = &mockedWriter{} 40 | var w = newWriter(mw, &logger{}) 41 | 42 | // Test write 43 | err := w.write(Event{Name: "test", TargetID: "target_id"}) 44 | assert.NoError(t, err) 45 | assert.Equal(t, []string{"{\"name\":\"test\",\"targetID\":\"target_id\"}\n"}, mw.w) 46 | 47 | // Test close 48 | err = w.close() 49 | assert.NoError(t, err) 50 | assert.True(t, mw.c) 51 | } 52 | --------------------------------------------------------------------------------