nekocat 0.1.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.
- nekocat-0.1.0/.gitignore +5 -0
- nekocat-0.1.0/PKG-INFO +102 -0
- nekocat-0.1.0/README.md +73 -0
- nekocat-0.1.0/nekocat/__init__.py +5 -0
- nekocat-0.1.0/nekocat/__version__.py +7 -0
- nekocat-0.1.0/nekocat/cli/__init__.py +9 -0
- nekocat-0.1.0/nekocat/cli/cmd_catch.py +132 -0
- nekocat-0.1.0/nekocat/cli/cmd_chat.py +85 -0
- nekocat-0.1.0/nekocat/cli/cmd_disable.py +48 -0
- nekocat-0.1.0/nekocat/cli/cmd_enable.py +23 -0
- nekocat-0.1.0/nekocat/cli/cmd_logs.py +86 -0
- nekocat-0.1.0/nekocat/cli/cmd_look.py +81 -0
- nekocat-0.1.0/nekocat/cli/cmd_meow.py +48 -0
- nekocat-0.1.0/nekocat/cli/cmd_open.py +43 -0
- nekocat-0.1.0/nekocat/cli/cmd_pair.py +74 -0
- nekocat-0.1.0/nekocat/cli/cmd_setup.py +222 -0
- nekocat-0.1.0/nekocat/cli/cmd_share.py +102 -0
- nekocat-0.1.0/nekocat/cli/cmd_shell.py +97 -0
- nekocat-0.1.0/nekocat/cli/cmd_status.py +55 -0
- nekocat-0.1.0/nekocat/cli/cmd_throw.py +102 -0
- nekocat-0.1.0/nekocat/cli/cmd_uwu.py +38 -0
- nekocat-0.1.0/nekocat/cli/main.py +73 -0
- nekocat-0.1.0/nekocat/core/__init__.py +1 -0
- nekocat-0.1.0/nekocat/core/config.py +240 -0
- nekocat-0.1.0/nekocat/core/identity.py +241 -0
- nekocat-0.1.0/nekocat/core/logger.py +193 -0
- nekocat-0.1.0/nekocat/core/models.py +328 -0
- nekocat-0.1.0/nekocat/core/peer_cache.py +225 -0
- nekocat-0.1.0/nekocat/daemon/__init__.py +7 -0
- nekocat-0.1.0/nekocat/daemon/auth.py +348 -0
- nekocat-0.1.0/nekocat/daemon/chat.py +189 -0
- nekocat-0.1.0/nekocat/daemon/crypto.py +260 -0
- nekocat-0.1.0/nekocat/daemon/discovery.py +294 -0
- nekocat-0.1.0/nekocat/daemon/lan_protocol.py +247 -0
- nekocat-0.1.0/nekocat/daemon/main.py +300 -0
- nekocat-0.1.0/nekocat/daemon/notification.py +160 -0
- nekocat-0.1.0/nekocat/daemon/pairing.py +217 -0
- nekocat-0.1.0/nekocat/daemon/router.py +490 -0
- nekocat-0.1.0/nekocat/daemon/shell.py +332 -0
- nekocat-0.1.0/nekocat/daemon/transfer.py +403 -0
- nekocat-0.1.0/nekocat/ipc/__init__.py +1 -0
- nekocat-0.1.0/nekocat/ipc/client.py +280 -0
- nekocat-0.1.0/nekocat/ipc/protocol.py +161 -0
- nekocat-0.1.0/nekocat/ipc/server.py +152 -0
- nekocat-0.1.0/pypi-AgEIcHlwaS5vcmcCJDJmZTE3MzAwLTdiMjYtNDM5NS1iNDNjLTVjMjU0OTE4NzA3OQACKlszLCJiMzE5YmM3Zi1mODExLTRlMGUtYmVlMy04MDg0NDQ0YTIyZDMiXQAABiA5ylHSJqkL-nGc6nxd7BarCQb1RtUD4oAwNpIzKyKQXQ.txt +0 -0
- nekocat-0.1.0/pyproject.toml +52 -0
- nekocat-0.1.0/tests/__init__.py +0 -0
- nekocat-0.1.0/tests/test_config.py +53 -0
- nekocat-0.1.0/tests/test_crypto.py +65 -0
- nekocat-0.1.0/tests/test_identity.py +37 -0
- nekocat-0.1.0/tests/test_ipc_protocol.py +50 -0
- nekocat-0.1.0/tests/test_models.py +64 -0
nekocat-0.1.0/.gitignore
ADDED
nekocat-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nekocat
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A LAN communication platform — chat, files, shell, and more over your local network
|
|
5
|
+
Project-URL: Homepage, https://github.com/nekocat/nekocat
|
|
6
|
+
Project-URL: Issues, https://github.com/nekocat/nekocat/issues
|
|
7
|
+
License: MIT
|
|
8
|
+
Keywords: chat,communication,daemon,file-transfer,lan,network
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Intended Audience :: End Users/Desktop
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
16
|
+
Classifier: Topic :: Communications
|
|
17
|
+
Classifier: Topic :: System :: Networking
|
|
18
|
+
Requires-Python: >=3.11
|
|
19
|
+
Requires-Dist: aiofiles>=23.0
|
|
20
|
+
Requires-Dist: click>=8.1
|
|
21
|
+
Requires-Dist: cryptography>=41.0
|
|
22
|
+
Requires-Dist: plyer>=2.1
|
|
23
|
+
Requires-Dist: rich>=13.0
|
|
24
|
+
Provides-Extra: dev
|
|
25
|
+
Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
|
|
26
|
+
Requires-Dist: pytest-cov>=4.1; extra == 'dev'
|
|
27
|
+
Requires-Dist: pytest>=7.4; extra == 'dev'
|
|
28
|
+
Description-Content-Type: text/markdown
|
|
29
|
+
|
|
30
|
+
# NekoCat 🐱
|
|
31
|
+
|
|
32
|
+
> A LAN communication platform. Chat, share files, send notifications, open remote shells — all over your local network with a single `pip install`.
|
|
33
|
+
|
|
34
|
+
```
|
|
35
|
+
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
|
36
|
+
XX /\_/\ XX nekocat XX
|
|
37
|
+
XX ( o.o ) XX a lan communication XX
|
|
38
|
+
XX > < XX package XX
|
|
39
|
+
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
## Quick Start
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
pip install nekocat
|
|
46
|
+
neko setup
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
That's it. The daemon starts automatically and will restart on every boot.
|
|
50
|
+
|
|
51
|
+
## Commands
|
|
52
|
+
|
|
53
|
+
| Command | Description |
|
|
54
|
+
|---------|-------------|
|
|
55
|
+
| `neko setup` | First-time setup: generate keys, register autostart, start daemon |
|
|
56
|
+
| `neko look` | Discover NekoCat devices on your LAN |
|
|
57
|
+
| `neko throw file.txt Laptop` | Send a file (or folder) to a device |
|
|
58
|
+
| `neko catch` | Wait for incoming transfers |
|
|
59
|
+
| `neko chat` | Open LAN broadcast chat |
|
|
60
|
+
| `neko meow "Hello!"` | Send a desktop notification |
|
|
61
|
+
| `neko shell Laptop` | Open a remote terminal |
|
|
62
|
+
| `neko share ~/Projects` | Share a folder read-only |
|
|
63
|
+
| `neko pair Laptop` | Pair with a new device |
|
|
64
|
+
| `neko open` | Open the received-files directory |
|
|
65
|
+
| `neko logs` | View transfer, chat, and error history |
|
|
66
|
+
| `neko status` | Show daemon status |
|
|
67
|
+
| `neko enable` | Enable autostart on boot |
|
|
68
|
+
| `neko disable` | Disable autostart on boot |
|
|
69
|
+
| `neko uwu` | ✨ |
|
|
70
|
+
|
|
71
|
+
## Architecture
|
|
72
|
+
|
|
73
|
+
```
|
|
74
|
+
neko (CLI) ──IPC─► nekod (Daemon) ──LAN─► peers
|
|
75
|
+
localhost:54320 port:54322
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
The CLI never touches the network directly. Every LAN operation is handled by `nekod`, which runs as a background service.
|
|
79
|
+
|
|
80
|
+
## Security
|
|
81
|
+
|
|
82
|
+
- Each installation generates a unique **Node ID** and an **Ed25519 identity key pair**.
|
|
83
|
+
- All sessions use **X25519 ephemeral key exchange** so traffic is encrypted with a fresh key each time.
|
|
84
|
+
- First-time connections show a **6-digit pairing code** that must match on both devices.
|
|
85
|
+
- Once paired, devices are trusted automatically.
|
|
86
|
+
|
|
87
|
+
## Configuration
|
|
88
|
+
|
|
89
|
+
| OS | Path |
|
|
90
|
+
|----|------|
|
|
91
|
+
| Windows | `%APPDATA%\NekoCat\` |
|
|
92
|
+
| Linux | `~/.config/nekocat/` |
|
|
93
|
+
| macOS | `~/Library/Application Support/NekoCat/` |
|
|
94
|
+
|
|
95
|
+
Config files:
|
|
96
|
+
- `config.json` — general settings
|
|
97
|
+
- `trusted_peers.json` — paired devices and their public keys
|
|
98
|
+
- `nekocat.db` — SQLite database for peer cache and all logs
|
|
99
|
+
|
|
100
|
+
## License
|
|
101
|
+
|
|
102
|
+
MIT
|
nekocat-0.1.0/README.md
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
# NekoCat 🐱
|
|
2
|
+
|
|
3
|
+
> A LAN communication platform. Chat, share files, send notifications, open remote shells — all over your local network with a single `pip install`.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
|
7
|
+
XX /\_/\ XX nekocat XX
|
|
8
|
+
XX ( o.o ) XX a lan communication XX
|
|
9
|
+
XX > < XX package XX
|
|
10
|
+
XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
pip install nekocat
|
|
17
|
+
neko setup
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
That's it. The daemon starts automatically and will restart on every boot.
|
|
21
|
+
|
|
22
|
+
## Commands
|
|
23
|
+
|
|
24
|
+
| Command | Description |
|
|
25
|
+
|---------|-------------|
|
|
26
|
+
| `neko setup` | First-time setup: generate keys, register autostart, start daemon |
|
|
27
|
+
| `neko look` | Discover NekoCat devices on your LAN |
|
|
28
|
+
| `neko throw file.txt Laptop` | Send a file (or folder) to a device |
|
|
29
|
+
| `neko catch` | Wait for incoming transfers |
|
|
30
|
+
| `neko chat` | Open LAN broadcast chat |
|
|
31
|
+
| `neko meow "Hello!"` | Send a desktop notification |
|
|
32
|
+
| `neko shell Laptop` | Open a remote terminal |
|
|
33
|
+
| `neko share ~/Projects` | Share a folder read-only |
|
|
34
|
+
| `neko pair Laptop` | Pair with a new device |
|
|
35
|
+
| `neko open` | Open the received-files directory |
|
|
36
|
+
| `neko logs` | View transfer, chat, and error history |
|
|
37
|
+
| `neko status` | Show daemon status |
|
|
38
|
+
| `neko enable` | Enable autostart on boot |
|
|
39
|
+
| `neko disable` | Disable autostart on boot |
|
|
40
|
+
| `neko uwu` | ✨ |
|
|
41
|
+
|
|
42
|
+
## Architecture
|
|
43
|
+
|
|
44
|
+
```
|
|
45
|
+
neko (CLI) ──IPC─► nekod (Daemon) ──LAN─► peers
|
|
46
|
+
localhost:54320 port:54322
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
The CLI never touches the network directly. Every LAN operation is handled by `nekod`, which runs as a background service.
|
|
50
|
+
|
|
51
|
+
## Security
|
|
52
|
+
|
|
53
|
+
- Each installation generates a unique **Node ID** and an **Ed25519 identity key pair**.
|
|
54
|
+
- All sessions use **X25519 ephemeral key exchange** so traffic is encrypted with a fresh key each time.
|
|
55
|
+
- First-time connections show a **6-digit pairing code** that must match on both devices.
|
|
56
|
+
- Once paired, devices are trusted automatically.
|
|
57
|
+
|
|
58
|
+
## Configuration
|
|
59
|
+
|
|
60
|
+
| OS | Path |
|
|
61
|
+
|----|------|
|
|
62
|
+
| Windows | `%APPDATA%\NekoCat\` |
|
|
63
|
+
| Linux | `~/.config/nekocat/` |
|
|
64
|
+
| macOS | `~/Library/Application Support/NekoCat/` |
|
|
65
|
+
|
|
66
|
+
Config files:
|
|
67
|
+
- `config.json` — general settings
|
|
68
|
+
- `trusted_peers.json` — paired devices and their public keys
|
|
69
|
+
- `nekocat.db` — SQLite database for peer cache and all logs
|
|
70
|
+
|
|
71
|
+
## License
|
|
72
|
+
|
|
73
|
+
MIT
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""
|
|
2
|
+
``nekocat catch`` — wait for and accept incoming file transfers.
|
|
3
|
+
|
|
4
|
+
Listens for push events from the daemon and prompts the user to accept
|
|
5
|
+
or reject each incoming transfer. Displays a progress bar while receiving.
|
|
6
|
+
|
|
7
|
+
Usage
|
|
8
|
+
-----
|
|
9
|
+
nekocat catch
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.progress import (
|
|
19
|
+
BarColumn,
|
|
20
|
+
DownloadColumn,
|
|
21
|
+
Progress,
|
|
22
|
+
TransferSpeedColumn,
|
|
23
|
+
TimeRemainingColumn,
|
|
24
|
+
TextColumn,
|
|
25
|
+
)
|
|
26
|
+
|
|
27
|
+
from nekocat.ipc.client import IPCClient
|
|
28
|
+
from nekocat.ipc.protocol import CMD, IPCRequest
|
|
29
|
+
|
|
30
|
+
console = Console()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@click.command()
|
|
34
|
+
def catch() -> None:
|
|
35
|
+
"""Wait for incoming files from peers."""
|
|
36
|
+
asyncio.run(_catch())
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
async def _catch() -> None:
|
|
40
|
+
console.print("[cyan]Waiting for incoming files… (Ctrl+C to stop)[/cyan]")
|
|
41
|
+
|
|
42
|
+
async with IPCClient() as client:
|
|
43
|
+
await client.send(IPCRequest(cmd=CMD.RECEIVE_START))
|
|
44
|
+
|
|
45
|
+
active_tasks: dict[str, object] = {}
|
|
46
|
+
|
|
47
|
+
with Progress(
|
|
48
|
+
TextColumn("[bold blue]{task.fields[filename]}"),
|
|
49
|
+
BarColumn(),
|
|
50
|
+
DownloadColumn(),
|
|
51
|
+
TransferSpeedColumn(),
|
|
52
|
+
TimeRemainingColumn(),
|
|
53
|
+
console=console,
|
|
54
|
+
) as progress:
|
|
55
|
+
try:
|
|
56
|
+
async for push in client.stream_push():
|
|
57
|
+
if push.cmd == CMD.PUSH_INCOMING_TRANSFER:
|
|
58
|
+
d = push.data
|
|
59
|
+
from_name = d.get("from", "Unknown")
|
|
60
|
+
file_name = d.get("file_name", "?")
|
|
61
|
+
file_size = int(d.get("file_size", 0))
|
|
62
|
+
job_id = d.get("job_id", "")
|
|
63
|
+
|
|
64
|
+
console.print(
|
|
65
|
+
f"\n[bold]{from_name}[/bold] wants to send "
|
|
66
|
+
f"[cyan]{file_name}[/cyan] "
|
|
67
|
+
f"({_human_size(file_size)})"
|
|
68
|
+
)
|
|
69
|
+
answer = click.prompt(
|
|
70
|
+
"Accept? [Y/n]",
|
|
71
|
+
default="Y",
|
|
72
|
+
show_default=False,
|
|
73
|
+
).strip().lower()
|
|
74
|
+
|
|
75
|
+
if answer in ("", "y", "yes"):
|
|
76
|
+
await client.send(
|
|
77
|
+
IPCRequest(
|
|
78
|
+
cmd=CMD.ACCEPT_TRANSFER,
|
|
79
|
+
payload={"job_id": job_id},
|
|
80
|
+
)
|
|
81
|
+
)
|
|
82
|
+
task_id = progress.add_task(
|
|
83
|
+
"Receiving…",
|
|
84
|
+
total=file_size,
|
|
85
|
+
filename=file_name,
|
|
86
|
+
)
|
|
87
|
+
active_tasks[job_id] = task_id
|
|
88
|
+
else:
|
|
89
|
+
await client.send(
|
|
90
|
+
IPCRequest(
|
|
91
|
+
cmd=CMD.REJECT_TRANSFER,
|
|
92
|
+
payload={"job_id": job_id},
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
console.print("[yellow]Rejected.[/yellow]")
|
|
96
|
+
|
|
97
|
+
elif push.cmd == CMD.PUSH_TRANSFER_PROGRESS:
|
|
98
|
+
d = push.data
|
|
99
|
+
job_id = d.get("job_id", "")
|
|
100
|
+
task_id = active_tasks.get(job_id)
|
|
101
|
+
if task_id is not None:
|
|
102
|
+
progress.update(
|
|
103
|
+
task_id, # type: ignore[arg-type]
|
|
104
|
+
completed=d.get("transferred_bytes", 0),
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
elif push.cmd == CMD.PUSH_TRANSFER_COMPLETE:
|
|
108
|
+
d = push.data
|
|
109
|
+
job_id = d.get("job_id", "")
|
|
110
|
+
task_id = active_tasks.pop(job_id, None)
|
|
111
|
+
if task_id is not None:
|
|
112
|
+
progress.update(task_id, completed=progress.tasks[0].total) # type: ignore
|
|
113
|
+
console.print(
|
|
114
|
+
f"[green]✓ Saved to:[/green] {d.get('save_path', '(unknown)')}"
|
|
115
|
+
)
|
|
116
|
+
|
|
117
|
+
elif push.cmd == CMD.PUSH_TRANSFER_ERROR:
|
|
118
|
+
d = push.data
|
|
119
|
+
job_id = d.get("job_id", "")
|
|
120
|
+
active_tasks.pop(job_id, None)
|
|
121
|
+
console.print(f"[red]✗ Transfer error:[/red] {d.get('error')}")
|
|
122
|
+
|
|
123
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
124
|
+
pass
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _human_size(n: int) -> str:
|
|
128
|
+
for unit in ("B", "KB", "MB", "GB"):
|
|
129
|
+
if n < 1024:
|
|
130
|
+
return f"{n:.1f} {unit}"
|
|
131
|
+
n //= 1024
|
|
132
|
+
return f"{n:.1f} TB"
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""
|
|
2
|
+
``nekocat chat`` — interactive LAN group chat.
|
|
3
|
+
|
|
4
|
+
Opens a live chat session. Messages from other peers arrive in real time
|
|
5
|
+
via daemon push events. Ctrl+C exits.
|
|
6
|
+
|
|
7
|
+
Usage
|
|
8
|
+
-----
|
|
9
|
+
nekocat chat
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import datetime
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.markup import escape
|
|
20
|
+
|
|
21
|
+
from nekocat.ipc.client import IPCClient
|
|
22
|
+
from nekocat.ipc.protocol import CMD, IPCRequest
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.command()
|
|
28
|
+
@click.option("--history", "show_history", is_flag=True, default=True,
|
|
29
|
+
help="Show recent chat history on startup.")
|
|
30
|
+
def chat(show_history: bool) -> None:
|
|
31
|
+
"""Open an interactive LAN chat session."""
|
|
32
|
+
asyncio.run(_chat(show_history))
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
async def _chat(show_history: bool) -> None:
|
|
36
|
+
async with IPCClient() as client:
|
|
37
|
+
if show_history:
|
|
38
|
+
resp = await client.send(IPCRequest(cmd=CMD.CHAT_HISTORY, payload={"limit": 50}))
|
|
39
|
+
if resp.status == "ok":
|
|
40
|
+
msgs = resp.data.get("messages", [])
|
|
41
|
+
if msgs:
|
|
42
|
+
console.print("[dim]── recent history ──[/dim]")
|
|
43
|
+
for m in msgs:
|
|
44
|
+
_print_message(m)
|
|
45
|
+
console.print("[dim]────────────────────[/dim]")
|
|
46
|
+
|
|
47
|
+
console.print("[cyan]LAN chat — type and press Enter. Ctrl+C to exit.[/cyan]\n")
|
|
48
|
+
|
|
49
|
+
# Register as subscriber for push events
|
|
50
|
+
await client.send(IPCRequest(cmd=CMD.CHAT_SEND, payload={"content": ""}))
|
|
51
|
+
|
|
52
|
+
# Run input and receive loops concurrently
|
|
53
|
+
input_task = asyncio.create_task(_input_loop(client))
|
|
54
|
+
recv_task = asyncio.create_task(_recv_loop(client))
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
await asyncio.gather(input_task, recv_task)
|
|
58
|
+
except (KeyboardInterrupt, asyncio.CancelledError):
|
|
59
|
+
input_task.cancel()
|
|
60
|
+
recv_task.cancel()
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
async def _input_loop(client: IPCClient) -> None:
|
|
64
|
+
"""Read lines from stdin and send them as chat messages."""
|
|
65
|
+
loop = asyncio.get_running_loop()
|
|
66
|
+
while True:
|
|
67
|
+
line = await loop.run_in_executor(None, input)
|
|
68
|
+
line = line.strip()
|
|
69
|
+
if not line:
|
|
70
|
+
continue
|
|
71
|
+
await client.send(IPCRequest(cmd=CMD.CHAT_SEND, payload={"content": line}))
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
async def _recv_loop(client: IPCClient) -> None:
|
|
75
|
+
"""Listen for push events and print incoming chat messages."""
|
|
76
|
+
async for push in client.stream_push():
|
|
77
|
+
if push.cmd == CMD.PUSH_CHAT_MESSAGE:
|
|
78
|
+
_print_message(push.data)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _print_message(m: dict) -> None:
|
|
82
|
+
ts = datetime.datetime.fromtimestamp(m.get("timestamp", 0)).strftime("%H:%M")
|
|
83
|
+
user = escape(f"{m.get('from_username', '?')}@{m.get('from_hostname', '?')}")
|
|
84
|
+
content = escape(m.get("content", ""))
|
|
85
|
+
console.print(f"[dim]{ts}[/dim] [bold cyan]{user}[/bold cyan]: {content}")
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""
|
|
2
|
+
``nekocat disable`` — disable daemon autostart on boot.
|
|
3
|
+
|
|
4
|
+
Usage
|
|
5
|
+
-----
|
|
6
|
+
nekocat disable
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import platform
|
|
12
|
+
import subprocess
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import click
|
|
16
|
+
from rich.console import Console
|
|
17
|
+
|
|
18
|
+
console = Console()
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@click.command()
|
|
22
|
+
def disable() -> None:
|
|
23
|
+
"""Disable the daemon autostart on boot."""
|
|
24
|
+
system = platform.system()
|
|
25
|
+
try:
|
|
26
|
+
if system == "Windows":
|
|
27
|
+
subprocess.run(
|
|
28
|
+
"schtasks /Delete /F /TN \"NekoCat Daemon\"",
|
|
29
|
+
shell=True, check=True, capture_output=True,
|
|
30
|
+
)
|
|
31
|
+
elif system == "Linux":
|
|
32
|
+
subprocess.run(["systemctl", "--user", "disable", "nekod"], check=False)
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
svc = Path.home() / ".config" / "systemd" / "user" / "nekod.service"
|
|
35
|
+
if svc.exists():
|
|
36
|
+
svc.unlink()
|
|
37
|
+
elif system == "Darwin":
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
plist = Path.home() / "Library" / "LaunchAgents" / "com.nekocat.daemon.plist"
|
|
40
|
+
if plist.exists():
|
|
41
|
+
subprocess.run(["launchctl", "unload", str(plist)], check=False)
|
|
42
|
+
plist.unlink()
|
|
43
|
+
else:
|
|
44
|
+
console.print(f"[yellow]Autostart not supported on {system}.[/yellow]")
|
|
45
|
+
return
|
|
46
|
+
console.print("[green]✓[/green] Autostart disabled.")
|
|
47
|
+
except Exception as exc:
|
|
48
|
+
console.print(f"[red]Error disabling autostart:[/red] {exc}")
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
"""
|
|
2
|
+
``nekocat enable`` — enable daemon autostart on boot.
|
|
3
|
+
|
|
4
|
+
Usage
|
|
5
|
+
-----
|
|
6
|
+
nekocat enable
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
from rich.console import Console
|
|
13
|
+
|
|
14
|
+
from nekocat.cli.cmd_setup import _register_autostart
|
|
15
|
+
|
|
16
|
+
console = Console()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@click.command()
|
|
20
|
+
def enable() -> None:
|
|
21
|
+
"""Enable the daemon to start automatically on boot."""
|
|
22
|
+
_register_autostart()
|
|
23
|
+
console.print("[green]✓[/green] Autostart enabled.")
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
"""
|
|
2
|
+
``nekocat logs`` — view transfer, chat, and error history.
|
|
3
|
+
|
|
4
|
+
Usage
|
|
5
|
+
-----
|
|
6
|
+
nekocat logs
|
|
7
|
+
nekocat logs --limit 20
|
|
8
|
+
nekocat logs --type transfer
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import datetime
|
|
15
|
+
|
|
16
|
+
import click
|
|
17
|
+
from rich.console import Console
|
|
18
|
+
from rich.table import Table
|
|
19
|
+
|
|
20
|
+
from nekocat.ipc.client import IPCClient
|
|
21
|
+
from nekocat.ipc.protocol import CMD, IPCRequest
|
|
22
|
+
|
|
23
|
+
console = Console()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
@click.command()
|
|
27
|
+
@click.option("--limit", default=50, help="Number of log entries to show (default 50).")
|
|
28
|
+
@click.option(
|
|
29
|
+
"--type", "event_type", default=None,
|
|
30
|
+
help="Filter by type: transfer, chat, notification, shell_session, pair, error.",
|
|
31
|
+
)
|
|
32
|
+
def logs(limit: int, event_type: str | None) -> None:
|
|
33
|
+
"""View activity history (transfers, chat, notifications, errors)."""
|
|
34
|
+
asyncio.run(_logs(limit, event_type))
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
async def _logs(limit: int, event_type: str | None) -> None:
|
|
38
|
+
payload: dict = {"limit": limit}
|
|
39
|
+
if event_type:
|
|
40
|
+
payload["event_type"] = event_type
|
|
41
|
+
|
|
42
|
+
async with IPCClient() as client:
|
|
43
|
+
resp = await client.send(IPCRequest(cmd=CMD.LOGS_QUERY, payload=payload))
|
|
44
|
+
|
|
45
|
+
if resp.status == "error":
|
|
46
|
+
console.print(f"[red]Error:[/red] {resp.error}")
|
|
47
|
+
return
|
|
48
|
+
|
|
49
|
+
data = resp.data
|
|
50
|
+
entries = data.get("entries", [])
|
|
51
|
+
|
|
52
|
+
if not entries:
|
|
53
|
+
console.print("[yellow]No log entries found.[/yellow]")
|
|
54
|
+
return
|
|
55
|
+
|
|
56
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
57
|
+
table.add_column("Time", style="dim")
|
|
58
|
+
table.add_column("Type")
|
|
59
|
+
table.add_column("Peer")
|
|
60
|
+
table.add_column("Summary")
|
|
61
|
+
|
|
62
|
+
for e in entries:
|
|
63
|
+
ts = e.get("timestamp", 0)
|
|
64
|
+
time_str = datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M:%S")
|
|
65
|
+
etype = e.get("event_type", "?")
|
|
66
|
+
peer = e.get("display_name") or e.get("node_id", "") or ""
|
|
67
|
+
summary = e.get("summary", "")
|
|
68
|
+
|
|
69
|
+
type_colors = {
|
|
70
|
+
"transfer": "blue",
|
|
71
|
+
"chat": "cyan",
|
|
72
|
+
"notification": "magenta",
|
|
73
|
+
"shell_session": "green",
|
|
74
|
+
"pair": "yellow",
|
|
75
|
+
"error": "red",
|
|
76
|
+
}
|
|
77
|
+
color = type_colors.get(etype, "white")
|
|
78
|
+
|
|
79
|
+
table.add_row(
|
|
80
|
+
time_str,
|
|
81
|
+
f"[{color}]{etype}[/{color}]",
|
|
82
|
+
f"[dim]{peer}[/dim]",
|
|
83
|
+
summary,
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
console.print(table)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""
|
|
2
|
+
``nekocat look`` — discover and list peers on the LAN.
|
|
3
|
+
|
|
4
|
+
Shows online peers discovered by the daemon and, if --all is passed,
|
|
5
|
+
also shows cached offline peers with their last-seen timestamp.
|
|
6
|
+
|
|
7
|
+
Usage
|
|
8
|
+
-----
|
|
9
|
+
nekocat look
|
|
10
|
+
nekocat look --all
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import asyncio
|
|
16
|
+
|
|
17
|
+
import click
|
|
18
|
+
from rich.console import Console
|
|
19
|
+
from rich.table import Table
|
|
20
|
+
|
|
21
|
+
from nekocat.ipc.client import IPCClient
|
|
22
|
+
from nekocat.ipc.protocol import CMD, IPCRequest
|
|
23
|
+
|
|
24
|
+
console = Console()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@click.command()
|
|
28
|
+
@click.option("--all", "show_all", is_flag=True, help="Include offline cached peers.")
|
|
29
|
+
def look(show_all: bool) -> None:
|
|
30
|
+
"""List peers visible on the LAN."""
|
|
31
|
+
asyncio.run(_look(show_all))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
async def _look(show_all: bool) -> None:
|
|
35
|
+
async with IPCClient() as client:
|
|
36
|
+
resp = await client.send(IPCRequest(cmd=CMD.LOOK, payload={"all": show_all}))
|
|
37
|
+
|
|
38
|
+
if resp.status == "error":
|
|
39
|
+
console.print(f"[red]Error:[/red] {resp.error}")
|
|
40
|
+
return
|
|
41
|
+
|
|
42
|
+
peers: list[dict] = resp.data.get("peers", [])
|
|
43
|
+
|
|
44
|
+
if not peers:
|
|
45
|
+
console.print("[yellow]No peers found on the LAN.[/yellow]")
|
|
46
|
+
return
|
|
47
|
+
|
|
48
|
+
table = Table(title="LAN Peers", show_header=True, header_style="bold cyan")
|
|
49
|
+
table.add_column("Name", style="bold")
|
|
50
|
+
table.add_column("Hostname")
|
|
51
|
+
table.add_column("IP Address")
|
|
52
|
+
table.add_column("Status")
|
|
53
|
+
table.add_column("Trusted", justify="center")
|
|
54
|
+
table.add_column("Last Seen")
|
|
55
|
+
|
|
56
|
+
for p in peers:
|
|
57
|
+
status = p.get("status", "unknown")
|
|
58
|
+
status_style = "green" if status == "online" else "dim"
|
|
59
|
+
trusted = "✓" if p.get("trusted") else ""
|
|
60
|
+
name = f"{p.get('username', '?')}@{p.get('hostname', '?')}"
|
|
61
|
+
|
|
62
|
+
import time
|
|
63
|
+
last_seen = p.get("last_seen", 0.0)
|
|
64
|
+
delta = time.time() - last_seen
|
|
65
|
+
if delta < 60:
|
|
66
|
+
seen = f"{int(delta)}s ago"
|
|
67
|
+
elif delta < 3600:
|
|
68
|
+
seen = f"{int(delta/60)}m ago"
|
|
69
|
+
else:
|
|
70
|
+
seen = f"{int(delta/3600)}h ago"
|
|
71
|
+
|
|
72
|
+
table.add_row(
|
|
73
|
+
name,
|
|
74
|
+
p.get("hostname", "?"),
|
|
75
|
+
p.get("ip", "?"),
|
|
76
|
+
f"[{status_style}]{status}[/{status_style}]",
|
|
77
|
+
f"[green]{trusted}[/green]",
|
|
78
|
+
seen,
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
console.print(table)
|