Bootstrap

简单好用的macOS文件共享——EasyShare

EasyShare logo

前言

首先,我是一个Android开发者,这也是我第一次用Swift写东西,所以可能会有并不太地道的用法,请见谅。先看一下软件基本信息:

  1. 开发语言:Swift 5
  2. 操作系统:macOS 10.13及以上
  3. 功能:在同一个网络下,生成文件对应的二维码及链接,提供给其他设备进行下载
  4. 形式:GUI

看一下演示效果:(压缩得有点狠,将就看一看)
分享演示

功能分析

  1. 本地开启http服务
  2. 编写接口将文件写入流
  3. 添加GUI实现

本地开启http服务

通过各种搜索工具,我找到了一个叫做Perfect的库。同时,在查看Perfect文档的同时,我也发现Perfect做了Sqlite的ORM,所以也就一起拿来用了。本来是准备做常驻服务来着,所以就做了数据库来存东西,但是后来觉得不太方便使用,又去掉了,现在只是当作缓存在使用。
首先看一下两个表:tb_config和tb_share.

tb_config

keytyperemark
keyString主键
valueString配置参数
remarkString备注

tb_share

keytyperemark
idInt主键
nameString文件名
keyString文件标识,用于展示分享链接
pathString文件路径
createTimeInt64创建时间

数据库差不多就这样,看一下tb_share里面key的生成,其实就是时间戳加上文件名取md5之后再base64。能得到一个类似这样的结果:h-rdq6pojZWQcCr0j0kRAg

//
//  DataUtils.swift
//  EasyShare
//
//  Created by Michael Lee on 2020/5/4.
//  Copyright © 2020 Michael Lee. All rights reserved.
//
import PerfectCrypto

import Foundation

class DataUtils{
    
    static func generateKey(name :String) -> String{
        let md5 = "\(NSDate().timeIntervalSince1970)\(name)".digest(.md5)?.encode(.base64url)
        return String(validatingUTF8: md5!) ?? "\(NSDate().timeIntervalSince1970)\(name)"
    }
    
}

编写接口将文件写入流

现在通过Perfect创建本地服务。

//
//  Server.swift
//  EasyShare
//
//  Created by Michael Lee on 2020/5/3.
//  Copyright © 2020 Michael Lee. All rights reserved.
//

import Foundation
import PerfectHTTP
import PerfectLib
import PerfectHTTPServer

class ShareServer{

    static let instance = ShareServer()
    
    init() {
    	//添加api路由
        addApi()
        //添加web路由,本来是准备做h5展示再点击下载,后来觉得没必要,就没做
        addWeb()
        routes.add(api)
        routes.add(web)
    }
    
    //标记服务是否开启
    var state = 0
    
    //默认端口号
    var port :UInt16 = 8899
    
    let queue = DispatchQueue.global()
    
    var httpServer = HTTPServer()
    
    var routes = Routes()
    
    var api = Routes(baseUri: "/api")
    
    var web = Routes(baseUri: "/web")

	/// 开启服务
    func start(){
        if state == 1 {
            return
        }
        state = 1
        httpServer.serverName = "EasyShare"
        httpServer.addRoutes(routes)
        do{
            try httpServer.serverPort = UInt16(DbHelper.getConfig(key: ConfigMap.port).value) ?? port
        }catch{
            httpServer.serverPort = port
        }
        port = httpServer.serverPort
        
        queue.async {
            do{
                try self.httpServer.start()
            }catch{
                self.state = 0
                fatalError("\(error)")
            }
        }
    }
    
    /// 停止服务
    func stop(){
        httpServer.stop()
        state = 0
    }
    
