SHD Desktop public download instructions
This commit is contained in:
@@ -0,0 +1,702 @@
|
||||
import Cocoa
|
||||
import WebKit
|
||||
import AuthenticationServices
|
||||
import CryptoKit
|
||||
import Security
|
||||
|
||||
@MainActor
|
||||
private final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
|
||||
weak var controller: MainWindowController?
|
||||
|
||||
init(controller: MainWindowController) {
|
||||
self.controller = controller
|
||||
}
|
||||
|
||||
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||
if message.name == "shdDesktopAuth" {
|
||||
let origin = message.frameInfo.securityOrigin
|
||||
let trustedHosts = ["shd.xyz.su", "dev-shd.xyz.su"]
|
||||
guard message.frameInfo.isMainFrame,
|
||||
origin.protocol.lowercased() == "https",
|
||||
trustedHosts.contains(origin.host.lowercased()) else {
|
||||
return
|
||||
}
|
||||
let environment: MainWindowController.Environment = origin.host.lowercased() == "dev-shd.xyz.su" ? .dev : .production
|
||||
controller?.signInWithPasskeyInBrowser(for: environment)
|
||||
return
|
||||
}
|
||||
|
||||
if message.name == "shdNativePrint" {
|
||||
controller?.printCurrentPage()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class MainWindowController: NSWindowController, NSWindowDelegate, WKNavigationDelegate, WKUIDelegate, WKDownloadDelegate, ASWebAuthenticationPresentationContextProviding {
|
||||
|
||||
enum Environment: String, CaseIterable {
|
||||
case production = "https://shd.xyz.su"
|
||||
case dev = "https://dev-shd.xyz.su"
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .production: return "SHD Prod"
|
||||
case .dev: return "SHD Dev"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var webView: WKWebView!
|
||||
private var progressIndicator: NSProgressIndicator!
|
||||
private var currentEnv: Environment = .production
|
||||
private var observation: NSKeyValueObservation?
|
||||
private var authSession: ASWebAuthenticationSession?
|
||||
private var authAttemptID: UUID?
|
||||
|
||||
private static let authClientID = "shd-desktop"
|
||||
private static let authCallbackScheme = "shd-auth"
|
||||
private static let authCallbackURI = "shd-auth://callback"
|
||||
|
||||
init(initialEnv: Environment? = nil, initialRequest: URLRequest? = nil) {
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 1440, height: 920),
|
||||
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.center()
|
||||
window.title = "SHD"
|
||||
window.titleVisibility = .hidden
|
||||
window.titlebarAppearsTransparent = true
|
||||
window.isReleasedWhenClosed = false
|
||||
window.minSize = NSSize(width: 800, height: 500)
|
||||
|
||||
// Dark theme: ensures window and native tabs are always dark matching SHD
|
||||
window.appearance = NSAppearance(named: .darkAqua)
|
||||
let darkBg = NSColor(calibratedRed: 0.08, green: 0.08, blue: 0.08, alpha: 1.0)
|
||||
window.backgroundColor = darkBg
|
||||
|
||||
// Native macOS tabs support
|
||||
window.tabbingMode = .preferred
|
||||
window.tabbingIdentifier = "SHDMainTabGroup"
|
||||
|
||||
super.init(window: window)
|
||||
|
||||
window.delegate = self
|
||||
setupWebView(darkBg: darkBg)
|
||||
|
||||
if let initialRequest = initialRequest {
|
||||
webView.load(initialRequest)
|
||||
} else if let initialEnv = initialEnv {
|
||||
currentEnv = initialEnv
|
||||
navigateToEnvironment(currentEnv)
|
||||
} else {
|
||||
loadInitialEnvironment()
|
||||
}
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
private func setupWebView(darkBg: NSColor) {
|
||||
guard let window = window else { return }
|
||||
|
||||
let config = WKWebViewConfiguration()
|
||||
config.websiteDataStore = WKWebsiteDataStore.default()
|
||||
config.preferences.isElementFullscreenEnabled = true
|
||||
config.defaultWebpagePreferences.allowsContentJavaScript = true
|
||||
|
||||
// Immediate dark style injection before document parsing even starts
|
||||
let darkCSS = """
|
||||
html, body {
|
||||
background-color: #121212 !important;
|
||||
color: #e0e0e0 !important;
|
||||
}
|
||||
"""
|
||||
let darkScript = """
|
||||
(function() {
|
||||
var el = document.documentElement;
|
||||
if (el) { el.style.backgroundColor = '#121212'; }
|
||||
var style = document.createElement('style');
|
||||
style.id = '__shd_instant_dark__';
|
||||
style.textContent = '\(darkCSS.replacingOccurrences(of: "\n", with: " "))';
|
||||
if (document.head) {
|
||||
document.head.appendChild(style);
|
||||
} else if (el) {
|
||||
el.appendChild(style);
|
||||
}
|
||||
window.print = function() {
|
||||
if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.shdNativePrint) {
|
||||
window.webkit.messageHandlers.shdNativePrint.postMessage({});
|
||||
}
|
||||
};
|
||||
})();
|
||||
"""
|
||||
let userScript = WKUserScript(source: darkScript, injectionTime: .atDocumentStart, forMainFrameOnly: false)
|
||||
config.userContentController.addUserScript(userScript)
|
||||
|
||||
let passkeyBridgeScript = """
|
||||
(function() {
|
||||
document.addEventListener('click', function(event) {
|
||||
var path = window.location.pathname;
|
||||
while (path.length > 1 && path.endsWith('/')) {
|
||||
path = path.slice(0, -1);
|
||||
}
|
||||
if (path !== '/login' && path !== '/login/two-factor') {
|
||||
return;
|
||||
}
|
||||
|
||||
var target = event.target instanceof Element
|
||||
? event.target
|
||||
: (event.target && event.target.parentElement);
|
||||
if (!target || !target.closest('#btnPasskeyLogin, #twoFactorPasskey')) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.stopImmediatePropagation();
|
||||
|
||||
var bridge = window.webkit && window.webkit.messageHandlers
|
||||
? window.webkit.messageHandlers.shdDesktopAuth
|
||||
: null;
|
||||
if (bridge) {
|
||||
bridge.postMessage('passkey-login');
|
||||
}
|
||||
}, true);
|
||||
})();
|
||||
"""
|
||||
config.userContentController.addUserScript(
|
||||
WKUserScript(source: passkeyBridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: true)
|
||||
)
|
||||
config.userContentController.add(WeakScriptMessageHandler(controller: self), name: "shdNativePrint")
|
||||
config.userContentController.add(WeakScriptMessageHandler(controller: self), name: "shdDesktopAuth")
|
||||
|
||||
let container = NSView()
|
||||
container.translatesAutoresizingMaskIntoConstraints = false
|
||||
container.wantsLayer = true
|
||||
container.layer?.backgroundColor = darkBg.cgColor
|
||||
window.contentView = container
|
||||
|
||||
webView = WKWebView(frame: .zero, configuration: config)
|
||||
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||
webView.navigationDelegate = self
|
||||
webView.uiDelegate = self
|
||||
webView.allowsBackForwardNavigationGestures = true
|
||||
webView.isInspectable = true
|
||||
webView.underPageBackgroundColor = darkBg
|
||||
webView.setValue(false, forKey: "drawsBackground") // Prevents WebKit initial white canvas
|
||||
container.addSubview(webView)
|
||||
|
||||
progressIndicator = NSProgressIndicator()
|
||||
progressIndicator.isIndeterminate = false
|
||||
progressIndicator.style = .bar
|
||||
progressIndicator.minValue = 0.0
|
||||
progressIndicator.maxValue = 1.0
|
||||
progressIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||
progressIndicator.isDisplayedWhenStopped = false
|
||||
container.addSubview(progressIndicator)
|
||||
|
||||
// Layout: Pin webview to container but respect safe area so tab bar does NOT overlap topbar
|
||||
NSLayoutConstraint.activate([
|
||||
webView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
webView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
webView.topAnchor.constraint(equalTo: container.safeAreaLayoutGuide.topAnchor),
|
||||
webView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||
|
||||
progressIndicator.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||
progressIndicator.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||
progressIndicator.topAnchor.constraint(equalTo: container.safeAreaLayoutGuide.topAnchor),
|
||||
progressIndicator.heightAnchor.constraint(equalToConstant: 2.0)
|
||||
])
|
||||
|
||||
observation = webView.observe(\.estimatedProgress, options: [.new]) { [weak self] webView, _ in
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.progressIndicator.doubleValue = webView.estimatedProgress
|
||||
if webView.estimatedProgress >= 1.0 {
|
||||
self.progressIndicator.stopAnimation(nil)
|
||||
self.progressIndicator.isHidden = true
|
||||
} else {
|
||||
self.progressIndicator.isHidden = false
|
||||
self.progressIndicator.startAnimation(nil)
|
||||
}
|
||||
self.updateTitle()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func loadInitialEnvironment() {
|
||||
let savedEnvRaw = UserDefaults.standard.string(forKey: "SHDSelectedEnvironment")
|
||||
if let savedEnvRaw = savedEnvRaw, let env = Environment(rawValue: savedEnvRaw) {
|
||||
currentEnv = env
|
||||
} else {
|
||||
currentEnv = .production
|
||||
}
|
||||
navigateToEnvironment(currentEnv)
|
||||
}
|
||||
|
||||
func navigateToEnvironment(_ env: Environment) {
|
||||
currentEnv = env
|
||||
UserDefaults.standard.set(env.rawValue, forKey: "SHDSelectedEnvironment")
|
||||
if let url = URL(string: env.rawValue) {
|
||||
let request = URLRequest(url: url)
|
||||
webView.load(request)
|
||||
}
|
||||
updateTitle()
|
||||
}
|
||||
|
||||
func signInWithPasskeyInBrowser(for environment: Environment) {
|
||||
guard authSession == nil else { return }
|
||||
|
||||
guard environment == .dev else {
|
||||
presentDesktopAuthError("Вход по passkey в приложении пока подключён только для DEV.")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
let verifier = try Self.randomBase64URLString(byteCount: 32)
|
||||
let state = try Self.randomBase64URLString(byteCount: 32)
|
||||
let challenge = Self.base64URLString(for: Data(SHA256.hash(data: Data(verifier.utf8))))
|
||||
|
||||
guard let authorizationURL = makeAuthorizationURL(
|
||||
environment: environment,
|
||||
state: state,
|
||||
challenge: challenge
|
||||
) else {
|
||||
presentDesktopAuthError("Не удалось подготовить адрес входа.")
|
||||
return
|
||||
}
|
||||
|
||||
let attemptID = UUID()
|
||||
authAttemptID = attemptID
|
||||
|
||||
let completionHandler: ASWebAuthenticationSession.CompletionHandler = { [weak self] callbackURL, error in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.finishBrowserPasskeyLogin(
|
||||
callbackURL: callbackURL,
|
||||
error: error,
|
||||
expectedState: state,
|
||||
verifier: verifier,
|
||||
environment: environment,
|
||||
attemptID: attemptID
|
||||
)
|
||||
}
|
||||
}
|
||||
let session: ASWebAuthenticationSession
|
||||
if #available(macOS 14.4, *) {
|
||||
session = ASWebAuthenticationSession(
|
||||
url: authorizationURL,
|
||||
callback: .customScheme(Self.authCallbackScheme),
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
} else {
|
||||
session = ASWebAuthenticationSession(
|
||||
url: authorizationURL,
|
||||
callbackURLScheme: Self.authCallbackScheme,
|
||||
completionHandler: completionHandler
|
||||
)
|
||||
}
|
||||
session.presentationContextProvider = self
|
||||
authSession = session
|
||||
|
||||
if !session.start() {
|
||||
authSession = nil
|
||||
authAttemptID = nil
|
||||
presentDesktopAuthError("macOS не смог открыть окно входа. Попробуйте ещё раз.")
|
||||
}
|
||||
} catch {
|
||||
presentDesktopAuthError("Не удалось безопасно подготовить вход.")
|
||||
}
|
||||
}
|
||||
|
||||
private func makeAuthorizationURL(environment: Environment, state: String, challenge: String) -> URL? {
|
||||
guard var components = URLComponents(string: environment.rawValue) else { return nil }
|
||||
components.path = "/desktop-auth/authorize"
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "client_id", value: Self.authClientID),
|
||||
URLQueryItem(name: "redirect_uri", value: Self.authCallbackURI),
|
||||
URLQueryItem(name: "response_type", value: "code"),
|
||||
URLQueryItem(name: "state", value: state),
|
||||
URLQueryItem(name: "code_challenge", value: challenge),
|
||||
URLQueryItem(name: "code_challenge_method", value: "S256")
|
||||
]
|
||||
return components.url
|
||||
}
|
||||
|
||||
private func finishBrowserPasskeyLogin(
|
||||
callbackURL: URL?,
|
||||
error: Error?,
|
||||
expectedState: String,
|
||||
verifier: String,
|
||||
environment: Environment,
|
||||
attemptID: UUID
|
||||
) {
|
||||
guard authAttemptID == attemptID else { return }
|
||||
authAttemptID = nil
|
||||
authSession = nil
|
||||
|
||||
if let error {
|
||||
if (error as? ASWebAuthenticationSessionError)?.code != .canceledLogin {
|
||||
presentDesktopAuthError("Вход через браузер не завершился. Попробуйте ещё раз.")
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let callbackURL,
|
||||
callbackURL.scheme?.lowercased() == Self.authCallbackScheme,
|
||||
callbackURL.host?.lowercased() == "callback",
|
||||
let callbackComponents = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
|
||||
let queryItems = callbackComponents.queryItems else {
|
||||
presentDesktopAuthError("Сервер вернул некорректный ответ входа.")
|
||||
return
|
||||
}
|
||||
|
||||
if queryItems.contains(where: { $0.name == "error" }) {
|
||||
presentDesktopAuthError("Сервер не завершил вход. Попробуйте ещё раз.")
|
||||
return
|
||||
}
|
||||
|
||||
let stateValues = queryItems.filter { $0.name == "state" }
|
||||
let codeValues = queryItems.filter { $0.name == "code" }
|
||||
guard stateValues.count == 1,
|
||||
stateValues[0].value == expectedState,
|
||||
codeValues.count == 1,
|
||||
let code = codeValues[0].value,
|
||||
!code.isEmpty else {
|
||||
presentDesktopAuthError("Не удалось подтвердить ответ сервера. Начните вход заново.")
|
||||
return
|
||||
}
|
||||
|
||||
guard var completeComponents = URLComponents(string: environment.rawValue) else {
|
||||
presentDesktopAuthError("Не удалось завершить вход в выбранном контуре.")
|
||||
return
|
||||
}
|
||||
completeComponents.path = "/desktop-auth/complete"
|
||||
guard let completeURL = completeComponents.url else {
|
||||
presentDesktopAuthError("Не удалось завершить вход в выбранном контуре.")
|
||||
return
|
||||
}
|
||||
|
||||
var request = URLRequest(url: completeURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 30)
|
||||
request.httpMethod = "POST"
|
||||
request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("no-store", forHTTPHeaderField: "Cache-Control")
|
||||
request.httpBody = Self.formEncodedBody([
|
||||
URLQueryItem(name: "code", value: code),
|
||||
URLQueryItem(name: "code_verifier", value: verifier)
|
||||
])
|
||||
|
||||
currentEnv = environment
|
||||
UserDefaults.standard.set(environment.rawValue, forKey: "SHDSelectedEnvironment")
|
||||
webView.load(request)
|
||||
}
|
||||
|
||||
private func presentDesktopAuthError(_ message: String) {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "Вход в SHD"
|
||||
alert.informativeText = message
|
||||
alert.alertStyle = .warning
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.runModal()
|
||||
}
|
||||
|
||||
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||
window ?? NSApp.keyWindow ?? NSWindow()
|
||||
}
|
||||
|
||||
private static func randomBase64URLString(byteCount: Int) throws -> String {
|
||||
var bytes = [UInt8](repeating: 0, count: byteCount)
|
||||
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||
guard status == errSecSuccess else {
|
||||
throw DesktopAuthError.randomNumberGenerationFailed
|
||||
}
|
||||
return base64URLString(for: Data(bytes))
|
||||
}
|
||||
|
||||
private static func base64URLString(for data: Data) -> String {
|
||||
data.base64EncodedString()
|
||||
.replacingOccurrences(of: "+", with: "-")
|
||||
.replacingOccurrences(of: "/", with: "_")
|
||||
.replacingOccurrences(of: "=", with: "")
|
||||
}
|
||||
|
||||
private static func formEncodedBody(_ items: [URLQueryItem]) -> Data {
|
||||
var components = URLComponents()
|
||||
components.queryItems = items
|
||||
return Data((components.percentEncodedQuery ?? "").utf8)
|
||||
}
|
||||
|
||||
private enum DesktopAuthError: Error {
|
||||
case randomNumberGenerationFailed
|
||||
}
|
||||
|
||||
func reload() {
|
||||
webView.reload()
|
||||
}
|
||||
|
||||
func reloadIgnoringCache() {
|
||||
webView.reloadFromOrigin()
|
||||
}
|
||||
|
||||
func goBack() {
|
||||
if webView.canGoBack {
|
||||
webView.goBack()
|
||||
}
|
||||
}
|
||||
|
||||
func goForward() {
|
||||
if webView.canGoForward {
|
||||
webView.goForward()
|
||||
}
|
||||
}
|
||||
|
||||
func zoomIn() {
|
||||
webView.pageZoom += 0.1
|
||||
}
|
||||
|
||||
func zoomOut() {
|
||||
if webView.pageZoom > 0.4 {
|
||||
webView.pageZoom -= 0.1
|
||||
}
|
||||
}
|
||||
|
||||
func resetZoom() {
|
||||
webView.pageZoom = 1.0
|
||||
}
|
||||
|
||||
func showInspector() {
|
||||
if let inspector = webView.value(forKey: "_inspector") as? NSObject {
|
||||
inspector.perform(Selector(("show")))
|
||||
}
|
||||
}
|
||||
|
||||
func showConsole() {
|
||||
if let inspector = webView.value(forKey: "_inspector") as? NSObject {
|
||||
inspector.perform(Selector(("showConsole")))
|
||||
}
|
||||
}
|
||||
|
||||
func printCurrentPage() {
|
||||
guard let window = window else { return }
|
||||
let printInfo = NSPrintInfo.shared.copy() as? NSPrintInfo ?? NSPrintInfo.shared
|
||||
printInfo.horizontalPagination = .fit
|
||||
printInfo.verticalPagination = .automatic
|
||||
printInfo.isHorizontallyCentered = true
|
||||
printInfo.isVerticallyCentered = false
|
||||
|
||||
let printOp = webView.printOperation(with: printInfo)
|
||||
printOp.view?.frame = webView.bounds
|
||||
printOp.showsPrintPanel = true
|
||||
printOp.showsProgressPanel = true
|
||||
printOp.runModal(for: window, delegate: nil, didRun: nil, contextInfo: nil)
|
||||
}
|
||||
|
||||
private func cleanTitle(from raw: String) -> String {
|
||||
// Strip out repetitive brand suffixes for clean, elegant tab titles
|
||||
var title = raw
|
||||
if let range = title.range(of: " — SHD Project") {
|
||||
title = String(title[..<range.lowerBound])
|
||||
} else if let range = title.range(of: " - SHD Project") {
|
||||
title = String(title[..<range.lowerBound])
|
||||
} else if let range = title.range(of: " | SHD") {
|
||||
title = String(title[..<range.lowerBound])
|
||||
}
|
||||
title = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return title.isEmpty ? "SHD" : title
|
||||
}
|
||||
|
||||
private func updateTitle() {
|
||||
guard let window = window else { return }
|
||||
let raw = webView.title ?? ""
|
||||
let clean = cleanTitle(from: raw)
|
||||
window.title = clean
|
||||
}
|
||||
|
||||
// MARK: - NSWindowDelegate
|
||||
|
||||
func windowWillClose(_ notification: Notification) {
|
||||
authAttemptID = nil
|
||||
authSession?.cancel()
|
||||
authSession = nil
|
||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "shdNativePrint")
|
||||
webView.configuration.userContentController.removeScriptMessageHandler(forName: "shdDesktopAuth")
|
||||
AppDelegate.shared?.removeWindowController(self)
|
||||
}
|
||||
|
||||
// MARK: - WKNavigationDelegate
|
||||
|
||||
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||
updateTitle()
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, preferences: WKWebpagePreferences) async -> (WKNavigationActionPolicy, WKWebpagePreferences) {
|
||||
if navigationAction.shouldPerformDownload {
|
||||
return (.download, preferences)
|
||||
}
|
||||
|
||||
guard let url = navigationAction.request.url else {
|
||||
return (.allow, preferences)
|
||||
}
|
||||
|
||||
let host = url.host?.lowercased() ?? ""
|
||||
let isInternalHost = host == "shd.xyz.su" || host == "dev-shd.xyz.su" || host == "sso.xyz.su" || host == "git.xyz.su" || host.hasSuffix(".xyz.su")
|
||||
|
||||
if navigationAction.navigationType == .linkActivated {
|
||||
let openInNewTab = navigationAction.modifierFlags.contains(.command) || navigationAction.buttonNumber == 2
|
||||
if isInternalHost {
|
||||
if openInNewTab {
|
||||
AppDelegate.shared?.openTab(with: navigationAction.request, relativeTo: self.window)
|
||||
return (.cancel, preferences)
|
||||
}
|
||||
return (.allow, preferences)
|
||||
} else {
|
||||
NSWorkspace.shared.open(url)
|
||||
return (.cancel, preferences)
|
||||
}
|
||||
}
|
||||
|
||||
if isInternalHost {
|
||||
return (.allow, preferences)
|
||||
}
|
||||
|
||||
return (.allow, preferences)
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy {
|
||||
if navigationResponse.canShowMIMEType {
|
||||
return .allow
|
||||
}
|
||||
return .download
|
||||
}
|
||||
|
||||
// MARK: - WKDownloadDelegate
|
||||
|
||||
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
|
||||
download.delegate = self
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
|
||||
download.delegate = self
|
||||
}
|
||||
|
||||
func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String) async -> URL? {
|
||||
let downloadsDir = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSHomeDirectory())
|
||||
var destination = downloadsDir.appendingPathComponent(suggestedFilename)
|
||||
|
||||
var counter = 1
|
||||
let baseName = (suggestedFilename as NSString).deletingPathExtension
|
||||
let ext = (suggestedFilename as NSString).pathExtension
|
||||
|
||||
while FileManager.default.fileExists(atPath: destination.path) {
|
||||
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
|
||||
destination = downloadsDir.appendingPathComponent(newName)
|
||||
counter += 1
|
||||
}
|
||||
|
||||
return destination
|
||||
}
|
||||
|
||||
func downloadDidFinish(_ download: WKDownload) {
|
||||
NSSound(named: "Glass")?.play()
|
||||
}
|
||||
|
||||
// MARK: - WKUIDelegate (Native Alerts, Confirms, Prompts, Media Permissions & File Uploads)
|
||||
|
||||
func webView(_ webView: WKWebView, decideMediaCapturePermissionsFor origin: WKSecurityOrigin, initiatedBy frame: WKFrameInfo, type: WKMediaCaptureType) async -> WKPermissionDecision {
|
||||
let host = origin.host.lowercased()
|
||||
if host == "shd.xyz.su" || host == "dev-shd.xyz.su" || host.hasSuffix(".xyz.su") {
|
||||
return .grant
|
||||
}
|
||||
return .prompt
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
|
||||
if navigationAction.targetFrame == nil {
|
||||
if let url = navigationAction.request.url {
|
||||
let host = url.host?.lowercased() ?? ""
|
||||
if host == "shd.xyz.su" || host == "dev-shd.xyz.su" || host == "sso.xyz.su" || host == "git.xyz.su" || host.hasSuffix(".xyz.su") {
|
||||
AppDelegate.shared?.openTab(with: navigationAction.request, relativeTo: self.window)
|
||||
} else {
|
||||
NSWorkspace.shared.open(url)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo) async {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "SHD"
|
||||
alert.informativeText = message
|
||||
alert.alertStyle = .informational
|
||||
alert.addButton(withTitle: "OK")
|
||||
if let window = window {
|
||||
_ = await alert.beginSheetModal(for: window)
|
||||
} else {
|
||||
alert.runModal()
|
||||
}
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo) async -> Bool {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "SHD"
|
||||
alert.informativeText = message
|
||||
alert.alertStyle = .warning
|
||||
alert.addButton(withTitle: "Подтвердить")
|
||||
alert.addButton(withTitle: "Отмена")
|
||||
|
||||
let response: NSApplication.ModalResponse
|
||||
if let window = window {
|
||||
response = await alert.beginSheetModal(for: window)
|
||||
} else {
|
||||
response = alert.runModal()
|
||||
}
|
||||
return response == .alertFirstButtonReturn
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo) async -> String? {
|
||||
let alert = NSAlert()
|
||||
alert.messageText = "SHD"
|
||||
alert.informativeText = prompt
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.addButton(withTitle: "Отмена")
|
||||
|
||||
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
|
||||
input.stringValue = defaultText ?? ""
|
||||
alert.accessoryView = input
|
||||
|
||||
let response: NSApplication.ModalResponse
|
||||
if let window = window {
|
||||
response = await alert.beginSheetModal(for: window)
|
||||
} else {
|
||||
response = alert.runModal()
|
||||
}
|
||||
|
||||
if response == .alertFirstButtonReturn {
|
||||
return input.stringValue
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func webView(_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters, initiatedByFrame frame: WKFrameInfo) async -> [URL]? {
|
||||
let panel = NSOpenPanel()
|
||||
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
|
||||
panel.canChooseDirectories = parameters.allowsDirectories
|
||||
panel.canChooseFiles = true
|
||||
|
||||
let response: NSApplication.ModalResponse
|
||||
if let window = window {
|
||||
response = await panel.beginSheetModal(for: window)
|
||||
} else {
|
||||
response = panel.runModal()
|
||||
}
|
||||
|
||||
if response == .OK {
|
||||
return panel.urls
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user