Create SHD desktop app repository
This commit is contained in:
@@ -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"
|
||||
)
|
||||
]
|
||||
)
|
||||
Binary file not shown.
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleDevelopmentRegion</key>
|
||||
<string>ru</string>
|
||||
<key>CFBundleDisplayName</key>
|
||||
<string>SHD</string>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>SHD</string>
|
||||
<key>CFBundleIconFile</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIconName</key>
|
||||
<string>AppIcon</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>su.xyz.shd.desktop</string>
|
||||
<key>CFBundleInfoDictionaryVersion</key>
|
||||
<string>6.0</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>SHD</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>CFBundleURLTypes</key>
|
||||
<array>
|
||||
<dict>
|
||||
<key>CFBundleURLName</key>
|
||||
<string>su.xyz.shd.desktop.auth</string>
|
||||
<key>CFBundleURLSchemes</key>
|
||||
<array>
|
||||
<string>shd-auth</string>
|
||||
</array>
|
||||
</dict>
|
||||
</array>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>14.0</string>
|
||||
<key>NSHighResolutionCapable</key>
|
||||
<true/>
|
||||
<key>NSRequiresAquaSystemAppearance</key>
|
||||
<false/>
|
||||
<key>NSSupportsAutomaticGraphicsSwitching</key>
|
||||
<true/>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
<string>SHD использует микрофон для голосового ввода, записи аудиосообщений и работы с голосовым ассистентом.</string>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>SHD использует камеру для сканирования QR-кодов и загрузки фото в проект.</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsArbitraryLoads</key>
|
||||
<true/>
|
||||
</dict>
|
||||
</dict>
|
||||
</plist>
|
||||
Executable
+45
@@ -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"
|
||||
@@ -0,0 +1,648 @@
|
||||
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 == "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-macos"
|
||||
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)
|
||||
config.userContentController.add(WeakScriptMessageHandler(controller: self), name: "shdNativePrint")
|
||||
|
||||
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() {
|
||||
guard authSession == nil else { 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))))
|
||||
let environment = currentEnv
|
||||
|
||||
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")
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
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)
|
||||
|
||||
// Account Menu
|
||||
let accountMenuItem = NSMenuItem()
|
||||
let accountMenu = NSMenu(title: "Учётная запись")
|
||||
let browserLoginItem = accountMenu.addItem(
|
||||
withTitle: "Войти по passkey в системном браузере…",
|
||||
action: #selector(onBrowserPasskeyLogin),
|
||||
keyEquivalent: "l"
|
||||
)
|
||||
browserLoginItem.keyEquivalentModifierMask = [.command, .shift]
|
||||
browserLoginItem.target = self
|
||||
accountMenuItem.submenu = accountMenu
|
||||
mainMenu.addItem(accountMenuItem)
|
||||
|
||||
// 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 onBrowserPasskeyLogin() { currentActiveController?.signInWithPasskeyInBrowser() }
|
||||
@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()
|
||||
}
|
||||
Reference in New Issue
Block a user