Open6

Tauri v2のSidecarでPythonを使う

こるりりこるりり

PyInstallerで実行したいPythonファイルをバイナリ化する

$ pip install pyinstaller
$ pyinstaller --onefile --name my-sidecar double.py

Intel MacとAppleシリコンMacはMacから作成できるが、Windows向けexeはWindows機で実行する必要あり
それぞれファイル名にターゲットをつけて指定すると勝手に適切なものを読んでくれるらしい

こるりりこるりり

バイナリへの引数の受け渡しは
権限ファイルに指定する。任意の文字列の引数を渡したいなら

    {
      "identifier": "shell:allow-execute",
      "allow": [
        {
          "args": [
            {
              "validator": "\\S+"
            },
            {
              "validator": "\\S+"
            }
          ],
          "name": "binaries/sidecar-test",
          "sidecar": true
        }
      ]
    },

って書いて、
TypeScript側で呼び出すなら

const command = Command.sidecar('binaries/sidecar-test', [ "arg1", "arg2" ]);
const output = await command.execute();

Rustなら

use tauri_plugin_shell::ShellExt;
#[tauri::command]
async fn call_sidecar(app: tauri::AppHandle) {
  let sidecar_command = app
    .shell()
    .sidecar("sidecar-test")
    .unwrap()
    .args(["arg1", "arg2"]);
  let (mut _rx, mut _child) = sidecar_command.spawn().unwrap();
}
こるりりこるりり

Tauri -> Pythonのファイル受け渡し

(画像ファイル)

  • Base64をそのままargで渡すと大きなファイルのときに長すぎてメモリ使いすぎになる

たぶん一時ファイルにするのがいい

import { BaseDirectory, tempDir } from "@tauri-apps/api/path";
import { writeTextFile } from "@tauri-apps/plugin-fs";
import { Command } from "@tauri-apps/plugin-shell";

async function runPython(base64: string) {
  const filename = `image_${Date.now()}.b64`;
  
  try {
    await writeTextFile(filename, base64, { baseDir: BaseDirectory.Temp });
    const tempDirPath = await tempDir();
    const fullPath = `${tempDirPath}${filename}`;
    
    const command = Command.sidecar('binaries/sidecar-test', [fullPath]);
    return await command.execute();
  } catch (error) {
    throw error;
  } finally {
    await remove(filename, { baseDir: BaseDirectory.Temp });
  }
}

/** component **/
  const handleSendImage = async () => {
    setLoading(true);
    try {
      const response = await runPython(base64);
      console.log(response.stdout);
    } catch (error) {
      console.error(error);
    } finally {
      setLoading(false);
    }
  };
capabilities
    {
      "identifier": "shell:allow-execute",
      "allow": [
        {
          "args": [
            {
              "validator": "\\S+"
            }
          ],
          "name": "binaries/sidecar-test",
          "sidecar": true
        }
      ]
    },
    {
      "identifier": "fs:allow-write-text-file",
      "allow": [{ "path": "$TEMP/*" }]
    },
    {
      "identifier": "fs:allow-remove",
      "allow": [{ "path": "$TEMP/*" }]
    }

pythonからは普通にsys.argv[1]にfile pathが渡されるのでそれを読めばいい。

こるりりこるりり

Pythonの実行が遅い

  • ライブラリのインポートが遅い。毎回execute()すると処理が遅すぎる。
  • PythonでHTTPサーバーを立てて、Tauriマウント時に起動して常駐させておけばすぐ応答するのでは?
  • →うまくいった!爆速になった。ビルドしてもちゃんと動くのを確認済み(windowsはまだ未確認)
  • (これも一時ファイル作ってファイルパス渡したほうがいいのかも)
usePythonServer.ts
import { listen } from "@tauri-apps/api/event";
import { Child, Command } from "@tauri-apps/plugin-shell";
import { useEffect, useRef, useState } from "react";

type ServerStatus = 
	| { state: 'loading' }
	| { state: 'ready' }
	| { state: 'error'; message: string };

