diff --git a/ShadowsocksX-NG/AppDelegate.swift b/ShadowsocksX-NG/AppDelegate.swift index e2459c8..059671a 100755 --- a/ShadowsocksX-NG/AppDelegate.swift +++ b/ShadowsocksX-NG/AppDelegate.swift @@ -265,6 +265,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSUserNotificationCenterDele } qrcodeWinCtrl = SWBQRCodeWindowController(windowNibName: "SWBQRCodeWindowController") qrcodeWinCtrl.qrCode = profile.URL()!.absoluteString + qrcodeWinCtrl.legacyQRCode = profile.URL(legacy: true)!.absoluteString qrcodeWinCtrl.title = profile.title() qrcodeWinCtrl.showWindow(self) NSApp.activate(ignoringOtherApps: true) diff --git a/ShadowsocksX-NG/SWBQRCodeWindowController.h b/ShadowsocksX-NG/SWBQRCodeWindowController.h index 85a3e9a..ff9b194 100644 --- a/ShadowsocksX-NG/SWBQRCodeWindowController.h +++ b/ShadowsocksX-NG/SWBQRCodeWindowController.h @@ -11,6 +11,7 @@ @interface SWBQRCodeWindowController : NSWindowController +@property (nonatomic, copy) NSString *legacyQRCode; @property (nonatomic, copy) NSString *qrCode; @property (nonatomic, copy) NSString *title; diff --git a/ShadowsocksX-NG/SWBQRCodeWindowController.m b/ShadowsocksX-NG/SWBQRCodeWindowController.m index a7e97e9..e123be0 100644 --- a/ShadowsocksX-NG/SWBQRCodeWindowController.m +++ b/ShadowsocksX-NG/SWBQRCodeWindowController.m @@ -78,4 +78,13 @@ [pasteboard writeObjects:copiedObjects]; } +- (void)flagsChanged:(NSEvent *)event { + NSUInteger modifiers = event.modifierFlags & NSDeviceIndependentModifierFlagsMask; + if (modifiers & NSAlternateKeyMask) { + [self setQRCode:self.legacyQRCode]; + } else { + [self setQRCode:self.qrCode]; + } +} + @end diff --git a/ShadowsocksX-NG/ServerProfile.swift b/ShadowsocksX-NG/ServerProfile.swift index dfb1fa0..aff8b4e 100644 --- a/ShadowsocksX-NG/ServerProfile.swift +++ b/ShadowsocksX-NG/ServerProfile.swift @@ -31,7 +31,7 @@ class ServerProfile: NSObject, NSCopying { self.uuid = uuid } - convenience init?(url: URL?) { + convenience init?(url: URL) { self.init() func padBase64(string: String) -> String { @@ -44,14 +44,12 @@ class ServerProfile: NSObject, NSCopying { } } - func decodeUrl(url: URL?) -> String? { - guard let urlStr = url?.absoluteString else { - return nil - } + func decodeUrl(url: URL) -> String? { + let urlStr = url.absoluteString let index = urlStr.index(urlStr.startIndex, offsetBy: 5) let encodedStr = urlStr.substring(from: index) guard let data = Data(base64Encoded: padBase64(string: encodedStr)) else { - return url?.absoluteString + return url.absoluteString } guard let decoded = String(data: data, encoding: String.Encoding.utf8) else { return nil @@ -67,17 +65,40 @@ class ServerProfile: NSObject, NSCopying { return nil } guard let host = parsedUrl.host, let port = parsedUrl.port, - let method = parsedUrl.user, let password = parsedUrl.password else { + let user = parsedUrl.user else { return nil } self.serverHost = host self.serverPort = UInt16(port) - self.method = method.lowercased() - self.password = password + // This can be overriden by the fragment part of SIP002 URL remark = parsedUrl.queryItems? .filter({ $0.name == "Remark" }).first?.value ?? "" + + if let password = parsedUrl.password { + self.method = user.lowercased() + self.password = password + } else { + // SIP002 URL have no password section + guard let data = Data(base64Encoded: padBase64(string: user)), + let userInfo = String(data: data, encoding: .utf8) else { + return nil + } + + let parts = userInfo.characters.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: false) + if parts.count != 2 { + return nil + } + self.method = String(parts[0]).lowercased() + self.password = String(parts[1]) + + // SIP002 defines where to put the profile name + if let profileName = parsedUrl.fragment { + self.remark = profileName + } + } + if let otaStr = parsedUrl.queryItems? .filter({ $0.name == "OTA" }).first?.value { ota = NSString(string: otaStr).boolValue @@ -225,7 +246,7 @@ class ServerProfile: NSObject, NSCopying { return true } - func URL() -> Foundation.URL? { + private func makeLegacyURL() -> URL? { var url = URLComponents() url.host = serverHost @@ -254,6 +275,39 @@ class ServerProfile: NSObject, NSCopying { } return nil } + + func URL(legacy: Bool = false) -> URL? { + // If you want the URL from <= 1.5.1 + if (legacy) { + return self.makeLegacyURL() + } + + guard let rawUserInfo = "\(method):\(password)".data(using: .utf8) else { + return nil + } + let paddings = CharacterSet(charactersIn: "=") + let userInfo = rawUserInfo.base64EncodedString().trimmingCharacters(in: paddings) + + var items = [URLQueryItem(name: "OTA", value: ota.description)] + if enabledKcptun { + items.append(URLQueryItem(name: "Kcptun", value: enabledKcptun.description)) + items.append(contentsOf: kcptunProfile.urlQueryItems()) + } + + var comps = URLComponents() + + comps.scheme = "ss" + comps.host = serverHost + comps.port = Int(serverPort) + comps.user = userInfo + comps.path = "/" // This is required by SIP0002 for URLs with fragment or query + comps.fragment = remark + comps.queryItems = items + + let url = try? comps.asURL() + + return url + } func title() -> String { if remark.isEmpty { diff --git a/ShadowsocksX-NG/Utils.m b/ShadowsocksX-NG/Utils.m index c77a07a..92912c9 100644 --- a/ShadowsocksX-NG/Utils.m +++ b/ShadowsocksX-NG/Utils.m @@ -56,7 +56,10 @@ void ScanQRCodeOnScreen() { NSLog(@"%@", feature.messageString); if ( [feature.messageString hasPrefix:@"ss://"] ) { - [foundSSUrls addObject:[NSURL URLWithString:feature.messageString]]; + NSURL *url = [NSURL URLWithString:feature.messageString]; + if (url) { + [foundSSUrls addObject:url]; + } } } CGImageRelease(image); diff --git a/ShadowsocksX-NGTests/ServerProfileTests.swift b/ShadowsocksX-NGTests/ServerProfileTests.swift index d942895..c3b4d42 100644 --- a/ShadowsocksX-NGTests/ServerProfileTests.swift +++ b/ShadowsocksX-NGTests/ServerProfileTests.swift @@ -31,7 +31,7 @@ class ServerProfileTests: XCTestCase { } func testInitWithSelfGeneratedURL() { - let newProfile = ServerProfile.init(url: profile.URL()) + let newProfile = ServerProfile.init(url: profile.URL()!) XCTAssertEqual(newProfile?.serverHost, profile.serverHost) XCTAssertEqual(newProfile?.serverPort, profile.serverPort) @@ -42,7 +42,7 @@ class ServerProfileTests: XCTestCase { } func testInitWithPlainURL() { - let url = URL(string: "ss://aes-256-cfb:password@example.com:8388") + let url = URL(string: "ss://aes-256-cfb:password@example.com:8388")! let profile = ServerProfile(url: url) @@ -57,7 +57,7 @@ class ServerProfileTests: XCTestCase { } func testInitWithPlainURLandQuery() { - let url = URL(string: "ss://aes-256-cfb:password@example.com:8388?Remark=Prism&OTA=true") + let url = URL(string: "ss://aes-256-cfb:password@example.com:8388?Remark=Prism&OTA=true")! let profile = ServerProfile(url: url) @@ -72,7 +72,7 @@ class ServerProfileTests: XCTestCase { } func testInitWithPlainURLandAnotherQuery() { - let url = URL(string: "ss://aes-256-cfb:password@example.com:8388?Remark=Prism&OTA=0") + let url = URL(string: "ss://aes-256-cfb:password@example.com:8388?Remark=Prism&OTA=0")! let profile = ServerProfile(url: url) @@ -88,7 +88,7 @@ class ServerProfileTests: XCTestCase { func testInitWithBase64EncodedURL() { // "ss://aes-256-cfb:password@example.com:8388" - let url = URL(string: "ss://YWVzLTI1Ni1jZmI6cGFzc3dvcmRAZXhhbXBsZS5jb206ODM4OA") + let url = URL(string: "ss://YWVzLTI1Ni1jZmI6cGFzc3dvcmRAZXhhbXBsZS5jb206ODM4OA")! let profile = ServerProfile(url: url) @@ -104,7 +104,7 @@ class ServerProfileTests: XCTestCase { func testInitWithBase64EncodedURLandQuery() { // "ss://aes-256-cfb:password@example.com:8388?Remark=Prism&OTA=true" - let url = URL(string: "ss://YWVzLTI1Ni1jZmI6cGFzc3dvcmRAZXhhbXBsZS5jb206ODM4OD9SZW1hcms9UHJpc20mT1RBPXRydWU") + let url = URL(string: "ss://YWVzLTI1Ni1jZmI6cGFzc3dvcmRAZXhhbXBsZS5jb206ODM4OD9SZW1hcms9UHJpc20mT1RBPXRydWU")! let profile = ServerProfile(url: url) @@ -119,15 +119,7 @@ class ServerProfileTests: XCTestCase { } func testInitWithEmptyURL() { - let url = URL(string: "ss://") - - let profile = ServerProfile(url: url) - - XCTAssertNil(profile) - } - - func testInitWithInvalidURL() { - let url = URL(string: "ss://invalid url") + let url = URL(string: "ss://")! let profile = ServerProfile(url: url) @@ -136,13 +128,47 @@ class ServerProfileTests: XCTestCase { func testInitWithBase64EncodedInvalidURL() { // "ss://invalid url" - let url = URL(string: "ss://aW52YWxpZCB1cmw") + let url = URL(string: "ss://aW52YWxpZCB1cmw")! let profile = ServerProfile(url: url) XCTAssertNil(profile) } + func testInitWithSIP002URL() { + // "ss://aes-256-cfb:password@example.com:8388?Remark=Prism&OTA=true" + let url = URL(string: "ss://YWVzLTI1Ni1jZmI6cGFzc3dvcmQ=@example.com:8388/?Remark=Prism&OTA=true")! + + let profile = ServerProfile(url: url) + + XCTAssertNotNil(profile) + + XCTAssertEqual(profile?.serverHost, "example.com") + XCTAssertEqual(profile?.serverPort, 8388) + XCTAssertEqual(profile?.method, "aes-256-cfb") + XCTAssertEqual(profile?.password, "password") + XCTAssertEqual(profile?.remark, "Prism") + XCTAssertEqual(profile?.ota, true) + } + + func testInitWithSIP002URLProfileName() { + let url = URL(string: "ss://YWVzLTI1Ni1jZmI6cGFzc3dvcmQ=@example.com:8388/#Name")! + + let profile = ServerProfile(url: url) + + XCTAssertNotNil(profile) + XCTAssertEqual(profile?.remark, "Name") + } + + func testInitWithSIP002URLProfileNameOverride() { + let url = URL(string: "ss://YWVzLTI1Ni1jZmI6cGFzc3dvcmQ=@example.com:8388/?Remark=Name#Overriden")! + + let profile = ServerProfile(url: url) + + XCTAssertNotNil(profile) + XCTAssertEqual(profile?.remark, "Overriden") + } + func testPerformanceExample() { // This is an example of a performance test case. self.measure {