SHA256
fix(ios): restrict authentication capabilities and validate bootstrap types
Require iOS 18.4 for explicit upload denial in the isolated authentication window. Add origin-bound generated download policy, app-switcher privacy, typed metadata regressions and original community icon assets. Seven portable tests and Swift formatting checks pass; Apple SDK validation remains external.
This commit is contained in:
1 parent
74c2c9e6e8
commit
69c48169e2
15 files changed
+321
-92
No files matched your search
@@ -13,6 +13,7 @@ DerivedData/
|
|||||||
*.xcuserstate
|
*.xcuserstate
|
||||||
xcuserdata/
|
xcuserdata/
|
||||||
ios/Yuvomi.xcodeproj/
|
ios/Yuvomi.xcodeproj/
|
||||||
|
ios/Info.plist
|
||||||
.swiftpm/
|
.swiftpm/
|
||||||
.build/
|
.build/
|
||||||
artifacts/
|
artifacts/
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"indentation": { "spaces": 4 },
|
||||||
|
"lineLength": 120,
|
||||||
|
"respectsExistingLineBreaks": true
|
||||||
|
}
|
||||||
+6
-4
@@ -6,10 +6,12 @@ let package = Package(
|
|||||||
name: "YuvomiPolicy",
|
name: "YuvomiPolicy",
|
||||||
products: [.library(name: "Yuvomi", targets: ["Yuvomi"])],
|
products: [.library(name: "Yuvomi", targets: ["Yuvomi"])],
|
||||||
targets: [
|
targets: [
|
||||||
.target(name: "Yuvomi", path: "Sources", exclude: [
|
.target(
|
||||||
|
name: "Yuvomi", path: "Sources",
|
||||||
|
exclude: [
|
||||||
"AuthenticationView.swift", "BrowserModel.swift", "ContentView.swift",
|
"AuthenticationView.swift", "BrowserModel.swift", "ContentView.swift",
|
||||||
"ServerProbe.swift", "YuvomiApp.swift", "PrivacyInfo.xcprivacy"
|
"ServerProbe.swift", "YuvomiApp.swift", "PrivacyInfo.xcprivacy", "Assets.xcassets",
|
||||||
], sources: ["ServerAddress.swift"]),
|
], sources: ["ServerAddress.swift", "ServerMetadata.swift"]),
|
||||||
.testTarget(name: "YuvomiPolicyTests", dependencies: ["Yuvomi"], path: "Tests")
|
.testTarget(name: "YuvomiPolicyTests", dependencies: ["Yuvomi"], path: "Tests"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 7.4 KiB |
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"images": [
|
||||||
|
{
|
||||||
|
"filename": "AppIcon.png",
|
||||||
|
"idiom": "universal",
|
||||||
|
"platform": "ios",
|
||||||
|
"size": "1024x1024"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info": {
|
||||||
|
"author": "Yuvomi Mobile contributors",
|
||||||
|
"version": 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ struct AuthenticationView: View {
|
|||||||
var body: some View {
|
var body: some View {
|
||||||
NavigationStack {
|
NavigationStack {
|
||||||
IsolatedAuthentication(url: destination, model: model)
|
IsolatedAuthentication(url: destination, model: model)
|
||||||
.navigationTitle("Isolated web sign-in")
|
.navigationTitle(model.authenticationHost)
|
||||||
.navigationBarTitleDisplayMode(.inline)
|
.navigationBarTitleDisplayMode(.inline)
|
||||||
.toolbar { Button("Close") { model.authentication = nil } }
|
.toolbar { Button("Close") { model.authentication = nil } }
|
||||||
}
|
}
|
||||||
@@ -33,15 +33,26 @@ struct IsolatedAuthentication: UIViewRepresentable {
|
|||||||
}
|
}
|
||||||
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
func updateUIView(_ uiView: WKWebView, context: Context) {}
|
||||||
static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
|
static func dismantleUIView(_ uiView: WKWebView, coordinator: Coordinator) {
|
||||||
uiView.stopLoading(); uiView.navigationDelegate = nil; uiView.uiDelegate = nil
|
uiView.stopLoading()
|
||||||
|
uiView.navigationDelegate = nil
|
||||||
|
uiView.uiDelegate = nil
|
||||||
}
|
}
|
||||||
final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
|
final class Coordinator: NSObject, WKNavigationDelegate, WKUIDelegate {
|
||||||
let model: BrowserModel
|
let model: BrowserModel
|
||||||
init(_ model: BrowserModel) { self.model = model }
|
init(_ model: BrowserModel) { self.model = model }
|
||||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
|
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
model.authenticationHost = webView.url?.host ?? "Sign in"
|
||||||
|
}
|
||||||
|
func webView(
|
||||||
|
_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
|
||||||
|
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
||||||
|
) {
|
||||||
guard let url = navigationAction.request.url, url.scheme == "https",
|
guard let url = navigationAction.request.url, url.scheme == "https",
|
||||||
url.user == nil, url.password == nil else { decisionHandler(.cancel); return }
|
url.user == nil, url.password == nil
|
||||||
|
else {
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
return
|
||||||
|
}
|
||||||
decisionHandler(.allow)
|
decisionHandler(.allow)
|
||||||
}
|
}
|
||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
@@ -50,8 +61,17 @@ struct IsolatedAuthentication: UIViewRepresentable {
|
|||||||
model.web.load(URLRequest(url: url))
|
model.web.load(URLRequest(url: url))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
|
func webView(
|
||||||
|
_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
|
||||||
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
|
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
|
||||||
decisionHandler: @escaping (WKPermissionDecision) -> Void) { decisionHandler(.deny) }
|
decisionHandler: @escaping (WKPermissionDecision) -> Void
|
||||||
|
) { decisionHandler(.deny) }
|
||||||
|
func webView(
|
||||||
|
_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters,
|
||||||
|
initiatedByFrame frame: WKFrameInfo,
|
||||||
|
completionHandler: @escaping @MainActor @Sendable ([URL]?) -> Void
|
||||||
|
) {
|
||||||
|
completionHandler(nil)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+106
-45
@@ -13,6 +13,7 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI
|
|||||||
@Published var error: String?
|
@Published var error: String?
|
||||||
@Published var externalURL: URL?
|
@Published var externalURL: URL?
|
||||||
@Published var authentication: WebDestination?
|
@Published var authentication: WebDestination?
|
||||||
|
@Published var authenticationHost = "Sign in"
|
||||||
@Published var download: WebDestination?
|
@Published var download: WebDestination?
|
||||||
private(set) var server: ServerAddress?
|
private(set) var server: ServerAddress?
|
||||||
private(set) var web: WKWebView
|
private(set) var web: WKWebView
|
||||||
@@ -45,7 +46,8 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI
|
|||||||
web.allowsBackForwardNavigationGestures = true
|
web.allowsBackForwardNavigationGestures = true
|
||||||
web.isOpaque = false
|
web.isOpaque = false
|
||||||
if #available(iOS 16.4, *) { web.isInspectable = false }
|
if #available(iOS 16.4, *) { web.isInspectable = false }
|
||||||
observations.append(web.observe(\.canGoBack, options: [.new]) { [weak self] view, _ in
|
observations.append(
|
||||||
|
web.observe(\.canGoBack, options: [.new]) { [weak self] view, _ in
|
||||||
self?.canGoBack = view.canGoBack
|
self?.canGoBack = view.canGoBack
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -65,7 +67,8 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI
|
|||||||
func rememberPath() {
|
func rememberPath() {
|
||||||
guard let url = web.url, server?.owns(url) == true else { return }
|
guard let url = web.url, server?.owns(url) == true else { return }
|
||||||
let path = url.path
|
let path = url.path
|
||||||
guard !["auth", "reset", "invite", "login"].contains(where: { path.localizedCaseInsensitiveContains($0) }) else { return }
|
guard !["auth", "reset", "invite", "login"].contains(where: { path.localizedCaseInsensitiveContains($0) })
|
||||||
|
else { return }
|
||||||
UserDefaults.standard.set(path, forKey: "path")
|
UserDefaults.standard.set(path, forKey: "path")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,58 +83,79 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI
|
|||||||
web.navigationDelegate = nil
|
web.navigationDelegate = nil
|
||||||
web.uiDelegate = nil
|
web.uiDelegate = nil
|
||||||
observations.removeAll()
|
observations.removeAll()
|
||||||
WKWebsiteDataStore.default().removeData(ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
|
WKWebsiteDataStore.default().removeData(
|
||||||
modifiedSince: .distantPast) { [weak self] in
|
ofTypes: WKWebsiteDataStore.allWebsiteDataTypes(),
|
||||||
|
modifiedSince: .distantPast
|
||||||
|
) { [weak self] in
|
||||||
guard let self else { return }
|
guard let self else { return }
|
||||||
self.web = Self.makeWebView()
|
self.web = Self.makeWebView()
|
||||||
self.configureWebView()
|
self.configureWebView()
|
||||||
self.canGoBack = false; self.loading = false; self.error = nil
|
self.canGoBack = false
|
||||||
|
self.loading = false
|
||||||
|
self.error = nil
|
||||||
completion()
|
completion()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func startAuthentication(_ url: URL) {
|
func startAuthentication(_ url: URL) {
|
||||||
guard url.scheme == "https", url.user == nil, url.password == nil else { return }
|
guard url.scheme == "https", url.user == nil, url.password == nil else { return }
|
||||||
|
authenticationHost = url.host ?? "Sign in"
|
||||||
authentication = WebDestination(url: url)
|
authentication = WebDestination(url: url)
|
||||||
}
|
}
|
||||||
|
|
||||||
func removeDownload() {
|
func removeDownload() {
|
||||||
download = nil
|
download = nil
|
||||||
if let location = downloadLocation { try? FileManager.default.removeItem(at: location.deletingLastPathComponent()) }
|
if let location = downloadLocation {
|
||||||
|
try? FileManager.default.removeItem(at: location.deletingLastPathComponent())
|
||||||
|
}
|
||||||
downloadLocation = nil
|
downloadLocation = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
|
func webView(
|
||||||
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction,
|
||||||
guard let url = navigationAction.request.url else { decisionHandler(.cancel); return }
|
decisionHandler: @escaping (WKNavigationActionPolicy) -> Void
|
||||||
if server?.owns(url) == true {
|
) {
|
||||||
|
guard let url = navigationAction.request.url else {
|
||||||
|
decisionHandler(.cancel)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if server?.owns(url) == true || (navigationAction.shouldPerformDownload && server?.ownsDownload(url) == true) {
|
||||||
decisionHandler(navigationAction.shouldPerformDownload ? .download : .allow)
|
decisionHandler(navigationAction.shouldPerformDownload ? .download : .allow)
|
||||||
} else {
|
} else {
|
||||||
decisionHandler(.cancel)
|
decisionHandler(.cancel)
|
||||||
if navigationAction.targetFrame?.isMainFrame != false {
|
if navigationAction.targetFrame?.isMainFrame != false {
|
||||||
if url.scheme == "https", url.host != nil, url.user == nil, url.password == nil {
|
if url.scheme == "https", url.host != nil, url.user == nil, url.password == nil {
|
||||||
externalURL = url
|
externalURL = url
|
||||||
} else { error = "This link type is blocked for your security." }
|
} else {
|
||||||
|
error = "This link type is blocked for your security."
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
|
func webView(
|
||||||
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) {
|
_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse,
|
||||||
|
decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void
|
||||||
|
) {
|
||||||
if let response = navigationResponse.response as? HTTPURLResponse,
|
if let response = navigationResponse.response as? HTTPURLResponse,
|
||||||
navigationResponse.isForMainFrame, response.statusCode >= 400 {
|
navigationResponse.isForMainFrame, response.statusCode >= 400
|
||||||
|
{
|
||||||
error = "The server is unavailable (HTTP \(response.statusCode))."
|
error = "The server is unavailable (HTTP \(response.statusCode))."
|
||||||
}
|
}
|
||||||
guard let url = navigationResponse.response.url, server?.owns(url) == true else {
|
guard let url = navigationResponse.response.url, server?.ownsDownload(url) == true else {
|
||||||
decisionHandler(.cancel); return
|
decisionHandler(.cancel)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
if !navigationResponse.canShowMIMEType {
|
if !navigationResponse.canShowMIMEType {
|
||||||
guard navigationResponse.response.expectedContentLength <= 100 * 1024 * 1024 else {
|
guard navigationResponse.response.expectedContentLength <= 100 * 1024 * 1024 else {
|
||||||
error = "This file exceeds the 100 MB download limit."
|
error = "This file exceeds the 100 MB download limit."
|
||||||
decisionHandler(.cancel); return
|
decisionHandler(.cancel)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
decisionHandler(.download)
|
decisionHandler(.download)
|
||||||
} else { decisionHandler(.allow) }
|
} else {
|
||||||
|
decisionHandler(.allow)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
||||||
@@ -146,30 +170,43 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI
|
|||||||
}
|
}
|
||||||
|
|
||||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
loading = false; deadline?.invalidate(); rememberPath()
|
loading = false
|
||||||
|
deadline?.invalidate()
|
||||||
|
rememberPath()
|
||||||
}
|
}
|
||||||
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { failed(error) }
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) { failed(error) }
|
||||||
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) { failed(error) }
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
||||||
|
failed(error)
|
||||||
|
}
|
||||||
private func failed(_ failure: Error) {
|
private func failed(_ failure: Error) {
|
||||||
loading = false; deadline?.invalidate()
|
loading = false
|
||||||
|
deadline?.invalidate()
|
||||||
guard (failure as NSError).code != NSURLErrorCancelled else { return }
|
guard (failure as NSError).code != NSURLErrorCancelled else { return }
|
||||||
error = "Unable to connect securely. Check your network, VPN and server certificate. Cached pages may still be available."
|
error =
|
||||||
|
"Unable to connect securely. Check your network, VPN and server certificate. Cached pages may still be available."
|
||||||
}
|
}
|
||||||
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
func webViewWebContentProcessDidTerminate(_ webView: WKWebView) {
|
||||||
error = "Yuvomi's browser was interrupted. Tap Retry to restore the page."
|
error = "Yuvomi's browser was interrupted. Tap Retry to restore the page."
|
||||||
loading = false
|
loading = false
|
||||||
}
|
}
|
||||||
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
|
func webView(
|
||||||
for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
|
_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration,
|
||||||
|
for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures
|
||||||
|
) -> WKWebView? {
|
||||||
if let url = navigationAction.request.url {
|
if let url = navigationAction.request.url {
|
||||||
if server?.owns(url) == true { web.load(navigationAction.request) }
|
if server?.owns(url) == true {
|
||||||
else if url.scheme == "https", url.user == nil, url.password == nil { externalURL = url }
|
web.load(navigationAction.request)
|
||||||
|
} else if url.scheme == "https", url.user == nil, url.password == nil {
|
||||||
|
externalURL = url
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
func webView(_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
|
func webView(
|
||||||
|
_ webView: WKWebView, requestMediaCapturePermissionFor origin: WKSecurityOrigin,
|
||||||
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
|
initiatedByFrame frame: WKFrameInfo, type: WKMediaCaptureType,
|
||||||
decisionHandler: @escaping (WKPermissionDecision) -> Void) {
|
decisionHandler: @escaping (WKPermissionDecision) -> Void
|
||||||
|
) {
|
||||||
// File input capture uses Apple's picker. Live camera/microphone access is not required by upstream.
|
// File input capture uses Apple's picker. Live camera/microphone access is not required by upstream.
|
||||||
decisionHandler(.deny)
|
decisionHandler(.deny)
|
||||||
}
|
}
|
||||||
@@ -181,28 +218,46 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI
|
|||||||
}
|
}
|
||||||
private func attach(_ download: WKDownload) {
|
private func attach(_ download: WKDownload) {
|
||||||
guard activeDownload == nil, self.download == nil else {
|
guard activeDownload == nil, self.download == nil else {
|
||||||
download.cancel { _ in }; error = "Finish the current download before starting another."; return
|
download.cancel { _ in }
|
||||||
|
error = "Finish the current download before starting another."
|
||||||
|
return
|
||||||
}
|
}
|
||||||
activeDownload = download; download.delegate = self
|
activeDownload = download
|
||||||
|
download.delegate = self
|
||||||
|
}
|
||||||
|
func download(
|
||||||
|
_ download: WKDownload, decideDestinationUsing response: URLResponse,
|
||||||
|
suggestedFilename: String, completionHandler: @escaping (URL?) -> Void
|
||||||
|
) {
|
||||||
|
guard let url = response.url, server?.ownsDownload(url) == true,
|
||||||
|
response.expectedContentLength <= 100 * 1024 * 1024
|
||||||
|
else {
|
||||||
|
completionHandler(nil)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
func download(_ download: WKDownload, decideDestinationUsing response: URLResponse,
|
|
||||||
suggestedFilename: String, completionHandler: @escaping (URL?) -> Void) {
|
|
||||||
guard let url = response.url, server?.owns(url) == true,
|
|
||||||
response.expectedContentLength <= 100 * 1024 * 1024 else { completionHandler(nil); return }
|
|
||||||
do {
|
do {
|
||||||
let folder = Self.exportDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
let folder = Self.exportDirectory.appendingPathComponent(UUID().uuidString, isDirectory: true)
|
||||||
try FileManager.default.createDirectory(at: folder, withIntermediateDirectories: true,
|
try FileManager.default.createDirectory(
|
||||||
|
at: folder, withIntermediateDirectories: true,
|
||||||
attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication])
|
attributes: [.protectionKey: FileProtectionType.completeUntilFirstUserAuthentication])
|
||||||
var values = URLResourceValues(); values.isExcludedFromBackup = true
|
var values = URLResourceValues()
|
||||||
var directory = folder; try directory.setResourceValues(values)
|
values.isExcludedFromBackup = true
|
||||||
let name = String(suggestedFilename.map { $0.isLetter || $0.isNumber || "._ -".contains($0) ? $0 : "_" }.prefix(120))
|
var directory = folder
|
||||||
|
try directory.setResourceValues(values)
|
||||||
|
let name = String(
|
||||||
|
suggestedFilename.map { $0.isLetter || $0.isNumber || "._ -".contains($0) ? $0 : "_" }.prefix(120))
|
||||||
let safeName = name.isEmpty || name == "." || name == ".." ? "download" : name
|
let safeName = name.isEmpty || name == "." || name == ".." ? "download" : name
|
||||||
downloadLocation = folder.appendingPathComponent(safeName)
|
downloadLocation = folder.appendingPathComponent(safeName)
|
||||||
completionHandler(downloadLocation)
|
completionHandler(downloadLocation)
|
||||||
} catch { completionHandler(nil); self.error = "Could not prepare the download." }
|
} catch {
|
||||||
|
completionHandler(nil)
|
||||||
|
self.error = "Could not prepare the download."
|
||||||
}
|
}
|
||||||
func download(_ download: WKDownload, willPerformHTTPRedirection response: HTTPURLResponse,
|
}
|
||||||
newRequest request: URLRequest, decisionHandler: @escaping (WKDownload.RedirectPolicy) -> Void) {
|
func download(
|
||||||
|
_ download: WKDownload, willPerformHTTPRedirection response: HTTPURLResponse,
|
||||||
|
newRequest request: URLRequest, decisionHandler: @escaping (WKDownload.RedirectPolicy) -> Void
|
||||||
|
) {
|
||||||
// Do not replay session credentials to a redirect target.
|
// Do not replay session credentials to a redirect target.
|
||||||
decisionHandler(.cancel)
|
decisionHandler(.cancel)
|
||||||
}
|
}
|
||||||
@@ -211,14 +266,18 @@ final class BrowserModel: NSObject, ObservableObject, WKNavigationDelegate, WKUI
|
|||||||
activeDownload = nil
|
activeDownload = nil
|
||||||
guard let location = downloadLocation,
|
guard let location = downloadLocation,
|
||||||
let size = try? location.resourceValues(forKeys: [.fileSizeKey]).fileSize,
|
let size = try? location.resourceValues(forKeys: [.fileSizeKey]).fileSize,
|
||||||
size <= 100 * 1024 * 1024 else {
|
size <= 100 * 1024 * 1024
|
||||||
removeDownload(); error = "Download exceeded the size limit."; return
|
else {
|
||||||
|
removeDownload()
|
||||||
|
error = "Download exceeded the size limit."
|
||||||
|
return
|
||||||
}
|
}
|
||||||
self.download = WebDestination(url: location)
|
self.download = WebDestination(url: location)
|
||||||
}
|
}
|
||||||
func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) {
|
func download(_ download: WKDownload, didFailWithError error: Error, resumeData: Data?) {
|
||||||
guard activeDownload === download else { return }
|
guard activeDownload === download else { return }
|
||||||
activeDownload = nil; removeDownload()
|
activeDownload = nil
|
||||||
|
removeDownload()
|
||||||
self.error = "Download failed. Check your connection and sign-in, then retry."
|
self.error = "Download failed. Check your connection and sign-in, then retry."
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -243,6 +302,8 @@ struct ExportView: UIViewControllerRepresentable {
|
|||||||
let completion: () -> Void
|
let completion: () -> Void
|
||||||
init(_ completion: @escaping () -> Void) { self.completion = completion }
|
init(_ completion: @escaping () -> Void) { self.completion = completion }
|
||||||
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { completion() }
|
func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { completion() }
|
||||||
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { completion() }
|
func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) {
|
||||||
|
completion()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,10 @@ struct ContentView: View {
|
|||||||
Image(systemName: "wifi.exclamationmark").font(.largeTitle)
|
Image(systemName: "wifi.exclamationmark").font(.largeTitle)
|
||||||
Text("Connection interrupted").font(.title2).bold()
|
Text("Connection interrupted").font(.title2).bold()
|
||||||
Text(error)
|
Text(error)
|
||||||
Button("Retry") { browser.error = nil; browser.web.reload() }
|
Button("Retry") {
|
||||||
|
browser.error = nil
|
||||||
|
browser.web.reload()
|
||||||
|
}
|
||||||
Button("Keep viewing") { browser.error = nil }
|
Button("Keep viewing") { browser.error = nil }
|
||||||
Button("Server settings") { settings = true }
|
Button("Server settings") { settings = true }
|
||||||
}
|
}
|
||||||
@@ -30,11 +33,17 @@ struct ContentView: View {
|
|||||||
.background(Color(uiColor: .systemGroupedBackground))
|
.background(Color(uiColor: .systemGroupedBackground))
|
||||||
.accessibilityElement(children: .contain)
|
.accessibilityElement(children: .contain)
|
||||||
}
|
}
|
||||||
if browser.loading { ProgressView("Loading Yuvomi").padding().background(.regularMaterial).cornerRadius(12) }
|
if browser.loading {
|
||||||
|
ProgressView("Loading Yuvomi").padding().background(.regularMaterial).cornerRadius(12)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
.toolbar {
|
.toolbar {
|
||||||
ToolbarItem(placement: .navigationBarLeading) {
|
ToolbarItem(placement: .navigationBarLeading) {
|
||||||
Button { browser.web.goBack() } label: { Image(systemName: "chevron.backward") }
|
Button {
|
||||||
|
browser.web.goBack()
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "chevron.backward")
|
||||||
|
}
|
||||||
.disabled(!browser.canGoBack).accessibilityLabel("Back")
|
.disabled(!browser.canGoBack).accessibilityLabel("Back")
|
||||||
}
|
}
|
||||||
ToolbarItem(placement: .navigationBarTrailing) {
|
ToolbarItem(placement: .navigationBarTrailing) {
|
||||||
@@ -70,10 +79,11 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
.task {
|
.task {
|
||||||
if server == nil, let existing = try? ServerAddress(input) {
|
if server == nil, let existing = try? ServerAddress(input) {
|
||||||
server = existing; browser.open(existing)
|
server = existing
|
||||||
|
browser.open(existing)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.onChange(of: scenePhase) { phase in
|
.onChange(of: scenePhase) { _, phase in
|
||||||
if phase == .background { browser.rememberPath() }
|
if phase == .background { browser.rememberPath() }
|
||||||
}
|
}
|
||||||
.overlay {
|
.overlay {
|
||||||
@@ -93,31 +103,49 @@ struct ContentView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Section {
|
Section {
|
||||||
Text("Changing servers erases cookies, storage and cached household data. Exported files remain. Sign out in Yuvomi first to revoke the server session.")
|
Text(
|
||||||
|
"Changing servers erases cookies, storage and cached household data. Exported files remain. Sign out in Yuvomi first to revoke the server session."
|
||||||
|
)
|
||||||
Button("Erase local data and change server", role: .destructive) {
|
Button("Erase local data and change server", role: .destructive) {
|
||||||
settings = false; erasing = true
|
settings = false
|
||||||
|
erasing = true
|
||||||
browser.erase {
|
browser.erase {
|
||||||
UserDefaults.standard.removeObject(forKey: "origin")
|
UserDefaults.standard.removeObject(forKey: "origin")
|
||||||
UserDefaults.standard.removeObject(forKey: "path")
|
UserDefaults.standard.removeObject(forKey: "path")
|
||||||
server = nil; input = ""; erasing = false
|
server = nil
|
||||||
|
input = ""
|
||||||
|
erasing = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Section("About") {
|
Section("About") {
|
||||||
Text("Independent community contribution. MIT licensed. Not endorsed by upstream Yuvomi. No mobile telemetry. Your server's own privacy settings still apply.")
|
Text(
|
||||||
|
"Independent community contribution. MIT licensed. Not endorsed by upstream Yuvomi. No mobile telemetry. Your server's own privacy settings still apply."
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}.navigationTitle("Server settings")
|
}.navigationTitle("Server settings")
|
||||||
.toolbar { Button("Done") { settings = false } }
|
.toolbar { Button("Done") { settings = false } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.alert("External destination", isPresented: Binding(
|
.alert(
|
||||||
|
"External destination",
|
||||||
|
isPresented: Binding(
|
||||||
get: { browser.externalURL != nil }, set: { if !$0 { browser.externalURL = nil } }
|
get: { browser.externalURL != nil }, set: { if !$0 { browser.externalURL = nil } }
|
||||||
), presenting: browser.externalURL) { url in
|
), presenting: browser.externalURL
|
||||||
Button("Open in browser") { UIApplication.shared.open(url); browser.externalURL = nil }
|
) { url in
|
||||||
Button("Isolated sign-in window") { browser.startAuthentication(url); browser.externalURL = nil }
|
Button("Open in browser") {
|
||||||
|
UIApplication.shared.open(url)
|
||||||
|
browser.externalURL = nil
|
||||||
|
}
|
||||||
|
Button("Isolated sign-in window") {
|
||||||
|
browser.startAuthentication(url)
|
||||||
|
browser.externalURL = nil
|
||||||
|
}
|
||||||
Button("Cancel", role: .cancel) { browser.externalURL = nil }
|
Button("Cancel", role: .cancel) { browser.externalURL = nil }
|
||||||
} message: { url in
|
} message: { url in
|
||||||
Text("Destination: \(url.host ?? "")\nFor SSO, use the isolated window only if you trust this provider. Browser sessions do not transfer into the app.")
|
Text(
|
||||||
|
"Destination: \(url.host ?? "")\nFor SSO, use the isolated window only if you trust this provider. Browser sessions do not transfer into the app."
|
||||||
|
)
|
||||||
}
|
}
|
||||||
.sheet(item: $browser.authentication) { destination in
|
.sheet(item: $browser.authentication) { destination in
|
||||||
AuthenticationView(destination: destination.url, model: browser)
|
AuthenticationView(destination: destination.url, model: browser)
|
||||||
@@ -130,13 +158,15 @@ struct ContentView: View {
|
|||||||
private func connect() {
|
private func connect() {
|
||||||
do {
|
do {
|
||||||
let candidate = try ServerAddress(input)
|
let candidate = try ServerAddress(input)
|
||||||
connecting = true; message = nil
|
connecting = true
|
||||||
|
message = nil
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
defer { connecting = false }
|
defer { connecting = false }
|
||||||
do {
|
do {
|
||||||
try await ServerProbe.check(candidate)
|
try await ServerProbe.check(candidate)
|
||||||
UserDefaults.standard.set(candidate.url.absoluteString, forKey: "origin")
|
UserDefaults.standard.set(candidate.url.absoluteString, forKey: "origin")
|
||||||
server = candidate; browser.open(candidate)
|
server = candidate
|
||||||
|
browser.open(candidate)
|
||||||
} catch { message = ConnectionError.unavailable.localizedDescription }
|
} catch { message = ConnectionError.unavailable.localizedDescription }
|
||||||
}
|
}
|
||||||
} catch { message = ConnectionError.invalidAddress.localizedDescription }
|
} catch { message = ConnectionError.invalidAddress.localizedDescription }
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ struct ServerAddress: Equatable {
|
|||||||
parts.user == nil, parts.password == nil,
|
parts.user == nil, parts.password == nil,
|
||||||
parts.query == nil, parts.fragment == nil,
|
parts.query == nil, parts.fragment == nil,
|
||||||
parts.percentEncodedPath.isEmpty || parts.percentEncodedPath == "/",
|
parts.percentEncodedPath.isEmpty || parts.percentEncodedPath == "/",
|
||||||
parts.port == nil || (1...65535).contains(parts.port!) else {
|
parts.port == nil || (1...65535).contains(parts.port!)
|
||||||
|
else {
|
||||||
throw ConnectionError.invalidAddress
|
throw ConnectionError.invalidAddress
|
||||||
}
|
}
|
||||||
parts.scheme = "https"
|
parts.scheme = "https"
|
||||||
@@ -25,11 +26,20 @@ struct ServerAddress: Equatable {
|
|||||||
|
|
||||||
func owns(_ candidate: URL) -> Bool {
|
func owns(_ candidate: URL) -> Bool {
|
||||||
guard let parts = URLComponents(url: candidate, resolvingAgainstBaseURL: false),
|
guard let parts = URLComponents(url: candidate, resolvingAgainstBaseURL: false),
|
||||||
let own = URLComponents(url: url, resolvingAgainstBaseURL: false) else { return false }
|
let own = URLComponents(url: url, resolvingAgainstBaseURL: false)
|
||||||
|
else { return false }
|
||||||
return parts.scheme?.lowercased() == "https" && parts.user == nil && parts.password == nil
|
return parts.scheme?.lowercased() == "https" && parts.user == nil && parts.password == nil
|
||||||
&& parts.host?.lowercased() == own.host?.lowercased()
|
&& parts.host?.lowercased() == own.host?.lowercased()
|
||||||
&& (parts.port ?? 443) == (own.port ?? 443)
|
&& (parts.port ?? 443) == (own.port ?? 443)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ownsDownload(_ candidate: URL) -> Bool {
|
||||||
|
if owns(candidate) { return true }
|
||||||
|
guard candidate.scheme == "blob",
|
||||||
|
let inner = URL(string: String(candidate.absoluteString.dropFirst(5)))
|
||||||
|
else { return false }
|
||||||
|
return owns(inner)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ConnectionError: LocalizedError {
|
enum ConnectionError: LocalizedError {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
enum ServerMetadata {
|
||||||
|
private struct Bootstrap: Decodable {
|
||||||
|
let appName: String
|
||||||
|
let setupRequired: Bool
|
||||||
|
let passwordResetEnabled: Bool
|
||||||
|
}
|
||||||
|
private struct Authentication: Decodable {
|
||||||
|
let enabled: Bool
|
||||||
|
let passwordLoginEnabled: Bool
|
||||||
|
}
|
||||||
|
static func validate(bootstrap: Data, authentication: Data) throws {
|
||||||
|
let decoder = JSONDecoder()
|
||||||
|
decoder.keyDecodingStrategy = .convertFromSnakeCase
|
||||||
|
_ = try decoder.decode(Bootstrap.self, from: bootstrap)
|
||||||
|
_ = try decoder.decode(Authentication.self, from: authentication)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
final class NoRedirect: NSObject, URLSessionTaskDelegate {
|
final class NoRedirect: NSObject, URLSessionTaskDelegate {
|
||||||
func urlSession(_ session: URLSession, task: URLSessionTask,
|
func urlSession(
|
||||||
|
_ session: URLSession, task: URLSessionTask,
|
||||||
willPerformHTTPRedirection response: HTTPURLResponse,
|
willPerformHTTPRedirection response: HTTPURLResponse,
|
||||||
newRequest request: URLRequest,
|
newRequest request: URLRequest,
|
||||||
completionHandler: @escaping (URLRequest?) -> Void) {
|
completionHandler: @escaping (URLRequest?) -> Void
|
||||||
|
) {
|
||||||
completionHandler(nil)
|
completionHandler(nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -19,16 +21,11 @@ enum ServerProbe {
|
|||||||
let session = URLSession(configuration: configuration, delegate: NoRedirect(), delegateQueue: nil)
|
let session = URLSession(configuration: configuration, delegate: NoRedirect(), delegateQueue: nil)
|
||||||
defer { session.invalidateAndCancel() }
|
defer { session.invalidateAndCancel() }
|
||||||
let version = try await json(session, server.url.appendingPathComponent("api/v1/version"))
|
let version = try await json(session, server.url.appendingPathComponent("api/v1/version"))
|
||||||
guard version["app_name"] is String,
|
|
||||||
version["setup_required"] is Bool,
|
|
||||||
version["password_reset_enabled"] is Bool else { throw ConnectionError.incompatible }
|
|
||||||
let auth = try await json(session, server.url.appendingPathComponent("api/v1/auth/oidc/config"))
|
let auth = try await json(session, server.url.appendingPathComponent("api/v1/auth/oidc/config"))
|
||||||
guard auth["enabled"] is Bool, auth["password_login_enabled"] is Bool else {
|
try ServerMetadata.validate(bootstrap: version, authentication: auth)
|
||||||
throw ConnectionError.incompatible
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static func json(_ session: URLSession, _ url: URL) async throws -> [String: Any] {
|
private static func json(_ session: URLSession, _ url: URL) async throws -> Data {
|
||||||
var request = URLRequest(url: url)
|
var request = URLRequest(url: url)
|
||||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||||
let (stream, response) = try await session.bytes(for: request)
|
let (stream, response) = try await session.bytes(for: request)
|
||||||
@@ -38,9 +35,6 @@ enum ServerProbe {
|
|||||||
guard data.count < 65_536 else { throw ConnectionError.incompatible }
|
guard data.count < 65_536 else { throw ConnectionError.incompatible }
|
||||||
data.append(byte)
|
data.append(byte)
|
||||||
}
|
}
|
||||||
guard let result = try JSONSerialization.jsonObject(with: data) as? [String: Any] else {
|
return data
|
||||||
throw ConnectionError.incompatible
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import XCTest
|
import XCTest
|
||||||
|
|
||||||
@testable import Yuvomi
|
@testable import Yuvomi
|
||||||
|
|
||||||
final class ServerAddressTests: XCTestCase {
|
final class ServerAddressTests: XCTestCase {
|
||||||
@@ -8,21 +9,32 @@ final class ServerAddressTests: XCTestCase {
|
|||||||
XCTAssertEqual(try ServerAddress("https://[::1]:8443").url.port, 8443)
|
XCTAssertEqual(try ServerAddress("https://[::1]:8443").url.port, 8443)
|
||||||
}
|
}
|
||||||
func testUnsafeConfigurations() {
|
func testUnsafeConfigurations() {
|
||||||
for value in ["http://family.example", "javascript:alert(1)", "https://u:p@family.example",
|
for value in [
|
||||||
|
"http://family.example", "javascript:alert(1)", "https://u:p@family.example",
|
||||||
"https://family.example/subpath", "https://family.example?token=secret",
|
"https://family.example/subpath", "https://family.example?token=secret",
|
||||||
"https://family.example/#token", "https://family.example:0",
|
"https://family.example/#token", "https://family.example:0",
|
||||||
"https://family.example:65536", "https://family.example/%2f",
|
"https://family.example:65536", "https://family.example/%2f",
|
||||||
"https://family.example\\@evil.test", "https:///", ""] {
|
"https://family.example\\@evil.test", "https:///", "",
|
||||||
|
] {
|
||||||
XCTAssertThrowsError(try ServerAddress(value), value)
|
XCTAssertThrowsError(try ServerAddress(value), value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func testOriginIsolation() throws {
|
func testOriginIsolation() throws {
|
||||||
let server = try ServerAddress("https://family.example")
|
let server = try ServerAddress("https://family.example")
|
||||||
XCTAssertTrue(server.owns(URL(string: "https://FAMILY.example:443/tasks?id=1")!))
|
XCTAssertTrue(server.owns(URL(string: "https://FAMILY.example:443/tasks?id=1")!))
|
||||||
for value in ["https://family.example.evil.test", "https://family.example@evil.test",
|
for value in [
|
||||||
|
"https://family.example.evil.test", "https://family.example@evil.test",
|
||||||
"https://user@family.example", "http://family.example", "https://family.example:8443",
|
"https://user@family.example", "http://family.example", "https://family.example:8443",
|
||||||
"file:///etc/passwd", "data:text/html,hi"] {
|
"file:///etc/passwd", "data:text/html,hi",
|
||||||
|
] {
|
||||||
XCTAssertFalse(server.owns(URL(string: value)!), value)
|
XCTAssertFalse(server.owns(URL(string: value)!), value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
func testBlobExportsAreOriginBound() throws {
|
||||||
|
let server = try ServerAddress("https://family.example")
|
||||||
|
XCTAssertTrue(server.ownsDownload(URL(string: "blob:https://family.example/unique-id")!))
|
||||||
|
XCTAssertFalse(server.ownsDownload(URL(string: "blob:https://evil.example/unique-id")!))
|
||||||
|
XCTAssertFalse(server.ownsDownload(URL(string: "blob:http://family.example/unique-id")!))
|
||||||
|
XCTAssertFalse(server.ownsDownload(URL(string: "file:///tmp/export")!))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
import XCTest
|
||||||
|
|
||||||
|
@testable import Yuvomi
|
||||||
|
|
||||||
|
final class ServerMetadataTests: XCTestCase {
|
||||||
|
private let bootstrap = Data(#"{"app_name":"Yuvomi","setup_required":false,"password_reset_enabled":true}"#.utf8)
|
||||||
|
private let authentication = Data(#"{"enabled":false,"password_login_enabled":true}"#.utf8)
|
||||||
|
func testAcceptsActualAnonymousMetadataWithoutVersion() throws {
|
||||||
|
try ServerMetadata.validate(bootstrap: bootstrap, authentication: authentication)
|
||||||
|
}
|
||||||
|
func testRejectsNumericBooleanAndMissingAuthenticationMetadata() {
|
||||||
|
let numeric = Data(#"{"enabled":1,"password_login_enabled":true}"#.utf8)
|
||||||
|
XCTAssertThrowsError(try ServerMetadata.validate(bootstrap: bootstrap, authentication: numeric))
|
||||||
|
XCTAssertThrowsError(try ServerMetadata.validate(bootstrap: bootstrap, authentication: Data("{}".utf8)))
|
||||||
|
}
|
||||||
|
func testRejectsProxyHtmlAndInvalidJson() {
|
||||||
|
XCTAssertThrowsError(
|
||||||
|
try ServerMetadata.validate(bootstrap: Data("<html>SSO proxy</html>".utf8), authentication: authentication))
|
||||||
|
XCTAssertThrowsError(try ServerMetadata.validate(bootstrap: Data("null".utf8), authentication: authentication))
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -2,7 +2,7 @@ name: Yuvomi
|
|||||||
options:
|
options:
|
||||||
bundleIdPrefix: community.yuvomi
|
bundleIdPrefix: community.yuvomi
|
||||||
deploymentTarget:
|
deploymentTarget:
|
||||||
iOS: "16.0"
|
iOS: "18.4"
|
||||||
settings:
|
settings:
|
||||||
base:
|
base:
|
||||||
SWIFT_VERSION: "5.0"
|
SWIFT_VERSION: "5.0"
|
||||||
@@ -40,6 +40,7 @@ targets:
|
|||||||
base:
|
base:
|
||||||
PRODUCT_BUNDLE_IDENTIFIER: community.yuvomi.mobile
|
PRODUCT_BUNDLE_IDENTIFIER: community.yuvomi.mobile
|
||||||
TARGETED_DEVICE_FAMILY: "1,2"
|
TARGETED_DEVICE_FAMILY: "1,2"
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon
|
||||||
dependencies:
|
dependencies:
|
||||||
- sdk: WebKit.framework
|
- sdk: WebKit.framework
|
||||||
YuvomiTests:
|
YuvomiTests:
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Generate the original community Y mark (not the upstream logo), without dependencies."""
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import struct
|
||||||
|
import zlib
|
||||||
|
|
||||||
|
ROOT = Path(__file__).resolve().parents[1]
|
||||||
|
|
||||||
|
|
||||||
|
def png(size):
|
||||||
|
raw = bytearray()
|
||||||
|
segments = [(32, 29, 54, 54), (54, 54, 76, 29), (54, 54, 54, 80)]
|
||||||
|
for y in range(size):
|
||||||
|
raw.append(0)
|
||||||
|
for x in range(size):
|
||||||
|
coverage = 0
|
||||||
|
for dx, dy in [(0.25, 0.25), (0.75, 0.25), (0.25, 0.75), (0.75, 0.75)]:
|
||||||
|
px, py = (x + dx) * 108 / size, (y + dy) * 108 / size
|
||||||
|
for ax, ay, bx, by in segments:
|
||||||
|
projection = max(0, min(1, ((px-ax)*(bx-ax)+(py-ay)*(by-ay))/((bx-ax)**2+(by-ay)**2)))
|
||||||
|
if (px-ax-projection*(bx-ax))**2 + (py-ay-projection*(by-ay))**2 <= 4.5**2:
|
||||||
|
coverage += 1
|
||||||
|
break
|
||||||
|
raw.extend(round(background + (255-background)*coverage/4) for background in (108, 58, 237))
|
||||||
|
|
||||||
|
def chunk(name, data):
|
||||||
|
return struct.pack(">I", len(data)) + name + data + struct.pack(">I", zlib.crc32(name+data))
|
||||||
|
return b"\x89PNG\r\n\x1a\n" + chunk(b"IHDR", struct.pack(">IIBBBBB", size, size, 8, 2, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(raw, 9)) + chunk(b"IEND", b"")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
folder = ROOT / "ios/Sources/Assets.xcassets/AppIcon.appiconset"
|
||||||
|
folder.mkdir(parents=True, exist_ok=True)
|
||||||
|
(folder / "AppIcon.png").write_bytes(png(1024))
|
||||||
|
(folder / "Contents.json").write_text(json.dumps({"images": [
|
||||||
|
{"filename": "AppIcon.png", "idiom": "universal", "platform": "ios", "size": "1024x1024"}
|
||||||
|
], "info": {"author": "Yuvomi Mobile contributors", "version": 1}}, indent=2) + "\n")
|
||||||
Reference in new issue
Block a user