const usePythonServer = () => {
	const [serverStatus, setServerStatus] = useState<ServerStatus>({ state: 'loading' });
	const processRef = useRef<Child | null>(null);
	const isShuttingDownRef = useRef<boolean>(false);

	const waitForServer = async () => {
		for (let i = 0; i < 50; i++) {
			try {
				const response = await fetch("http://127.0.0.1:5001/health");
				if (response.ok) return;
			} catch (error) {
				console.warn("サーバー起動中:", error);
			}
			await new Promise(resolve => setTimeout(resolve, 400));
		}
		
		throw new Error("サーバー起動タイムアウト: ポート5001が既に使用されている可能性があります");
	};

	useEffect(() => {
		let closeUnlisten: (() => void) | null = null;
		let destroyedUnlisten: (() => void) | null = null;

		const startServer = async () => {
			const command = Command.sidecar('binaries/sidecar-test');
			
			try {
				const process = await command.spawn();
				processRef.current = process;
				
				await waitForServer();
				setServerStatus({ state: 'ready' });
			} catch (error) {
				console.error("サーバー起動エラー:", error);
				setServerStatus({ state: 'error', message: `起動エラー: ${error}` });
			}
		};
		
		const shutdownServer = async () => {
			if (isShuttingDownRef.current) return;
			isShuttingDownRef.current = true;
			
			try {
				await fetch("http://127.0.0.1:5001/shutdown", {
					method: "POST",
					headers: { "Content-Type": "application/json" }
				});
			} catch (error) {
				console.error("シャットダウンエラー:", error);
			}
		};
		
		const setupListeners = async () => {
			closeUnlisten = await listen('tauri://close-requested', shutdownServer);
			destroyedUnlisten = await listen('tauri://destroyed', shutdownServer);
		};
		
		startServer();
		setupListeners();

		return () => {
			closeUnlisten?.();
			destroyedUnlisten?.();
		};
	}, []);

	const runPython = async (base64: string) => {
		const response = await fetch("http://127.0.0.1:5001/process", {
			method: "POST",
			headers: { "Content-Type": "application/json" },
			body: JSON.stringify({ image: base64 }),
		});
		
		if (!response.ok) {
			throw new Error(`HTTP ${response.status}: ${response.statusText}`);
		}
		
		return await response.json();
	};

	return {
		serverStatus,
		runPython,
	};
};

export default usePythonServer;
python
import base64
import io
import os
import signal
import socket
import sys
import threading
from flask import Flask, request, jsonify
from flask_cors import CORS
from PIL import Image


class ImageServer:
    def __init__(self, host="127.0.0.1", port=5001):
        self.host = host
        self.port = port
        self.shutdown_requested = False
        self.app = self._create_app()
        
    def _create_app(self):
        app = Flask(__name__)
        
        CORS(app, 
            origins=["http://localhost:1420", "https://tauri.localhost", "tauri://localhost"],
            methods=["GET", "POST", "OPTIONS"],
            allow_headers=["Content-Type"],
            supports_credentials=True
        )
        
        app.add_url_rule("/health", "health", self.health, methods=["GET"])
        app.add_url_rule("/shutdown", "shutdown", self.shutdown, methods=["POST"])
        app.add_url_rule("/process", "process", self.process, methods=["POST"])
        
        return app
    
    def health(self):
        return jsonify({"status": "ok"})
    
    def shutdown(self):
        if self.shutdown_requested:
            return jsonify({"status": "already_shutting_down", "message": "シャットダウン中です"})
        
        self.shutdown_requested = True
        
        threading.Timer(0.1, lambda: os._exit(0)).start()
        
        return jsonify({"status": "shutting_down", "message": "サーバーを終了しています..."})
    
    def process(self):
        if self.shutdown_requested:
            return jsonify({"status": "error", "message": "サーバーが終了しています"}), 503
        
        try:
            image_b64 = request.json["image"]
            image_data = base64.b64decode(image_b64)
            image = Image.open(io.BytesIO(image_data))
            
            width, height = image.size
            file_size = len(image_data)
            
            return jsonify({
                "status": "ok",
                "result": "processed",
                "image_info": {
                    "width": width,
                    "height": height,
                    "file_size_bytes": file_size,
                    "format": image.format
                }
            })
            
        except Exception as e:
            return jsonify({"status": "error", "message": str(e)}), 400
    
    def is_port_available(self):
        try:
            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
                sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
                sock.bind((self.host, self.port))
                return True
        except OSError:
            return False
    
    def setup_signal_handlers(self):
        def signal_handler(signum):
            print(f"シグナル {signum} を受信。終了します...", file=sys.stderr)
            self.shutdown_requested = True
            os._exit(0)
        
        signal.signal(signal.SIGTERM, signal_handler)
        signal.signal(signal.SIGINT, signal_handler)
    
    def run(self):
        if not self.is_port_available():
            print(f"エラー: ポート {self.port} は既に使用されています。", file=sys.stderr)
            sys.exit(1)
        
        self.setup_signal_handlers()
        
        print(f"Python Flask サーバーを {self.host}:{self.port} で起動します...", file=sys.stderr)
        
        try:
            self.app.run(host=self.host, port=self.port, debug=False, threaded=True)
        except KeyboardInterrupt:
            print("キーボード割り込みで終了します...", file=sys.stderr)
            sys.exit(0)
        except Exception as e:
            print(f"サーバー起動エラー: {e}", file=sys.stderr)
            sys.exit(1)


if __name__ == "__main__":
    server = ImageServer()
    server.run()
capabilities
{
      "identifier": "shell:allow-spawn",
      "allow": [
        {
          "name": "binaries/sidecar-test",
          "sidecar": true
        }
      ]
    },
    {
      "identifier": "shell:allow-kill",
      "allow": [
        {
          "name": "binaries/sidecar-test",
          "sidecar": true
        }
      ]
    },
    "core:window:allow-destroy",
    "core:window:allow-close",
    {
      "identifier": "fs:allow-write-text-file",
      "allow": [{ "path": "$TEMP/*" }]
    },
    {
      "identifier": "fs:allow-remove",
      "allow": [{ "path": "$TEMP/*" }]
    }