commit 4d6e26db88783aba2f9dc0f83a2af2365946c550 Author: bulava92 Date: Wed Oct 28 16:59:02 2026 +0300 SHD Desktop public download instructions diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..30cc244 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +.DS_Store +Apple/.build/ +Apple/dist/ +Apple/.swiftpm/ +Windows/bin/ +Windows/obj/ +Windows/.vs/ +Windows/publish/ +Windows/dist/ diff --git a/Apple/Package.swift b/Apple/Package.swift new file mode 100644 index 0000000..2c7b113 --- /dev/null +++ b/Apple/Package.swift @@ -0,0 +1,18 @@ +// swift-tools-version: 6.0 +import PackageDescription + +let package = Package( + name: "SHD", + platforms: [ + .macOS(.v14) + ], + products: [ + .executable(name: "SHD", targets: ["SHD"]) + ], + targets: [ + .executableTarget( + name: "SHD", + path: "Sources/SHD" + ) + ] +) diff --git a/Apple/Resources/AppIcon.icns b/Apple/Resources/AppIcon.icns new file mode 100644 index 0000000..8939119 Binary files /dev/null and b/Apple/Resources/AppIcon.icns differ diff --git a/Apple/Resources/Info.plist b/Apple/Resources/Info.plist new file mode 100644 index 0000000..6b2ba6d --- /dev/null +++ b/Apple/Resources/Info.plist @@ -0,0 +1,56 @@ + + + + + CFBundleDevelopmentRegion + ru + CFBundleDisplayName + SHD + CFBundleExecutable + SHD + CFBundleIconFile + AppIcon + CFBundleIconName + AppIcon + CFBundleIdentifier + su.xyz.shd.desktop + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + SHD + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0.0 + CFBundleVersion + 1 + CFBundleURLTypes + + + CFBundleURLName + su.xyz.shd.desktop.auth + CFBundleURLSchemes + + shd-auth + + + + LSMinimumSystemVersion + 14.0 + NSHighResolutionCapable + + NSRequiresAquaSystemAppearance + + NSSupportsAutomaticGraphicsSwitching + + NSMicrophoneUsageDescription + SHD использует микрофон для голосового ввода, записи аудиосообщений и работы с голосовым ассистентом. + NSCameraUsageDescription + SHD использует камеру для сканирования QR-кодов и загрузки фото в проект. + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + + + diff --git a/Apple/Scripts/build-app.sh b/Apple/Scripts/build-app.sh new file mode 100755 index 0000000..c86484f --- /dev/null +++ b/Apple/Scripts/build-app.sh @@ -0,0 +1,45 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +BUILD_DIR="$PROJECT_DIR/.build" +OUTPUT_ARCHIVE="${1:-$PROJECT_DIR/dist/SHD-macOS-arm64.zip}" +STAGE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/shd-app.XXXXXX")" +STAGE_APP="$STAGE_DIR/SHD.app" +VERIFY_DIR="$STAGE_DIR/verify" + +cleanup() { + rm -rf "$STAGE_DIR" +} +trap cleanup EXIT + +if [[ -e "$OUTPUT_ARCHIVE" ]]; then + echo "Выходной файл уже существует: $OUTPUT_ARCHIVE" >&2 + echo "Удалите его или укажите другой путь." >&2 + exit 1 +fi + +cd "$PROJECT_DIR" +swift build -c release + +mkdir -p "$STAGE_APP/Contents/MacOS" "$STAGE_APP/Contents/Resources" +cp "$BUILD_DIR/release/SHD" "$STAGE_APP/Contents/MacOS/SHD" +chmod +x "$STAGE_APP/Contents/MacOS/SHD" +cp "$PROJECT_DIR/Resources/Info.plist" "$STAGE_APP/Contents/Info.plist" +printf 'APPL????' > "$STAGE_APP/Contents/PkgInfo" + +if [[ -f "$PROJECT_DIR/Resources/AppIcon.icns" ]]; then + cp "$PROJECT_DIR/Resources/AppIcon.icns" "$STAGE_APP/Contents/Resources/AppIcon.icns" +fi + +xattr -cr "$STAGE_APP" +codesign --force --deep --sign - "$STAGE_APP" +codesign --verify --deep --strict --verbose=2 "$STAGE_APP" + +mkdir -p "$(dirname "$OUTPUT_ARCHIVE")" +ditto -c -k --sequesterRsrc --keepParent "$STAGE_APP" "$OUTPUT_ARCHIVE" +mkdir -p "$VERIFY_DIR" +ditto -x -k "$OUTPUT_ARCHIVE" "$VERIFY_DIR" +codesign --verify --deep --strict --verbose=2 "$VERIFY_DIR/SHD.app" +echo "Собран архив приложения: $OUTPUT_ARCHIVE" diff --git a/Apple/Sources/SHD/MainWindowController.swift b/Apple/Sources/SHD/MainWindowController.swift new file mode 100644 index 0000000..97060c2 --- /dev/null +++ b/Apple/Sources/SHD/MainWindowController.swift @@ -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[.. (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 + } +} diff --git a/Apple/Sources/SHD/main.swift b/Apple/Sources/SHD/main.swift new file mode 100644 index 0000000..1750084 --- /dev/null +++ b/Apple/Sources/SHD/main.swift @@ -0,0 +1,212 @@ +import Cocoa +import UserNotifications + +@MainActor +final class AppDelegate: NSObject, NSApplicationDelegate, UNUserNotificationCenterDelegate { + static weak var shared: AppDelegate? + + private(set) var windowControllers: [MainWindowController] = [] + + override init() { + super.init() + AppDelegate.shared = self + } + + func applicationDidFinishLaunching(_ notification: Notification) { + setupMainMenu() + setupNotifications() + + let initialController = MainWindowController() + addWindowController(initialController) + initialController.showWindow(nil) + NSApp.activate(ignoringOtherApps: true) + } + + func addWindowController(_ controller: MainWindowController) { + if !windowControllers.contains(where: { $0 === controller }) { + windowControllers.append(controller) + } + } + + func removeWindowController(_ controller: MainWindowController) { + windowControllers.removeAll(where: { $0 === controller }) + } + + private func setupNotifications() { + let center = UNUserNotificationCenter.current() + center.delegate = self + center.requestAuthorization(options: [.alert, .badge, .sound]) { _, _ in } + } + + nonisolated func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) { + completionHandler([.banner, .badge, .sound]) + } + + func applicationShouldHandleReopen(_ sender: NSApplication, hasVisibleWindows flag: Bool) -> Bool { + if !flag { + if let first = windowControllers.first { + first.showWindow(nil) + } else { + let controller = MainWindowController() + addWindowController(controller) + controller.showWindow(nil) + } + } + return true + } + + private func setupMainMenu() { + let mainMenu = NSMenu() + + // App Menu + let appMenuItem = NSMenuItem() + let appMenu = NSMenu(title: "SHD") + appMenu.addItem(withTitle: "О программе SHD", action: #selector(NSApplication.orderFrontStandardAboutPanel(_:)), keyEquivalent: "") + appMenu.addItem(NSMenuItem.separator()) + appMenu.addItem(withTitle: "Скрыть SHD", action: #selector(NSApplication.hide(_:)), keyEquivalent: "h") + let hideOthersItem = appMenu.addItem(withTitle: "Скрыть остальные", action: #selector(NSApplication.hideOtherApplications(_:)), keyEquivalent: "h") + hideOthersItem.keyEquivalentModifierMask = [.command, .option] + appMenu.addItem(withTitle: "Показать все", action: #selector(NSApplication.unhideAllApplications(_:)), keyEquivalent: "") + appMenu.addItem(NSMenuItem.separator()) + appMenu.addItem(withTitle: "Завершить SHD", action: #selector(NSApplication.terminate(_:)), keyEquivalent: "q") + appMenuItem.submenu = appMenu + mainMenu.addItem(appMenuItem) + + // File Menu (New Tab, New Window, Print, Close) + let fileMenuItem = NSMenuItem() + let fileMenu = NSMenu(title: "Файл") + fileMenu.addItem(withTitle: "Новая вкладка", action: #selector(onNewTab), keyEquivalent: "t") + fileMenu.addItem(withTitle: "Новое окно", action: #selector(onNewWindow), keyEquivalent: "n") + fileMenu.addItem(NSMenuItem.separator()) + fileMenu.addItem(withTitle: "Напечатать…", action: #selector(onPrint), keyEquivalent: "p") + fileMenu.addItem(NSMenuItem.separator()) + fileMenu.addItem(withTitle: "Закрыть вкладку", action: #selector(onCloseTabOrWindow), keyEquivalent: "w") + fileMenuItem.submenu = fileMenu + mainMenu.addItem(fileMenuItem) + + // Edit Menu + let editMenuItem = NSMenuItem() + let editMenu = NSMenu(title: "Правка") + editMenu.addItem(withTitle: "Отменить", action: Selector(("undo:")), keyEquivalent: "z") + let redoItem = editMenu.addItem(withTitle: "Повторить", action: Selector(("redo:")), keyEquivalent: "z") + redoItem.keyEquivalentModifierMask = [.command, .shift] + editMenu.addItem(NSMenuItem.separator()) + editMenu.addItem(withTitle: "Вырезать", action: #selector(NSText.cut(_:)), keyEquivalent: "x") + editMenu.addItem(withTitle: "Скопировать", action: #selector(NSText.copy(_:)), keyEquivalent: "c") + editMenu.addItem(withTitle: "Вставить", action: #selector(NSText.paste(_:)), keyEquivalent: "v") + editMenu.addItem(withTitle: "Выбрать всё", action: #selector(NSText.selectAll(_:)), keyEquivalent: "a") + editMenuItem.submenu = editMenu + mainMenu.addItem(editMenuItem) + + // View Menu + let viewMenuItem = NSMenuItem() + let viewMenu = NSMenu(title: "Вид") + viewMenu.addItem(withTitle: "Обновить", action: #selector(onReload), keyEquivalent: "r") + let hardReload = viewMenu.addItem(withTitle: "Очистить кэш и обновить", action: #selector(onHardReload), keyEquivalent: "r") + hardReload.keyEquivalentModifierMask = [.command, .shift] + viewMenu.addItem(NSMenuItem.separator()) + viewMenu.addItem(withTitle: "Увеличить", action: #selector(onZoomIn), keyEquivalent: "+") + viewMenu.addItem(withTitle: "Уменьшить", action: #selector(onZoomOut), keyEquivalent: "-") + viewMenu.addItem(withTitle: "Исходный масштаб", action: #selector(onResetZoom), keyEquivalent: "0") + viewMenu.addItem(NSMenuItem.separator()) + viewMenu.addItem(withTitle: "Полноэкранный режим", action: #selector(NSWindow.toggleFullScreen(_:)), keyEquivalent: "f") + viewMenu.addItem(NSMenuItem.separator()) + let inspectorItem = viewMenu.addItem(withTitle: "Инспектор веб-страницы", action: #selector(onShowInspector), keyEquivalent: "i") + inspectorItem.keyEquivalentModifierMask = [.command, .option] + let consoleItem = viewMenu.addItem(withTitle: "Консоль JavaScript", action: #selector(onShowConsole), keyEquivalent: "c") + consoleItem.keyEquivalentModifierMask = [.command, .option] + viewMenuItem.submenu = viewMenu + mainMenu.addItem(viewMenuItem) + + // Environment Menu + let envMenuItem = NSMenuItem() + let envMenu = NSMenu(title: "Контур") + let prodItem = envMenu.addItem(withTitle: "SHD Production (shd.xyz.su)", action: #selector(onSelectProd), keyEquivalent: "1") + prodItem.target = self + let devItem = envMenu.addItem(withTitle: "SHD Dev (dev-shd.xyz.su)", action: #selector(onSelectDev), keyEquivalent: "2") + devItem.target = self + envMenuItem.submenu = envMenu + mainMenu.addItem(envMenuItem) + + // History Menu + let historyMenuItem = NSMenuItem() + let historyMenu = NSMenu(title: "История") + historyMenu.addItem(withTitle: "Назад", action: #selector(onGoBack), keyEquivalent: "[") + historyMenu.addItem(withTitle: "Вперед", action: #selector(onGoForward), keyEquivalent: "]") + historyMenuItem.submenu = historyMenu + mainMenu.addItem(historyMenuItem) + + // Window Menu + let windowMenuItem = NSMenuItem() + let windowMenu = NSMenu(title: "Окно") + windowMenu.addItem(withTitle: "Убрать в Dock", action: #selector(NSWindow.performMiniaturize(_:)), keyEquivalent: "m") + windowMenu.addItem(withTitle: "Масштабировать", action: #selector(NSWindow.performZoom(_:)), keyEquivalent: "") + windowMenu.addItem(NSMenuItem.separator()) + windowMenu.addItem(withTitle: "Объединить все окна", action: #selector(NSWindow.mergeAllWindows(_:)), keyEquivalent: "") + windowMenuItem.submenu = windowMenu + mainMenu.addItem(windowMenuItem) + + NSApp.mainMenu = mainMenu + NSApp.windowsMenu = windowMenu + } + + private var currentActiveController: MainWindowController? { + guard let keyWindow = NSApp.keyWindow else { return windowControllers.first } + return windowControllers.first(where: { $0.window === keyWindow }) ?? windowControllers.first + } + + @objc private func onNewWindow() { + let controller = MainWindowController() + addWindowController(controller) + controller.showWindow(nil) + controller.window?.center() + } + + @objc private func onNewTab() { + let controller = MainWindowController() + addWindowController(controller) + if let keyWindow = NSApp.keyWindow, let newWindow = controller.window { + keyWindow.addTabbedWindow(newWindow, ordered: .above) + newWindow.makeKeyAndOrderFront(nil) + } else { + controller.showWindow(nil) + } + } + + func openTab(with request: URLRequest, relativeTo parentWindow: NSWindow?) { + let controller = MainWindowController(initialRequest: request) + addWindowController(controller) + if let hostWindow = parentWindow ?? NSApp.keyWindow, let newWindow = controller.window { + hostWindow.addTabbedWindow(newWindow, ordered: .above) + newWindow.makeKeyAndOrderFront(nil) + } else { + controller.showWindow(nil) + } + } + + @objc private func onCloseTabOrWindow() { + if let keyWindow = NSApp.keyWindow { + keyWindow.performClose(nil) + } + } + + @objc private func onPrint() { currentActiveController?.printCurrentPage() } + @objc private func onReload() { currentActiveController?.reload() } + @objc private func onHardReload() { currentActiveController?.reloadIgnoringCache() } + @objc private func onZoomIn() { currentActiveController?.zoomIn() } + @objc private func onZoomOut() { currentActiveController?.zoomOut() } + @objc private func onResetZoom() { currentActiveController?.resetZoom() } + @objc private func onGoBack() { currentActiveController?.goBack() } + @objc private func onGoForward() { currentActiveController?.goForward() } + @objc private func onSelectProd() { currentActiveController?.navigateToEnvironment(.production) } + @objc private func onSelectDev() { currentActiveController?.navigateToEnvironment(.dev) } + @objc private func onShowInspector() { currentActiveController?.showInspector() } + @objc private func onShowConsole() { currentActiveController?.showConsole() } +} + +MainActor.assumeIsolated { + let app = NSApplication.shared + let delegate = AppDelegate() + app.delegate = delegate + app.run() +} diff --git a/Apple/downloads/SHD-macOS-arm64.zip b/Apple/downloads/SHD-macOS-arm64.zip new file mode 100644 index 0000000..2c24d18 Binary files /dev/null and b/Apple/downloads/SHD-macOS-arm64.zip differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..3df56e5 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# SHD Desktop — скачать и установить + +## macOS + +Готовое приложение уже собрано из актуального кода и лежит в репозитории: + +**[Скачать ZIP из Gitea](https://git.xyz.su/shd/shd-desktop-app/raw/branch/main/Apple/downloads/SHD-macOS-arm64.zip)** · **[скачать из GitHub](https://github.com/bulava92/shd-desktop-app/raw/refs/heads/main/Apple/downloads/SHD-macOS-arm64.zip)** + +В файле репозитория: [`Apple/downloads/SHD-macOS-arm64.zip`](Apple/downloads/SHD-macOS-arm64.zip). + +Нужен Mac с процессором Apple Silicon и macOS 14 или новее. Для установки не нужно скачивать исходники или что-либо собирать. + +1. Скачайте ZIP по ссылке выше. +2. Откройте ZIP двойным щелчком — появится `SHD.app`. +3. Закройте старую версию SHD, если она запущена. +4. Перетащите `SHD.app` в папку «Программы» и согласитесь заменить старую версию, если macOS спросит. +5. Откройте SHD из папки «Программы». + +Сборка не нотарифицирована Apple, поэтому macOS может предупредить о неизвестном разработчике. Если вы скачали приложение из этого репозитория и доверяете ему, после первой попытки запуска откройте «Системные настройки» → «Конфиденциальность и безопасность» → «Открыть всё равно». [Инструкция Apple](https://support.apple.com/en-gb/102445). + +## Windows + +Готового Windows ZIP пока нет. Его можно собрать на Windows 10 или 11 x64. + +### 1. Установите Git и .NET 10 SDK + +Откройте PowerShell и выполните: + +```powershell +winget install --id Git.Git -e --source winget +winget install --id Microsoft.DotNet.SDK.10 --exact --source winget +``` + +Закройте PowerShell и откройте его снова. + +### 2. Скачайте проект + +Создайте папку `Projects` в домашней папке, скачайте репозиторий и перейдите в него: + +```powershell +New-Item -ItemType Directory -Force -Path "$HOME\Projects" | Out-Null +Set-Location "$HOME\Projects" +git clone https://git.xyz.su/shd/shd-desktop-app.git +Set-Location .\shd-desktop-app +``` + +Оба репозитория открыты: войти в аккаунт не нужно. Если Gitea недоступен, используйте GitHub: `git clone https://github.com/bulava92/shd-desktop-app.git`. + +### 3. Соберите и установите приложение + +В том же PowerShell выполните: + +```powershell +$ErrorActionPreference = 'Stop' +powershell -NoProfile -ExecutionPolicy Bypass -File .\Windows\Scripts\build.ps1 +if ($LASTEXITCODE -ne 0) { throw 'Сборка не удалась.' } +$packageDir = Join-Path $env:TEMP ("SHD-Windows-" + [guid]::NewGuid()) +Expand-Archive -LiteralPath .\Windows\dist\SHD-Windows-x64.zip -DestinationPath $packageDir +powershell -NoProfile -ExecutionPolicy Bypass -File (Join-Path $packageDir 'install.ps1') +``` + +Установщик добавит SHD в меню «Пуск» и запустит приложение. Для запуска нужен Microsoft Edge WebView2 Runtime; если его нет, SHD предложит установить [WebView2 Runtime](https://developer.microsoft.com/en-us/microsoft-edge/webview2/). Дополнительные сведения — в [`Windows/README.md`](Windows/README.md). + +## Вход + +Вход по passkey в приложении пока работает только на DEV. Для PROD используйте Safari или другой браузер. diff --git a/Windows/README.md b/Windows/README.md new file mode 100644 index 0000000..7970506 --- /dev/null +++ b/Windows/README.md @@ -0,0 +1,39 @@ +# Установка SHD для Windows + +Если у вас есть только исходники, выполните раздел «Windows» в [основной инструкции](../README.md), чтобы установить инструменты, скачать проект и собрать установочный ZIP. + +## Если у вас уже есть установочный ZIP + +1. Распакуйте `SHD-Windows-x64.zip` в любую папку. +2. Откройте PowerShell в этой папке. +3. Выполните команду: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\install.ps1 +``` + +SHD скопируется в `%LOCALAPPDATA%\Programs\SHD`, появится в меню «Пуск» и запустится. + +## Если ZIP ещё нет + +Пока готовый установочный ZIP не опубликован. На Windows с установленным .NET 10 SDK выполните из корня скачанного проекта: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File .\Windows\Scripts\build.ps1 +``` + +Файл будет создан по адресу `Windows\dist\SHD-Windows-x64.zip`. Затем распакуйте его и выполните команду установки выше. + +Для запуска приложения нужен Microsoft Edge WebView2 Runtime. Обычно он уже установлен вместе с Microsoft Edge. Если Runtime отсутствует, SHD покажет ссылку на его установку. + +## Удаление + +В PowerShell выполните: + +```powershell +powershell -NoProfile -ExecutionPolicy Bypass -File "$env:LOCALAPPDATA\Programs\SHD\uninstall.ps1" +``` + +SHD исчезнет из меню «Пуск». Профиль сайта и сохранённые данные останутся на компьютере. + +В приложении выберите «Контур» → «SHD Production» или «SHD Dev». Вход по passkey пока работает только на DEV; для PROD используйте браузер. diff --git a/Windows/SHD.Windows.sln b/Windows/SHD.Windows.sln new file mode 100644 index 0000000..c1f2acd --- /dev/null +++ b/Windows/SHD.Windows.sln @@ -0,0 +1,18 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SHD.Windows", "SHD.Windows\SHD.Windows.csproj", "{C4A2FC67-73BD-4E76-85C1-2F2F43C7EC71}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {C4A2FC67-73BD-4E76-85C1-2F2F43C7EC71}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C4A2FC67-73BD-4E76-85C1-2F2F43C7EC71}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C4A2FC67-73BD-4E76-85C1-2F2F43C7EC71}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C4A2FC67-73BD-4E76-85C1-2F2F43C7EC71}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/Windows/SHD.Windows/App.xaml b/Windows/SHD.Windows/App.xaml new file mode 100644 index 0000000..fcd5e2d --- /dev/null +++ b/Windows/SHD.Windows/App.xaml @@ -0,0 +1,50 @@ + + + + + + + + + + + + + diff --git a/Windows/SHD.Windows/App.xaml.cs b/Windows/SHD.Windows/App.xaml.cs new file mode 100644 index 0000000..426a55a --- /dev/null +++ b/Windows/SHD.Windows/App.xaml.cs @@ -0,0 +1,137 @@ +using System.IO.Pipes; +using System.Text; +using System.Windows; +using System.Windows.Threading; + +namespace SHD.Windows; + +public partial class App : Application +{ + private const string MutexName = @"Local\SHD.Desktop.Windows"; + private const string PipeName = "SHD.Desktop.Windows.Auth"; + private static readonly UTF8Encoding Utf8WithoutBom = new(encoderShouldEmitUTF8Identifier: false); + private Mutex? _instanceMutex; + private bool _ownsMutex; + private MainWindow? _mainWindow; + private readonly CancellationTokenSource _pipeCancellation = new(); + + protected override async void OnStartup(StartupEventArgs e) + { + base.OnStartup(e); + + _instanceMutex = new Mutex(initiallyOwned: true, MutexName, out _ownsMutex); + if (!_ownsMutex) + { + var command = e.Args.FirstOrDefault(IsDesktopAuthCallback) ?? "activate"; + await ForwardToPrimaryInstanceAsync(command); + Shutdown(); + return; + } + + _mainWindow = new MainWindow(); + MainWindow = _mainWindow; + _mainWindow.Show(); + _ = ListenForSecondInstanceAsync(_pipeCancellation.Token); + + var callback = e.Args.FirstOrDefault(IsDesktopAuthCallback); + if (callback is not null) + { + await _mainWindow.Dispatcher.InvokeAsync(() => _mainWindow.HandleAuthCallback(callback), DispatcherPriority.Loaded); + } + } + + protected override void OnExit(ExitEventArgs e) + { + _pipeCancellation.Cancel(); + if (_ownsMutex) + { + try { _instanceMutex?.ReleaseMutex(); } + catch (ApplicationException) { } + } + _instanceMutex?.Dispose(); + _pipeCancellation.Dispose(); + base.OnExit(e); + } + + private async Task ListenForSecondInstanceAsync(CancellationToken cancellationToken) + { + while (!cancellationToken.IsCancellationRequested) + { + try + { + await using var server = new NamedPipeServerStream( + PipeName, + PipeDirection.In, + 1, + PipeTransmissionMode.Byte, + PipeOptions.Asynchronous | PipeOptions.CurrentUserOnly); + await server.WaitForConnectionAsync(cancellationToken); + using var reader = new StreamReader(server, Utf8WithoutBom, detectEncodingFromByteOrderMarks: false, bufferSize: 1024, leaveOpen: true); + var message = await reader.ReadLineAsync(cancellationToken); + if (string.IsNullOrWhiteSpace(message)) continue; + + await Dispatcher.InvokeAsync(() => + { + var windows = Application.Current.Windows.OfType().ToArray(); + if (IsDesktopAuthCallback(message)) + { + var authWindow = windows.FirstOrDefault(window => window.CanHandleAuthCallback(message)); + if (authWindow is not null) + { + if (authWindow.WindowState == WindowState.Minimized) authWindow.WindowState = WindowState.Normal; + authWindow.Activate(); + authWindow.HandleAuthCallback(message); + return; + } + } + + var fallbackWindow = windows.FirstOrDefault() ?? _mainWindow; + if (fallbackWindow is null) return; + if (fallbackWindow.WindowState == WindowState.Minimized) fallbackWindow.WindowState = WindowState.Normal; + fallbackWindow.Activate(); + if (IsDesktopAuthCallback(message)) fallbackWindow.HandleAuthCallback(message); + }); + } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) + { + return; + } + catch (IOException) + { + if (!cancellationToken.IsCancellationRequested) await Task.Delay(150, cancellationToken); + } + catch (ObjectDisposedException) when (cancellationToken.IsCancellationRequested) + { + return; + } + } + } + + private static async Task ForwardToPrimaryInstanceAsync(string command) + { + for (var attempt = 0; attempt < 12; attempt++) + { + try + { + await using var client = new NamedPipeClientStream(".", PipeName, PipeDirection.Out, PipeOptions.Asynchronous); + await client.ConnectAsync(500); + await using var writer = new StreamWriter(client, Utf8WithoutBom, bufferSize: 1024, leaveOpen: true); + await writer.WriteLineAsync(command); + await writer.FlushAsync(); + return; + } + catch (IOException) + { + await Task.Delay(150); + } + catch (TimeoutException) + { + await Task.Delay(150); + } + } + } + + private static bool IsDesktopAuthCallback(string value) => + Uri.TryCreate(value, UriKind.Absolute, out var uri) && + uri.Scheme.Equals("shd-auth", StringComparison.OrdinalIgnoreCase); +} diff --git a/Windows/SHD.Windows/BrowserTab.cs b/Windows/SHD.Windows/BrowserTab.cs new file mode 100644 index 0000000..e281779 --- /dev/null +++ b/Windows/SHD.Windows/BrowserTab.cs @@ -0,0 +1,13 @@ +using Microsoft.Web.WebView2.Wpf; +using System.Windows.Controls; + +namespace SHD.Windows; + +internal sealed class BrowserTab(WebView2 view, TabItem item, ProgressBar progress, TextBlock title, ShdEnvironment environment) +{ + public WebView2 View { get; } = view; + public TabItem Item { get; } = item; + public ProgressBar Progress { get; } = progress; + public TextBlock Title { get; } = title; + public ShdEnvironment Environment { get; set; } = environment; +} diff --git a/Windows/SHD.Windows/DesktopAuthFlow.cs b/Windows/SHD.Windows/DesktopAuthFlow.cs new file mode 100644 index 0000000..76e5aea --- /dev/null +++ b/Windows/SHD.Windows/DesktopAuthFlow.cs @@ -0,0 +1,81 @@ +using System.Security.Cryptography; +using System.Text; + +namespace SHD.Windows; + +internal sealed record DesktopAuthCallback(string Code, string State); + +internal static class DesktopAuthFlow +{ + public const string CallbackUri = "shd-auth://callback"; + private const string ClientId = "shd-desktop"; + + public static PendingDesktopAuth Create(ShdEnvironment environment, BrowserTab targetTab) + { + var verifier = Base64Url(RandomNumberGenerator.GetBytes(32)); + var state = Base64Url(RandomNumberGenerator.GetBytes(32)); + var challenge = Base64Url(SHA256.HashData(Encoding.ASCII.GetBytes(verifier))); + var url = environment.BaseUrl() + "/desktop-auth/authorize" + + "?client_id=" + Uri.EscapeDataString(ClientId) + + "&redirect_uri=" + Uri.EscapeDataString(CallbackUri) + + "&response_type=code" + + "&state=" + Uri.EscapeDataString(state) + + "&code_challenge=" + Uri.EscapeDataString(challenge) + + "&code_challenge_method=S256"; + + return new PendingDesktopAuth(state, verifier, environment, targetTab, new Uri(url)); + } + + public static Uri CompleteUri(ShdEnvironment environment) => + new(environment.BaseUrl() + "/desktop-auth/complete"); + + public static bool TryParseCallback(string value, out DesktopAuthCallback? callback, out bool serverReturnedError) + { + callback = null; + serverReturnedError = false; + if (!Uri.TryCreate(value, UriKind.Absolute, out var uri) || + !uri.Scheme.Equals("shd-auth", StringComparison.OrdinalIgnoreCase) || + !uri.Host.Equals("callback", StringComparison.OrdinalIgnoreCase) || + uri.AbsolutePath is not ("" or "/") || uri.UserInfo.Length != 0 || uri.Port != -1 || + uri.Fragment.Length != 0) + return false; + + var values = new Dictionary>(StringComparer.Ordinal); + foreach (var pair in uri.Query.TrimStart('?').Split('&', StringSplitOptions.RemoveEmptyEntries)) + { + var separator = pair.IndexOf('='); + var name = Decode(separator < 0 ? pair : pair[..separator]); + var item = Decode(separator < 0 ? "" : pair[(separator + 1)..]); + if (!values.TryGetValue(name, out var list)) values[name] = list = []; + list.Add(item); + } + + if (values.ContainsKey("error")) + { + if (values["error"].Count != 1 || + !values.TryGetValue("state", out var errorStates) || errorStates.Count != 1 || string.IsNullOrWhiteSpace(errorStates[0])) + return false; + serverReturnedError = true; + callback = new DesktopAuthCallback("", errorStates[0]); + return true; + } + + if (!values.TryGetValue("code", out var codes) || codes.Count != 1 || string.IsNullOrWhiteSpace(codes[0]) || + !values.TryGetValue("state", out var states) || states.Count != 1 || string.IsNullOrWhiteSpace(states[0])) + return false; + + callback = new DesktopAuthCallback(codes[0], states[0]); + return true; + } + + public static string FormEncode(string value) => Uri.EscapeDataString(value).Replace("%20", "+", StringComparison.Ordinal); + + private static string Decode(string value) => Uri.UnescapeDataString(value.Replace("+", " ", StringComparison.Ordinal)); + + private static string Base64Url(byte[] value) => Convert.ToBase64String(value) + .TrimEnd('=') + .Replace('+', '-') + .Replace('/', '_'); +} + +internal sealed record PendingDesktopAuth(string State, string Verifier, ShdEnvironment Environment, BrowserTab TargetTab, Uri AuthorizationUri); diff --git a/Windows/SHD.Windows/MainWindow.xaml b/Windows/SHD.Windows/MainWindow.xaml new file mode 100644 index 0000000..81a98fa --- /dev/null +++ b/Windows/SHD.Windows/MainWindow.xaml @@ -0,0 +1,92 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +