0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

pywebview x reflex でマルチウィンドウなデスクトップアプリを試す

0
Posted at

近年、python単体でwebアプリ開発できるフレームワークが増えているらしい。

HTMLコンテンツをGUIで表示できるpywebviewと組み合わせることで、pythonにおけるデスクトップアプリ開発の選択肢が大幅に広がりそうな気がする。
特にマルチウィンドウで動作させられるとデスクトップアプリとしての意味も出そうなので、まずはreflexで試す。

pywebview、reflexについてはそれぞれ次の記事を参照。

なお、webアプリフレームワークのNiceGuiはpywebviewを利用することでデスクトップアプリ開発もサポートしているらしい。
参考: https://nicegui.io/documentation/section_configuration_deployment#native_mode

基本的な方針

  • reflexを別プロセスで動作させ、URL指定でpywebviewにその内容を表示する
  • ウィンドウ操作はプロセス間通信を通してpywebview側のプロセスで実行する

ソースコード

フォルダ構成

実験用に、reflex init --name demo_app --template blankで生成されたファイルにpywebview用のmain.pyを追加し、demo_app.pyを編集した。

.
├─main.py
├─rxconfig.py
├─requirements.txt
└─demo_app
   ├─__init__.py
   └─demo_app.py

main.py

reflex、pywebviewの両方を起動、協調させるためのコード。

main.py(一部抜粋)
def main():
    server_process = subprocess.Popen(
        [sys.executable, "-m", "reflex", "run", f"--frontend-port={REFLEX_PORT}"],
        cwd=".",
        shell=False,
    )
    atexit.register(lambda p=server_process: kill_process(p))

    if not wait_for_server("127.0.0.1", REFLEX_PORT):
        return

    threading.Thread(target=recv_socket_message, daemon=True).start()

    webview.create_window(
        title="Multi Window Demo",
        url=f"http://127.0.0.1:{REFLEX_PORT}",
        x=0,
        y=0,
        width=800,
        height=400,
    )
    webview.start(debug=False)

def recv_socket_message():
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    atexit.register(sock.close)
    sock.bind(("127.0.0.1", SOCK_PORT))
    sock.listen()

    while True:
        try:
            conn, addr = sock.accept()
            data = conn.recv(1024)
            conn.close()
        except Exception:
            traceback.print_exc()
            continue
        if not data:
            continue

        msg = data.decode()
        print(f"Received message from server: {msg}")

        webview.create_window(
            title="Sub Window",
            url=f"http://127.0.0.1:{REFLEX_PORT}/incrementer",
            width=400,
            height=200,
            x=400,
            y=200,
        )

説明:

  • reflexはsubprocess.Popen()で別プロセスで動作
  • reflex側とののプロセス通信はthreading.Thread()で別スレッドで実施
    • 今回はプロセス通信方法としてソケット通信を採用
  • メッセージを通してウィンドウを操作することで、reflex側と協調
コード全体
main.py(コード全体)
import atexit
import os
import signal
import socket
import subprocess
import sys
import threading
import time
import traceback

import requests
import webview

REFLEX_PORT = 3000
SOCK_PORT = 65432


def main():
    server_process = subprocess.Popen(
        [sys.executable, "-m", "reflex", "run", f"--frontend-port={REFLEX_PORT}"],
        cwd=".",
        shell=False,
    )
    atexit.register(lambda p=server_process: kill_process(p))

    if not wait_for_server("127.0.0.1", REFLEX_PORT):
        return

    threading.Thread(target=recv_socket_message, daemon=True).start()

    webview.create_window(
        title="Multi Window Demo",
        url=f"http://127.0.0.1:{REFLEX_PORT}",
        x=0,
        y=0,
        width=800,
        height=400,
    )
    webview.start(debug=False)


def kill_process(process: subprocess.Popen):
    os.kill(process.pid, signal.CTRL_C_EVENT)
    print(f"Sending ctrl+c event to process {process.pid}.")


def wait_for_server(
    host: str = "127.0.0.1", port: int = REFLEX_PORT, max_retries: int = 60
) -> bool:
    print("Checking HTTP response...")
    for i in range(max_retries):
        try:
            response = requests.get(f"http://{host}:{port}/", timeout=2)
            print(f"Server responded with status {response.status_code}")
            return True
        except requests.exceptions.ConnectionError:
            pass
        except Exception as e:
            print(f"Request error: {e}")
        time.sleep(1)
    print("Failed to get HTTP response")
    return False


def recv_socket_message():
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    atexit.register(sock.close)
    sock.bind(("127.0.0.1", SOCK_PORT))
    sock.listen()

    while True:
        try:
            conn, addr = sock.accept()
            data = conn.recv(1024)
            conn.close()
        except Exception:
            traceback.print_exc()
            continue
        if not data:
            continue

        msg = data.decode()
        print(f"Received message from server: {msg}")

        webview.create_window(
            title="Sub Window",
            url=f"http://127.0.0.1:{REFLEX_PORT}/incrementer",
            width=400,
            height=200,
            x=400,
            y=200,
        )


if __name__ == "__main__":
    main()

demo_app/demo_app.py

アプリで表示する複数のウィンドウを実装したもの。

demo_app/demo_app.py
import socket

import reflex as rx

SOCK_PORT = 65432


def send_message(message: str):
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.connect(("127.0.0.1", SOCK_PORT))
    sock.sendall(message.encode())
    sock.close()


class State(rx.State):
    count: str = rx.LocalStorage("0", sync=True)

    @rx.event
    def create_incrementer_window(self):
        send_message("create_incrementer_window")

    @rx.event
    def increment(self):
        self.count = str(int(self.count) + 1)


def index() -> rx.Component:
    # Main window
    return rx.container(
        rx.vstack(
            rx.heading("Multi Window Demo", size="9"),
            rx.text("Count: ", State.count, size="5"),
            rx.button("Create Incrementer", on_click=State.create_incrementer_window),
        ),
    )


def incrementer() -> rx.Component:
    # Sub window
    return rx.container(
        rx.vstack(
            rx.heading("Incrementer Window", size="6"),
            rx.text("Count: ", State.count, size="5"),
            rx.button("Increment", on_click=State.increment()),
        ),
    )


app = rx.App()
app.add_page(index)
app.add_page(incrementer)

説明:

  • rx.App()indexincrementerページをそれぞれ登録
    • indexページがメインウィンドウ、incrementerページがサブウィンドウに対応
  • State.create_incrementer_window イベントでソケットにメッセージを送り、メインプロセス(pywebview)へサブウィンドウ生成を依頼
  • rx.Stateクラスを継承したクラスのプロパティにrx.LocalStorage(..., sync=True)を指定することで、複数ウィンドウ間で状態を同期

動作イメージ

ezgif-6747d0138dade269.gif

図. マルチウィンドウの動作イメージ。以下の操作を行っている。

  1. python main.pyでGUIを起動
  2. メインウィンドウのボタンクリックでサブウィンドウを生成
  3. サブウィンドウのボタンクリックでカウンターの値をインクリメント

マルチウィンドウの動作、状態の共有ともに問題なくできていることが分かる。

まとめ

pywebviewを使えばwebアプリを気軽にデスクトップアプリ化できることが分かった。
特に、プロセス通信を経由すればウィンドウ操作なども自由にできる。

今回はreflexを試したが、他のwebアプリでも同様にできるものと思われる。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?