Skip to content

Commit b94f032

Browse files
committed
Add manual IP/port input for sending files
Users can now send files by entering a peer's IP address (IPv4/IPv6, optional port) directly, even if no peers are discovered. The CLI, language messages, and documentation have been updated to reflect this feature. Peer IDs are cached for manual targets to improve future transfers. Version bumped to 0.1.12.
1 parent ae0d80f commit b94f032

File tree

6 files changed

+147
-22
lines changed

6 files changed

+147
-22
lines changed

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ Requirements: install [deps](https://github.com/scarletkc/glitter/blob/main/requ
7575
## Usage
7676

7777
- [1] List peers: Show online devices (name/IP/version)
78-
- [2] Send file: Select a peer and input a path (quotes are allowed)
78+
- [2] Send file: Select a peer or enter an IP(v4/v6:port) and input a path (quotes are allowed)
7979
- [3] Incoming requests: Review transfer requests; Accept/Decline and choose a save directory
8080
- [4] Check updates: Open‑source repo link and latest version info
8181
- [5] History: Show the latest transfer records

docs/README.zh-CN.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ Glitter 提供了一个**简洁、基于终端**的替代方案,相比 GUI 工
7575
## 用法
7676

7777
- [1] 查看在线客户端:显示名称/IP/版本
78-
- [2] 发送文件:选择目标并输入路径(支持带引号)
78+
- [2] 发送文件:输入客户端编号或 IP 地址(支持 IPv4/IPv6,可选端口),再输入路径(支持带引号)
7979
- [3] 待处理请求:接收端确认/拒绝,并可选择保存目录
8080
- [4] 查看更新:显示当前版本与最新版本(需联网),并提供项目地址
8181
- [5] 传输记录:查看最近记录

glitter/__init__.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@
44

55
__all__ = ["__version__"]
66

7-
__version__ = "0.1.11"
7+
__version__ = "0.1.12"
88

glitter/cli.py

Lines changed: 134 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from __future__ import annotations
66

7+
import ipaddress
78
import os
89
import re
910
import sys
@@ -132,6 +133,8 @@ def __init__(
132133
self.ui = ui or TerminalUI()
133134
self._identity_public = identity_public or b""
134135
self._trust_store = trust_store
136+
self._manual_peer_ids: dict[str, str] = {}
137+
self._manual_peer_lock = threading.Lock()
135138

136139
if isinstance(transfer_port, int) and 1 <= transfer_port <= 65535:
137140
preferred_port = transfer_port
@@ -189,6 +192,16 @@ def should_show_local_fingerprint(self, peer: PeerInfo) -> bool:
189192
return True
190193
return self._trust_store.get(peer_id) is None
191194

195+
def cached_peer_id_for_ip(self, ip: str) -> Optional[str]:
196+
with self._manual_peer_lock:
197+
return self._manual_peer_ids.get(ip)
198+
199+
def remember_peer_id_for_ip(self, ip: str, peer_id: str) -> None:
200+
if not peer_id:
201+
return
202+
with self._manual_peer_lock:
203+
self._manual_peer_ids[ip] = peer_id
204+
192205
def clear_trusted_fingerprints(self) -> bool:
193206
if not self._trust_store:
194207
return False
@@ -276,7 +289,7 @@ def send_file(
276289
file_path: Path,
277290
progress_cb: Optional[Callable[[int, int], None]] = None,
278291
cancel_event: Optional[threading.Event] = None,
279-
) -> tuple[str, str]:
292+
) -> tuple[str, str, Optional[str]]:
280293
return self._transfer_service.send_file(
281294
peer.ip,
282295
peer.transfer_port,
@@ -508,24 +521,118 @@ def list_peers_cli(ui: TerminalUI, app: GlitterApp, language: str) -> None:
508521

509522
def send_file_cli(ui: TerminalUI, app: GlitterApp, language: str) -> None:
510523
peers = app.list_peers()
511-
if not peers:
524+
default_port = app.transfer_port
525+
if peers:
526+
list_peers_cli(ui, app, language)
527+
else:
512528
ui.print(get_message("no_peers", language))
513-
return
514-
list_peers_cli(ui, app, language)
529+
ui.print(get_message("manual_target_hint", language))
530+
ui.blank()
531+
532+
def parse_manual_target(raw: str) -> Optional[dict[str, object]]:
533+
"""Validate manual IPv4/IPv6 input and optional port."""
534+
text = raw.strip()
535+
if not text:
536+
return None
537+
port = default_port
538+
normalized_ip: Optional[str] = None
539+
if text.startswith("["):
540+
closing = text.find("]")
541+
if closing == -1:
542+
return None
543+
host_part = text[1:closing].strip()
544+
remainder = text[closing + 1 :].strip()
545+
if remainder:
546+
if not remainder.startswith(":"):
547+
return None
548+
port_text = remainder[1:].strip()
549+
if not port_text.isdigit():
550+
return None
551+
port = int(port_text)
552+
try:
553+
normalized_ip = ipaddress.ip_address(host_part).compressed
554+
except ValueError:
555+
return None
556+
if not (1 <= port <= 65535):
557+
return None
558+
return {
559+
"ip": normalized_ip,
560+
"port": port,
561+
"display": text,
562+
"normalized_ip": normalized_ip,
563+
}
564+
565+
host_candidate = text
566+
if ":" in text:
567+
possible_host, possible_port = text.rsplit(":", 1)
568+
possible_host = possible_host.strip()
569+
possible_port = possible_port.strip()
570+
if possible_port.isdigit():
571+
port_candidate = int(possible_port)
572+
if not (1 <= port_candidate <= 65535):
573+
return None
574+
try:
575+
normalized_candidate = ipaddress.ip_address(possible_host).compressed
576+
except ValueError:
577+
pass
578+
else:
579+
host_candidate = possible_host
580+
port = port_candidate
581+
normalized_ip = normalized_candidate
582+
host_candidate = host_candidate.strip()
583+
try:
584+
normalized_ip = ipaddress.ip_address(host_candidate).compressed
585+
except ValueError:
586+
return None
587+
if not (1 <= port <= 65535):
588+
return None
589+
return {
590+
"ip": normalized_ip,
591+
"port": port,
592+
"display": text,
593+
"normalized_ip": normalized_ip,
594+
}
595+
596+
selected_peer: Optional[PeerInfo] = None
597+
manual_selection = False
598+
manual_target_info: Optional[dict[str, object]] = None
515599
while True:
516-
choice = ui.input(get_message("prompt_peer_index", language)).strip()
600+
prompt = get_message("prompt_peer_target", language, port=default_port)
601+
choice = ui.input(prompt).strip()
517602
if not choice:
518603
ui.print(get_message("operation_cancelled", language))
519604
return
520-
if not choice.isdigit():
521-
ui.print(get_message("invalid_choice", language))
522-
continue
523-
idx = int(choice) - 1
524-
if 0 <= idx < len(peers):
605+
if choice.isdigit() and peers:
606+
idx = int(choice) - 1
607+
if 0 <= idx < len(peers):
608+
selected_peer = peers[idx]
609+
break
610+
manual_target = parse_manual_target(choice)
611+
if manual_target:
612+
normalized_ip = manual_target["ip"]
613+
cached_peer_id = app.cached_peer_id_for_ip(normalized_ip)
614+
peer_identifier = cached_peer_id or f"manual:{normalized_ip}:{manual_target['port']}"
615+
selected_peer = PeerInfo(
616+
peer_id=peer_identifier,
617+
name=manual_target["display"],
618+
ip=normalized_ip,
619+
transfer_port=manual_target["port"],
620+
language=language,
621+
version=__version__,
622+
last_seen=time.time(),
623+
)
624+
manual_target_info = manual_target
625+
if cached_peer_id:
626+
selected_peer.peer_id = cached_peer_id
627+
manual_selection = True
525628
break
526-
ui.print(get_message("invalid_choice", language))
527-
peer = peers[idx]
528-
if peer.version != __version__:
629+
ui.print(get_message("invalid_peer_target", language))
630+
631+
peer = selected_peer
632+
if peer is None:
633+
ui.print(get_message("operation_cancelled", language))
634+
return
635+
if not manual_selection and peer.version != __version__:
529636
ui.print(
530637
get_message(
531638
"version_mismatch_send",
@@ -603,14 +710,16 @@ def report_progress(sent: int, total: int) -> None:
603710

604711
def worker() -> None:
605712
try:
606-
result, file_hash = app.send_file(
713+
result, file_hash, responder_id = app.send_file(
607714
peer,
608715
file_path,
609716
progress_cb=report_progress,
610717
cancel_event=cancel_event,
611718
)
612719
result_holder["result"] = result
613720
result_holder["hash"] = file_hash
721+
if responder_id:
722+
result_holder["responder_id"] = responder_id
614723
except TransferCancelled as exc:
615724
result_holder["cancelled"] = True
616725
result_holder["hash"] = getattr(exc, "file_hash", None)
@@ -634,6 +743,17 @@ def worker() -> None:
634743
if progress_shown["value"]:
635744
ui.blank()
636745

746+
responder_id_obj = result_holder.get("responder_id")
747+
if (
748+
manual_selection
749+
and manual_target_info
750+
and isinstance(manual_target_info.get("normalized_ip"), str)
751+
and isinstance(responder_id_obj, str)
752+
):
753+
normalized_ip = manual_target_info["normalized_ip"] # type: ignore[index]
754+
app.remember_peer_id_for_ip(normalized_ip, responder_id_obj)
755+
peer.peer_id = responder_id_obj
756+
637757
if result_holder.get("cancelled"):
638758
ui.print(get_message("send_cancelled", language))
639759
app.log_history(

glitter/language.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,9 +23,11 @@
2323
"menu_pending": " ({count} pending)",
2424
"prompt_choice": "Choose an option: ",
2525
"no_peers": "No peers online right now.",
26+
"manual_target_hint": "You can enter an IP address (IPv4/IPv6, optional :port).",
2627
"peer_entry": "{index}. {name} ({ip}) — last seen {seconds}s ago — v{version}",
2728
"peer_version_warning": "! Version mismatch detected (remote {version}, local {current}). Transfers may be unreliable; please update.",
28-
"prompt_peer_index": "Select peer number: ",
29+
"prompt_peer_target": "Select peer number or enter IP: ",
30+
"invalid_peer_target": "Invalid selection. Enter a valid number or IP address.",
2931
"invalid_choice": "Invalid choice. Try again.",
3032
"prompt_file_path": "Enter file or directory path to send: ",
3133
"file_not_found": "Path not found or not a sendable file/directory.",
@@ -101,9 +103,11 @@
101103
"menu_pending": "({count} 个待处理)",
102104
"prompt_choice": "请选择操作:",
103105
"no_peers": "当前没有在线客户端。",
106+
"manual_target_hint": "可手动输入 IP 地址(支持 IPv4/IPv6,可选端口)。",
104107
"peer_entry": "{index}. {name}({ip})— 最近 {seconds} 秒前在线 — v{version}",
105108
"peer_version_warning": "! 版本不一致(对方 {version},本地 {current}),传输可能异常,请尽快更新。",
106-
"prompt_peer_index": "请选择客户端编号:",
109+
"prompt_peer_target": "请输入客户端编号或 IP 地址:",
110+
"invalid_peer_target": "输入无效,请输入正确的编号或 IP 地址。",
107111
"invalid_choice": "输入无效,请重试。",
108112
"prompt_file_path": "请输入要发送的文件或目录路径:",
109113
"file_not_found": "路径不存在或不是可发送的文件/目录。",

glitter/transfer.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -248,7 +248,7 @@ def send_file(
248248
file_path: Path,
249249
progress_cb: Optional[Callable[[int, int], None]] = None,
250250
cancel_event: Optional[threading.Event] = None,
251-
) -> tuple[str, str]:
251+
) -> tuple[str, str, Optional[str]]:
252252
if not file_path.exists():
253253
raise FileNotFoundError(f"path does not exist: {file_path}")
254254

@@ -313,6 +313,7 @@ def send_file(
313313
metadata["original_size"] = original_size
314314
message = json.dumps(metadata, ensure_ascii=False) + "\n"
315315

316+
responder_id: Optional[str] = None
316317
try:
317318
with socket.create_connection((target_ip, target_port), timeout=10) as sock:
318319
# Allow ample time for the receiver to review and accept the transfer
@@ -346,7 +347,7 @@ def send_file(
346347
break
347348
cipher: Optional[StreamCipher] = None
348349
if response == "DECLINE":
349-
return "declined", file_hash
350+
return "declined", file_hash, responder_id
350351
if not response.startswith("ACCEPT"):
351352
raise RuntimeError(f"unexpected response: {response}")
352353
payload_text = response[6:].strip()
@@ -426,7 +427,7 @@ def send_file(
426427
sock.shutdown(socket.SHUT_WR)
427428
except OSError:
428429
pass
429-
return "accepted", file_hash
430+
return "accepted", file_hash, responder_id
430431
finally:
431432
if cleanup_path:
432433
with contextlib.suppress(OSError):

0 commit comments

Comments
 (0)