如果一个文件要从手机发送给电脑,没有数据线,但这个文件又有点保密,该怎么办。只有将电脑和手机连接到同一个WiFi路由器,用下面我的程序就能解决。
1.安卓程序,用beeware生成
python
import socket
import os
import threading
import time
import toga
from toga.style import Pack
from toga.style.pack import COLUMN, ROW
import re
class FileTransferApp(toga.App):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
# 只初始化必要的属性
self.file_path = ""
self.file_name = ""
self.file_uri = None
self.usetime = 0
self.sudu = 0
self.server_running = False
self.temp_files = []
def startup(self):
# 主窗口 - 必须在 startup 中创建
self.main_window = toga.MainWindow(title="局域网文件传输工具", size=(380, 600))
# 创建UI组件
self.create_ui()
# 设置窗口内容
self.main_window.content = self.main_box
self.main_window.show()
# 获取本机IP
self.get_local_ip()
# 检查是否有启动intent(从其他应用分享的文件)
self.check_launch_intent()
def create_ui(self):
# 本机IP显示(作为服务器)
local_ip_box = toga.Box(style=Pack(direction=COLUMN, padding=3))
local_ip_box.add(toga.Label("本机IP:", style=Pack(padding=(0, 3))))
self.local_ip_label = toga.Label(
"正在获取...",
style=Pack(padding=3, font_weight="bold")
)
local_ip_box.add(self.local_ip_label)
# 启动服务器按钮
self.start_server_btn = toga.Button(
"启动服务器",
on_press=self.start_fwq,
style=Pack(padding=8, background_color="#4CAF50")
)
local_ip_box.add(self.start_server_btn)
# 分隔线
separator = toga.Box(style=Pack(direction=ROW, background_color="#E0E0E0", padding=1))
# 对方IP输入
target_ip_box = toga.Box(style=Pack(direction=COLUMN, padding=3))
target_ip_box.add(toga.Label("对方IP:", style=Pack(padding=(0, 3))))
ip_input_box = toga.Box(style=Pack(direction=ROW, padding=3))
self.ip_prefix_input = toga.TextInput(
readonly=True,
style=Pack(flex=1, padding=3, width=100)
)
self.ip_suffix_input = toga.TextInput(
placeholder="最后一段",
style=Pack(flex=1, padding=3, width=50)
)
ip_input_box.add(self.ip_prefix_input)
ip_input_box.add(toga.Label(".", style=Pack(padding=3)))
ip_input_box.add(self.ip_suffix_input)
target_ip_box.add(ip_input_box)
# 文件信息显示
file_box = toga.Box(style=Pack(direction=COLUMN, padding=5))
file_box.add(toga.Label("共享的文件:", style=Pack(padding=(0, 3), font_weight="bold")))
self.file_info_label = toga.Label(
"等待其他App分享文件...",
style=Pack(padding=5, text_align="center")
)
file_box.add(self.file_info_label)
# 发送按钮
self.send_btn = toga.Button(
"发送文件",
on_press=self.start_send,
style=Pack(padding=8, background_color="#2196F3"),
enabled=False
)
file_box.add(self.send_btn)
# 进度显示
progress_box = toga.Box(style=Pack(direction=COLUMN, padding=5))
progress_box.add(toga.Label("发送进度:", style=Pack(padding=(0, 3))))
self.send_progress = toga.ProgressBar(max=100, value=0, style=Pack(padding=3))
progress_box.add(self.send_progress)
progress_box.add(toga.Label("接收进度:", style=Pack(padding=(0, 3))))
self.receive_progress = toga.ProgressBar(max=100, value=0, style=Pack(padding=3))
progress_box.add(self.receive_progress)
# 日志区域
log_box = toga.Box(style=Pack(direction=COLUMN, padding=5))
log_box.add(toga.Label("传输日志:", style=Pack(padding=(0, 3))))
self.log_area = toga.MultilineTextInput(
readonly=True,
style=Pack(flex=1, height=150, padding=3)
)
log_box.add(self.log_area)
# 主布局
self.main_box = toga.Box(
children=[local_ip_box, separator, target_ip_box, file_box, progress_box, log_box],
style=Pack(direction=COLUMN, padding=8)
)
def get_local_ip(self):
"""获取本机局域网IP地址"""
def _get_ip():
try:
# 尝试通过连接外部地址来获取本机IP
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
# 在主线程中更新UI
self._safe_ui_update(lambda: self.update_local_ip(ip))
except Exception as e:
# 如果上述方法失败,尝试其他方法
try:
hostname = socket.gethostname()
ip = socket.gethostbyname(hostname)
self._safe_ui_update(lambda: self.update_local_ip(ip))
except:
self._safe_ui_update(lambda: self.update_local_ip("无法获取IP"))
threading.Thread(target=_get_ip, daemon=True).start()
def _safe_ui_update(self, update_func):
"""安全地在UI线程中执行更新"""
try:
if hasattr(self, 'main_window') and self.main_window and hasattr(self.main_window, 'app'):
self.main_window.app._impl.loop.call_soon_threadsafe(update_func)
else:
# 如果主窗口尚未初始化,稍后重试
threading.Timer(0.1, lambda: self._safe_ui_update(update_func)).start()
except Exception as e:
print(f"UI更新错误: {e}")
def update_local_ip(self, ip):
"""更新本地IP显示"""
self.local_ip_label.text = ip
# 设置对方IP前缀(前3段)
if ip != "无法获取IP" and ip.count('.') == 3:
ip_parts = ip.split('.')
ip_prefix = '.'.join(ip_parts[0:3])
self.ip_prefix_input.value = ip_prefix
def request_all_files_permission(self):
"""请求所有文件访问权限"""
try:
from rubicon.java import JavaClass
Python = JavaClass('com.chaquo.python.Python')
Context = JavaClass('android.content.Context')
Intent = JavaClass('android.content.Intent')
Settings = JavaClass('android.provider.Settings')
Uri = JavaClass('android.net.Uri')
# 获取应用上下文
context = Python.getPlatform().getApplication()
# 检查是否已有权限
if hasattr(Settings, 'ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION'):
# 跳转到所有文件访问权限设置页面
intent = Intent(Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION)
uri = Uri.fromParts("package", context.getPackageName(), None)
intent.setData(uri)
intent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
self.log("请在设置中授予所有文件访问权限")
else:
# 对于较旧的Android版本,请求标准存储权限
self.log("请求存储权限...")
except Exception as e:
self.log(f"请求所有文件权限失败: {e}")
def check_launch_intent(self):
"""检查启动intent,处理从其他应用分享的文件"""
try:
# 使用rubicon-java获取Android Intent
from rubicon.java import JavaClass
# 获取Intent类
Intent = JavaClass("android.content.Intent")
Uri = JavaClass("android.net.Uri")
# 获取当前Activity
activity = self._impl.native
# 获取启动intent
intent = activity.getIntent()
if intent is None:
return
action = intent.getAction()
mime_type = intent.getType()
# 检查是否是分享文件的intent
if action == Intent.ACTION_SEND and mime_type is not None:
self.log("检测到文件分享intent")
# 获取文件URI
stream_extra = intent.getParcelableExtra(Intent.EXTRA_STREAM)
if stream_extra is not None:
file_uri = stream_extra.toString()
self.handle_shared_file(file_uri)
except Exception as e:
self.log(f"检查启动intent时出错: {e}")
def handle_intent(self, intent):
"""处理从其他App分享的intent(当应用已在运行时)"""
try:
from rubicon.java import JavaClass
Intent = JavaClass("android.content.Intent")
action = intent.getAction()
mime_type = intent.getType()
if action == Intent.ACTION_SEND and mime_type is not None:
self.log("接收到文件分享intent")
# 获取文件URI
stream_extra = intent.getParcelableExtra(Intent.EXTRA_STREAM)
if stream_extra is not None:
file_uri = stream_extra.toString()
self.handle_shared_file(file_uri)
except Exception as e:
self.log(f"处理intent时出错: {e}")
def handle_shared_file(self, file_uri_str):
"""处理分享的文件URI"""
try:
self.log(f"处理分享文件: {file_uri_str}")
# 使用rubicon-java解析URI和获取文件信息
from rubicon.java import JavaClass
Uri = JavaClass("android.net.Uri")
# 解析URI
file_uri = Uri.parse(file_uri_str)
# 获取ContentResolver
activity = self._impl.native
content_resolver = activity.getContentResolver()
# 查询文件信息
cursor = content_resolver.query(
file_uri,
None, # projection - 所有列
None, # selection
None, # selectionArgs
None # sortOrder
)
if cursor is not None and cursor.moveToFirst():
# 获取文件名
display_name_index = cursor.getColumnIndex("_display_name")
if display_name_index >= 0:
self.file_name = cursor.getString(display_name_index)
else:
# 如果无法获取文件名,使用默认名称
self.file_name = f"shared_file_{int(time.time())}"
cursor.close()
else:
# 如果无法查询到文件信息,使用默认名称
self.file_name = f"shared_file_{int(time.time())}"
self.file_uri = file_uri_str
# 更新UI
self.file_info_label.text = f"文件: {self.file_name}\n准备发送"
self.send_btn.enabled = True
self.log(f"已准备发送文件: {self.file_name}")
except Exception as e:
self.log(f"处理分享文件时出错: {e}")
def read_shared_file(self, file_uri_str):
"""使用ContentResolver读取分享的文件内容到临时文件"""
try:
self.log("开始读取分享的文件...")
from rubicon.java import JavaClass
Uri = JavaClass("android.net.Uri")
File = JavaClass("java.io.File")
FileOutputStream = JavaClass("java.io.FileOutputStream")
# 获取Activity和ContentResolver
activity = self._impl.native
content_resolver = activity.getContentResolver()
# 解析URI
file_uri = Uri.parse(file_uri_str)
# 打开输入流
input_stream = content_resolver.openInputStream(file_uri)
if input_stream is None:
self.log("无法打开文件输入流")
return None
# 创建临时文件
cache_dir = activity.getCacheDir()
temp_file = File(cache_dir, self.file_name)
# 创建输出流
output_stream = FileOutputStream(temp_file)
# 复制数据 - 使用较小的缓冲区避免cython错误
buffer_size = 8192 # 8KB缓冲区
# 正确创建Java字节数组
# 使用反射API创建byte数组
Array = JavaClass("java.lang.reflect.Array")
Byte = JavaClass("java.lang.Byte")
buffer = Array.newInstance(Byte.TYPE, buffer_size)
total_read = 0
bytes_read = input_stream.read(buffer)
while bytes_read != -1:
output_stream.write(buffer, 0, bytes_read)
total_read += bytes_read
bytes_read = input_stream.read(buffer)
# 关闭流
input_stream.close()
output_stream.close()
temp_file_path = temp_file.getAbsolutePath()
self.temp_files.append(temp_file_path) # 记录临时文件,用于后续清理
self.log(f"文件读取完成,大小: {total_read} 字节")
return temp_file_path
except Exception as e:
self.log(f"读取分享文件时出错: {e}")
return None
def read_shared_file_simple(self, file_uri_str):
"""简化版文件读取方法,使用Python原生方式"""
try:
self.log("开始读取分享的文件(简化版)...")
from rubicon.java import JavaClass
Uri = JavaClass("android.net.Uri")
File = JavaClass("java.io.File")
FileOutputStream = JavaClass("java.io.FileOutputStream")
# 获取Activity和ContentResolver
activity = self._impl.native
content_resolver = activity.getContentResolver()
# 解析URI
file_uri = Uri.parse(file_uri_str)
# 打开输入流
input_stream = content_resolver.openInputStream(file_uri)
if input_stream is None:
self.log("无法打开文件输入流")
return None
# 创建临时文件
cache_dir = activity.getCacheDir()
temp_file = File(cache_dir, self.file_name)
# 创建输出流
output_stream = FileOutputStream(temp_file)
# 使用Python字节数组进行读取
buffer_size = 8192 # 8KB缓冲区
total_read = 0
# 使用更简单的方法读取数据
while True:
# 创建一个新的字节数组
Array = JavaClass("java.lang.reflect.Array")
Byte = JavaClass("java.lang.Byte")
buffer = Array.newInstance(Byte.TYPE, buffer_size)
bytes_read = input_stream.read(buffer)
if bytes_read == -1:
break
output_stream.write(buffer, 0, bytes_read)
total_read += bytes_read
# 关闭流
input_stream.close()
output_stream.close()
temp_file_path = temp_file.getAbsolutePath()
self.temp_files.append(temp_file_path) # 记录临时文件,用于后续清理
self.log(f"文件读取完成,大小: {total_read} 字节")
return temp_file_path
except Exception as e:
self.log(f"读取分享文件时出错: {e}")
return None
def read_shared_file_fallback(self, file_uri_str):
"""备用文件读取方法,使用更简单的方式"""
try:
self.log("使用备用方法读取分享的文件...")
# 如果上述方法都失败,尝试使用更直接的方式
# 这里我们直接使用文件URI作为路径(仅适用于file:// URI)
if file_uri_str.startswith("file://"):
file_path = file_uri_str[7:] # 移除"file://"前缀
if os.path.exists(file_path):
return file_path
self.log("无法读取分享的文件URI")
return None
except Exception as e:
self.log(f"备用方法读取文件时出错: {e}")
return None
def cleanup_temp_files(self):
"""清理临时文件"""
for temp_file in self.temp_files:
try:
if os.path.exists(temp_file):
os.remove(temp_file)
self.log(f"已清理临时文件: {temp_file}")
except Exception as e:
self.log(f"清理临时文件时出错: {e}")
self.temp_files = []
def detect_encoding(self, data):
"""检测字节数据的编码"""
# 常见编码列表
encodings = ['utf-8', 'gbk', 'gb2312', 'latin-1', 'ascii']
for encoding in encodings:
try:
decoded = data.decode(encoding)
return encoding, decoded
except UnicodeDecodeError:
continue
# 如果所有编码都失败,使用 'utf-8' 并忽略错误
return 'utf-8', data.decode('utf-8', errors='ignore')
def start_server(self, host='0.0.0.0', port=8888):
"""启动文件接收服务器"""
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
server_socket.bind((host, port))
server_socket.listen(1)
self.log(f"服务器启动,监听端口 {port}")
while self.server_running:
try:
server_socket.settimeout(1.0)
client_socket, addr = server_socket.accept()
self.log(f"连接来自 {addr}")
# 接收文件名和大小
raw_data = client_socket.recv(1024)
# 检测编码并解码
encoding, file_info = self.detect_encoding(raw_data)
# 修复: 处理文件信息格式错误
parts = file_info.strip().split('|')
if len(parts) < 2:
self.log("文件信息格式错误")
client_socket.close()
continue
file_name = parts[0]
try:
file_size = int(parts[1])
except ValueError:
self.log("文件大小格式错误")
client_socket.close()
continue
# 创建接收目录 - 保存到Download文件夹
from rubicon.java import JavaClass
File = JavaClass("java.io.File")
FileOutputStream = JavaClass("java.io.FileOutputStream")
Environment = JavaClass("android.os.Environment")
# 获取Download目录
download_dir = Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS)
if download_dir is None:
# 备用方案:使用应用的files目录
activity = self._impl.native
download_dir = activity.getFilesDir()
receive_file = File(download_dir, file_name)
file_output = FileOutputStream(receive_file)
# 接收文件数据
received = 0
while received < file_size:
data = client_socket.recv(4096)
if not data:
break
file_output.write(data)
received += len(data)
progress = int(received / file_size * 100)
self._safe_ui_update(lambda p=progress: self.update_receive_progress(p))
file_output.close()
client_socket.close()
self.log(f"文件 {file_name} 接收完成")
self.log(f"保存路径: {receive_file.getAbsolutePath()}")
except socket.timeout:
continue
except Exception as e:
if self.server_running:
self.log(f"接收文件时出错: {e}")
except Exception as e:
self.log(f"服务器启动失败: {e}")
finally:
server_socket.close()
self.server_running = False
self._safe_ui_update(lambda: self.update_server_button(False))
def update_receive_progress(self, value):
"""更新接收进度"""
self.receive_progress.value = value
def update_server_button(self, running):
"""更新服务器按钮状态"""
self.start_server_btn.enabled = not running
if running:
self.start_server_btn.text = "服务器运行中"
self.start_server_btn.style.background_color = "#FF9800"
else:
self.start_server_btn.text = "启动服务器"
self.start_server_btn.style.background_color = "#4CAF50"
def start_fwq(self, widget):
"""启动服务器并请求权限"""
# 首先请求文件权限
self.request_all_files_permission()
# 然后启动服务器
if not self.server_running:
self.server_running = True
self.update_server_button(True)
threading.Thread(
target=self.start_server,
args=('0.0.0.0', 8888),
daemon=True
).start()
def send_file(self, server_ip, file_path, port=8888):
"""发送文件到目标服务器"""
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
client_socket.connect((server_ip, port))
file_size = os.path.getsize(file_path)
# 发送文件信息(使用UTF-8编码)
# 按照接收端的协议,发送固定1024字节的文件信息
file_info = f"{self.file_name}|{file_size}"
file_info_bytes = file_info.encode('utf-8')
# 如果文件信息不足1024字节,用空格填充
if len(file_info_bytes) < 1024:
file_info_bytes += b' ' * (1024 - len(file_info_bytes))
else:
# 如果超过1024字节,截断
file_info_bytes = file_info_bytes[:1024]
# 发送固定1024字节的文件信息
client_socket.send(file_info_bytes)
# 分块发送文件数据
with open(file_path, 'rb') as file:
sent = 0
start = time.time()
while sent < file_size:
data = file.read(4096)
client_socket.send(data)
sent += len(data)
progress = int(sent / file_size * 100)
self._safe_ui_update(lambda p=progress: self.update_send_progress(p))
self.usetime = time.time() - start
self.sudu = file_size / 1024 / 1024 / self.usetime if self.usetime > 0 else 0
except Exception as e:
self.log(f"发送文件时出错: {e}")
finally:
client_socket.close()
def update_send_progress(self, value):
"""更新发送进度"""
self.send_progress.value = value
def start_send(self, widget):
# 构建完整IP地址
ip_prefix = self.ip_prefix_input.value.strip()
ip_suffix = self.ip_suffix_input.value.strip()
if not ip_prefix or not ip_suffix:
self.main_window.error_dialog("错误", "请输入完整的对方IP地址")
return
server_ip = f"{ip_prefix}.{ip_suffix}"
# 验证IP格式
ip_pattern = re.compile(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$')
if not ip_pattern.match(server_ip):
self.main_window.error_dialog("错误", "请输入有效的IP地址")
return
if not self.file_uri:
self.main_window.error_dialog("错误", "没有可发送的文件")
return
# 重置进度条
self.send_progress.value = 0
# 在新线程中发送文件
threading.Thread(
target=self.send_file_thread,
args=(server_ip,),
daemon=True
).start()
def send_file_thread(self, server_ip):
try:
self.log("开始发送文件...")
# 尝试多种方法读取分享的文件
temp_file_path = None
# 方法1: 使用标准方法
temp_file_path = self.read_shared_file(self.file_uri)
# 方法2: 如果方法1失败,使用简化方法
if not temp_file_path or not os.path.exists(temp_file_path):
self.log("标准方法失败,尝试简化方法")
temp_file_path = self.read_shared_file_simple(self.file_uri)
# 方法3: 如果方法2失败,使用备用方法
if not temp_file_path or not os.path.exists(temp_file_path):
self.log("简化方法失败,尝试备用方法")
temp_file_path = self.read_shared_file_fallback(self.file_uri)
if temp_file_path and os.path.exists(temp_file_path):
self.send_file(server_ip, temp_file_path)
self._safe_ui_update(lambda: self.send_complete())
# 清理临时文件
self.cleanup_temp_files()
else:
self.log("无法读取分享的文件,请检查文件权限")
except Exception as e:
self.log(f"发送文件时出错: {e}")
def send_complete(self):
"""发送完成处理"""
self.send_progress.value = 100
current_text = self.log_area.value
new_text = f"{current_text}文件发送完成!耗时{self.usetime:.4f}秒, 速度:{self.sudu:.2f} MB/s\n"
self.log_area.value = new_text
def log(self, message):
"""记录日志"""
def _log():
current_text = self.log_area.value
new_text = f"{current_text}{message}\n" if current_text else f"{message}\n"
self.log_area.value = new_text
self._safe_ui_update(_log)
def main():
return FileTransferApp("文件传输工具", "com.example.filetransfer")
if __name__ == "__main__":
app = main()
app.main_loop()
记得修改AndroidManifest.xml,下面是参考
XML
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" >
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE" />
<application
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/formal_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:requestLegacyExternalStorage="true"
android:theme="@style/AppTheme.Launcher" >
<!-- https://developer.android.com/guide/topics/resources/runtime-changes#HandlingTheChange -->
<activity
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"
android:name="org.beeware.android.MainActivity"
android:exported="true" >
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="com.example.sendfile.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths">
</meta-data>
</provider>
</application>
</manifest>
运行界面:

2.win版,实际是原代码,linux也可以运行
python
# -*- coding: utf-8 -*-
"""
Created on Mon Sep 8 14:31:28 2025
@author: YBK
"""
import socket
import os
import tkinter as tk
from tkinter import filedialog, messagebox, scrolledtext
import threading
from tkinter import ttk
import time
from tkinter import LabelFrame
import struct
import sys
import subprocess
import pyperclip
from datetime import datetime
# 尝试导入tkinterdnd2,如果失败则使用标准tkinter
try:
from tkinterdnd2 import DND_FILES, TkinterDnD
HAS_DND = True
except ImportError:
# 回退到标准 tkinter
print("警告: tkinterdnd2未安装,拖拽功能不可用")
print("如需拖拽功能,请运行: pip install tkinterdnd2")
TkinterDnD = tk.Tk
DND_FILES = None
HAS_DND = False
class FileTransferGUI:
def __init__(self, master):
self.master = master
master.title("局域网文件传输工具")
master.geometry("700x700") # 增加尺寸以适应更多功能
# 服务器运行状态
self.server_running = False
# 存储接收的文件信息
self.received_files = [] # 存储(文件名, 路径, 大小, 接收时间)的列表
# 默认保存路径 - 使用正确的路径分隔符
if sys.platform == "win32":
# Windows下使用反斜杠
self.save_path = os.path.join(os.path.expanduser('~'), 'Downloads')
else:
# Linux/macOS使用斜杠
self.save_path = '/root/sd/Download'
# ========== 主布局 ==========
main_frame = tk.Frame(master)
main_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=10)
# 左侧控制面板
left_frame = tk.Frame(main_frame)
left_frame.pack(side=tk.LEFT, fill=tk.Y, padx=(0, 10))
# 右侧文件列表和日志
right_frame = tk.Frame(main_frame)
right_frame.pack(side=tk.RIGHT, fill=tk.BOTH, expand=True)
# ========== 左侧控制面板 ==========
# IP设置
tk.Label(left_frame, text="对方IP:").pack(pady=5, anchor=tk.W)
self.ip_entry = tk.Entry(left_frame, width=25)
self.ip_entry.pack(pady=5, fill=tk.X)
ip_parts = self.get_local_ip().split('.')
ip_prefix = '.'.join(ip_parts[0:3])
self.ip_entry.insert(0, f"{ip_prefix}.")
tk.Label(left_frame, text="本机IP:").pack(pady=5, anchor=tk.W)
self.bip_entry = tk.Entry(left_frame, width=25)
self.bip_entry.pack(pady=5, fill=tk.X)
self.ipfwq = self.get_local_ip()
self.bip_entry.insert(0, self.ipfwq)
# 保存路径设置
path_frame = tk.LabelFrame(left_frame, text="文件保存路径")
path_frame.pack(fill=tk.X, pady=10)
self.save_path_var = tk.StringVar(value=self.save_path)
tk.Entry(path_frame, textvariable=self.save_path_var, width=25).pack(side=tk.LEFT, fill=tk.X, expand=True, padx=5, pady=5)
tk.Button(path_frame, text="浏览", command=self.browse_save_path, width=8).pack(side=tk.RIGHT, padx=5, pady=5)
# 服务器控制
server_frame = tk.Frame(left_frame)
server_frame.pack(pady=10, fill=tk.X)
self.bt_fwq = tk.Button(server_frame, text="启动本机服务器", command=self.start_fwq, width=15)
self.bt_fwq.pack(side=tk.LEFT, padx=(0, 5))
self.stop_fwq_btn = tk.Button(server_frame, text="停止服务器", command=self.stop_fwq,
state=tk.DISABLED, width=12)
self.stop_fwq_btn.pack(side=tk.LEFT)
# 文件选择
tk.Button(left_frame, text="选择文件", command=self.select_file).pack(pady=5, fill=tk.X)
self.file_path = ""
self.file_name = ""
# 文件拖放区域(兼容模式)
if HAS_DND:
# 使用tkinterdnd2的拖拽
drop_frame = tk.LabelFrame(left_frame, text="拖放文件到此区域")
drop_frame.pack(fill=tk.X, pady=5)
drop_label = tk.Label(drop_frame, text="将文件拖放到此区域", height=2)
drop_label.pack(pady=5, padx=5)
drop_label.drop_target_register(DND_FILES)
drop_label.dnd_bind('<<Drop>>', self.on_file_drop)
else:
# 使用标准按钮
drop_frame = tk.LabelFrame(left_frame, text="文件操作")
drop_frame.pack(fill=tk.X, pady=5)
tk.Button(drop_frame, text="点击选择文件", command=self.select_file).pack(pady=5)
# 发送按钮
tk.Button(left_frame, text="发送文件", command=self.start_send).pack(pady=10, fill=tk.X)
# 进度条
progress_frame = tk.LabelFrame(left_frame, text="传输进度")
progress_frame.pack(fill=tk.X, pady=10)
tk.Label(progress_frame, text="发送进度:").pack(anchor=tk.W, pady=(5,0))
self.progress_var = tk.DoubleVar()
self.progress_bar = ttk.Progressbar(progress_frame, variable=self.progress_var, maximum=100)
self.progress_bar.pack(fill=tk.X, padx=5, pady=(0,10))
tk.Label(progress_frame, text="接收进度:").pack(anchor=tk.W)
self.progress_var1 = tk.DoubleVar()
self.progress_bar1 = ttk.Progressbar(progress_frame, variable=self.progress_var1, maximum=100)
self.progress_bar1.pack(fill=tk.X, padx=5, pady=(0,5))
# 状态栏
self.status_var = tk.StringVar()
self.status_var.set("就绪")
status_bar = tk.Label(left_frame, textvariable=self.status_var, bd=1, relief=tk.SUNKEN,
anchor=tk.W, height=2)
status_bar.pack(side=tk.BOTTOM, fill=tk.X, pady=(10, 0))
# ========== 右侧文件列表和日志 ==========
# 接收文件列表区域
files_frame = tk.LabelFrame(right_frame, text="接收的文件列表")
files_frame.pack(fill=tk.BOTH, expand=True, pady=(0, 10))
# 创建Treeview
columns = ("文件名", "大小", "接收时间", "保存路径", "状态")
self.tree = ttk.Treeview(files_frame, columns=columns, show="headings", height=8)
# 设置列标题
self.tree.heading("文件名", text="文件名")
self.tree.heading("大小", text="大小")
self.tree.heading("接收时间", text="接收时间")
self.tree.heading("保存路径", text="保存路径")
self.tree.heading("状态", text="状态")
# 设置列宽度
self.tree.column("文件名", width=150)
self.tree.column("大小", width=80)
self.tree.column("接收时间", width=120)
self.tree.column("保存路径", width=150)
self.tree.column("状态", width=80)
# 添加滚动条
tree_scroll = ttk.Scrollbar(files_frame, orient="vertical", command=self.tree.yview)
self.tree.configure(yscrollcommand=tree_scroll.set)
# 布局Treeview和滚动条
self.tree.pack(side=tk.LEFT, fill=tk.BOTH, expand=True)
tree_scroll.pack(side=tk.RIGHT, fill=tk.Y)
# 文件操作按钮框架
buttons_frame = tk.Frame(right_frame)
buttons_frame.pack(fill=tk.X, pady=(0, 10))
tk.Button(buttons_frame, text="打开选中文件", command=self.open_selected_file,
width=12).pack(side=tk.LEFT, padx=2)
tk.Button(buttons_frame, text="复制文件路径", command=self.copy_selected_path,
width=12).pack(side=tk.LEFT, padx=2)
tk.Button(buttons_frame, text="打开文件夹", command=self.open_selected_folder,
width=12).pack(side=tk.LEFT, padx=2)
tk.Button(buttons_frame, text="删除记录", command=self.delete_selected_record,
width=12).pack(side=tk.LEFT, padx=2)
# 批量操作按钮
batch_frame = tk.Frame(right_frame)
batch_frame.pack(fill=tk.X, pady=(0, 10))
tk.Button(batch_frame, text="全部打开", command=self.open_all_files,
width=10).pack(side=tk.LEFT, padx=2)
tk.Button(batch_frame, text="清除列表", command=self.clear_file_list,
width=10).pack(side=tk.LEFT, padx=2)
# 日志区域
log_frame = tk.LabelFrame(right_frame, text="传输日志")
log_frame.pack(fill=tk.BOTH, expand=True)
self.log_area = scrolledtext.ScrolledText(log_frame, height=10)
self.log_area.pack(fill=tk.BOTH, expand=True)
self.log_area.config(state=tk.DISABLED)
# 如果支持拖拽,注册日志区域的拖拽目标
if HAS_DND:
self.log_area.drop_target_register(DND_FILES)
self.log_area.dnd_bind('<<Drop>>', self.on_file_drop)
# 绑定Treeview双击事件
self.tree.bind("<Double-1>", self.on_tree_double_click)
# 绑定键盘事件
self.master.bind("<Control-o>", lambda e: self.open_selected_file())
self.master.bind("<Control-c>", lambda e: self.copy_selected_path())
self.master.bind("<Delete>", lambda e: self.delete_selected_record())
# 创建保存目录
self.ensure_save_path()
def ensure_save_path(self):
"""确保保存目录存在"""
self.save_path = self.save_path_var.get()
# 确保路径使用正确的分隔符
self.save_path = os.path.normpath(self.save_path)
if not os.path.exists(self.save_path):
try:
os.makedirs(self.save_path, exist_ok=True)
self.log(f"创建保存目录: {self.save_path}")
except Exception as e:
self.log(f"创建目录失败: {e}")
# 回退到临时目录
self.save_path = os.path.join(os.path.expanduser('~'), 'Desktop')
if sys.platform != "win32":
self.save_path = '/tmp'
self.save_path_var.set(self.save_path)
os.makedirs(self.save_path, exist_ok=True)
def browse_save_path(self):
"""浏览并选择保存路径"""
directory = filedialog.askdirectory(title="选择文件保存目录")
if directory:
# 使用正确的路径格式
directory = os.path.normpath(directory)
self.save_path_var.set(directory)
self.save_path = directory
self.log(f"保存路径已更改为: {directory}")
def parse_dropped_files(self, data):
"""解析拖拽的文件路径,正确处理包含空格的情况"""
import re
files = []
if HAS_DND:
# tkinterdnd2 用花括号包裹包含空格的文件路径
brace_pattern = r'\{[^}]+\}'
brace_matches = re.findall(brace_pattern, data)
for match in brace_matches:
# 移除花括号
file_path = match[1:-1]
files.append(file_path)
# 从原始数据中移除已匹配的部分
data = data.replace(match, '', 1)
# 处理剩余的不带花括号的文件路径
remaining_files = [f.strip() for f in data.split() if f.strip()]
files.extend(remaining_files)
return files
def on_file_drop(self, event):
"""处理文件拖放事件"""
files = self.parse_dropped_files(event.data)
if files:
self.file_path = files[0]
self.file_name = os.path.basename(self.file_path)
self.log(f"已选择文件: {self.file_path}")
self.status_var.set(f"已选择: {self.file_name}")
def get_local_ip(self):
"""获取本机局域网IP地址"""
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
ip = s.getsockname()[0]
s.close()
return ip
except Exception as e:
return f"127.0.0.1"
def format_file_size(self, size_in_bytes):
"""格式化文件大小"""
for unit in ['B', 'KB', 'MB', 'GB']:
if size_in_bytes < 1024.0:
return f"{size_in_bytes:.2f} {unit}"
size_in_bytes /= 1024.0
return f"{size_in_bytes:.2f} TB"
def add_file_to_list(self, file_name, file_path, file_size, status="已完成"):
"""添加文件到Treeview列表"""
# 格式化文件大小
size_str = self.format_file_size(file_size)
# 获取当前时间
current_time = datetime.now().strftime("%H:%M:%S")
# 显示缩短的路径
short_path = file_path
if len(file_path) > 30:
short_path = "..." + file_path[-27:]
# 添加到Treeview
item_id = self.tree.insert("", tk.END, values=(file_name, size_str, current_time, short_path, status))
# 存储详细信息
self.received_files.append({
'id': item_id,
'name': file_name,
'path': file_path,
'size': file_size,
'time': current_time,
'status': status
})
# 更新状态
self.status_var.set(f"已添加: {file_name}")
# 如果列表太长,自动滚动到底部
self.tree.see(item_id)
def get_selected_file_info(self):
"""获取选中文件的信息"""
selection = self.tree.selection()
if not selection:
messagebox.showwarning("提示", "请先选择一个文件")
return None
item_id = selection[0]
for file_info in self.received_files:
if file_info['id'] == item_id:
return file_info
return None
def open_selected_file(self):
"""打开选中的文件"""
file_info = self.get_selected_file_info()
if not file_info:
return
file_path = file_info['path']
# 确保路径格式正确
file_path = os.path.normpath(file_path)
if not os.path.exists(file_path):
messagebox.showerror("错误", f"文件不存在:\n{file_path}")
# 更新状态
self.tree.set(file_info['id'], column="状态", value="文件丢失")
return
try:
if sys.platform == "win32":
# Windows使用startfile
os.startfile(file_path)
elif sys.platform == "darwin":
subprocess.call(["open", file_path])
else:
subprocess.call(["xdg-open", file_path])
self.log(f"已打开文件: {file_info['name']}")
self.status_var.set(f"正在打开: {file_info['name']}")
except Exception as e:
messagebox.showerror("错误", f"无法打开文件: {e}")
self.log(f"打开文件失败: {e}")
def copy_selected_path(self):
"""复制选中文件的路径"""
file_info = self.get_selected_file_info()
if not file_info:
return
file_path = file_info['path']
# 确保路径格式正确
file_path = os.path.normpath(file_path)
try:
# 尝试使用pyperclip
try:
import pyperclip
pyperclip.copy(file_path)
except ImportError:
# 备用方案:使用tkinter剪贴板
self.master.clipboard_clear()
self.master.clipboard_append(file_path)
self.master.update()
self.log(f"已复制路径: {file_path}")
self.status_var.set("路径已复制到剪贴板")
# 显示临时提示
self.show_temp_message("路径已复制!")
except Exception as e:
messagebox.showerror("错误", f"无法复制到剪贴板: {e}")
def open_selected_folder(self):
"""打开选中文件所在的文件夹"""
file_info = self.get_selected_file_info()
if not file_info:
return
file_path = file_info['path']
# 确保路径格式正确
file_path = os.path.normpath(file_path)
if not os.path.exists(file_path):
messagebox.showerror("错误", f"文件不存在:\n{file_path}")
return
try:
folder_path = os.path.dirname(file_path)
if sys.platform == "win32":
# Windows: 在资源管理器中打开并选中文件
# 注意:这里需要使用双引号包裹路径,以防路径中有空格
subprocess.run(f'explorer /select,"{file_path}"', shell=True)
elif sys.platform == "darwin":
subprocess.call(["open", "-R", file_path])
else:
subprocess.call(["xdg-open", folder_path])
self.log(f"已打开文件夹: {folder_path}")
self.status_var.set("正在打开文件夹")
except Exception as e:
messagebox.showerror("错误", f"无法打开文件夹: {e}")
self.log(f"打开文件夹失败: {e}")
def delete_selected_record(self):
"""删除选中的记录"""
selection = self.tree.selection()
if not selection:
messagebox.showwarning("提示", "请先选择一个文件记录")
return
for item_id in selection:
# 从Treeview删除
self.tree.delete(item_id)
# 从存储列表中删除
self.received_files = [f for f in self.received_files if f['id'] != item_id]
self.status_var.set("已删除选中记录")
self.log(f"删除了 {len(selection)} 条记录")
def open_all_files(self):
"""打开所有文件"""
if not self.received_files:
messagebox.showinfo("提示", "没有可打开的文件")
return
success_count = 0
for file_info in self.received_files:
file_path = file_info['path']
# 确保路径格式正确
file_path = os.path.normpath(file_path)
if os.path.exists(file_path):
try:
if sys.platform == "win32":
os.startfile(file_path)
elif sys.platform == "darwin":
subprocess.call(["open", file_path])
else:
subprocess.call(["xdg-open", file_path])
success_count += 1
except:
pass
self.log(f"尝试打开了 {success_count}/{len(self.received_files)} 个文件")
self.status_var.set(f"已打开 {success_count} 个文件")
def clear_file_list(self):
"""清空文件列表"""
if not self.received_files:
return
if messagebox.askyesno("确认", f"确定要清除所有 {len(self.received_files)} 条文件记录吗?"):
# 清空Treeview
for item in self.tree.get_children():
self.tree.delete(item)
# 清空存储列表
self.received_files.clear()
self.log("已清空文件列表")
self.status_var.set("文件列表已清空")
def on_tree_double_click(self, event):
"""Treeview双击事件"""
region = self.tree.identify("region", event.x, event.y)
if region == "cell":
# 双击文件打开
self.open_selected_file()
def show_temp_message(self, message, duration=1500):
"""显示临时消息"""
temp_label = tk.Label(
self.master,
text=message,
bg="lightgreen",
fg="black",
font=("Arial", 10, "bold")
)
# 计算位置
temp_label.place(relx=0.75, rely=0.25, anchor=tk.CENTER)
# 定时移除消息
def remove_label():
temp_label.destroy()
self.master.after(duration, remove_label)
def start_server(self, host, port=8888):
"""启动文件接收服务器(使用改进的协议)"""
self.server_running = True
server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
try:
server_socket.bind((host, port))
server_socket.listen(1)
self.log(f"服务器启动,监听 {host}:{port}")
# 确保保存目录存在
self.ensure_save_path()
while self.server_running:
try:
server_socket.settimeout(1.0)
client_socket, addr = server_socket.accept()
self.log(f"连接来自 {addr}")
# 接收固定1024字节的文件信息
file_info_bytes = client_socket.recv(1024)
if not file_info_bytes:
client_socket.close()
continue
# 解码文件信息(移除填充的空格)
try:
file_info = file_info_bytes.decode('utf-8').strip()
except UnicodeDecodeError:
# 如果utf-8失败,尝试其他编码
try:
file_info = file_info_bytes.decode('gbk').strip()
except:
# 最后尝试latin-1
file_info = file_info_bytes.decode('latin-1', errors='ignore').strip()
# 解析文件信息
parts = file_info.split('|')
if len(parts) < 2:
self.log(f"文件信息格式错误: {file_info}")
client_socket.close()
continue
file_name = parts[0]
try:
file_size = int(parts[1])
except ValueError:
self.log(f"文件大小格式错误: {parts[1]}")
client_socket.close()
continue
# 创建保存路径 - 使用os.path.join确保正确的路径分隔符
save_path = os.path.join(self.save_path, file_name)
# 如果文件已存在,添加时间戳
if os.path.exists(save_path):
name, ext = os.path.splitext(file_name)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
file_name = f"{name}_{timestamp}{ext}"
save_path = os.path.join(self.save_path, file_name)
self.log(f"开始接收文件: {file_name} ({file_size} 字节)")
# 接收文件数据
with open(save_path, 'wb') as file:
received = 0
while received < file_size:
# 计算剩余字节数
remaining = file_size - received
# 每次最多接收4096字节
chunk_size = min(4096, remaining)
data = client_socket.recv(chunk_size)
if not data:
break
file.write(data)
received += len(data)
# 更新进度
progress = int(received / file_size * 100)
self.progress_var1.set(progress)
client_socket.close()
if received == file_size:
# 添加到文件列表
self.master.after(0, lambda fn=file_name, fp=save_path, fs=file_size:
self.add_file_to_list(fn, fp, fs, "已完成"))
self.log(f"文件 {file_name} 接收完成")
self.log(f"保存路径: {save_path}")
# 显示完成对话框
self.master.after(0, lambda fn=file_name, sp=save_path:
messagebox.showinfo("接收完成",
f"文件 {fn} 接收完成\n\n保存到:\n{sp}"))
else:
self.log(f"文件接收不完整: {received}/{file_size} 字节")
self.master.after(0, lambda fn=file_name, rec=received, total=file_size:
self.add_file_to_list(fn, save_path, total, f"不完整 {rec}/{total}"))
except socket.timeout:
continue
except Exception as e:
if self.server_running:
self.log(f"接收文件时出错: {str(e)}")
except Exception as e:
self.log(f"服务器启动失败: {str(e)}")
finally:
server_socket.close()
self.server_running = False
self.master.after(0, self.update_server_button)
def update_server_button(self):
"""更新服务器按钮状态"""
self.bt_fwq.config(state=tk.NORMAL, bg="SystemButtonFace")
self.stop_fwq_btn.config(state=tk.DISABLED)
self.log("服务器已停止")
self.status_var.set("服务器已停止")
def start_fwq(self):
"""启动服务器"""
fwq_ip = self.bip_entry.get()
if not fwq_ip:
messagebox.showerror("错误", "请输入本机IP地址")
return
# 在新线程中启动服务器
server_thread = threading.Thread(target=self.start_server, args=(fwq_ip, 8888))
server_thread.daemon = True
server_thread.start()
self.bt_fwq.config(state=tk.DISABLED, bg="lightgray")
self.stop_fwq_btn.config(state=tk.NORMAL)
self.log(f"服务器已在 {fwq_ip}:8888 启动")
self.status_var.set("服务器运行中...")
def stop_fwq(self):
"""停止服务器"""
self.server_running = False
self.stop_fwq_btn.config(state=tk.DISABLED)
self.log("正在停止服务器...")
self.status_var.set("正在停止服务器...")
def send_file(self, server_ip, file_path, port=8888):
"""发送文件到目标服务器(使用改进的协议)"""
if not os.path.exists(file_path):
self.log(f"文件不存在: {file_path}")
return
# 确保文件路径格式正确
file_path = os.path.normpath(file_path)
self.file_name = os.path.basename(file_path)
client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
try:
client_socket.connect((server_ip, port))
file_size = os.path.getsize(file_path)
# 发送文件信息(使用UTF-8编码)
# 按照接收端的协议,发送固定1024字节的文件信息
file_info = f"{self.file_name}|{file_size}"
file_info_bytes = file_info.encode('utf-8')
# 如果文件信息不足1024字节,用空格填充
if len(file_info_bytes) < 1024:
file_info_bytes += b' ' * (1024 - len(file_info_bytes))
else:
# 如果超过1024字节,截断
file_info_bytes = file_info_bytes[:1024]
# 发送固定1024字节的文件信息
client_socket.send(file_info_bytes)
self.log(f"发送文件信息: {self.file_name} ({file_size} 字节)")
# 分块发送文件数据
with open(file_path, 'rb') as file:
sent = 0
start = time.time()
while sent < file_size:
data = file.read(4096)
client_socket.send(data)
sent += len(data)
progress = int(sent / file_size * 100)
self.progress_var.set(progress)
elapsed_time = time.time() - start
speed = file_size / 1024 / 1024 / elapsed_time if elapsed_time > 0 else 0
self.log(f"文件发送成功, 耗时{elapsed_time:.2f}秒, 速度:{speed:.2f} MB/s")
except ConnectionRefusedError:
self.log(f"连接被拒绝,请检查服务器IP和端口")
self.status_var.set("连接被拒绝")
except Exception as e:
self.log(f"发送文件时出错: {str(e)}")
finally:
client_socket.close()
def select_file(self):
"""选择文件"""
self.file_path = filedialog.askopenfilename()
if self.file_path:
# 确保文件路径格式正确
self.file_path = os.path.normpath(self.file_path)
self.file_name = os.path.basename(self.file_path)
self.log(f"已选择文件: {self.file_path}")
self.status_var.set(f"已选择: {self.file_name}")
def start_send(self):
"""开始发送文件"""
server_ip = self.ip_entry.get()
if not server_ip or not self.file_path:
messagebox.showerror("错误", "请输入对方IP并选择文件")
return
# 在新线程中发送文件
send_thread = threading.Thread(target=self.send_file_thread, args=(server_ip, self.file_path))
send_thread.daemon = True
send_thread.start()
def send_file_thread(self, server_ip, file_path):
"""发送文件的线程"""
try:
self.log("开始发送文件...")
self.send_file(server_ip, file_path)
self.progress_var.set(100)
# 发送完成后显示消息
self.master.after(0, lambda: messagebox.showinfo("发送完成", "文件发送完成"))
except Exception as e:
self.log(f"发送文件时出错: {str(e)}")
def log(self, message):
"""添加日志"""
self.log_area.config(state=tk.NORMAL)
self.log_area.insert(tk.END, message + "\n")
self.log_area.config(state=tk.DISABLED)
self.log_area.yview(tk.END)
if __name__ == "__main__":
# 检查必要的库
try:
import pyperclip
except ImportError:
print("注意: pyperclip未安装,复制功能可能受限")
print("如需完整功能,请运行: pip install pyperclip")
# 创建窗口
if HAS_DND:
root = TkinterDnD.Tk()
else:
root = tk.Tk()
app = FileTransferGUI(root)
# 设置窗口最小尺寸
root.minsize(600, 700)
root.mainloop()
运行界面:
