diff --git a/.github/workflows/systray.yml b/.github/workflows/systray.yml new file mode 100644 index 0000000..b815d64 --- /dev/null +++ b/.github/workflows/systray.yml @@ -0,0 +1,45 @@ +name: Systray + +on: + push: + branches: [ main ] + paths: + - 'src/net/cmd/systray/*' + pull_request: + branches: [ main ] + paths: + - 'src/net/cmd/systray/*' + +jobs: + + build: + name: Build on ${{ matrix.os }} + runs-on: ${{ matrix.os }} + strategy: + matrix: + include: + - os: ubuntu-16.04 + binary: permuter-systray-linux + - os: windows-latest + binary: permuter-systray.exe + - os: macos-latest + binary: permuter-systray-macos + steps: + - uses: actions/checkout@main + + - name: Install gtk3 + if: ${{ matrix.os == 'ubuntu-16.04' }} + run: sudo apt-get install libgtk-3-dev libappindicator3-dev + + - name: Setup Go environment + uses: actions/setup-go@v2.1.3 + + - name: Build + run: go build -o ${{ matrix.binary }} -ldflags "-s -w" tray.go + working-directory: src/net/cmd/systray/ + + - name: Upload artifact + uses: actions/upload-artifact@v2 + with: + name: ${{ matrix.binary }} + path: src/net/cmd/systray/${{ matrix.binary }} diff --git a/README.md b/README.md index 0bf7290..eb31c9d 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,6 @@ To allow others to use your computer for permuter runs, do the following: - install Docker (used for sandboxing and to ensure a consistent environment) - if on Linux, add yourself to the Docker group: `sudo usermod -aG docker $USER` - install required packages: `python3 -m pip install docker` - (optionally add `pystray` and `Pillow` for experimental systray support) - open a terminal, and run `./pah.py run-server` to start the server. There are a few required arguments (e.g. how many cores to use), see `--help` for more details. diff --git a/src/net/client.py b/src/net/client.py index 3231296..1a3442c 100644 --- a/src/net/client.py +++ b/src/net/client.py @@ -264,8 +264,7 @@ def start_client( feedback_queue, ) - thread = threading.Thread(target=conn.run) - thread.daemon = True + thread = threading.Thread(target=conn.run, daemon=True) thread.start() stats = (num_clients, num_servers, num_cores) diff --git a/src/net/cmd/icon.png b/src/net/cmd/icon.png deleted file mode 100644 index 1146885..0000000 Binary files a/src/net/cmd/icon.png and /dev/null differ diff --git a/src/net/cmd/icons/notok.ico b/src/net/cmd/icons/notok.ico new file mode 100644 index 0000000..2825d0d Binary files /dev/null and b/src/net/cmd/icons/notok.ico differ diff --git a/src/net/cmd/icons/ok.ico b/src/net/cmd/icons/ok.ico new file mode 100644 index 0000000..bc0b053 Binary files /dev/null and b/src/net/cmd/icons/ok.ico differ diff --git a/src/net/cmd/icons/okthink.ico b/src/net/cmd/icons/okthink.ico new file mode 100644 index 0000000..d3a8eac Binary files /dev/null and b/src/net/cmd/icons/okthink.ico differ diff --git a/src/net/cmd/run_server.py b/src/net/cmd/run_server.py index bdbf4e0..a88ddb4 100644 --- a/src/net/cmd/run_server.py +++ b/src/net/cmd/run_server.py @@ -1,22 +1,25 @@ from argparse import ArgumentParser, Namespace +import base64 from dataclasses import dataclass +from enum import Enum from functools import partial +import json import os +import platform import queue import random +from subprocess import Popen, PIPE import sys import time import threading import traceback from typing import Any, Callable, Dict, List, Optional, TYPE_CHECKING -if TYPE_CHECKING: - import pystray - from ...helpers import static_assert_unreachable -from ..core import CancelToken, ServerError +from ..core import CancelToken, ServerError, read_config from ..server import ( Client, + Config, IoActivity, IoConnect, IoDisconnect, @@ -25,7 +28,6 @@ from ..server import ( IoServerFailed, IoShutdown, IoUserRemovePermuter, - IoWillSleep, IoWorkDone, PermuterHandle, Server, @@ -34,9 +36,6 @@ from ..server import ( from .base import Command -SYSTRAY_UPDATE_INTERVAL = 20.0 - - class RunServerCommand(Command): command = "run-server" help = """Run a permuter server, allowing anyone with access to the central @@ -66,8 +65,7 @@ class RunServerCommand(Command): "--systray", dest="systray", action="store_true", - help="""Make the server controllable through the system tray. - Warning: buggy!""", + help="""Make the server controllable through the system tray.""", ) parser.add_argument( "--min-priority", @@ -91,6 +89,15 @@ class RunServerCommand(Command): class SystrayState: + def server_reconnecting(self) -> None: + pass + + def server_connected(self) -> None: + pass + + def server_failed(self, graceful: bool, message: Optional[str] = None) -> None: + pass + def connect(self, handle: PermuterHandle, nickname: str, fn_name: str) -> None: pass @@ -100,10 +107,7 @@ class SystrayState: def work_done(self, handle: PermuterHandle, is_improvement: bool) -> None: pass - def will_sleep(self) -> None: - pass - - def server_failed(self) -> None: + def stop(self) -> None: pass @@ -114,125 +118,312 @@ class Permuter: iterations: int = 0 improvements: int = 0 last_systray_update: float = 0.0 + slot: "Optional[ClientSlot]" = None + + +@dataclass +class ClientSlot: + menu_id: int + iterations_id: int + improvements_id: int + stop_id: int + permuter: Optional[PermuterHandle] = None + + +class SystrayStatus(Enum): + CONNECTING = 0 + CONNECTED = 1 + FAILED = 2 + RECONNECTING = 3 class RealSystrayState(SystrayState): + _CLIENT_SLOTS = 10 + _UPDATE_INTERVAL = 2.0 + _MENU_TOOLTIP = "permuter@home" _permuters: Dict[PermuterHandle, Permuter] + _onclick: Dict[int, Callable[[], None]] + _client_slots: List[ClientSlot] def __init__( self, + config: Config, io_queue: "queue.Queue[IoActivity]", - update_menu: "Callable[[List[pystray.MenuItem], bool], None]", ) -> None: self._io_queue = io_queue - self._update_menu = update_menu self._permuters = {} + self._onclick = {} + self._status = SystrayStatus.CONNECTING + self._fail_message: Optional[str] = None - def _remove_permuter(self, handle: PermuterHandle, *_: Any) -> None: + def load_icon(fname: str) -> str: + path = os.path.join(os.path.dirname(__file__), "icons", fname) + with open(path, "rb") as f: + data = f.read() + return base64.b64encode(data).decode("ascii") + + self._icons = { + "working": load_icon("okthink.ico"), + "passive": load_icon("ok.ico"), + "fail": load_icon("notok.ico"), + } + self._current_icon = "working" + + next_id = 100 + + def add_item( + menu: List[dict], + title: str, + onclick: Optional[Callable[[], None]] = None, + *, + submenu: Optional[List[dict]] = None, + hidden: bool = False, + ) -> int: + nonlocal next_id + next_id += 1 + obj = { + "title": title, + "enabled": onclick is not None or submenu is not None, + "hidden": hidden, + "__id": next_id, + } + if onclick is not None: + self._onclick[next_id] = onclick + if submenu is not None: + obj["items"] = submenu + menu.append(obj) + return next_id + + menu: List[dict] = [] + self._status_id = add_item(menu, "Connecting...") + self._client_slots = [] + for i in range(self._CLIENT_SLOTS): + submenu: List[dict] = [] + remove_cb = partial(self._remove_permuter, i) + self._client_slots.append( + ClientSlot( + iterations_id=add_item(submenu, ""), + improvements_id=add_item(submenu, ""), + stop_id=add_item(submenu, "Stop", remove_cb), + menu_id=add_item(menu, "", submenu=submenu, hidden=True), + ) + ) + self._more_id = add_item(menu, "", hidden=True) + add_item(menu, "Quit", self._quit) + + try: + fname = "permuter-systray" + if ( + sys.platform in ("win32", "msys", "cygwin") + or "microsoft" in platform.uname().release.lower() + ): + fname += ".exe" + path = os.path.join(os.path.dirname(__file__), "systray", fname) + self._proc = Popen( + [path], + stdout=PIPE, + stdin=PIPE, + universal_newlines=True, + ) + assert self._proc.stdout is not None + self._proc_stdout = self._proc.stdout + assert self._proc.stdin is not None + self._proc_stdin = self._proc.stdin + + self._send( + { + "icon": self._icons[self._current_icon], + "tooltip": self._MENU_TOOLTIP, + "items": menu, + } + ) + + resp_str = self._proc_stdout.readline() + assert resp_str + resp = json.loads(resp_str) + assert isinstance(resp, dict) + assert resp.get("type") == "ready" + except Exception: + raise Exception("Failed to initialize systray!") + + self._read_thread = threading.Thread(target=self._read_loop, daemon=True) + self._read_thread.start() + + def _send(self, msg: dict) -> None: + data = json.dumps(msg) + self._proc_stdin.write(data + "\n") + self._proc_stdin.flush() + + def _update_item( + self, id: int, title: str, *, hidden: bool = False, enabled: bool = False + ) -> None: + self._send( + { + "type": "update-item", + "item": { + "title": title, + "enabled": enabled, + "hidden": hidden, + "__id": id, + }, + "seq_id": -1, + } + ) + + def _remove_permuter(self, slot_index: int) -> None: + slot = self._client_slots[slot_index] + if not slot.permuter: + return + handle = slot.permuter self._io_queue.put((None, (handle, IoUserRemovePermuter()))) def _quit(self) -> None: self._io_queue.put((None, IoShutdown())) - def _update(self, flush: bool = True) -> None: - import pystray + def _read_loop(self) -> None: + while True: + resp_str = self._proc_stdout.readline() + if not resp_str: + break + try: + resp = json.loads(resp_str) + except Exception: + raise Exception(f"Failed to parse systray JSON: {resp_str}") from None + if resp["type"] == "clicked": + id = resp["__id"] + if id in self._onclick: + self._onclick[id]() - title = "Currently permuting:" if self._permuters else "" - items: List[pystray.MenuItem] = [ - pystray.MenuItem(title, None, enabled=False), - ] + def _permuter_slot(self, perm: Permuter) -> Optional[ClientSlot]: + for slot in self._client_slots: + if slot.permuter is not None and self._permuters[slot.permuter] is perm: + return slot + return None - for handle, perm in self._permuters.items(): - items.append( - pystray.MenuItem( - f"{perm.fn_name} ({perm.nickname})", - pystray.Menu( - pystray.MenuItem( - f"Iterations: {perm.iterations}", None, enabled=False - ), - pystray.MenuItem( - f"Improvements found: {perm.improvements}", - None, - enabled=False, - ), - pystray.MenuItem( - "Stop", partial(self._remove_permuter, handle) - ), - ), - ), + def _update_permuter(self, perm: Permuter, slot: ClientSlot) -> None: + self._update_item( + slot.iterations_id, + f"Iterations: {perm.iterations}", + ) + self._update_item( + slot.improvements_id, + f"Improvements found: {perm.improvements}", + ) + + def _update_status(self) -> None: + if self._status == SystrayStatus.CONNECTING: + status = "Reconnecting..." + icon = "working" + elif self._status == SystrayStatus.RECONNECTING: + status = "Disconnected, will reconnect..." + icon = "fail" + elif self._status == SystrayStatus.CONNECTED: + if self._permuters: + status = "Currently permuting:" + icon = "working" + else: + status = "Not running" + icon = "passive" + elif self._status == SystrayStatus.FAILED: + if self._fail_message: + status = f"Error: {self._fail_message}" + else: + status = "Error occurred" + icon = "fail" + else: + assert False, f"bad status {self._status}" + + self._update_item(self._status_id, status) + if self._current_icon != icon: + self._current_icon = icon + self._send( + { + "type": "update-menu", + "menu": { + "tooltip": self._MENU_TOOLTIP, + "icon": self._icons[icon], + }, + } ) - items.append(pystray.MenuItem("Quit", self._quit)) + def _fill_slots(self) -> None: + has_more = False + while True: + key = next((k for k, p in self._permuters.items() if p.slot is None), None) + if key is None: + break + chosen_slot: Optional[ClientSlot] = None + for i in range(self._CLIENT_SLOTS - 1, -1, -1): + slot = self._client_slots[i] + if slot.permuter is None: + chosen_slot = slot + elif chosen_slot is not None: + break + if chosen_slot is None: + has_more = True + break + perm = self._permuters[key] + perm.slot = chosen_slot + chosen_slot.permuter = key + self._update_permuter(perm, chosen_slot) + self._update_item( + chosen_slot.menu_id, f"{perm.fn_name} ({perm.nickname})", enabled=True + ) + self._update_item(self._more_id, "More...", hidden=not has_more) - self._update_menu(items, flush) + def _hide_slot(self, slot: ClientSlot) -> None: + if slot.permuter is not None: + self._update_item(slot.menu_id, "", hidden=True) + slot.permuter = None - def initial_update(self) -> None: - self._update() + def server_reconnecting(self) -> None: + self._status = SystrayStatus.CONNECTING + self._update_status() + + def server_connected(self) -> None: + self._status = SystrayStatus.CONNECTED + self._update_status() + + def server_failed(self, graceful: bool, message: Optional[str] = None) -> None: + self._status = SystrayStatus.RECONNECTING if graceful else SystrayStatus.FAILED + self._fail_message = message + self._permuters = {} + self._update_status() + for slot in self._client_slots: + self._hide_slot(slot) + self._fill_slots() def connect(self, handle: PermuterHandle, nickname: str, fn_name: str) -> None: - self._permuters[handle] = Permuter(nickname, fn_name) - self._update() + perm = Permuter(nickname, fn_name) + self._permuters[handle] = perm + self._fill_slots() + self._update_status() def disconnect(self, handle: PermuterHandle) -> None: + slot = self._permuters[handle].slot del self._permuters[handle] - self._update() + self._update_status() + if slot: + self._hide_slot(slot) + self._fill_slots() def work_done(self, handle: PermuterHandle, is_improvement: bool) -> None: perm = self._permuters[handle] perm.iterations += 1 if is_improvement: perm.improvements += 1 - flush = time.time() > perm.last_systray_update + SYSTRAY_UPDATE_INTERVAL - if flush: + if perm.slot and time.time() > perm.last_systray_update + self._UPDATE_INTERVAL: perm.last_systray_update = time.time() - self._update(flush) + self._update_permuter(perm, perm.slot) - def will_sleep(self) -> None: - self._update() - - def server_failed(self) -> None: - self._permuters = {} - self._update() - - -def run_with_systray( - io_queue: "queue.Queue[IoActivity]", - loop: Callable[[SystrayState], None], -) -> None: - try: - from PIL import Image - import pystray - except ModuleNotFoundError: - print( - "Systray requires the pystray and Pillow packages to be installed.\n" - "Run `python3 -m pip install --upgrade pystray Pillow`." - ) - sys.exit(1) - - menu_items: List[pystray.MenuItem] = [] - - icon = pystray.Icon( - name="permuter@home", - title="permuter@home", - icon=Image.open(os.path.join(os.path.dirname(__file__), "icon.png")), - menu=pystray.Menu(lambda: menu_items), - ) - - def update_menu(items: List[pystray.MenuItem], flush: bool) -> None: - nonlocal menu_items - menu_items = items - if flush: - icon.update_menu() - - systray = RealSystrayState(io_queue, update_menu) - systray.initial_update() - - def inner(icon: pystray.Icon) -> None: - icon.visible = True - loop(systray) - icon.stop() - - icon.run(inner) + def stop(self) -> None: + try: + self._send({"type": "exit"}) + except BrokenPipeError: + # The systray process may have been killed by Ctrl+C. + pass + self._proc.wait() + self._read_thread.join() class Reconnector: @@ -300,31 +491,35 @@ def main_loop( continue if not isinstance(activity, tuple): - if isinstance(activity, IoWillSleep): - systray.will_sleep() - - elif isinstance(activity, IoShutdown): + if isinstance(activity, IoShutdown): break elif isinstance(activity, IoReconnect): print("reconnecting...") try: + systray.server_reconnecting() reconnector.mark_start() server.start() + systray.server_connected() except EOFError: delay = reconnector.reconnect_eventually() print(f"failed again, reconnecting in {delay} seconds...") + systray.server_failed(True) except ServerError as e: print("failed!", e.message) + systray.server_failed(False, e.message) except Exception: print("failed!") traceback.print_exc() + systray.server_failed(False) elif isinstance(activity, IoServerFailed): + if activity.message: + print("Server error:", activity.message) print("disconnected from permuter@home") server.stop() reconnector.mark_stop() - systray.server_failed() + systray.server_failed(activity.graceful, activity.message) if activity.graceful: delay = reconnector.reconnect_eventually() @@ -364,18 +559,22 @@ def main_loop( def server_main(options: ServerOptions, use_systray: bool) -> None: io_queue: "queue.Queue[IoActivity]" = queue.Queue() + config = read_config() - server = Server(options, io_queue) - server.start() + systray: SystrayState + if use_systray: + systray = RealSystrayState(config, io_queue) + else: + systray = SystrayState() try: + server = Server(options, config, io_queue) + server.start() - def cmdline_ui(systray: SystrayState) -> None: + try: + systray.server_connected() main_loop(io_queue, server, systray) - - if use_systray: - run_with_systray(io_queue, cmdline_ui) - else: - cmdline_ui(SystrayState()) + finally: + server.stop() finally: - server.stop() + systray.stop() diff --git a/src/net/cmd/systray/.gitignore b/src/net/cmd/systray/.gitignore new file mode 100644 index 0000000..49f8d19 --- /dev/null +++ b/src/net/cmd/systray/.gitignore @@ -0,0 +1,2 @@ +permuter-systray +permuter-systray.exe diff --git a/src/net/cmd/systray/LICENSE b/src/net/cmd/systray/LICENSE new file mode 100644 index 0000000..dc9823b --- /dev/null +++ b/src/net/cmd/systray/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2017 Zack Young + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/src/net/cmd/systray/README.md b/src/net/cmd/systray/README.md new file mode 100644 index 0000000..5dee2f8 --- /dev/null +++ b/src/net/cmd/systray/README.md @@ -0,0 +1,13 @@ +# systray + +This directory contains a Go application that shows a system tray, which the Python code interacts with. + +It is a fork of https://github.com/felixhao28/systray-portable. + +To build it: + +- install Go +- if on Linux, install dependencies: `libgtk-3-dev`, `libappindicator3-dev` +- run `go build` + +If on Windows, this needs to be done *outside* of WSL. diff --git a/src/net/cmd/systray/go.mod b/src/net/cmd/systray/go.mod new file mode 100644 index 0000000..4abd881 --- /dev/null +++ b/src/net/cmd/systray/go.mod @@ -0,0 +1,7 @@ +module permuter-systray + +go 1.15 + +require github.com/getlantern/systray v1.1.0 + +replace github.com/getlantern/systray v1.1.0 => github.com/simonlindholm/systray v1.1.1-0.20210502122945-b7c77212cd56 diff --git a/src/net/cmd/systray/go.sum b/src/net/cmd/systray/go.sum new file mode 100644 index 0000000..bd6cf25 --- /dev/null +++ b/src/net/cmd/systray/go.sum @@ -0,0 +1,4 @@ +github.com/simonlindholm/systray v1.1.1-0.20210502122945-b7c77212cd56 h1:UZcM1HdV25CQhhJD340jxRLRGl0V11V0wIoUDKTOZMI= +github.com/simonlindholm/systray v1.1.1-0.20210502122945-b7c77212cd56/go.mod h1:N5dpnnWiJhCxh+gXuNgDS2p5MjgcVR/TGwWuaDc4gLk= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9 h1:YTzHMGlqJu67/uEo1lBv0n3wBXhXNeUbB1XfN2vmTm0= +golang.org/x/sys v0.0.0-20200515095857-1151b9dac4a9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= diff --git a/src/net/cmd/systray/tray.go b/src/net/cmd/systray/tray.go new file mode 100644 index 0000000..67c3878 --- /dev/null +++ b/src/net/cmd/systray/tray.go @@ -0,0 +1,287 @@ +package main + +import ( + "bufio" + "encoding/base64" + "encoding/json" + "fmt" + "os" + "os/signal" + "reflect" + "strings" + "syscall" + + "github.com/getlantern/systray" +) + +func main() { + systray.Run(onReady, onExit) +} + +func onExit() { + os.Exit(0) +} + +// Item represents an item in the menu +type Item struct { + Icon string `json:"icon"` + Title string `json:"title"` + Tooltip string `json:"tooltip"` + Enabled bool `json:"enabled"` + Checked bool `json:"checked"` + Hidden bool `json:"hidden"` + Items []Item `json:"items"` + InternalID int `json:"__id"` +} + +// Menu has an icon, title and list of items +type Menu struct { + Icon string `json:"icon"` + Title string `json:"title"` + Tooltip string `json:"tooltip"` + Items []Item `json:"items"` +} + +// Action for an item?.. +type Action struct { + Type string `json:"type"` + Item Item `json:"item"` + Menu Menu `json:"menu"` +} + +// ClickEvent for an click event +type ClickEvent struct { + Type string `json:"type"` + InternalID int `json:"__id"` +} + +func readJSON(reader *bufio.Reader, v interface{}) error { + input, err := reader.ReadString('\n') + if err != nil { + return err + } + if len(input) < 1 { + return fmt.Errorf("Empty line") + } + + lineReader := strings.NewReader(input[0 : len(input)-1]) + if err := json.NewDecoder(lineReader).Decode(v); err != nil { + return err + } + + return nil +} + +func addMenuItem(items *[]*systray.MenuItem, seqID2InternalID *[]int, internalID2SeqID *map[int]int, item *Item, parent *systray.MenuItem) { + if item.Title == "" { + systray.AddSeparator() + *items = append(*items, nil) + } else { + var menuItem *systray.MenuItem + if parent == nil { + menuItem = systray.AddMenuItem(item.Title, item.Tooltip) + } else { + menuItem = parent.AddSubMenuItem(item.Title, item.Tooltip) + } + if item.Checked { + menuItem.Check() + } else { + menuItem.Uncheck() + } + if item.Enabled { + menuItem.Enable() + } else { + menuItem.Disable() + } + if len(item.Icon) > 0 { + icon, err := base64.StdEncoding.DecodeString(item.Icon) + if err != nil { + fmt.Fprintln(os.Stderr, err) + } else { + menuItem.SetIcon(icon) + } + } + for i := 0; i < len(item.Items); i++ { + subitem := item.Items[i] + addMenuItem(items, seqID2InternalID, internalID2SeqID, &subitem, menuItem) + } + if item.Hidden { + menuItem.Hide() + } + *items = append(*items, menuItem) + } + seqID := len(*items) - 1 + (*internalID2SeqID)[item.InternalID] = seqID + *seqID2InternalID = append(*seqID2InternalID, item.InternalID) +} + +func onReady() { + signalChannel := make(chan os.Signal, 2) + signal.Notify(signalChannel, os.Interrupt, syscall.SIGTERM) + go func() { + for sig := range signalChannel { + switch sig { + case os.Interrupt, syscall.SIGTERM: + // handle SIGINT, SIGTERM + fmt.Fprintln(os.Stderr, "Quit") + systray.Quit() + default: + fmt.Fprintln(os.Stderr, "Unhandled signal:", sig) + } + } + }() + + items := make([]*systray.MenuItem, 0) + seqID2InternalID := make([]int, 0) + internalID2SeqID := make(map[int]int) + fmt.Println(`{"type": "ready"}`) + reader := bufio.NewReader(os.Stdin) + + var menu Menu + if err := readJSON(reader, &menu); err != nil { + fmt.Fprintln(os.Stderr, err) + systray.Quit() + return + } + + icon, err := base64.StdEncoding.DecodeString(menu.Icon) + if err != nil { + fmt.Fprintln(os.Stderr, err) + systray.Quit() + return + } + + systray.SetIcon(icon) + systray.SetTitle(menu.Title) + systray.SetTooltip(menu.Tooltip) + + updateItem := func(action Action) { + item := action.Item + seqID := internalID2SeqID[action.Item.InternalID] + menuItem := items[seqID] + if menuItem == nil { + return + } + if item.Hidden { + menuItem.Hide() + } else { + if item.Checked { + menuItem.Check() + } else { + menuItem.Uncheck() + } + if item.Enabled { + menuItem.Enable() + } else { + menuItem.Disable() + } + menuItem.SetTitle(item.Title) + menuItem.SetTooltip(item.Tooltip) + if len(item.Icon) > 0 { + icon, err := base64.StdEncoding.DecodeString(item.Icon) + if err != nil { + fmt.Fprintln(os.Stderr, err) + } else { + menuItem.SetIcon(icon) + } + } + menuItem.Show() + for _, child := range item.Items { + seqID = internalID2SeqID[child.InternalID] + items[seqID].Show() + } + } + } + + updateMenu := func(action Action) { + m := action.Menu + if menu.Title != m.Title { + menu.Title = m.Title + systray.SetTitle(menu.Title) + } + if menu.Icon != m.Icon && m.Icon != "" { + menu.Icon = m.Icon + icon, err := base64.StdEncoding.DecodeString(menu.Icon) + if err != nil { + fmt.Fprintln(os.Stderr, err) + } else { + systray.SetIcon(icon) + } + } + if menu.Tooltip != m.Tooltip { + menu.Tooltip = m.Tooltip + systray.SetTooltip(menu.Tooltip) + } + } + + update := func(action Action) { + switch action.Type { + case "update-item": + updateItem(action) + case "update-menu": + updateMenu(action) + case "update-item-and-menu": + updateItem(action) + updateMenu(action) + case "exit": + systray.Quit() + } + } + + for i := 0; i < len(menu.Items); i++ { + item := menu.Items[i] + addMenuItem(&items, &seqID2InternalID, &internalID2SeqID, &item, nil) + } + + go func(reader *bufio.Reader) { + for { + var action Action + if err := readJSON(reader, &action); err != nil { + fmt.Fprintln(os.Stderr, err) + systray.Quit() + break + } + update(action) + } + }(reader) + + stdoutEnc := json.NewEncoder(os.Stdout) + for { + itemsCnt := 0 + for _, ch := range items { + if ch != nil { + itemsCnt++ + } + } + cases := make([]reflect.SelectCase, itemsCnt) + caseCnt2SeqID := make([]int, len(items)) + itemsCnt = 0 + for i, ch := range items { + if ch == nil { + continue + } + cases[itemsCnt] = reflect.SelectCase{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(ch.ClickedCh)} + caseCnt2SeqID[itemsCnt] = i + itemsCnt++ + } + + remaining := len(cases) + for remaining > 0 { + chosen, _, ok := reflect.Select(cases) + if !ok { + // The chosen channel has been closed, so zero out the channel to disable the case + cases[chosen].Chan = reflect.ValueOf(nil) + remaining-- + continue + } + seqID := caseCnt2SeqID[chosen] + err := stdoutEnc.Encode(ClickEvent{ + Type: "clicked", + InternalID: seqID2InternalID[seqID], + }) + if err != nil { + fmt.Fprintln(os.Stderr, err) + } + } + } +} diff --git a/src/net/evaluator.py b/src/net/evaluator.py index 845bf45..e9138a6 100644 --- a/src/net/evaluator.py +++ b/src/net/evaluator.py @@ -291,13 +291,14 @@ def main() -> None: p = Process( target=multiprocess_worker, args=(worker_queue, local_queue, task_queue), + daemon=True, ) - p.daemon = True p.start() local_queues.append(local_queue) - reader_thread = threading.Thread(target=read_loop, args=(task_queue, port)) - reader_thread.daemon = True + reader_thread = threading.Thread( + target=read_loop, args=(task_queue, port), daemon=True + ) reader_thread.start() remaining_work: Counter[str] = Counter() diff --git a/src/net/server.py b/src/net/server.py index eea7a59..65d0c0b 100644 --- a/src/net/server.py +++ b/src/net/server.py @@ -19,8 +19,10 @@ import nacl.utils from ..helpers import exception_to_string, static_assert_unreachable from .core import ( CancelToken, + Config, PermuterData, Port, + ServerError, SocketPort, connect, file_read_fixed, @@ -102,6 +104,7 @@ class NeedMoreWork: @dataclass class NetThreadDisconnected: graceful: bool + message: Optional[str] = None class Heartbeat: @@ -195,6 +198,7 @@ class IoUserRemovePermuter: @dataclass class IoServerFailed: graceful: bool + message: Optional[str] class IoReconnect: @@ -205,10 +209,6 @@ class IoShutdown: pass -class IoWillSleep: - pass - - @dataclass class IoWorkDone: score: Optional[int] @@ -219,7 +219,7 @@ PermuterHandle = Tuple[int, CancelToken] IoMessage = Union[ IoConnect, IoDisconnect, IoImmediateDisconnect, IoUserRemovePermuter, IoWorkDone ] -IoGlobalMessage = Union[IoReconnect, IoShutdown, IoServerFailed, IoWillSleep] +IoGlobalMessage = Union[IoReconnect, IoShutdown, IoServerFailed] IoActivity = Tuple[ Optional[CancelToken], Union[Tuple[PermuterHandle, IoMessage], IoGlobalMessage] ] @@ -250,12 +250,10 @@ class NetThread: self._controller_queue = queue.Queue() self._next_work_id = 0 - self._read_thread = threading.Thread(target=self.read_loop) - self._read_thread.daemon = True + self._read_thread = threading.Thread(target=self.read_loop, daemon=True) self._read_thread.start() - self._write_thread = threading.Thread(target=self.write_loop) - self._write_thread.daemon = True + self._write_thread = threading.Thread(target=self.write_loop, daemon=True) self._write_thread.start() def stop(self) -> None: @@ -336,6 +334,10 @@ class NetThread: self._main_queue.put(msg) except EOFError: self._main_queue.put(NetThreadDisconnected(graceful=True)) + except ServerError as e: + self._main_queue.put( + NetThreadDisconnected(graceful=False, message=e.message) + ) except Exception: traceback.print_exc() self._main_queue.put(NetThreadDisconnected(graceful=False)) @@ -452,19 +454,20 @@ class ServerInner: self._last_heartbeat = time.time() self._last_heartbeat_lock = threading.Lock() self._heartbeat_stop = threading.Event() - self._heartbeat_thread = threading.Thread(target=self._heartbeat_loop) - self._heartbeat_thread.daemon = True + self._heartbeat_thread = threading.Thread( + target=self._heartbeat_loop, daemon=True + ) self._heartbeat_thread.start() # Start a thread for reading evaluator results and sending them on to # the main loop queue. - self._read_eval_thread = threading.Thread(target=self._read_eval_loop) - self._read_eval_thread.daemon = True + self._read_eval_thread = threading.Thread( + target=self._read_eval_loop, daemon=True + ) self._read_eval_thread.start() # Start a thread for the main loop. - self._main_thread = threading.Thread(target=self._main_loop) - self._main_thread.daemon = True + self._main_thread = threading.Thread(target=self._main_loop, daemon=True) self._main_thread.start() def _send_controller(self, msg: Output) -> None: @@ -590,7 +593,7 @@ class ServerInner: self._need_work() elif isinstance(msg, NetThreadDisconnected): - self._send_io_global(IoServerFailed(msg.graceful)) + self._send_io_global(IoServerFailed(msg.graceful, msg.message)) else: static_assert_unreachable(msg) @@ -674,9 +677,6 @@ class ServerInner: self._handle_message(msg) - if not self._active and self._main_queue.empty(): - self._send_io_global(IoWillSleep()) - def _heartbeat_loop(self) -> None: second_attempt = False while True: @@ -894,19 +894,24 @@ class Server: _server: Optional[ServerInner] _options: ServerOptions + _config: Config _io_queue: "queue.Queue[IoActivity]" def __init__( - self, options: ServerOptions, io_queue: "queue.Queue[IoActivity]" + self, + options: ServerOptions, + config: Config, + io_queue: "queue.Queue[IoActivity]", ) -> None: self._server = None self._options = options + self._config = config self._io_queue = io_queue def start(self) -> None: assert self._server is None - net_port = connect() + net_port = connect(self._config) net_port.send_json( { "method": "connect_server",