肇鑫的技术博客

肇鑫 / Owen Zhao

独立开发者,主要开发 iOS、watchOS、macOS 应用。

目前在维护 SleepTapRooster Time,以及 Markdown Writer 相关工具。

最新文章

Get Critical Alert Permission from User instead of Apple

通用

When a user uses "Do not disturb" mode, your notifications no longer get sound and banner, unless your app can post a Critical Alert.

However, to get the entitlement of critical alert, you have to ask Apple to give your permission. Someone complains that their requests to Apple even took months without a response.

Critical Alerts entitlement

I found a new way to get the permission directly from the user, instead of Apple.

Steps

UNUserNotificationCenter.current().requestAuthorization(options: [.sound, .alert, .criticalAlert]) { granted, error in
    guard error == nil else {
        DispatchQueue.main.async {
            NSSound.beep()
            let alert = NSAlert(error: error!)
            alert.runModal()
        }
        
        return
    }
}

Above code will get an error as you don't have the entitlement of critical alert. But we could use code below:

UNUserNotificationCenter.current().requestAuthorization(options: [.sound, .alert]) { granted, error in
    guard error == nil else {
        DispatchQueue.main.async {
            NSSound.beep()
            let alert = NSAlert(error: error!)
            alert.runModal()
        }
        
        return
    }
    
    if granted {
        UNUserNotificationCenter.current().requestAuthorization(options: [.sound, .alert, .criticalAlert]) { granted, error in
            /* You will get an error here as you don't have the critical alert entitlement.
                But since you have already got the permission for normal notifications, you now get a disabled critical alert permission.
                You are just one step to the critical alert. Ask your user to manually enable the permisssion.
                The error should be igonred here.
                */

            UNUserNotificationCenter.current().getNotificationSettings { settings in
                if settings.criticalAlertSetting != .enabled {
                    DispatchQueue.main.async {
                        NSSound.beep()
                        showCriticalAlert = true
                    }
                }
            }
        }
    }
}

We request the permission twice. First, we request the common notification permission. When a user allow that, we request the critical alert permission. Since we have already got the common notification permission the second permission request won't pop up. Also as we don't have critical alert entitlement from Apple, we will get an error. But the error can be ignored. Then when you open the app's notification settings, there is a critical alert permission, with disabled state. So what you only need to do is to show an alert and ask the user to enable the critical alert if the user wants to get notified in "Do not disturb" mode.

Sample App

I have already got an sample app on sale in App Store. You can try it for free.

Stand Reminder

Get Macs, IP Addresses and Internet IP Addresses

通用

We need to use C function to get those values. And then we check the values with command ifconfig and understand which to which.

Get Local MACs and IP Addresses

private func getLocalIPAndMACAdress() {
    var address : String?

    // Get list of all interfaces on the local machine:
    var ifaddr:UnsafeMutablePointer<ifaddrs>!
    if getifaddrs(&ifaddr) == 0 {
        // For each interface ...
        var ptr:UnsafeMutablePointer<ifaddrs>! = ifaddr
        var networkLinkDictionary = [String:[String]]()
        
        repeat {
            defer { ptr = ptr.pointee.ifa_next}
            let interface = ptr.pointee
            // Check interface name:
            let name = String(cString: interface.ifa_name)
            // Convert interface address to a human readable string:
            var hostname = [CChar](repeating: 0, count: Int(NI_MAXHOST))
            getnameinfo(interface.ifa_addr, socklen_t(interface.ifa_addr.pointee.sa_len),
                        &hostname, socklen_t(hostname.count),
                        nil, socklen_t(0), NI_NUMERICHOST)
            address = String(cString: hostname)
            var values = networkLinkDictionary[name] ?? []
            values.append(address!)
            networkLinkDictionary.updateValue(values, forKey: name)
        } while ptr != nil
   
        freeifaddrs(ifaddr)
        
        set(networkLinkDictionary: networkLinkDictionary)
        
        #if DEBUG
        networkLinkDictionary.keys.sorted().forEach { key in
            print(key, "\t", networkLinkDictionary[key]!)
        }
        #endif
    }
}

private func set(networkLinkDictionary:[String:[String]]) {
    networkLinkDictionary.keys.sorted().forEach {
        switch $0 {
            // for iMac 5K, en0 is ethernet; en1 is WiFi
        case "en0":
            enthernet = getNetworkLink(values: networkLinkDictionary["en0"] ?? [])
            #if DEBUG
            debugPrint(enthernet)
            #endif
        case "en1":
            wifi = getNetworkLink(values: networkLinkDictionary["en1"] ?? [])
            #if DEBUG
            debugPrint(wifi)
            #endif
        default:
            break
        }
    }
}