    func addApi() {
        /// 获取分享详情
        api.add(method: .get, uri: "/info/{key}", handler: {request,response in
            let key = request.urlVariables["key"]
            let share = ShareDTO()
            do {
                try share.find([("key",key!)])
            }catch{
                do{
                    try response.setBody(json: self.createResponseBody(code: 500, message: "key error", data: nil))
                }catch{
                    response.setBody(string: "\(error)")
                }
                response.completed()
                return
            }
            if(share.id == 0){
                do{
                    try response.setBody(json: self.createResponseBody(code: 404, message: "key not found", data: nil))
                }catch{
                    response.setBody(string: "\(error)")
                }
                response.completed()
            }else{
                do{
                    try response.setBody(json:
                        self.createResponseBody(
                            code: 200,
                            message: "success",
                            data: share.asDataDict()))
                }catch{
                    response.setBody(string: "\(error)")
                }
                response.completed()
            }
        })
        
        /// 下载
        api.add(method: .get, uri: "/download/{key}", handler: {request,response in
            let share = ShareDTO()
            let key = request.urlVariables["key"]
            do {
                try share.find([("key",key!)])
            }catch{
                do{
                    try response.setBody(json: self.createResponseBody(code: 500, message: "key error", data: nil))
                }catch{
                    response.setBody(string: "\(error)")
                }
                response.completed()
                return
            }
            
            if(share.id == 0){
                do{
                    try response.setBody(json: self.createResponseBody(code: 404, message: "key not found", data: nil))
                }catch{
                    response.setBody(string: "\(error)")
                }
                response.completed()
                return
            }
            //获取文件
            let file = File(share.path + "/" + share.name)
            if(file.exists && !file.isDir){
                do{
                    try file.open()
                    let size = file.size
                    let contentType = MimeType.forExtension(file.path.filePathExtension)
                    response.status = .ok
                    response.isStreaming = true
                    response.setHeader(.contentType, value: contentType)
                    response.setHeader(.contentLength, value: "\(size)")
                    response.setHeader(.acceptRanges, value: "bytes")
                    response.setHeader(.contentDisposition, value: "attachment;filename=\"\(share.name)\"")
                    self.pushBody(response: response, file: file)
                }catch{
                    response.setBody(string: "\(error)")
                    response.completed()
                }
            }else{
                //文件不存在
                do{
                    try response.setBody(json: self.createResponseBody(code: 404, message: "file not exists", data: nil))
                }catch{
                    response.setBody(string: "\(error)")
                }
                response.completed()
            }
        })
        
    }
    
    /// 往response里面写流
    func pushBody(response:HTTPResponse,file:File){
        let readSize = 5 * 1024 * 1024//每次读5m
        var bytes :[UInt8]
        do {
           bytes = try file.readSomeBytes(count: readSize)
        }catch{
           bytes = [UInt8]()
        }
        if(bytes.count==0){
            file.close()
            response.completed()
            return
        }
        response.appendBody(bytes: bytes)
        response.push(callback: { bool in
            if(bool){
                self.pushBody(response: response, file: file)
            }else{
                file.close()
                response.completed(status: HTTPResponseStatus.gatewayTimeout)
            }
        })
    }
    
    func addWeb() {
    }
    
    /// 生成json返回值
    func createResponseBody(code:Int,message:String,data:Any?) -> [String:Any] {
        return ["code":code,"message":message,"data":data ?? [String:Any]()]
    }
    
}

以上就是本地服务的所有代码,本来东西很少,所以就没有分开了。

添加GUI实现

我找了很久,终于找到一个叫做Share Extension的东西,就是会显示在分享菜单里面,但是这个东西怎么用,根本就没有文档,而且网上找出来全部是iOS相关的东西。这里我就自己摸索着写的。

//
//  ShareViewController.swift
//  ShareExtension
//
//  Created by Michael Lee on 2020/5/4.
//  Copyright © 2020 Michael Lee. All rights reserved.
//

import Cocoa
import PerfectLib
import SwiftUI

class ShareViewController: NSViewController {
    
    @IBOutlet weak var titleCell: NSTextFieldCell!
    @IBOutlet weak var imageCell: NSImageCell!
    @IBOutlet weak var urlCell: NSTextFieldCell!
    
    var id = 0
    var url = ""
    
    override var nibName: NSNib.Name? {
        return NSNib.Name("ShareViewController")
    }

    override func loadView() {
        super.loadView()
        // 这个可以获取到分享是点的哪里
        let provider = (self.extensionContext!.inputItems[0] as! NSExtensionItem).attachments?[0]
        // 获取URL对象
        provider?.loadItem(forTypeIdentifier: kUTTypeURL as String, options: nil, completionHandler: {data,error in
            let url = data as! NSURL
            // 获取绝对路径
            let path = url.absoluteString
            if(path!.starts(with: "file://")){
            	//走文件分享的方法
                self.shareFile(path: url.path!)
            }else if(path!.starts(with: "http://") || path!.starts(with: "https://")){
            	//分享网页链接
                self.shareWeb(path: path!)
            }else{
            	//都不是,则直接结束
                self.extensionContext!.completeRequest(returningItems: [NSExtensionItem()], completionHandler: nil)
            }
        })
    }

    func shareFile(path:String) {
    	// 初始化数据库
        DbHelper.create()
        // 开启本地服务
        ShareServer.instance.start()
        let file = File(path)
        if(file.exists){
        	// 开始写入数据库
            let splits = file.path.split(separator: "/")
            let name = splits[splits.count-1]
            let share = ShareDTO()
            share.name = "\(name)"
            share.path = file.path.replacingOccurrences(of: "/\(name)", with: "")
            share.key = DataUtils.generateKey(name: share.name)
            do{
                try share.save{ id in
                    share.id = id as! Int
                    self.id = share.id
                }
            }catch{
                fatalError("\(error)")
            }
            // 这一句只是打印一下数据库中所有的分享,调试用的,没啥实质用处
            self.findAllShare()
            // 显示窗口信息
            self.showWindowInfo(
                url: "http://\(self.getIFAddresses()[0]):\(ShareServer.instance.port)/api/download/\(share.key)",
                title: share.name
            )
        }else{
            self.extensionContext!.completeRequest(returningItems: [NSExtensionItem()], completionHandler: nil)
        }
    }
    
