44
55from __future__ import annotations
66
7+ import ipaddress
78import os
89import re
910import 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
509522def 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 (
0 commit comments