private func getNetworkLink(values:[String]) -> NetworkLink {
    let networkLink:NetworkLink
    
    switch values.count {
    case 1:
        networkLink = NetworkLink(MAC: values[0])
    case 2:
        networkLink = NetworkLink(MAC: values[0], ipv6: values[1])
    case 3:
        networkLink = NetworkLink(MAC: values[0], ipv6: values[1], ipv4: values[2])
    default:
        networkLink = NetworkLink(MAC: "")
    }
    
    return networkLink
}

public struct NetworkLink:Equatable {
    public init(MAC:String, ipv6:String? = nil, ipv4:String? = nil){
        self.MAC = MAC
        self.ipv6 = ipv6
        self.ipv4 = ipv4
    }
    
    public let MAC:String
    public var ipv6:String? = nil
    public var ipv4:String? = nil
}

Get Internet IP Addresses

We use ipify.org to get internet IP addresses.

Don't use URLSession.share

Create a new URLSession each time your own. If you use URLSession.share, you app won't detect internet IP changes, it will always show the IP when the app starts.

I had tried 3 different frameworks on github.com. They all has the above issue. So I write the code myself.

private func setInterIP(type:IPType) async {
    let url:URL
    
    if !reachable {
        switch type {
        case .ipv4:
            internetIPV4 = NSLocalizedString("Inactive", bundle: .module, comment: "")
        case .ipv6:
            internetIPV6 = NSLocalizedString("Inactive", bundle: .module, comment: "")
        }
        
        return
    }
    
    switch type {
    case .ipv4:
        url = URL(string: "https://api.ipify.org?format=json")!
        
        if internetIPV4.isEmpty {
            internetIPV4 = "..."
        }
    case .ipv6:
        url = URL(string: "https://api64.ipify.org?format=json")!
        
        if internetIPV6.isEmpty {
            internetIPV6 = "..."
        }
    }
    let urlSessionConfiguration = URLSessionConfiguration.default
    let urlSession = URLSession(configuration: urlSessionConfiguration)
    
    do {
        let (data, urlResponse) = try await urlSession.data(from: url)
        DispatchQueue.main.async { [self] in
            if let httpResponse = urlResponse as? HTTPURLResponse, httpResponse.statusCode == 200 {
                if let dic = try? JSONSerialization.jsonObject(with: data, options: []) as? [String:String] {
                    switch type {
                    case .ipv4:
                        internetIPV4 = dic["ip"]!
                        #if DEBUG
                        debugPrint(internetIPV4)
                        #endif
                    case .ipv6:
                        internetIPV6 = dic["ip"]!
                        #if DEBUG
                        debugPrint(internetIPV6)
                        #endif
                    }
                }
            } else {
                #if DEBUG
                debugPrint(urlResponse)
                #endif
            }
        }
    } catch {
        print(error)
    }
}

微博账户过期提示算法的改进

通用

对于使用RestAPI的应用,微博强制用户必须每30天登录一次。我原本的思路是类似这样的:

设备1:@小明 x号登录,x+27天后开始提醒。
设备2:@小明 y号登录,y+27天后开始提醒。(x < y)

但是实际使用中我发现,当x+30之后,在不重新登录账户@小明的情况下,还是可以通过@小明的验证信息发送微博。

分析

表面上,设备1上的授权是过期的,但是为什么还能继续发微博呢?我使用微博提供的API查询了token的信息。结果显示,实际上的设备1上的授权时间,要比x+30要长。

我分析,可是这样的。我原本以为微博服务器会记录每次用户登录的授权,即

设备1:@小明 x号登录,x+30授权结束。
设备2:@小明 y号登录,y+30授权结束。 (x<y)

但实际上,为了方便,微博根本没有记录每次的过期时间,而是每次小明通过同一个应用的授权进行登录时,就自动延长了该授权对应的时间。所以实际上发生的,可能是这样的:

设备1:@小明 x号登录,x+30授权结束。
设备2:@小明 y号登录,y+30授权结束。 (x<y)
因为x<y,所以设备1和2上的@小明,都变成y+30授权结束。

结论

根据上面的分析,新算法就变成了,当x+27时,先向微博服务器进行查询,查看剩余时间是否不足3天,如果超出,则先不进行提示,而是更新下一个时点,这样用户就可以少登录几次了。

