mineconnect 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- mineconnect-1.0.0/MANIFEST.in +4 -0
- mineconnect-1.0.0/PKG-INFO +17 -0
- mineconnect-1.0.0/README.md +160 -0
- mineconnect-1.0.0/mineconnect/__init__.py +44 -0
- mineconnect-1.0.0/mineconnect/creds/d75d9307-b76a-410d-9692-16c3cd0926b7.json +1 -0
- mineconnect-1.0.0/mineconnect/creds/f06e9c55-aab2-455b-81d5-08423eb92a39.json +1 -0
- mineconnect-1.0.0/mineconnect/server.py +184 -0
- mineconnect-1.0.0/mineconnect/tcp_bridge.py +161 -0
- mineconnect-1.0.0/mineconnect/tunnel_bedrock.yml +10 -0
- mineconnect-1.0.0/mineconnect/tunnel_combined.yml +13 -0
- mineconnect-1.0.0/mineconnect/tunnel_java.yml +10 -0
- mineconnect-1.0.0/mineconnect/tunnel_setup.py +99 -0
- mineconnect-1.0.0/mineconnect/udp_bridge.py +112 -0
- mineconnect-1.0.0/mineconnect.egg-info/PKG-INFO +17 -0
- mineconnect-1.0.0/mineconnect.egg-info/SOURCES.txt +20 -0
- mineconnect-1.0.0/mineconnect.egg-info/dependency_links.txt +1 -0
- mineconnect-1.0.0/mineconnect.egg-info/entry_points.txt +2 -0
- mineconnect-1.0.0/mineconnect.egg-info/requires.txt +1 -0
- mineconnect-1.0.0/mineconnect.egg-info/top_level.txt +1 -0
- mineconnect-1.0.0/requirements.txt +1 -0
- mineconnect-1.0.0/setup.cfg +4 -0
- mineconnect-1.0.0/setup.py +34 -0
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mineconnect
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Minecraft TCP/UDP to WebSocket bridge with Cloudflare tunnel support
|
|
5
|
+
Author: MineConnect
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Requires-Python: >=3.8
|
|
12
|
+
Requires-Dist: websockets>=12.0
|
|
13
|
+
Dynamic: author
|
|
14
|
+
Dynamic: classifier
|
|
15
|
+
Dynamic: requires-dist
|
|
16
|
+
Dynamic: requires-python
|
|
17
|
+
Dynamic: summary
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# MineConnect - Installation & Usage
|
|
2
|
+
|
|
3
|
+
A Python module for bridging Minecraft Java and Bedrock connections through WebSocket to Cloudflare tunnels.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
### From Source (Development)
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
cd c:\Projects\MineConnect\server
|
|
11
|
+
pip install -e .
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
### From PyPI (Future)
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pip install mineconnect
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## One-Time Setup
|
|
21
|
+
|
|
22
|
+
Before using MineConnect, run the tunnel setup (requires cloudflared installed):
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
mineconnect-setup
|
|
26
|
+
```
|
|
27
|
+
|
|
28
|
+
Or:
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
python -m mineconnect.tunnel_setup
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
This will:
|
|
35
|
+
1. Authenticate with Cloudflare
|
|
36
|
+
2. Create two tunnels (java and bedrock)
|
|
37
|
+
3. Configure DNS routing
|
|
38
|
+
4. Update configuration files
|
|
39
|
+
|
|
40
|
+
## Usage
|
|
41
|
+
|
|
42
|
+
### Simple API
|
|
43
|
+
|
|
44
|
+
```python
|
|
45
|
+
from mineconnect import mcs
|
|
46
|
+
|
|
47
|
+
# Use default ports (25565 for Java, 19132 for Bedrock)
|
|
48
|
+
session = mcs.config()
|
|
49
|
+
session.start()
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
### Custom Ports
|
|
53
|
+
|
|
54
|
+
```python
|
|
55
|
+
from mineconnect import mcs
|
|
56
|
+
|
|
57
|
+
# Custom Minecraft server ports
|
|
58
|
+
session = mcs.config(tcp_port=25566, udp_port=19133)
|
|
59
|
+
session.start()
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
### Single Tunnel Mode
|
|
63
|
+
|
|
64
|
+
```python
|
|
65
|
+
from mineconnect import mcs
|
|
66
|
+
|
|
67
|
+
# Use one tunnel with two ingress rules instead of two separate tunnels
|
|
68
|
+
session = mcs.config(tcp_port=25565, udp_port=19132, single_tunnel=True)
|
|
69
|
+
session.start()
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
### Full Example
|
|
73
|
+
|
|
74
|
+
```python
|
|
75
|
+
from mineconnect import mcs
|
|
76
|
+
|
|
77
|
+
def main():
|
|
78
|
+
# Configure server with custom ports
|
|
79
|
+
session = mcs.config(
|
|
80
|
+
tcp_port=25565, # Minecraft Java Edition port
|
|
81
|
+
udp_port=19132, # Geyser/Bedrock port
|
|
82
|
+
single_tunnel=False # Use two separate tunnels
|
|
83
|
+
)
|
|
84
|
+
|
|
85
|
+
# Start server - blocks and shows logs until Ctrl+C
|
|
86
|
+
try:
|
|
87
|
+
session.start()
|
|
88
|
+
except KeyboardInterrupt:
|
|
89
|
+
print("Server stopped by user")
|
|
90
|
+
|
|
91
|
+
if __name__ == "__main__":
|
|
92
|
+
main()
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
## Parameters
|
|
96
|
+
|
|
97
|
+
### `mcs.config(tcp_port, udp_port, single_tunnel)`
|
|
98
|
+
|
|
99
|
+
- **tcp_port** (int, default: 25565): Port where your Minecraft Java Edition server is running
|
|
100
|
+
- **udp_port** (int, default: 19132): Port where your Geyser/Bedrock server is running
|
|
101
|
+
- **single_tunnel** (bool, default: False): Use single tunnel with two ingress rules instead of two separate tunnels
|
|
102
|
+
|
|
103
|
+
### `session.start()`
|
|
104
|
+
|
|
105
|
+
Starts all services:
|
|
106
|
+
- TCP WebSocket bridge (port 8764)
|
|
107
|
+
- UDP WebSocket bridge (port 8765)
|
|
108
|
+
- Cloudflared tunnel(s)
|
|
109
|
+
|
|
110
|
+
Logs are displayed in real-time. Press Ctrl+C to stop all services gracefully.
|
|
111
|
+
|
|
112
|
+
## Prerequisites
|
|
113
|
+
|
|
114
|
+
1. **Python 3.8+**
|
|
115
|
+
2. **cloudflared**: Install from https://developers.cloudflare.com/cloudflare-one/connections/connect-apps/install-and-setup/installation/
|
|
116
|
+
3. **Minecraft Java Server**: Running on specified tcp_port (default: localhost:25565)
|
|
117
|
+
4. **Geyser** (for Bedrock support): Running on specified udp_port (default: localhost:19132)
|
|
118
|
+
|
|
119
|
+
## Architecture
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
Minecraft Client → Cloudflare Tunnel → WebSocket Bridge → Local MC Server
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
- **TCP Bridge**: Handles Java Edition connections via persistent WebSocket
|
|
126
|
+
- **UDP Bridge**: Handles Bedrock Edition connections via WebSocket
|
|
127
|
+
- **Cloudflared**: Creates secure tunnels from Cloudflare edge to local bridges
|
|
128
|
+
|
|
129
|
+
## Troubleshooting
|
|
130
|
+
|
|
131
|
+
### "Cannot start TCP-WS-Bridge" or "Cannot start UDP-WS-Bridge"
|
|
132
|
+
|
|
133
|
+
Make sure the websockets package is installed:
|
|
134
|
+
```bash
|
|
135
|
+
pip install websockets
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
### "Cannot reach MC" errors
|
|
139
|
+
|
|
140
|
+
Ensure your Minecraft server is running on the configured port before starting MineConnect.
|
|
141
|
+
|
|
142
|
+
### Cloudflared not found
|
|
143
|
+
|
|
144
|
+
Install cloudflared and make sure it's in your PATH:
|
|
145
|
+
- Windows: Download from Cloudflare and add to PATH
|
|
146
|
+
- Linux: `wget https://github.com/cloudflare/cloudflared/releases/latest/download/cloudflared-linux-amd64`
|
|
147
|
+
- macOS: `brew install cloudflared`
|
|
148
|
+
|
|
149
|
+
## Development
|
|
150
|
+
|
|
151
|
+
To modify the package:
|
|
152
|
+
|
|
153
|
+
1. Clone the repository
|
|
154
|
+
2. Install in editable mode: `pip install -e server/`
|
|
155
|
+
3. Make changes to files in `server/mineconnect/`
|
|
156
|
+
4. Changes take effect immediately
|
|
157
|
+
|
|
158
|
+
## License
|
|
159
|
+
|
|
160
|
+
MIT License
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MineConnect - Minecraft Server Connection Bridge
|
|
3
|
+
|
|
4
|
+
Simple API for bridging Minecraft Java and Bedrock connections through
|
|
5
|
+
WebSocket to Cloudflare tunnels.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
from mineconnect import mcs
|
|
9
|
+
|
|
10
|
+
session = mcs.config(tcp_port=25565, udp_port=19132, single_tunnel=False)
|
|
11
|
+
session.start()
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from .server import MineConnectServer
|
|
15
|
+
|
|
16
|
+
__version__ = "1.0.0"
|
|
17
|
+
__all__ = ["MineConnectServer", "mcs"]
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class _MCS:
|
|
21
|
+
"""Simple configuration wrapper for MineConnect."""
|
|
22
|
+
|
|
23
|
+
@staticmethod
|
|
24
|
+
def config(tcp_port=25565, udp_port=19132, single_tunnel=False):
|
|
25
|
+
"""
|
|
26
|
+
Configure a MineConnect server session.
|
|
27
|
+
|
|
28
|
+
Args:
|
|
29
|
+
tcp_port (int): Minecraft Java Edition server port (default: 25565)
|
|
30
|
+
udp_port (int): Geyser/Bedrock server port (default: 19132)
|
|
31
|
+
single_tunnel (bool): Use single tunnel with two ingress rules (default: False)
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
MineConnectServer: Configured server instance
|
|
35
|
+
"""
|
|
36
|
+
return MineConnectServer(
|
|
37
|
+
tcp_port=tcp_port,
|
|
38
|
+
udp_port=udp_port,
|
|
39
|
+
single_tunnel=single_tunnel
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# Export the main API
|
|
44
|
+
mcs = _MCS()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"AccountTag":"7b8aa8d0e13cb1182b8c73cd58bd2b6c","TunnelSecret":"Tzu9w16Q40cmcBD11Yc0Rj8Bv9EOdDiwH1436kRe9nk=","TunnelID":"d75d9307-b76a-410d-9692-16c3cd0926b7","Endpoint":""}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"AccountTag":"7b8aa8d0e13cb1182b8c73cd58bd2b6c","TunnelSecret":"TvzSIyTh1nveCeMlj3SdEOlQ3WxxxlGFApqAeIkRhtE=","TunnelID":"f06e9c55-aab2-455b-81d5-08423eb92a39","Endpoint":""}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MineConnect Server — Main server orchestration class
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
import subprocess
|
|
6
|
+
import sys
|
|
7
|
+
import os
|
|
8
|
+
import time
|
|
9
|
+
import signal
|
|
10
|
+
import threading
|
|
11
|
+
import logging
|
|
12
|
+
from typing import Optional, List, Tuple
|
|
13
|
+
|
|
14
|
+
logging.basicConfig(
|
|
15
|
+
level=logging.INFO,
|
|
16
|
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MineConnectServer:
|
|
21
|
+
"""
|
|
22
|
+
Main server class that orchestrates TCP/UDP bridges and cloudflared tunnels.
|
|
23
|
+
|
|
24
|
+
Usage:
|
|
25
|
+
server = MineConnectServer(tcp_port=25565, udp_port=19132)
|
|
26
|
+
server.start() # Blocks until stopped with Ctrl+C
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, tcp_port: int = 25565, udp_port: int = 19132,
|
|
30
|
+
single_tunnel: bool = False, ws_tcp_port: int = 8764,
|
|
31
|
+
ws_udp_port: int = 8765):
|
|
32
|
+
"""
|
|
33
|
+
Initialize MineConnect server configuration.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
tcp_port: Minecraft Java Edition server port (default: 25565)
|
|
37
|
+
udp_port: Geyser/Bedrock server port (default: 19132)
|
|
38
|
+
single_tunnel: Use one tunnel with two ingress rules (default: False)
|
|
39
|
+
ws_tcp_port: WebSocket port for TCP bridge (default: 8764)
|
|
40
|
+
ws_udp_port: WebSocket port for UDP bridge (default: 8765)
|
|
41
|
+
"""
|
|
42
|
+
self.tcp_port = tcp_port
|
|
43
|
+
self.udp_port = udp_port
|
|
44
|
+
self.single_tunnel = single_tunnel
|
|
45
|
+
self.ws_tcp_port = ws_tcp_port
|
|
46
|
+
self.ws_udp_port = ws_udp_port
|
|
47
|
+
|
|
48
|
+
self.logger = logging.getLogger("mineconnect-server")
|
|
49
|
+
self._children: List[Tuple[str, subprocess.Popen]] = []
|
|
50
|
+
self._running = False
|
|
51
|
+
|
|
52
|
+
# Get package directory for config files
|
|
53
|
+
self.package_dir = os.path.dirname(os.path.abspath(__file__))
|
|
54
|
+
|
|
55
|
+
def _launch(self, name: str, cmd: List[str], cwd: Optional[str] = None) -> Optional[subprocess.Popen]:
|
|
56
|
+
"""Launch a subprocess and attach logging."""
|
|
57
|
+
self.logger.info("Starting %s: %s", name, " ".join(cmd))
|
|
58
|
+
try:
|
|
59
|
+
proc = subprocess.Popen(
|
|
60
|
+
cmd,
|
|
61
|
+
stdout=subprocess.PIPE,
|
|
62
|
+
stderr=subprocess.STDOUT,
|
|
63
|
+
text=True,
|
|
64
|
+
cwd=cwd,
|
|
65
|
+
)
|
|
66
|
+
except FileNotFoundError as exc:
|
|
67
|
+
self.logger.error("Cannot start %s: %s", name, exc)
|
|
68
|
+
return None
|
|
69
|
+
|
|
70
|
+
self._children.append((name, proc))
|
|
71
|
+
|
|
72
|
+
def _reader():
|
|
73
|
+
for line in proc.stdout:
|
|
74
|
+
self.logger.info("[%s] %s", name, line.rstrip())
|
|
75
|
+
|
|
76
|
+
threading.Thread(target=_reader, daemon=True).start()
|
|
77
|
+
return proc
|
|
78
|
+
|
|
79
|
+
def _shutdown(self, *_):
|
|
80
|
+
"""Gracefully shutdown all services."""
|
|
81
|
+
if not self._running:
|
|
82
|
+
return
|
|
83
|
+
|
|
84
|
+
self.logger.info("Shutting down all services…")
|
|
85
|
+
self._running = False
|
|
86
|
+
|
|
87
|
+
for name, proc in self._children:
|
|
88
|
+
self.logger.info(" Stopping %s", name)
|
|
89
|
+
proc.terminate()
|
|
90
|
+
|
|
91
|
+
for name, proc in self._children:
|
|
92
|
+
try:
|
|
93
|
+
proc.wait(timeout=5)
|
|
94
|
+
except subprocess.TimeoutExpired:
|
|
95
|
+
proc.kill()
|
|
96
|
+
|
|
97
|
+
self._children.clear()
|
|
98
|
+
|
|
99
|
+
def start(self):
|
|
100
|
+
"""
|
|
101
|
+
Start the MineConnect server with all bridges and tunnels.
|
|
102
|
+
|
|
103
|
+
This method blocks until interrupted with Ctrl+C or terminated.
|
|
104
|
+
Logs are displayed to the console in real-time.
|
|
105
|
+
"""
|
|
106
|
+
self.logger.info("=" * 60)
|
|
107
|
+
self.logger.info(" MineConnect Server Starting")
|
|
108
|
+
self.logger.info(" TCP Port: %d | UDP Port: %d", self.tcp_port, self.udp_port)
|
|
109
|
+
self.logger.info(" Single Tunnel Mode: %s", self.single_tunnel)
|
|
110
|
+
self.logger.info("=" * 60)
|
|
111
|
+
|
|
112
|
+
# Register signal handlers
|
|
113
|
+
signal.signal(signal.SIGINT, self._shutdown)
|
|
114
|
+
signal.signal(signal.SIGTERM, self._shutdown)
|
|
115
|
+
|
|
116
|
+
self._running = True
|
|
117
|
+
py = sys.executable
|
|
118
|
+
|
|
119
|
+
try:
|
|
120
|
+
# 1) TCP<->WS bridge for Java Edition
|
|
121
|
+
tcp_bridge_path = os.path.join(self.package_dir, "tcp_bridge.py")
|
|
122
|
+
self._launch("TCP-WS-Bridge", [
|
|
123
|
+
py, tcp_bridge_path,
|
|
124
|
+
"--ws-port", str(self.ws_tcp_port),
|
|
125
|
+
"--mc-port", str(self.tcp_port),
|
|
126
|
+
])
|
|
127
|
+
|
|
128
|
+
# 2) UDP<->WS bridge for Bedrock Edition
|
|
129
|
+
udp_bridge_path = os.path.join(self.package_dir, "udp_bridge.py")
|
|
130
|
+
self._launch("UDP-WS-Bridge", [
|
|
131
|
+
py, udp_bridge_path,
|
|
132
|
+
"--ws-port", str(self.ws_udp_port),
|
|
133
|
+
"--geyser-port", str(self.udp_port),
|
|
134
|
+
])
|
|
135
|
+
|
|
136
|
+
time.sleep(1) # Let bridges initialize
|
|
137
|
+
|
|
138
|
+
# 3) Cloudflared tunnel(s)
|
|
139
|
+
# Run from package directory where configs and creds are located
|
|
140
|
+
if self.single_tunnel:
|
|
141
|
+
cfg = os.path.join(self.package_dir, "tunnel_combined.yml")
|
|
142
|
+
self._launch("Cloudflared", ["cloudflared", "tunnel", "--config", cfg, "run"], cwd=self.package_dir)
|
|
143
|
+
else:
|
|
144
|
+
java_cfg = os.path.join(self.package_dir, "tunnel_java.yml")
|
|
145
|
+
bedrock_cfg = os.path.join(self.package_dir, "tunnel_bedrock.yml")
|
|
146
|
+
|
|
147
|
+
self._launch("Cloudflared-Java", [
|
|
148
|
+
"cloudflared", "tunnel",
|
|
149
|
+
"--config", java_cfg,
|
|
150
|
+
"run",
|
|
151
|
+
], cwd=self.package_dir)
|
|
152
|
+
self._launch("Cloudflared-Bedrock", [
|
|
153
|
+
"cloudflared", "tunnel",
|
|
154
|
+
"--config", bedrock_cfg,
|
|
155
|
+
"run",
|
|
156
|
+
], cwd=self.package_dir)
|
|
157
|
+
|
|
158
|
+
self.logger.info("=" * 60)
|
|
159
|
+
self.logger.info(" All services running. Press Ctrl+C to stop.")
|
|
160
|
+
self.logger.info("=" * 60)
|
|
161
|
+
|
|
162
|
+
# Monitor processes
|
|
163
|
+
while self._running:
|
|
164
|
+
for name, proc in self._children:
|
|
165
|
+
if proc.poll() is not None:
|
|
166
|
+
self.logger.warning("%s exited (code %s)", name, proc.returncode)
|
|
167
|
+
if self._running:
|
|
168
|
+
self.logger.error("Service crashed! Initiating shutdown...")
|
|
169
|
+
self._shutdown()
|
|
170
|
+
break
|
|
171
|
+
time.sleep(2)
|
|
172
|
+
|
|
173
|
+
except Exception as exc:
|
|
174
|
+
self.logger.error("Fatal error: %s", exc)
|
|
175
|
+
self._shutdown()
|
|
176
|
+
raise
|
|
177
|
+
finally:
|
|
178
|
+
if self._running:
|
|
179
|
+
self._shutdown()
|
|
180
|
+
self.logger.info("MineConnect server stopped.")
|
|
181
|
+
|
|
182
|
+
def stop(self):
|
|
183
|
+
"""Manually stop the server."""
|
|
184
|
+
self._shutdown()
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
"""
|
|
2
|
+
TCP <-> WebSocket Bridge for Minecraft Java Edition
|
|
3
|
+
|
|
4
|
+
Runs a WebSocket server. The client keeps a persistent WebSocket and
|
|
5
|
+
sends framed messages:
|
|
6
|
+
0x01 + data = new TCP session to MC (+ first chunk)
|
|
7
|
+
0x02 + data = data for current session
|
|
8
|
+
0x03 = close current session
|
|
9
|
+
|
|
10
|
+
The bridge opens/closes TCP connections to the MC server accordingly.
|
|
11
|
+
|
|
12
|
+
Flow:
|
|
13
|
+
MC Java Client -> [Client Tunnel] -> persistent WS -> [This Bridge] -> TCP -> MC Server
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
import asyncio
|
|
17
|
+
import logging
|
|
18
|
+
import argparse
|
|
19
|
+
|
|
20
|
+
try:
|
|
21
|
+
import websockets
|
|
22
|
+
except ImportError:
|
|
23
|
+
raise SystemExit("Install websockets: pip install websockets")
|
|
24
|
+
|
|
25
|
+
logger = logging.getLogger("tcp-ws-bridge")
|
|
26
|
+
|
|
27
|
+
MSG_NEW = b'\x01'
|
|
28
|
+
MSG_DATA = b'\x02'
|
|
29
|
+
MSG_CLOSE = b'\x03'
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
async def handle_connection(websocket, mc_host: str, mc_port: int):
|
|
33
|
+
"""Handle persistent WS from client tunnel, manage TCP sessions to MC."""
|
|
34
|
+
peer = websocket.remote_address
|
|
35
|
+
logger.info("Client tunnel connected: %s", peer)
|
|
36
|
+
|
|
37
|
+
reader = None
|
|
38
|
+
writer = None
|
|
39
|
+
tcp_read_task = None
|
|
40
|
+
|
|
41
|
+
async def _close_tcp():
|
|
42
|
+
nonlocal reader, writer, tcp_read_task
|
|
43
|
+
if tcp_read_task and not tcp_read_task.done():
|
|
44
|
+
tcp_read_task.cancel()
|
|
45
|
+
tcp_read_task = None
|
|
46
|
+
if writer:
|
|
47
|
+
try:
|
|
48
|
+
writer.close()
|
|
49
|
+
except OSError:
|
|
50
|
+
pass
|
|
51
|
+
reader = None
|
|
52
|
+
writer = None
|
|
53
|
+
|
|
54
|
+
async def _tcp_reader():
|
|
55
|
+
"""Read from MC server TCP and send back over WS."""
|
|
56
|
+
nonlocal reader
|
|
57
|
+
try:
|
|
58
|
+
while reader:
|
|
59
|
+
data = await reader.read(65536)
|
|
60
|
+
if not data:
|
|
61
|
+
break
|
|
62
|
+
await websocket.send(MSG_DATA + data)
|
|
63
|
+
except (ConnectionResetError, asyncio.CancelledError, OSError):
|
|
64
|
+
pass
|
|
65
|
+
except websockets.ConnectionClosed:
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
try:
|
|
69
|
+
async for message in websocket:
|
|
70
|
+
if isinstance(message, str):
|
|
71
|
+
message = message.encode("utf-8")
|
|
72
|
+
if not message:
|
|
73
|
+
continue
|
|
74
|
+
|
|
75
|
+
msg_type = message[0:1]
|
|
76
|
+
payload = message[1:]
|
|
77
|
+
|
|
78
|
+
if msg_type == MSG_NEW:
|
|
79
|
+
# Close any existing session
|
|
80
|
+
await _close_tcp()
|
|
81
|
+
|
|
82
|
+
# Open new TCP to MC server
|
|
83
|
+
try:
|
|
84
|
+
reader, writer = await asyncio.open_connection(mc_host, mc_port)
|
|
85
|
+
logger.info("New TCP session to %s:%s for %s", mc_host, mc_port, peer)
|
|
86
|
+
except (ConnectionRefusedError, OSError) as exc:
|
|
87
|
+
logger.error("Cannot reach MC: %s", exc)
|
|
88
|
+
await websocket.send(MSG_CLOSE)
|
|
89
|
+
continue
|
|
90
|
+
|
|
91
|
+
# Start reading TCP responses
|
|
92
|
+
tcp_read_task = asyncio.ensure_future(_tcp_reader())
|
|
93
|
+
|
|
94
|
+
# Forward the first chunk
|
|
95
|
+
if payload:
|
|
96
|
+
writer.write(payload)
|
|
97
|
+
await writer.drain()
|
|
98
|
+
|
|
99
|
+
elif msg_type == MSG_DATA:
|
|
100
|
+
if writer:
|
|
101
|
+
try:
|
|
102
|
+
writer.write(payload)
|
|
103
|
+
await writer.drain()
|
|
104
|
+
except (ConnectionResetError, OSError):
|
|
105
|
+
await _close_tcp()
|
|
106
|
+
await websocket.send(MSG_CLOSE)
|
|
107
|
+
|
|
108
|
+
elif msg_type == MSG_CLOSE:
|
|
109
|
+
logger.info("Client closed TCP session for %s", peer)
|
|
110
|
+
await _close_tcp()
|
|
111
|
+
|
|
112
|
+
except websockets.ConnectionClosed:
|
|
113
|
+
logger.info("Client tunnel disconnected: %s", peer)
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
logger.error("Bridge error for %s: %s", peer, exc)
|
|
116
|
+
finally:
|
|
117
|
+
await _close_tcp()
|
|
118
|
+
logger.info("Session ended for %s", peer)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
async def run_bridge(ws_host: str, ws_port: int, mc_host: str, mc_port: int):
|
|
122
|
+
"""Main bridge coroutine."""
|
|
123
|
+
logger.info(
|
|
124
|
+
"TCP<->WS Bridge: ws://%s:%s <-> tcp://%s:%s",
|
|
125
|
+
ws_host, ws_port, mc_host, mc_port,
|
|
126
|
+
)
|
|
127
|
+
|
|
128
|
+
async with websockets.serve(
|
|
129
|
+
lambda ws: handle_connection(ws, mc_host, mc_port),
|
|
130
|
+
ws_host,
|
|
131
|
+
ws_port,
|
|
132
|
+
max_size=None,
|
|
133
|
+
ping_interval=20,
|
|
134
|
+
ping_timeout=30,
|
|
135
|
+
):
|
|
136
|
+
logger.info("Bridge ready – waiting for connections…")
|
|
137
|
+
await asyncio.Future() # run forever
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def main():
|
|
141
|
+
"""CLI entry point."""
|
|
142
|
+
logging.basicConfig(
|
|
143
|
+
level=logging.INFO,
|
|
144
|
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
parser = argparse.ArgumentParser(description="TCP<->WS Bridge for MC Java")
|
|
148
|
+
parser.add_argument("--ws-host", default="127.0.0.1")
|
|
149
|
+
parser.add_argument("--ws-port", type=int, default=8764)
|
|
150
|
+
parser.add_argument("--mc-host", default="127.0.0.1")
|
|
151
|
+
parser.add_argument("--mc-port", type=int, default=25565)
|
|
152
|
+
args = parser.parse_args()
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
asyncio.run(run_bridge(args.ws_host, args.ws_port, args.mc_host, args.mc_port))
|
|
156
|
+
except KeyboardInterrupt:
|
|
157
|
+
logger.info("Shutting down…")
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
if __name__ == "__main__":
|
|
161
|
+
main()
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Cloudflared tunnel config – Bedrock Edition (UDP via WS bridge)
|
|
2
|
+
# Replace TUNNEL_ID_HERE after running setup_tunnels.py
|
|
3
|
+
|
|
4
|
+
tunnel: d75d9307-b76a-410d-9692-16c3cd0926b7
|
|
5
|
+
credentials-file: ./creds/d75d9307-b76a-410d-9692-16c3cd0926b7.json
|
|
6
|
+
|
|
7
|
+
ingress:
|
|
8
|
+
- hostname: bedrock.ideadev.me
|
|
9
|
+
service: http://localhost:8765
|
|
10
|
+
- service: http_status:404
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Cloudflared tunnel config – Combined (single tunnel, both editions)
|
|
2
|
+
# Use with: python start_server.py --single-tunnel
|
|
3
|
+
# Replace TUNNEL_ID_HERE after running setup_tunnels.py
|
|
4
|
+
|
|
5
|
+
tunnel: TUNNEL_ID_HERE
|
|
6
|
+
credentials-file: ~/.cloudflared/TUNNEL_ID_HERE.json
|
|
7
|
+
|
|
8
|
+
ingress:
|
|
9
|
+
- hostname: java.ideadev.me
|
|
10
|
+
service: http://localhost:8764
|
|
11
|
+
- hostname: bedrock.ideadev.me
|
|
12
|
+
service: http://localhost:8765
|
|
13
|
+
- service: http_status:404
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# Cloudflared tunnel config – Java Edition (TCP via WS bridge)
|
|
2
|
+
# Replace TUNNEL_ID_HERE after running setup_tunnels.py
|
|
3
|
+
|
|
4
|
+
tunnel: f06e9c55-aab2-455b-81d5-08423eb92a39
|
|
5
|
+
credentials-file: ./creds/f06e9c55-aab2-455b-81d5-08423eb92a39.json
|
|
6
|
+
|
|
7
|
+
ingress:
|
|
8
|
+
- hostname: java.ideadev.me
|
|
9
|
+
service: http://localhost:8764
|
|
10
|
+
- service: http_status:404
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
"""
|
|
2
|
+
One-time setup for MineConnect cloudflared tunnels.
|
|
3
|
+
|
|
4
|
+
Creates two named tunnels, routes DNS to ideadev.me subdomains,
|
|
5
|
+
and patches the YAML config files with the real tunnel IDs.
|
|
6
|
+
|
|
7
|
+
Usage:
|
|
8
|
+
mineconnect-setup # After pip install
|
|
9
|
+
OR
|
|
10
|
+
python -m mineconnect.tunnel_setup
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import subprocess
|
|
14
|
+
import os
|
|
15
|
+
import sys
|
|
16
|
+
import json
|
|
17
|
+
|
|
18
|
+
DOMAIN = "ideadev.me"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _run(cmd, check=False):
|
|
22
|
+
print(f" $ {' '.join(cmd)}")
|
|
23
|
+
result = subprocess.run(cmd, capture_output=True, text=True)
|
|
24
|
+
out = (result.stdout + result.stderr).strip()
|
|
25
|
+
if out:
|
|
26
|
+
for line in out.splitlines():
|
|
27
|
+
print(f" {line}")
|
|
28
|
+
if check and result.returncode != 0:
|
|
29
|
+
sys.exit(f"Command failed (exit {result.returncode})")
|
|
30
|
+
return result
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _patch_config(config_dir, filename, tunnel_id):
|
|
34
|
+
path = os.path.join(config_dir, filename)
|
|
35
|
+
if not os.path.exists(path):
|
|
36
|
+
return
|
|
37
|
+
with open(path, "r", encoding="utf-8") as fh:
|
|
38
|
+
text = fh.read()
|
|
39
|
+
text = text.replace("TUNNEL_ID_HERE", tunnel_id)
|
|
40
|
+
with open(path, "w", encoding="utf-8") as fh:
|
|
41
|
+
fh.write(text)
|
|
42
|
+
print(f" -> Updated {filename}")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main():
|
|
46
|
+
print("=" * 56)
|
|
47
|
+
print(" MineConnect — Tunnel Setup")
|
|
48
|
+
print("=" * 56)
|
|
49
|
+
|
|
50
|
+
# Get config directory (parent of package)
|
|
51
|
+
package_dir = os.path.dirname(os.path.abspath(__file__))
|
|
52
|
+
config_dir = os.path.dirname(package_dir)
|
|
53
|
+
|
|
54
|
+
# 1 — Login
|
|
55
|
+
print("\n[1/5] Cloudflare authentication")
|
|
56
|
+
print(" A browser window will open — please log in.")
|
|
57
|
+
_run(["cloudflared", "tunnel", "login"])
|
|
58
|
+
|
|
59
|
+
# 2 — Create tunnels
|
|
60
|
+
for name in ("mineconnect-java", "mineconnect-bedrock"):
|
|
61
|
+
print(f"\n[2/5] Creating tunnel: {name}")
|
|
62
|
+
_run(["cloudflared", "tunnel", "create", name])
|
|
63
|
+
|
|
64
|
+
# 3 — DNS routing
|
|
65
|
+
print(f"\n[3/5] Routing java.{DOMAIN}")
|
|
66
|
+
_run([
|
|
67
|
+
"cloudflared", "tunnel", "route", "dns",
|
|
68
|
+
"mineconnect-java", f"java.{DOMAIN}",
|
|
69
|
+
])
|
|
70
|
+
|
|
71
|
+
print(f"\n[4/5] Routing bedrock.{DOMAIN}")
|
|
72
|
+
_run([
|
|
73
|
+
"cloudflared", "tunnel", "route", "dns",
|
|
74
|
+
"mineconnect-bedrock", f"bedrock.{DOMAIN}",
|
|
75
|
+
])
|
|
76
|
+
|
|
77
|
+
# 4 — Patch config files with real tunnel IDs
|
|
78
|
+
print("\n[5/5] Updating config files")
|
|
79
|
+
result = _run(["cloudflared", "tunnel", "list", "-o", "json"])
|
|
80
|
+
if result.stdout:
|
|
81
|
+
try:
|
|
82
|
+
tunnels = json.loads(result.stdout)
|
|
83
|
+
for t in tunnels:
|
|
84
|
+
name = t.get("name", "")
|
|
85
|
+
tid = t.get("id", "")
|
|
86
|
+
if name == "mineconnect-java":
|
|
87
|
+
_patch_config(config_dir, "tunnel_java.yml", tid)
|
|
88
|
+
elif name == "mineconnect-bedrock":
|
|
89
|
+
_patch_config(config_dir, "tunnel_bedrock.yml", tid)
|
|
90
|
+
except json.JSONDecodeError:
|
|
91
|
+
print(" !! Could not parse tunnel list — edit configs manually.")
|
|
92
|
+
|
|
93
|
+
print("\n" + "=" * 56)
|
|
94
|
+
print(" Setup complete!")
|
|
95
|
+
print("=" * 56)
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
main()
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""
|
|
2
|
+
UDP <-> WebSocket Bridge for Minecraft Bedrock Edition (Geyser)
|
|
3
|
+
|
|
4
|
+
Runs a WebSocket server. Each incoming WebSocket connection gets its own
|
|
5
|
+
UDP socket to communicate with Geyser. Packets are relayed bidirectionally.
|
|
6
|
+
|
|
7
|
+
Flow:
|
|
8
|
+
Bedrock Client -> [Client Bridge] -> WebSocket -> [This Bridge] -> UDP -> Geyser
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
import argparse
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
import websockets
|
|
17
|
+
except ImportError:
|
|
18
|
+
raise SystemExit("Install websockets: pip install websockets")
|
|
19
|
+
|
|
20
|
+
logger = logging.getLogger("udp-ws-bridge")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class GeyserRelay(asyncio.DatagramProtocol):
|
|
24
|
+
"""Relays UDP responses from Geyser back through the WebSocket."""
|
|
25
|
+
|
|
26
|
+
def __init__(self, websocket):
|
|
27
|
+
self.websocket = websocket
|
|
28
|
+
self.transport = None
|
|
29
|
+
|
|
30
|
+
def connection_made(self, transport):
|
|
31
|
+
self.transport = transport
|
|
32
|
+
|
|
33
|
+
def datagram_received(self, data, addr):
|
|
34
|
+
asyncio.ensure_future(self._forward(data))
|
|
35
|
+
|
|
36
|
+
async def _forward(self, data):
|
|
37
|
+
try:
|
|
38
|
+
await self.websocket.send(data)
|
|
39
|
+
except websockets.ConnectionClosed:
|
|
40
|
+
pass
|
|
41
|
+
|
|
42
|
+
def error_received(self, exc):
|
|
43
|
+
logger.warning("UDP error: %s", exc)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
async def handle_connection(websocket, geyser_host: str, geyser_port: int):
|
|
47
|
+
"""Bridge one WebSocket client to Geyser over UDP."""
|
|
48
|
+
peer = websocket.remote_address
|
|
49
|
+
logger.info("New WebSocket client: %s", peer)
|
|
50
|
+
|
|
51
|
+
loop = asyncio.get_event_loop()
|
|
52
|
+
transport, protocol = await loop.create_datagram_endpoint(
|
|
53
|
+
lambda: GeyserRelay(websocket),
|
|
54
|
+
remote_addr=(geyser_host, geyser_port),
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
async for message in websocket:
|
|
59
|
+
if isinstance(message, str):
|
|
60
|
+
message = message.encode("utf-8")
|
|
61
|
+
if isinstance(message, bytes):
|
|
62
|
+
transport.sendto(message)
|
|
63
|
+
except websockets.ConnectionClosed:
|
|
64
|
+
logger.info("Client %s disconnected", peer)
|
|
65
|
+
except Exception as exc:
|
|
66
|
+
logger.error("Bridge error for %s: %s", peer, exc)
|
|
67
|
+
finally:
|
|
68
|
+
transport.close()
|
|
69
|
+
logger.info("Relay closed for %s", peer)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
async def run_bridge(ws_host: str, ws_port: int, geyser_host: str, geyser_port: int):
|
|
73
|
+
"""Main bridge coroutine."""
|
|
74
|
+
logger.info(
|
|
75
|
+
"UDP<->WS Bridge: ws://%s:%s <-> udp://%s:%s",
|
|
76
|
+
ws_host, ws_port, geyser_host, geyser_port,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
async with websockets.serve(
|
|
80
|
+
lambda ws: handle_connection(ws, geyser_host, geyser_port),
|
|
81
|
+
ws_host,
|
|
82
|
+
ws_port,
|
|
83
|
+
max_size=None,
|
|
84
|
+
ping_interval=20,
|
|
85
|
+
ping_timeout=30,
|
|
86
|
+
):
|
|
87
|
+
logger.info("Bridge ready – waiting for connections…")
|
|
88
|
+
await asyncio.Future() # run forever
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def main():
|
|
92
|
+
"""CLI entry point."""
|
|
93
|
+
logging.basicConfig(
|
|
94
|
+
level=logging.INFO,
|
|
95
|
+
format="%(asctime)s [%(levelname)s] %(message)s",
|
|
96
|
+
)
|
|
97
|
+
|
|
98
|
+
parser = argparse.ArgumentParser(description="UDP<->WS Bridge for Geyser")
|
|
99
|
+
parser.add_argument("--ws-host", default="127.0.0.1")
|
|
100
|
+
parser.add_argument("--ws-port", type=int, default=8765)
|
|
101
|
+
parser.add_argument("--geyser-host", default="127.0.0.1")
|
|
102
|
+
parser.add_argument("--geyser-port", type=int, default=19132)
|
|
103
|
+
args = parser.parse_args()
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
asyncio.run(run_bridge(args.ws_host, args.ws_port, args.geyser_host, args.geyser_port))
|
|
107
|
+
except KeyboardInterrupt:
|
|
108
|
+
logger.info("Shutting down…")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
if __name__ == "__main__":
|
|
112
|
+
main()
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: mineconnect
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Minecraft TCP/UDP to WebSocket bridge with Cloudflare tunnel support
|
|
5
|
+
Author: MineConnect
|
|
6
|
+
Classifier: Programming Language :: Python :: 3
|
|
7
|
+
Classifier: Programming Language :: Python :: 3.8
|
|
8
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
9
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
10
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
11
|
+
Requires-Python: >=3.8
|
|
12
|
+
Requires-Dist: websockets>=12.0
|
|
13
|
+
Dynamic: author
|
|
14
|
+
Dynamic: classifier
|
|
15
|
+
Dynamic: requires-dist
|
|
16
|
+
Dynamic: requires-python
|
|
17
|
+
Dynamic: summary
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
MANIFEST.in
|
|
2
|
+
README.md
|
|
3
|
+
requirements.txt
|
|
4
|
+
setup.py
|
|
5
|
+
mineconnect/__init__.py
|
|
6
|
+
mineconnect/server.py
|
|
7
|
+
mineconnect/tcp_bridge.py
|
|
8
|
+
mineconnect/tunnel_bedrock.yml
|
|
9
|
+
mineconnect/tunnel_combined.yml
|
|
10
|
+
mineconnect/tunnel_java.yml
|
|
11
|
+
mineconnect/tunnel_setup.py
|
|
12
|
+
mineconnect/udp_bridge.py
|
|
13
|
+
mineconnect.egg-info/PKG-INFO
|
|
14
|
+
mineconnect.egg-info/SOURCES.txt
|
|
15
|
+
mineconnect.egg-info/dependency_links.txt
|
|
16
|
+
mineconnect.egg-info/entry_points.txt
|
|
17
|
+
mineconnect.egg-info/requires.txt
|
|
18
|
+
mineconnect.egg-info/top_level.txt
|
|
19
|
+
mineconnect/creds/d75d9307-b76a-410d-9692-16c3cd0926b7.json
|
|
20
|
+
mineconnect/creds/f06e9c55-aab2-455b-81d5-08423eb92a39.json
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
websockets>=12.0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
mineconnect
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
websockets>=12.0
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""
|
|
2
|
+
MineConnect - Minecraft TCP/UDP to WebSocket Bridge with Cloudflare Tunnels
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from setuptools import setup, find_packages
|
|
6
|
+
|
|
7
|
+
with open("requirements.txt", "r", encoding="utf-8") as f:
|
|
8
|
+
requirements = [line.strip() for line in f if line.strip() and not line.startswith("#")]
|
|
9
|
+
|
|
10
|
+
setup(
|
|
11
|
+
name="mineconnect",
|
|
12
|
+
version="1.0.0",
|
|
13
|
+
description="Minecraft TCP/UDP to WebSocket bridge with Cloudflare tunnel support",
|
|
14
|
+
author="MineConnect",
|
|
15
|
+
packages=find_packages(),
|
|
16
|
+
install_requires=requirements,
|
|
17
|
+
python_requires=">=3.8",
|
|
18
|
+
entry_points={
|
|
19
|
+
"console_scripts": [
|
|
20
|
+
"mineconnect-setup=mineconnect.tunnel_setup:main",
|
|
21
|
+
],
|
|
22
|
+
},
|
|
23
|
+
classifiers=[
|
|
24
|
+
"Programming Language :: Python :: 3",
|
|
25
|
+
"Programming Language :: Python :: 3.8",
|
|
26
|
+
"Programming Language :: Python :: 3.9",
|
|
27
|
+
"Programming Language :: Python :: 3.10",
|
|
28
|
+
"Programming Language :: Python :: 3.11",
|
|
29
|
+
],
|
|
30
|
+
package_data={
|
|
31
|
+
"mineconnect": ["*.yml", "creds/*.json"],
|
|
32
|
+
},
|
|
33
|
+
include_package_data=True,
|
|
34
|
+
)
|