    func shareWeb(path:String){
    	// 直接显示窗口信息
        self.showWindowInfo(url: path, title: "我是真的不知道Title怎么获取😅👌")
    }
    
    func showWindowInfo(url:String,title:String) {
        self.url = url
        self.urlCell.stringValue = url
        self.titleCell.stringValue = title
        self.imageCell.image = self.generateQRCodeImage(self.url, size: NSSize(width: 600, height: 600))
    }
    
    @IBAction func copy(_ sender: NSButton) {
    	// 拷贝到粘贴板
        let pasteboard = NSPasteboard.general
        pasteboard.declareTypes([NSPasteboard.PasteboardType.string], owner: nil)
        let b = pasteboard.setString(url, forType: NSPasteboard.PasteboardType.string)
        print(b)
    }
    
    @IBAction func send(_ sender: AnyObject?) {
        // 结束分享
        ShareServer.instance.stop()
        let share = ShareDTO()
        do{
            try share.delete(id)
        }catch{
            NSLog("\(error)")
        }
        self.extensionContext!.completeRequest(returningItems: [NSExtensionItem()], completionHandler: nil)
    }

	/// 生成二维码
    func generateQRCodeImage(_ content: String, size: NSSize) -> NSImage?{
        // 创建滤镜
        guard let filter = CIFilter(name: "CIQRCodeGenerator") else {return nil}
        // 还原滤镜的默认属性
        filter.setDefaults()
        //1.3 设置生成的二维码的容错率
        //value = @"L/M/Q/H"
        filter.setValue("L", forKey: "inputCorrectionLevel")
        // 设置需要生成的二维码数据
        let contentData = content.data(using: String.Encoding.utf8)
        filter.setValue(contentData, forKey: "inputMessage")


        // 从滤镜中取出生成的图片
        guard let ciImage = filter.outputImage else {return nil}

        let context = CIContext(options: nil)
        let bitmapImage = context.createCGImage(ciImage, from: ciImage.extent)

        let colorSpace = CGColorSpaceCreateDeviceGray()
        let bitmapContext = CGContext(data: nil, width: Int(size.width), height: Int(size.height), bitsPerComponent: 8, bytesPerRow: 0, space: colorSpace, bitmapInfo: CGImageAlphaInfo.none.rawValue)

        //draw image
        let scale = min(size.width / ciImage.extent.width, size.height / ciImage.extent.height)
        bitmapContext!.interpolationQuality = CGInterpolationQuality.none
        bitmapContext?.scaleBy(x: scale, y: scale)
        bitmapContext?.draw(bitmapImage!, in: ciImage.extent)

        //保存bitmap到图片
        guard let scaledImage = bitmapContext?.makeImage() else {return nil}

        return NSImage(cgImage: scaledImage, size: size)
    }
    
    /// 获取本地IP地址
    func getIFAddresses() -> [String] {
        var addresses = [String]()
        
        // Get list of all interfaces on the local machine:
        var ifaddr : UnsafeMutablePointer<ifaddrs>? = nil
        if getifaddrs(&ifaddr) == 0 {
          
          var ptr = ifaddr
          while ptr != nil {
            let flags = Int32((ptr?.pointee.ifa_flags)!)
            var addr = ptr?.pointee.ifa_addr.pointee
            
            // Check for running IPv4, IPv6 interfaces. Skip the loopback interface.
            if (flags & (IFF_UP|IFF_RUNNING|IFF_LOOPBACK)) == (IFF_UP|IFF_RUNNING) {
              if addr?.sa_family == UInt8(AF_INET) && addr?.sa_family != UInt8(AF_INET6) {
                
                // Convert interface address to a human readable string:
                var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
                if (getnameinfo(&addr!, socklen_t((addr?.sa_len)!), &hostname, socklen_t(hostname.count),
                                nil, socklen_t(0), NI_NUMERICHOST) == 0) {
                  if let address = String(validatingUTF8: hostname) {
                    addresses.append(address)
                  }
                }
              }
            }
            ptr = ptr?.pointee.ifa_next
          }
 
            freeifaddrs(ifaddr)
        }
        print("Local IP \(addresses)")
        return addresses
    }
    
    func findAllShare(){
        let share = ShareDTO()
        do{
            try share.findAll()
            let rows = share.rows()
            for row in rows{
                print(row.asDataDict())
            }
        }catch{
            fatalError("\(error)")
        }
    }

}

收个尾

主要代码都在上面了,其实就两个主要的类。贴个GitHub地址,欢迎各位点星。
GitHub
要直接用的也可以在release里面直接下载,有任何意见或建议欢迎提issue。

;