思路改变编程:记一次思路改变节省大量编程时间的小事

通用

使用微博RestAPI一直存在一条人为的限制,就是必须要在文章末尾添加一个验证链接。

比如你发微博“这是我的第一条微博。”,用微博RestAPI是没法直接发的,必须发成“这是我的第一条微博。https://poster.parussoft.com/index.html”。

如果你不加上这个链接,微博服务器就会提示错误,不让发微博。

这个链接对于用户其实是没有意义的。并且直接加在微博末尾,就会有人点击它,然后奇怪为什么会有一个跟内容没什么关系的链接在那里。

为了尽量减少用户点击这个链接的可能,咕唧从2018年3月底发布的1.7.13版开始,增加了一个折叠隐藏该链接的功能。利用的原理是,微博官方微博网页版和客户端,会在内容过长时,自动折叠微博,而咕唧会在字数允许的情况下,添加空行,将该链接隐藏起来。

但是这个功能存在限制,因为只有官方的网页版和客户端才有折叠的功能。其它第三方的客户端,官方的html5版以及国际版,都不具备折叠的功能。在这些微博客户端中,咕唧发布的微博就会变得特别长。因此,时不时的,我就会收到评论或者私信,问为什么我发的微博会那么长?

咕唧2决定彻底解决这个问题

我的思路是,既然这个链接必不可少,那么我可以将其从无用的链接,转换为有用的链接。这样用户点击了也不会显得突兀。类似这样的查询“https://poster.parussoft.com/jumping.php?query_string”。

weibo_forcing_ur

思路1

思路确定好之后,我发现,生成的链接必须经过服务器的处理才可以。那么就有两个思路:

  1. 直接保存对应的html文件到磁盘。(这样占用磁盘较多)
  2. 将查询条件和结果保存的数据库,动态查询。(这样占用磁盘少,但是CPU占用高。因此还需要缓存查询结果。)

我觉得第2条更好。这样就需要在服务器端使用PHP和Sqlite。思路总结完,我在服务器远程安装好PHP和Sqlite3之后,就睡觉了。

思路2

第二天,开始继续前一天的工作。我发现微博账户的用户页,其实是需要和其它链接分开的。因为微博账户的用户页,可以直接根据微博用户的id自动生成。我又想到,既然可以自动生成,那么该项就不用保存到数据库,直接通过id生成并跳转就可以了。

之后,我又想到,链接的跳转也是同样的道理,可以直接将链接做为查询的字段,这样就能直接跳转了。

这样一来,思路1中的PHP、数据库、缓存就都不需要了,服务器端只需要写一个能判断查询类型并生成跳转链接的Javascript网页就全搞定。

结论

不同的思路,需要的工作量完全不同。多思考,比埋头苦干,效率更高。

Locale苹果官方文档补遗

通用

我们知道,在Xcode中可以通过指定运行时的语言,来测试相应的翻译。如图,我指定了中文作为在测试时的语言。

测试时指定语言

这个方式,在测试翻译的情况下是没有问题的。

最近我需要在应用中通过WKWebView,根据用户所使用的语言来动态加载网页。为了达到这个目的,我使用了

Locale.current.languageCode

根据苹果的文档,languageCode的含义是

The language code of the locale, or nil if has none.

但是实际使用中,我发现它并不能返回zh

iOS_12.1_Locale_iPhoneX

如图,实际上返回的是en_CN

我就此问题向苹果提交了错误报告。苹果回复说,从macOS 10.13, iOS 11开始,Locale.current.languageCode返回的实际上是Bundle.main.preferredLocalizations.first。这是因为苹果认为这样的用户体验最好。

苹果举例说,如果一个用户对于语言的偏好顺序是[ "zh-CN", "de-CN", "en-CN" ],而某个应用仅支持德语和英语,那么Locale.current.languageCode返回的就是de_CN

按照苹果的建议,如果想要测试中文,那么第一步就是添加中文的翻译。于是我添加了简体中文的翻译。这一下,简体中文的内容正确了。

简体中文

另外,对比一下没有中文翻译时,iOS 10和iOS 11的不同。

Locale 对比

事情还没有完,因为我希望的是用户懂中文时,使用中文;否则使用英文。而现在的方式仅能识别简体中文,当用户使用繁体中文时,还是会显示英文。于是我改了代码,新的代码

private func isChinese() -> Bool {
    let languages = Locale.preferredLanguages
    
    for lang in languages {
        if lang.hasPrefix("zh") {
            return true
        }
    }
    
    return false
}