1.前言
我之前從手機上傳輸到電腦上一些apk進行分析,都是使用es文件瀏覽器這款軟件獲取 app,傳輸方面使用QQ,這樣很麻煩,走外網流量暫且不提,總是感覺浪費掉了局域網這個環境。簡單研究了一下es文件瀏覽器的局域網傳輸文件的功能,感覺還是挺好用的,就是這個軟件只有 安卓和ios版的,沒有桌面版的,於是我就開始構思寫一個桌面版的快傳功能的軟件,可以與 安卓/ios版的es文件瀏覽器的快傳功能直接對接,從而實現 從安卓,ios通過局域網傳輸文件到電腦。
2.分析協議
要做到傳輸文件這種功能,首先就是要知道 es文件瀏覽器這款軟件快傳功能使用的是什么協議,所以需要抓包。
快傳功能需要進行一些設置,否則手機會自動建立一個熱點(手機建立wifi熱點,另一台手機連接后其實也是在一個局域網):
2.1 如何發現接受設備
當手機要發送文件時,是如何發現另一台手機的,按照流程另一台手機是需要點擊 接收 按鈕的:
點擊接受按鈕后我直接在電腦上使用 wireshark 進行抓包:
看到,接受端需要發送一個 udp的組播數據包,組播地址是224.0.0.1
,端口是6343
,數據內容就是 用戶名:ip地址:receive
這種格式。
用python發送 udp組播數據包,測試發送端(手機)是否能發現:
import socket
import time
def get_host_ip():
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 53))
ip = s.getsockname()[0]
finally:
s.close()
return ip
def send_UDP():
localIp = get_host_ip()
port= 6343
castAddr = '224.0.0.1'
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.bind((localIp,port))
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL , 1) #設置ttl
while True:
sock.sendto(b'%s:%s:receive' % (name.encode(),localIp.encode()), (castAddr,port) )
print("已發送")
time.sleep(1)
if __name__ == '__main__':
name = "wshuo"
send_UDP()
成功發現了,並且成功解析出我定義的名字了。其實這一步主要是找到接收方的IP地址。后面傳輸時會用到。
2.2 傳輸文件協議分析
具體傳輸文件使用協議不同於發現,發現用到是組播udp,局域網內的設備使用 wireshark 抓包都抓取到。
由於 es文件瀏覽器 沒有桌面端的軟件,我要實現抓包文件具體傳輸就需要讓局域網傳輸的流量流經我的電腦。我采用電腦建立熱點的這種方法,然后兩個手機同時連接電腦的熱點,這樣倆個手機傳輸產生的數據包都會流經我的網卡。然后再使用 wireshark 進行抓包:
傳輸文件使用的是TCP協議,接受端在 42135端口建立服務,由於數據包大小限制,我們看到每一條數據都是零散的,可以使用追蹤TCP流 來查看具體數據傳輸:
發送端發送一個請求頭,可以看到是類似於HTTP請求,但是使用的是 MYPOST,不是POST,是一個自定義的 應用層 協議,基於TCP協議。 除去請求頭后面的也不是具體的文件數據,因為這里傳輸的是一個圖片,圖片的文件頭不是這樣的。這部分數據經過我后續的分析其實是一個縮略圖,並且由Append-Data
請求頭決定,如果沒有Append-Data
請求頭,這部分數據就不存在。
繼續尋找圖片數據:
可以看到,當獲得完縮略圖數據后,接受端會響應一個響應頭,然后就繼續接受數據了,首先是文件名,然后是file,這個相當於是類型,因為可能傳輸的是一個文件夾folder, 然后是文件大小,最后就是圖片的數據了。
文件結束后,會有 File end 標識,然后整個傳輸流接受會有 OVER 標識。
3.總結
傳輸流程:
udp組播數據:用戶名:ip地址:receive
tcp連接請求頭: MYPOST /test2.jpg HTTP/1.1\r\nConnection: Keep-Alive\r\nContent-Type: application/file\r\nFiles-Number: 1\r\nItems-Number: 1\r\nContent-Length: 3700879\r\nUser-Agent: Dalvik\r\nHost: localhost\r\nsender-name: wshuo\r\n\r\n
字段 | 作用 |
---|---|
test2.jpg | 接收端顯示的文件名,具體保存時使用的文件名在tcp連接數據流中 |
Content-Type | application/file表示傳輸的是一個文件,application/folder表示是一個文件夾,同樣顯示時使用,不作為具體保存使用 |
Files-Number | 傳輸文件數量,可以選擇多個文件同時傳輸 |
Items-Number | 傳輸條目數量,當傳輸一個文件夾時,會遞歸傳輸該文件夾內所有文件夾和文件 |
Content-Length | 文件總大小,文件夾大小為0,所有文件大小之和 |
sender-name | 發送端的名字 |
Append-Data | 可有可無,如果有,表示是否添加縮略圖數據,一般傳輸圖片時會有此字段 |
tcp連接響應頭:Transfer-Version: 1\r\nServer: ES Name Response Server\r\nContent-Type: text/html\r\nContent-Length: 2\r\nConnection: close\r\n\r\nOK
請求頭,沒啥解析的,上面這類的表示接收端接受數據。
tcp連接響應頭:HTTP/1.1 404 Not Found\r\n\r\n
這類表示接收端拒絕接受數據。
tcp連接數據流:
文件數據流:
這是兩個文件數據例子,如果多個文件繼續疊加即可。
文件夾數據流:
文件夾數據較為簡單,字段也就兩個。
4. python接收端編寫
經過上面的分析,python代碼需要實現分為以下幾步:
- python發送udp組播數據包
- 使用socket建立tcp連接服務端
- 接收請求頭數據
- 返回響應頭數據
- 接收具體文件或文件夾數據流
- 從數據流中解析出文件夾和文件並將其保存
原理倒是不復雜,但是細節有些復雜,比如接受圖片時會有 Append-Data字段,同時會有縮略圖數據,由於 python socket服務端的recv會阻塞住,那么我就不知道什么時候才能將縮略圖數據接受完畢,只有接受完所有數據才能返回響應頭數據,否則可能會導致縮略圖數據與真正的文件數據混到一起,從而導致無法解析。這部分我采用在讀取縮略圖數據是 循環讀取,settimeout()方法,當沒有數據返回是就會報錯,容錯處理跳出 讀取循環。這樣接下來返回響應頭數據再次讀取就是真正的文件流數據了(非阻塞的方法實現較為復雜,所以這一步比較粗暴但是很有效)。
另一個難點就是解析 從數據流中解析文件夾和文件,開始時我想在讀取接收端的數據直接解析出來,后來發現這種方法很難實現,因為你無法保證真正的數據流中是否包含 File end或者\r\n這些關鍵數據,這這些數據都會對解析進行干擾。所以這里我采用兩步,將真正數據流保存成數據文件,然后再對數據文件進行解析,這樣就簡單很多了,因為可以直接使用readline這樣的函數來實現解析 \r\n 了。
receviceData.py
import socket
import time
import os
from threading import Thread
response = """\ Transfer-Version: 1 Server: ES Name Response Server Content-Type: text/html Content-Length: 2 Connection: close OK"""
class MItemObject():
def __init__(self,f):
self.rootDir = "" #存儲根目錄
self.f = f
self.ItemName = f.readline()
self.ItemType = f.readline()
self.fileSize = None
self.fileRange = [None,None] # 文件數據位置
self.endPos = None #結束位置,也是下一條目開始位置
self.end = False #是否是最后一個條目
if (b"\r\n" not in self.ItemName) or (b"\r\n" not in self.ItemType):
raise BaseException(r"\r\n錯誤")
else:
self.ItemName = self.ItemName.strip(b"\r\n").decode()
self.ItemType = self.ItemType.strip(b"\r\n").decode()
if self.ItemType == "file":
self.fileSize = int(f.readline().strip(b"\r\n").decode())
self.fileRange = [f.tell(),f.tell()+self.fileSize]
f.seek(self.fileRange[1])
if f.readline().strip(b"\r\n").decode() != "File end":
raise BaseException(r"File end錯誤")
self.endPos = f.tell()
elif self.ItemType == "folder":
self.endPos = f.tell()
else:
raise BaseException(r"文件類型錯誤")
print(self.ItemName,self.ItemType,self.fileSize,self.fileRange,self.endPos)
if f.readline().strip(b"\r\n").decode() == "OVER":
self.end = True
self.saveData()
f.seek(self.endPos)
def saveData(self):
if self.ItemType == "folder":
if not os.path.exists(os.path.join(self.rootDir, self.ItemName)):
os.mkdir(os.path.join(self.rootDir, self.ItemName))
elif self.ItemType == "file":
self.f.seek(self.fileRange[0])
with open(os.path.join(self.rootDir, self.ItemName),"wb") as f:
f.write(self.f.read(self.fileSize))
self.f.seek(self.endPos)
def progress(arg):
scale = 50
i = int(arg * scale)
a = "*" * i
b = "." * (scale - i)
c = (i / scale) * 100
print("\r{:^3.0f}%[{}->{}]".format(c,a,b),end = "")
def get_host_ip():
try:
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(('8.8.8.8', 53))
ip = s.getsockname()[0]
finally:
s.close()
return ip
def send_UDP():
localIp = get_host_ip()
port= 6343
castAddr = '224.0.0.1'
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM, socket.IPPROTO_UDP)
sock.bind((localIp,port)) #綁定發送端口到SENDERPORT
sock.setsockopt(socket.IPPROTO_IP, socket.IP_MULTICAST_TTL , 1) #設置使用多播發送
while True:
sock.sendto(b'%s:%s:receive' % (name.encode(),localIp.encode()), (castAddr,port) )
time.sleep(1)
def server():
s = socket.socket()
s.bind(("0.0.0.0",42135))
s.listen(1)
while True:
conn,addr = s.accept()
data = conn.recv(1024)
conn.settimeout(2)
# 讀取頭信息
header = data.split(b"\r\n\r\n")[0]
extraData = data.split(b"\r\n\r\n")[1]
headerData = header.decode().split("\r\n")[1:]
headerDict = {i.split(":")[0].strip():i.split(":")[1].strip() for i in headerData}
# 讀取掉無用的數據,縮略圖
while True:
try:
extraData += conn.recv(1024)
except Exception as e:
# print(e)
break
conn.settimeout(None)
conn.send(response.encode())
time.sleep(0.5)
# 讀取主要數據
f = open("stream","wb")
streamLen = 0
allFileSize = int(headerDict["Content-Length"])
while True:
result = conn.recv(2048)
streamLen += len(result)
f.write(result)
if streamLen > allFileSize:
progress(1)
else:
progress(streamLen/allFileSize)
if not result:
break
f.close()
print("\n寫入完成!")
f = open("stream","rb")
while True:
item = MItemObject(f)
if item.end:
break
f.close()
os.remove("stream")
if __name__ == '__main__':
name = "wshuo" #用戶名
Thread(target=send_UDP).start()
server()
這個腳本最好在終端運行,因為有進度條效果:
可以接收文件夾,文件。
5. QT軟件編寫
只是一個接收端我還不滿足,我還想實現發送端的功能,這樣才算是一個完整的軟件。而python編寫發送端軟件不太友好,因為選擇文件傳輸時手動輸入文件名或文件夾名很不友好。PyQt5 可以寫界面,但是打包程序太大,思來想去還是用原生的c++ Qt來編寫吧。
這其中遇到的難點也不少,比如 QTcpSocket 使用起來不會阻塞線程,通信方面都是信號槽的方式,讓我有些不習慣。另外就是發送端的遞歸文件夾內部所有子文件夾或文件,將其變化為傳輸的數據流,這里依然采用兩步操作。更多細節難點我就不說了,都解決了。
6.軟件功能預覽
主界面:
接收界面:
藍色的字是可以點擊的,打開對應文件夾或文件。
發送界面:
可以拖拽進一個文件夾,然后就會遞歸讀取該文件夾下的所有文件夾與文件,或者可以拖拽進多個文件。但是不可以同時拖進文件夾和文件,因為同時處理文件夾和文件這部分邏輯邏輯比較復雜,所以我對這部分進行了限制。
設置界面:
可以設置用戶名,以及接收文件保存的路徑。
另外,這個軟件以及包含發送功能,和接受功能,這樣也可以實現電腦與電腦之間傳輸文件使用了。相當於彌補了 ES文件瀏覽器 傳輸文件功能在桌面端的空白了。