Create SHD desktop app repository
This commit is contained in:
@@ -0,0 +1,8 @@
|
|||||||
|
.DS_Store
|
||||||
|
Apple/.build/
|
||||||
|
Apple/dist/
|
||||||
|
Apple/.swiftpm/
|
||||||
|
Windows/bin/
|
||||||
|
Windows/obj/
|
||||||
|
Windows/.vs/
|
||||||
|
Windows/publish/
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
# SHD Desktop
|
||||||
|
|
||||||
|
Исходники настольного приложения SHD для Apple и Windows.
|
||||||
|
|
||||||
|
## Папки
|
||||||
|
|
||||||
|
- `Apple/` — приложение macOS на Swift и WKWebView.
|
||||||
|
- `Windows/` — место для версии Windows.
|
||||||
|
|
||||||
|
## Вход в macOS-приложении
|
||||||
|
|
||||||
|
Пункт **«Учётная запись → Войти по passkey в системном браузере…»** открывает страницу авторизации текущего контура в браузере по умолчанию (или в Safari, если браузер по умолчанию не поддерживает такой вход). После входа приложение принимает одноразовый код и отправляет его в WKWebView, чтобы сервер создал веб-сессию для выбранного контура.
|
||||||
|
|
||||||
|
Для работы сервер должен предоставить:
|
||||||
|
|
||||||
|
1. `GET /desktop-auth/authorize` с параметрами `client_id=shd-macos`, `redirect_uri=shd-auth://callback`, `response_type=code`, `state`, `code_challenge` и `code_challenge_method=S256`. После обычного входа, включая passkey, сервер возвращает браузер на `shd-auth://callback?code=…&state=…`.
|
||||||
|
2. `POST /desktop-auth/complete` с полями `code` и `code_verifier`. Сервер проверяет одноразовый код и PKCE, создаёт веб-сессию, устанавливает защищённую cookie и перенаправляет WKWebView на SHD.
|
||||||
|
|
||||||
|
Код должен быть одноразовым, связанным с PKCE и действовать не более 60 секунд. Callback URI фиксирован в приложении. Маршрут завершения должен принимать только такой код и verifier; исключение CSRF-защиты допустимо только для этого маршрута при обязательной проверке кода и PKCE.
|
||||||
|
|
||||||
|
## Сборка macOS-приложения
|
||||||
|
|
||||||
|
Из каталога `Apple/` запустить `./Scripts/build-app.sh`. Сборка создаёт проверенный архив `Apple/dist/SHD-macOS-arm64.zip` и не устанавливает приложение в `/Applications`.
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
# SHD для Windows
|
||||||
|
|
||||||
|
Здесь будет исходный код Windows-версии SHD. Планируемая оболочка — отдельное Windows-приложение с WebView2 и переключением DEV/PROD.
|
||||||
Reference in New Issue
Block a user