SHD Desktop public download instructions
This commit is contained in:
@@ -0,0 +1,9 @@
|
|||||||
|
.DS_Store
|
||||||
|
Apple/.build/
|
||||||
|
Apple/dist/
|
||||||
|
Apple/.swiftpm/
|
||||||
|
Windows/bin/
|
||||||
|
Windows/obj/
|
||||||
|
Windows/.vs/
|
||||||
|
Windows/publish/
|
||||||
|
Windows/dist/
|
||||||
@@ -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,702 @@
|
|||||||
|
import Cocoa
|
||||||
|
import WebKit
|
||||||
|
import AuthenticationServices
|
||||||
|
import CryptoKit
|
||||||
|
import Security
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
private final class WeakScriptMessageHandler: NSObject, WKScriptMessageHandler {
|
||||||
|
weak var controller: MainWindowController?
|
||||||
|
|
||||||
|
init(controller: MainWindowController) {
|
||||||
|
self.controller = controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
||||||
|
if message.name == "shdDesktopAuth" {
|
||||||
|
let origin = message.frameInfo.securityOrigin
|
||||||
|
let trustedHosts = ["shd.xyz.su", "dev-shd.xyz.su"]
|
||||||
|
guard message.frameInfo.isMainFrame,
|
||||||
|
origin.protocol.lowercased() == "https",
|
||||||
|
trustedHosts.contains(origin.host.lowercased()) else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let environment: MainWindowController.Environment = origin.host.lowercased() == "dev-shd.xyz.su" ? .dev : .production
|
||||||
|
controller?.signInWithPasskeyInBrowser(for: environment)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if message.name == "shdNativePrint" {
|
||||||
|
controller?.printCurrentPage()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
final class MainWindowController: NSWindowController, NSWindowDelegate, WKNavigationDelegate, WKUIDelegate, WKDownloadDelegate, ASWebAuthenticationPresentationContextProviding {
|
||||||
|
|
||||||
|
enum Environment: String, CaseIterable {
|
||||||
|
case production = "https://shd.xyz.su"
|
||||||
|
case dev = "https://dev-shd.xyz.su"
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .production: return "SHD Prod"
|
||||||
|
case .dev: return "SHD Dev"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var webView: WKWebView!
|
||||||
|
private var progressIndicator: NSProgressIndicator!
|
||||||
|
private var currentEnv: Environment = .production
|
||||||
|
private var observation: NSKeyValueObservation?
|
||||||
|
private var authSession: ASWebAuthenticationSession?
|
||||||
|
private var authAttemptID: UUID?
|
||||||
|
|
||||||
|
private static let authClientID = "shd-desktop"
|
||||||
|
private static let authCallbackScheme = "shd-auth"
|
||||||
|
private static let authCallbackURI = "shd-auth://callback"
|
||||||
|
|
||||||
|
init(initialEnv: Environment? = nil, initialRequest: URLRequest? = nil) {
|
||||||
|
let window = NSWindow(
|
||||||
|
contentRect: NSRect(x: 0, y: 0, width: 1440, height: 920),
|
||||||
|
styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
|
||||||
|
backing: .buffered,
|
||||||
|
defer: false
|
||||||
|
)
|
||||||
|
window.center()
|
||||||
|
window.title = "SHD"
|
||||||
|
window.titleVisibility = .hidden
|
||||||
|
window.titlebarAppearsTransparent = true
|
||||||
|
window.isReleasedWhenClosed = false
|
||||||
|
window.minSize = NSSize(width: 800, height: 500)
|
||||||
|
|
||||||
|
// Dark theme: ensures window and native tabs are always dark matching SHD
|
||||||
|
window.appearance = NSAppearance(named: .darkAqua)
|
||||||
|
let darkBg = NSColor(calibratedRed: 0.08, green: 0.08, blue: 0.08, alpha: 1.0)
|
||||||
|
window.backgroundColor = darkBg
|
||||||
|
|
||||||
|
// Native macOS tabs support
|
||||||
|
window.tabbingMode = .preferred
|
||||||
|
window.tabbingIdentifier = "SHDMainTabGroup"
|
||||||
|
|
||||||
|
super.init(window: window)
|
||||||
|
|
||||||
|
window.delegate = self
|
||||||
|
setupWebView(darkBg: darkBg)
|
||||||
|
|
||||||
|
if let initialRequest = initialRequest {
|
||||||
|
webView.load(initialRequest)
|
||||||
|
} else if let initialEnv = initialEnv {
|
||||||
|
currentEnv = initialEnv
|
||||||
|
navigateToEnvironment(currentEnv)
|
||||||
|
} else {
|
||||||
|
loadInitialEnvironment()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func setupWebView(darkBg: NSColor) {
|
||||||
|
guard let window = window else { return }
|
||||||
|
|
||||||
|
let config = WKWebViewConfiguration()
|
||||||
|
config.websiteDataStore = WKWebsiteDataStore.default()
|
||||||
|
config.preferences.isElementFullscreenEnabled = true
|
||||||
|
config.defaultWebpagePreferences.allowsContentJavaScript = true
|
||||||
|
|
||||||
|
// Immediate dark style injection before document parsing even starts
|
||||||
|
let darkCSS = """
|
||||||
|
html, body {
|
||||||
|
background-color: #121212 !important;
|
||||||
|
color: #e0e0e0 !important;
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
let darkScript = """
|
||||||
|
(function() {
|
||||||
|
var el = document.documentElement;
|
||||||
|
if (el) { el.style.backgroundColor = '#121212'; }
|
||||||
|
var style = document.createElement('style');
|
||||||
|
style.id = '__shd_instant_dark__';
|
||||||
|
style.textContent = '\(darkCSS.replacingOccurrences(of: "\n", with: " "))';
|
||||||
|
if (document.head) {
|
||||||
|
document.head.appendChild(style);
|
||||||
|
} else if (el) {
|
||||||
|
el.appendChild(style);
|
||||||
|
}
|
||||||
|
window.print = function() {
|
||||||
|
if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.shdNativePrint) {
|
||||||
|
window.webkit.messageHandlers.shdNativePrint.postMessage({});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
let userScript = WKUserScript(source: darkScript, injectionTime: .atDocumentStart, forMainFrameOnly: false)
|
||||||
|
config.userContentController.addUserScript(userScript)
|
||||||
|
|
||||||
|
let passkeyBridgeScript = """
|
||||||
|
(function() {
|
||||||
|
document.addEventListener('click', function(event) {
|
||||||
|
var path = window.location.pathname;
|
||||||
|
while (path.length > 1 && path.endsWith('/')) {
|
||||||
|
path = path.slice(0, -1);
|
||||||
|
}
|
||||||
|
if (path !== '/login' && path !== '/login/two-factor') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var target = event.target instanceof Element
|
||||||
|
? event.target
|
||||||
|
: (event.target && event.target.parentElement);
|
||||||
|
if (!target || !target.closest('#btnPasskeyLogin, #twoFactorPasskey')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
|
||||||
|
var bridge = window.webkit && window.webkit.messageHandlers
|
||||||
|
? window.webkit.messageHandlers.shdDesktopAuth
|
||||||
|
: null;
|
||||||
|
if (bridge) {
|
||||||
|
bridge.postMessage('passkey-login');
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
})();
|
||||||
|
"""
|
||||||
|
config.userContentController.addUserScript(
|
||||||
|
WKUserScript(source: passkeyBridgeScript, injectionTime: .atDocumentStart, forMainFrameOnly: true)
|
||||||
|
)
|
||||||
|
config.userContentController.add(WeakScriptMessageHandler(controller: self), name: "shdNativePrint")
|
||||||
|
config.userContentController.add(WeakScriptMessageHandler(controller: self), name: "shdDesktopAuth")
|
||||||
|
|
||||||
|
let container = NSView()
|
||||||
|
container.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
container.wantsLayer = true
|
||||||
|
container.layer?.backgroundColor = darkBg.cgColor
|
||||||
|
window.contentView = container
|
||||||
|
|
||||||
|
webView = WKWebView(frame: .zero, configuration: config)
|
||||||
|
webView.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
webView.navigationDelegate = self
|
||||||
|
webView.uiDelegate = self
|
||||||
|
webView.allowsBackForwardNavigationGestures = true
|
||||||
|
webView.isInspectable = true
|
||||||
|
webView.underPageBackgroundColor = darkBg
|
||||||
|
webView.setValue(false, forKey: "drawsBackground") // Prevents WebKit initial white canvas
|
||||||
|
container.addSubview(webView)
|
||||||
|
|
||||||
|
progressIndicator = NSProgressIndicator()
|
||||||
|
progressIndicator.isIndeterminate = false
|
||||||
|
progressIndicator.style = .bar
|
||||||
|
progressIndicator.minValue = 0.0
|
||||||
|
progressIndicator.maxValue = 1.0
|
||||||
|
progressIndicator.translatesAutoresizingMaskIntoConstraints = false
|
||||||
|
progressIndicator.isDisplayedWhenStopped = false
|
||||||
|
container.addSubview(progressIndicator)
|
||||||
|
|
||||||
|
// Layout: Pin webview to container but respect safe area so tab bar does NOT overlap topbar
|
||||||
|
NSLayoutConstraint.activate([
|
||||||
|
webView.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||||
|
webView.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||||
|
webView.topAnchor.constraint(equalTo: container.safeAreaLayoutGuide.topAnchor),
|
||||||
|
webView.bottomAnchor.constraint(equalTo: container.bottomAnchor),
|
||||||
|
|
||||||
|
progressIndicator.leadingAnchor.constraint(equalTo: container.leadingAnchor),
|
||||||
|
progressIndicator.trailingAnchor.constraint(equalTo: container.trailingAnchor),
|
||||||
|
progressIndicator.topAnchor.constraint(equalTo: container.safeAreaLayoutGuide.topAnchor),
|
||||||
|
progressIndicator.heightAnchor.constraint(equalToConstant: 2.0)
|
||||||
|
])
|
||||||
|
|
||||||
|
observation = webView.observe(\.estimatedProgress, options: [.new]) { [weak self] webView, _ in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self = self else { return }
|
||||||
|
self.progressIndicator.doubleValue = webView.estimatedProgress
|
||||||
|
if webView.estimatedProgress >= 1.0 {
|
||||||
|
self.progressIndicator.stopAnimation(nil)
|
||||||
|
self.progressIndicator.isHidden = true
|
||||||
|
} else {
|
||||||
|
self.progressIndicator.isHidden = false
|
||||||
|
self.progressIndicator.startAnimation(nil)
|
||||||
|
}
|
||||||
|
self.updateTitle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadInitialEnvironment() {
|
||||||
|
let savedEnvRaw = UserDefaults.standard.string(forKey: "SHDSelectedEnvironment")
|
||||||
|
if let savedEnvRaw = savedEnvRaw, let env = Environment(rawValue: savedEnvRaw) {
|
||||||
|
currentEnv = env
|
||||||
|
} else {
|
||||||
|
currentEnv = .production
|
||||||
|
}
|
||||||
|
navigateToEnvironment(currentEnv)
|
||||||
|
}
|
||||||
|
|
||||||
|
func navigateToEnvironment(_ env: Environment) {
|
||||||
|
currentEnv = env
|
||||||
|
UserDefaults.standard.set(env.rawValue, forKey: "SHDSelectedEnvironment")
|
||||||
|
if let url = URL(string: env.rawValue) {
|
||||||
|
let request = URLRequest(url: url)
|
||||||
|
webView.load(request)
|
||||||
|
}
|
||||||
|
updateTitle()
|
||||||
|
}
|
||||||
|
|
||||||
|
func signInWithPasskeyInBrowser(for environment: Environment) {
|
||||||
|
guard authSession == nil else { return }
|
||||||
|
|
||||||
|
guard environment == .dev else {
|
||||||
|
presentDesktopAuthError("Вход по passkey в приложении пока подключён только для DEV.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
do {
|
||||||
|
let verifier = try Self.randomBase64URLString(byteCount: 32)
|
||||||
|
let state = try Self.randomBase64URLString(byteCount: 32)
|
||||||
|
let challenge = Self.base64URLString(for: Data(SHA256.hash(data: Data(verifier.utf8))))
|
||||||
|
|
||||||
|
guard let authorizationURL = makeAuthorizationURL(
|
||||||
|
environment: environment,
|
||||||
|
state: state,
|
||||||
|
challenge: challenge
|
||||||
|
) else {
|
||||||
|
presentDesktopAuthError("Не удалось подготовить адрес входа.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let attemptID = UUID()
|
||||||
|
authAttemptID = attemptID
|
||||||
|
|
||||||
|
let completionHandler: ASWebAuthenticationSession.CompletionHandler = { [weak self] callbackURL, error in
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
self?.finishBrowserPasskeyLogin(
|
||||||
|
callbackURL: callbackURL,
|
||||||
|
error: error,
|
||||||
|
expectedState: state,
|
||||||
|
verifier: verifier,
|
||||||
|
environment: environment,
|
||||||
|
attemptID: attemptID
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
let session: ASWebAuthenticationSession
|
||||||
|
if #available(macOS 14.4, *) {
|
||||||
|
session = ASWebAuthenticationSession(
|
||||||
|
url: authorizationURL,
|
||||||
|
callback: .customScheme(Self.authCallbackScheme),
|
||||||
|
completionHandler: completionHandler
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
session = ASWebAuthenticationSession(
|
||||||
|
url: authorizationURL,
|
||||||
|
callbackURLScheme: Self.authCallbackScheme,
|
||||||
|
completionHandler: completionHandler
|
||||||
|
)
|
||||||
|
}
|
||||||
|
session.presentationContextProvider = self
|
||||||
|
authSession = session
|
||||||
|
|
||||||
|
if !session.start() {
|
||||||
|
authSession = nil
|
||||||
|
authAttemptID = nil
|
||||||
|
presentDesktopAuthError("macOS не смог открыть окно входа. Попробуйте ещё раз.")
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
presentDesktopAuthError("Не удалось безопасно подготовить вход.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makeAuthorizationURL(environment: Environment, state: String, challenge: String) -> URL? {
|
||||||
|
guard var components = URLComponents(string: environment.rawValue) else { return nil }
|
||||||
|
components.path = "/desktop-auth/authorize"
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "client_id", value: Self.authClientID),
|
||||||
|
URLQueryItem(name: "redirect_uri", value: Self.authCallbackURI),
|
||||||
|
URLQueryItem(name: "response_type", value: "code"),
|
||||||
|
URLQueryItem(name: "state", value: state),
|
||||||
|
URLQueryItem(name: "code_challenge", value: challenge),
|
||||||
|
URLQueryItem(name: "code_challenge_method", value: "S256")
|
||||||
|
]
|
||||||
|
return components.url
|
||||||
|
}
|
||||||
|
|
||||||
|
private func finishBrowserPasskeyLogin(
|
||||||
|
callbackURL: URL?,
|
||||||
|
error: Error?,
|
||||||
|
expectedState: String,
|
||||||
|
verifier: String,
|
||||||
|
environment: Environment,
|
||||||
|
attemptID: UUID
|
||||||
|
) {
|
||||||
|
guard authAttemptID == attemptID else { return }
|
||||||
|
authAttemptID = nil
|
||||||
|
authSession = nil
|
||||||
|
|
||||||
|
if let error {
|
||||||
|
if (error as? ASWebAuthenticationSessionError)?.code != .canceledLogin {
|
||||||
|
presentDesktopAuthError("Вход через браузер не завершился. Попробуйте ещё раз.")
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let callbackURL,
|
||||||
|
callbackURL.scheme?.lowercased() == Self.authCallbackScheme,
|
||||||
|
callbackURL.host?.lowercased() == "callback",
|
||||||
|
let callbackComponents = URLComponents(url: callbackURL, resolvingAgainstBaseURL: false),
|
||||||
|
let queryItems = callbackComponents.queryItems else {
|
||||||
|
presentDesktopAuthError("Сервер вернул некорректный ответ входа.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if queryItems.contains(where: { $0.name == "error" }) {
|
||||||
|
presentDesktopAuthError("Сервер не завершил вход. Попробуйте ещё раз.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let stateValues = queryItems.filter { $0.name == "state" }
|
||||||
|
let codeValues = queryItems.filter { $0.name == "code" }
|
||||||
|
guard stateValues.count == 1,
|
||||||
|
stateValues[0].value == expectedState,
|
||||||
|
codeValues.count == 1,
|
||||||
|
let code = codeValues[0].value,
|
||||||
|
!code.isEmpty else {
|
||||||
|
presentDesktopAuthError("Не удалось подтвердить ответ сервера. Начните вход заново.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard var completeComponents = URLComponents(string: environment.rawValue) else {
|
||||||
|
presentDesktopAuthError("Не удалось завершить вход в выбранном контуре.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
completeComponents.path = "/desktop-auth/complete"
|
||||||
|
guard let completeURL = completeComponents.url else {
|
||||||
|
presentDesktopAuthError("Не удалось завершить вход в выбранном контуре.")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var request = URLRequest(url: completeURL, cachePolicy: .reloadIgnoringLocalCacheData, timeoutInterval: 30)
|
||||||
|
request.httpMethod = "POST"
|
||||||
|
request.setValue("application/x-www-form-urlencoded; charset=utf-8", forHTTPHeaderField: "Content-Type")
|
||||||
|
request.setValue("no-store", forHTTPHeaderField: "Cache-Control")
|
||||||
|
request.httpBody = Self.formEncodedBody([
|
||||||
|
URLQueryItem(name: "code", value: code),
|
||||||
|
URLQueryItem(name: "code_verifier", value: verifier)
|
||||||
|
])
|
||||||
|
|
||||||
|
currentEnv = environment
|
||||||
|
UserDefaults.standard.set(environment.rawValue, forKey: "SHDSelectedEnvironment")
|
||||||
|
webView.load(request)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentDesktopAuthError(_ message: String) {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = "Вход в SHD"
|
||||||
|
alert.informativeText = message
|
||||||
|
alert.alertStyle = .warning
|
||||||
|
alert.addButton(withTitle: "OK")
|
||||||
|
alert.runModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
func presentationAnchor(for session: ASWebAuthenticationSession) -> ASPresentationAnchor {
|
||||||
|
window ?? NSApp.keyWindow ?? NSWindow()
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func randomBase64URLString(byteCount: Int) throws -> String {
|
||||||
|
var bytes = [UInt8](repeating: 0, count: byteCount)
|
||||||
|
let status = SecRandomCopyBytes(kSecRandomDefault, bytes.count, &bytes)
|
||||||
|
guard status == errSecSuccess else {
|
||||||
|
throw DesktopAuthError.randomNumberGenerationFailed
|
||||||
|
}
|
||||||
|
return base64URLString(for: Data(bytes))
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func base64URLString(for data: Data) -> String {
|
||||||
|
data.base64EncodedString()
|
||||||
|
.replacingOccurrences(of: "+", with: "-")
|
||||||
|
.replacingOccurrences(of: "/", with: "_")
|
||||||
|
.replacingOccurrences(of: "=", with: "")
|
||||||
|
}
|
||||||
|
|
||||||
|
private static func formEncodedBody(_ items: [URLQueryItem]) -> Data {
|
||||||
|
var components = URLComponents()
|
||||||
|
components.queryItems = items
|
||||||
|
return Data((components.percentEncodedQuery ?? "").utf8)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum DesktopAuthError: Error {
|
||||||
|
case randomNumberGenerationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
func reload() {
|
||||||
|
webView.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
func reloadIgnoringCache() {
|
||||||
|
webView.reloadFromOrigin()
|
||||||
|
}
|
||||||
|
|
||||||
|
func goBack() {
|
||||||
|
if webView.canGoBack {
|
||||||
|
webView.goBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func goForward() {
|
||||||
|
if webView.canGoForward {
|
||||||
|
webView.goForward()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func zoomIn() {
|
||||||
|
webView.pageZoom += 0.1
|
||||||
|
}
|
||||||
|
|
||||||
|
func zoomOut() {
|
||||||
|
if webView.pageZoom > 0.4 {
|
||||||
|
webView.pageZoom -= 0.1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func resetZoom() {
|
||||||
|
webView.pageZoom = 1.0
|
||||||
|
}
|
||||||
|
|
||||||
|
func showInspector() {
|
||||||
|
if let inspector = webView.value(forKey: "_inspector") as? NSObject {
|
||||||
|
inspector.perform(Selector(("show")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func showConsole() {
|
||||||
|
if let inspector = webView.value(forKey: "_inspector") as? NSObject {
|
||||||
|
inspector.perform(Selector(("showConsole")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func printCurrentPage() {
|
||||||
|
guard let window = window else { return }
|
||||||
|
let printInfo = NSPrintInfo.shared.copy() as? NSPrintInfo ?? NSPrintInfo.shared
|
||||||
|
printInfo.horizontalPagination = .fit
|
||||||
|
printInfo.verticalPagination = .automatic
|
||||||
|
printInfo.isHorizontallyCentered = true
|
||||||
|
printInfo.isVerticallyCentered = false
|
||||||
|
|
||||||
|
let printOp = webView.printOperation(with: printInfo)
|
||||||
|
printOp.view?.frame = webView.bounds
|
||||||
|
printOp.showsPrintPanel = true
|
||||||
|
printOp.showsProgressPanel = true
|
||||||
|
printOp.runModal(for: window, delegate: nil, didRun: nil, contextInfo: nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func cleanTitle(from raw: String) -> String {
|
||||||
|
// Strip out repetitive brand suffixes for clean, elegant tab titles
|
||||||
|
var title = raw
|
||||||
|
if let range = title.range(of: " — SHD Project") {
|
||||||
|
title = String(title[..<range.lowerBound])
|
||||||
|
} else if let range = title.range(of: " - SHD Project") {
|
||||||
|
title = String(title[..<range.lowerBound])
|
||||||
|
} else if let range = title.range(of: " | SHD") {
|
||||||
|
title = String(title[..<range.lowerBound])
|
||||||
|
}
|
||||||
|
title = title.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
return title.isEmpty ? "SHD" : title
|
||||||
|
}
|
||||||
|
|
||||||
|
private func updateTitle() {
|
||||||
|
guard let window = window else { return }
|
||||||
|
let raw = webView.title ?? ""
|
||||||
|
let clean = cleanTitle(from: raw)
|
||||||
|
window.title = clean
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - NSWindowDelegate
|
||||||
|
|
||||||
|
func windowWillClose(_ notification: Notification) {
|
||||||
|
authAttemptID = nil
|
||||||
|
authSession?.cancel()
|
||||||
|
authSession = nil
|
||||||
|
webView.configuration.userContentController.removeScriptMessageHandler(forName: "shdNativePrint")
|
||||||
|
webView.configuration.userContentController.removeScriptMessageHandler(forName: "shdDesktopAuth")
|
||||||
|
AppDelegate.shared?.removeWindowController(self)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WKNavigationDelegate
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
||||||
|
updateTitle()
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, preferences: WKWebpagePreferences) async -> (WKNavigationActionPolicy, WKWebpagePreferences) {
|
||||||
|
if navigationAction.shouldPerformDownload {
|
||||||
|
return (.download, preferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let url = navigationAction.request.url else {
|
||||||
|
return (.allow, preferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = url.host?.lowercased() ?? ""
|
||||||
|
let isInternalHost = host == "shd.xyz.su" || host == "dev-shd.xyz.su" || host == "sso.xyz.su" || host == "git.xyz.su" || host.hasSuffix(".xyz.su")
|
||||||
|
|
||||||
|
if navigationAction.navigationType == .linkActivated {
|
||||||
|
let openInNewTab = navigationAction.modifierFlags.contains(.command) || navigationAction.buttonNumber == 2
|
||||||
|
if isInternalHost {
|
||||||
|
if openInNewTab {
|
||||||
|
AppDelegate.shared?.openTab(with: navigationAction.request, relativeTo: self.window)
|
||||||
|
return (.cancel, preferences)
|
||||||
|
}
|
||||||
|
return (.allow, preferences)
|
||||||
|
} else {
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
return (.cancel, preferences)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if isInternalHost {
|
||||||
|
return (.allow, preferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (.allow, preferences)
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, decidePolicyFor navigationResponse: WKNavigationResponse) async -> WKNavigationResponsePolicy {
|
||||||
|
if navigationResponse.canShowMIMEType {
|
||||||
|
return .allow
|
||||||
|
}
|
||||||
|
return .download
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WKDownloadDelegate
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) {
|
||||||
|
download.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) {
|
||||||
|
download.delegate = self
|
||||||
|
}
|
||||||
|
|
||||||
|
func download(_ download: WKDownload, decideDestinationUsing response: URLResponse, suggestedFilename: String) async -> URL? {
|
||||||
|
let downloadsDir = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first ?? URL(fileURLWithPath: NSHomeDirectory())
|
||||||
|
var destination = downloadsDir.appendingPathComponent(suggestedFilename)
|
||||||
|
|
||||||
|
var counter = 1
|
||||||
|
let baseName = (suggestedFilename as NSString).deletingPathExtension
|
||||||
|
let ext = (suggestedFilename as NSString).pathExtension
|
||||||
|
|
||||||
|
while FileManager.default.fileExists(atPath: destination.path) {
|
||||||
|
let newName = ext.isEmpty ? "\(baseName) (\(counter))" : "\(baseName) (\(counter)).\(ext)"
|
||||||
|
destination = downloadsDir.appendingPathComponent(newName)
|
||||||
|
counter += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
return destination
|
||||||
|
}
|
||||||
|
|
||||||
|
func downloadDidFinish(_ download: WKDownload) {
|
||||||
|
NSSound(named: "Glass")?.play()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - WKUIDelegate (Native Alerts, Confirms, Prompts, Media Permissions & File Uploads)
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, decideMediaCapturePermissionsFor origin: WKSecurityOrigin, initiatedBy frame: WKFrameInfo, type: WKMediaCaptureType) async -> WKPermissionDecision {
|
||||||
|
let host = origin.host.lowercased()
|
||||||
|
if host == "shd.xyz.su" || host == "dev-shd.xyz.su" || host.hasSuffix(".xyz.su") {
|
||||||
|
return .grant
|
||||||
|
}
|
||||||
|
return .prompt
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, createWebViewWith configuration: WKWebViewConfiguration, for navigationAction: WKNavigationAction, windowFeatures: WKWindowFeatures) -> WKWebView? {
|
||||||
|
if navigationAction.targetFrame == nil {
|
||||||
|
if let url = navigationAction.request.url {
|
||||||
|
let host = url.host?.lowercased() ?? ""
|
||||||
|
if host == "shd.xyz.su" || host == "dev-shd.xyz.su" || host == "sso.xyz.su" || host == "git.xyz.su" || host.hasSuffix(".xyz.su") {
|
||||||
|
AppDelegate.shared?.openTab(with: navigationAction.request, relativeTo: self.window)
|
||||||
|
} else {
|
||||||
|
NSWorkspace.shared.open(url)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, runJavaScriptAlertPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo) async {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = "SHD"
|
||||||
|
alert.informativeText = message
|
||||||
|
alert.alertStyle = .informational
|
||||||
|
alert.addButton(withTitle: "OK")
|
||||||
|
if let window = window {
|
||||||
|
_ = await alert.beginSheetModal(for: window)
|
||||||
|
} else {
|
||||||
|
alert.runModal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, runJavaScriptConfirmPanelWithMessage message: String, initiatedByFrame frame: WKFrameInfo) async -> Bool {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = "SHD"
|
||||||
|
alert.informativeText = message
|
||||||
|
alert.alertStyle = .warning
|
||||||
|
alert.addButton(withTitle: "Подтвердить")
|
||||||
|
alert.addButton(withTitle: "Отмена")
|
||||||
|
|
||||||
|
let response: NSApplication.ModalResponse
|
||||||
|
if let window = window {
|
||||||
|
response = await alert.beginSheetModal(for: window)
|
||||||
|
} else {
|
||||||
|
response = alert.runModal()
|
||||||
|
}
|
||||||
|
return response == .alertFirstButtonReturn
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, runJavaScriptTextInputPanelWithPrompt prompt: String, defaultText: String?, initiatedByFrame frame: WKFrameInfo) async -> String? {
|
||||||
|
let alert = NSAlert()
|
||||||
|
alert.messageText = "SHD"
|
||||||
|
alert.informativeText = prompt
|
||||||
|
alert.addButton(withTitle: "OK")
|
||||||
|
alert.addButton(withTitle: "Отмена")
|
||||||
|
|
||||||
|
let input = NSTextField(frame: NSRect(x: 0, y: 0, width: 260, height: 24))
|
||||||
|
input.stringValue = defaultText ?? ""
|
||||||
|
alert.accessoryView = input
|
||||||
|
|
||||||
|
let response: NSApplication.ModalResponse
|
||||||
|
if let window = window {
|
||||||
|
response = await alert.beginSheetModal(for: window)
|
||||||
|
} else {
|
||||||
|
response = alert.runModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
if response == .alertFirstButtonReturn {
|
||||||
|
return input.stringValue
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func webView(_ webView: WKWebView, runOpenPanelWith parameters: WKOpenPanelParameters, initiatedByFrame frame: WKFrameInfo) async -> [URL]? {
|
||||||
|
let panel = NSOpenPanel()
|
||||||
|
panel.allowsMultipleSelection = parameters.allowsMultipleSelection
|
||||||
|
panel.canChooseDirectories = parameters.allowsDirectories
|
||||||
|
panel.canChooseFiles = true
|
||||||
|
|
||||||
|
let response: NSApplication.ModalResponse
|
||||||
|
if let window = window {
|
||||||
|
response = await panel.beginSheetModal(for: window)
|
||||||
|
} else {
|
||||||
|
response = panel.runModal()
|
||||||
|
}
|
||||||
|
|
||||||
|
if response == .OK {
|
||||||
|
return panel.urls
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
Binary file not shown.
@@ -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 или другой браузер.
|
||||||
@@ -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 используйте браузер.
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
<Application x:Class="SHD.Windows.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
ShutdownMode="OnLastWindowClose">
|
||||||
|
<Application.Resources>
|
||||||
|
<SolidColorBrush x:Key="ShellBackground" Color="#171717" />
|
||||||
|
<SolidColorBrush x:Key="ShellSurface" Color="#222222" />
|
||||||
|
<SolidColorBrush x:Key="ShellForeground" Color="#E8E8E8" />
|
||||||
|
<SolidColorBrush x:Key="ShellMuted" Color="#A8A8A8" />
|
||||||
|
<Style TargetType="MenuItem">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource ShellForeground}" />
|
||||||
|
<Setter Property="Background" Value="{StaticResource ShellSurface}" />
|
||||||
|
<Setter Property="Padding" Value="10,5" />
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="Button">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource ShellForeground}" />
|
||||||
|
<Setter Property="Background" Value="#303030" />
|
||||||
|
<Setter Property="BorderBrush" Value="#454545" />
|
||||||
|
<Setter Property="BorderThickness" Value="1" />
|
||||||
|
<Setter Property="Padding" Value="10,5" />
|
||||||
|
<Setter Property="Margin" Value="3,0" />
|
||||||
|
<Setter Property="Cursor" Value="Hand" />
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="ComboBox">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource ShellForeground}" />
|
||||||
|
<Setter Property="Background" Value="#292929" />
|
||||||
|
<Setter Property="BorderBrush" Value="#454545" />
|
||||||
|
<Setter Property="Padding" Value="8,4" />
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="ComboBoxItem">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource ShellForeground}" />
|
||||||
|
<Setter Property="Background" Value="#292929" />
|
||||||
|
<Setter Property="Padding" Value="8,5" />
|
||||||
|
<Style.Triggers>
|
||||||
|
<Trigger Property="IsHighlighted" Value="True">
|
||||||
|
<Setter Property="Background" Value="#3A3A3A" />
|
||||||
|
</Trigger>
|
||||||
|
<Trigger Property="IsSelected" Value="True">
|
||||||
|
<Setter Property="Background" Value="#454545" />
|
||||||
|
</Trigger>
|
||||||
|
</Style.Triggers>
|
||||||
|
</Style>
|
||||||
|
<Style TargetType="TabItem">
|
||||||
|
<Setter Property="Foreground" Value="{StaticResource ShellForeground}" />
|
||||||
|
<Setter Property="Background" Value="#222222" />
|
||||||
|
<Setter Property="BorderBrush" Value="#303030" />
|
||||||
|
<Setter Property="Padding" Value="10,6" />
|
||||||
|
</Style>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
@@ -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<MainWindow>().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);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<string, List<string>>(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);
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<Window x:Class="SHD.Windows.MainWindow"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
Title="SHD" Width="1320" Height="860" MinWidth="900" MinHeight="560"
|
||||||
|
WindowStartupLocation="CenterScreen" Background="#171717" Foreground="#E8E8E8"
|
||||||
|
FontFamily="Segoe UI" FontSize="13"
|
||||||
|
Loaded="Window_Loaded" Closing="Window_Closing" PreviewKeyDown="Window_PreviewKeyDown">
|
||||||
|
<DockPanel LastChildFill="True">
|
||||||
|
<Menu DockPanel.Dock="Top" Background="#222222" Foreground="#E8E8E8">
|
||||||
|
<MenuItem Header="_Файл">
|
||||||
|
<MenuItem Header="Новая вкладка" InputGestureText="Ctrl+T" Click="NewTab_Click" />
|
||||||
|
<MenuItem Header="Новое окно" InputGestureText="Ctrl+N" Click="NewWindow_Click" />
|
||||||
|
<Separator />
|
||||||
|
<MenuItem Header="Печать…" InputGestureText="Ctrl+P" Click="Print_Click" />
|
||||||
|
<MenuItem Header="Открыть папку загрузок" Click="Downloads_Click" />
|
||||||
|
<Separator />
|
||||||
|
<MenuItem Header="Закрыть вкладку" InputGestureText="Ctrl+W" Click="CloseTab_Click" />
|
||||||
|
<MenuItem Header="Выход" Click="Exit_Click" />
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="_Правка">
|
||||||
|
<MenuItem Header="Отменить" InputGestureText="Ctrl+Z" Click="Undo_Click" />
|
||||||
|
<MenuItem Header="Повторить" InputGestureText="Ctrl+Y" Click="Redo_Click" />
|
||||||
|
<Separator />
|
||||||
|
<MenuItem Header="Вырезать" InputGestureText="Ctrl+X" Click="Cut_Click" />
|
||||||
|
<MenuItem Header="Копировать" InputGestureText="Ctrl+C" Click="Copy_Click" />
|
||||||
|
<MenuItem Header="Вставить" InputGestureText="Ctrl+V" Click="Paste_Click" />
|
||||||
|
<MenuItem Header="Выбрать всё" InputGestureText="Ctrl+A" Click="SelectAll_Click" />
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="_Вид">
|
||||||
|
<MenuItem Header="Назад" InputGestureText="Alt+Left" Click="Back_Click" />
|
||||||
|
<MenuItem Header="Вперёд" InputGestureText="Alt+Right" Click="Forward_Click" />
|
||||||
|
<Separator />
|
||||||
|
<MenuItem Header="Обновить" InputGestureText="Ctrl+R" Click="Reload_Click" />
|
||||||
|
<MenuItem Header="Очистить кэш и обновить" InputGestureText="Ctrl+Shift+R" Click="HardReload_Click" />
|
||||||
|
<Separator />
|
||||||
|
<MenuItem Header="Увеличить" InputGestureText="Ctrl++" Click="ZoomIn_Click" />
|
||||||
|
<MenuItem Header="Уменьшить" InputGestureText="Ctrl+-" Click="ZoomOut_Click" />
|
||||||
|
<MenuItem Header="Исходный масштаб" InputGestureText="Ctrl+0" Click="ZoomReset_Click" />
|
||||||
|
<MenuItem Header="Полноэкранный режим" InputGestureText="F11" Click="FullScreen_Click" />
|
||||||
|
<Separator />
|
||||||
|
<MenuItem Header="Инструменты разработчика" InputGestureText="F12" Click="DevTools_Click" />
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="_Контур">
|
||||||
|
<MenuItem Header="SHD Production" Click="SelectProduction_Click" />
|
||||||
|
<MenuItem Header="SHD Dev" Click="SelectDev_Click" />
|
||||||
|
</MenuItem>
|
||||||
|
<MenuItem Header="_История">
|
||||||
|
<MenuItem Header="Назад" InputGestureText="Alt+Left" Click="Back_Click" />
|
||||||
|
<MenuItem Header="Вперёд" InputGestureText="Alt+Right" Click="Forward_Click" />
|
||||||
|
</MenuItem>
|
||||||
|
</Menu>
|
||||||
|
|
||||||
|
<Border DockPanel.Dock="Top" Background="#1C1C1C" BorderBrush="#303030" BorderThickness="0,0,0,1" Padding="8,7">
|
||||||
|
<Grid>
|
||||||
|
<Grid.ColumnDefinitions>
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="16" />
|
||||||
|
<ColumnDefinition Width="210" />
|
||||||
|
<ColumnDefinition Width="*" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
<ColumnDefinition Width="Auto" />
|
||||||
|
</Grid.ColumnDefinitions>
|
||||||
|
<Button x:Name="BackButton" Grid.Column="0" Content="←" Width="38" ToolTip="Назад" Click="Back_Click" />
|
||||||
|
<Button x:Name="ForwardButton" Grid.Column="1" Content="→" Width="38" ToolTip="Вперёд" Click="Forward_Click" />
|
||||||
|
<Button Grid.Column="2" Content="⟳" Width="38" ToolTip="Обновить" Click="Reload_Click" />
|
||||||
|
<ComboBox x:Name="EnvironmentPicker" Grid.Column="4" Height="32" Padding="8,4"
|
||||||
|
SelectedValuePath="Tag" SelectionChanged="EnvironmentPicker_SelectionChanged"
|
||||||
|
Background="#292929" Foreground="#E8E8E8" BorderBrush="#454545">
|
||||||
|
<ComboBoxItem Content="SHD Production" Tag="Production" />
|
||||||
|
<ComboBoxItem Content="SHD Dev" Tag="Dev" />
|
||||||
|
</ComboBox>
|
||||||
|
<TextBlock x:Name="PageTitle" Grid.Column="5" VerticalAlignment="Center" Margin="14,0,8,0"
|
||||||
|
Foreground="#BDBDBD" TextTrimming="CharacterEllipsis" />
|
||||||
|
<Button Grid.Column="6" Content="Печать" Click="Print_Click" />
|
||||||
|
<Button Grid.Column="7" Content="Загрузки" Click="Downloads_Click" />
|
||||||
|
<Button Grid.Column="8" Content="Инструменты" Click="DevTools_Click" />
|
||||||
|
</Grid>
|
||||||
|
</Border>
|
||||||
|
|
||||||
|
<StatusBar DockPanel.Dock="Bottom" Background="#202020" Foreground="#BDBDBD" BorderBrush="#303030" BorderThickness="0,1,0,0">
|
||||||
|
<StatusBarItem><TextBlock x:Name="StatusText" Text="Готово" /></StatusBarItem>
|
||||||
|
<Separator />
|
||||||
|
<StatusBarItem><TextBlock x:Name="StatusEnvironment" Text="SHD Production" /></StatusBarItem>
|
||||||
|
</StatusBar>
|
||||||
|
|
||||||
|
<TabControl x:Name="Tabs" Background="#171717" BorderThickness="0"
|
||||||
|
SelectionChanged="Tabs_SelectionChanged" />
|
||||||
|
</DockPanel>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,796 @@
|
|||||||
|
using Microsoft.Web.WebView2.Core;
|
||||||
|
using Microsoft.Web.WebView2.Wpf;
|
||||||
|
using System.Diagnostics;
|
||||||
|
using System.IO;
|
||||||
|
using System.Text;
|
||||||
|
using System.Windows;
|
||||||
|
using System.Windows.Controls;
|
||||||
|
using System.Windows.Input;
|
||||||
|
using System.Windows.Media;
|
||||||
|
using DrawingColor = System.Drawing.Color;
|
||||||
|
|
||||||
|
namespace SHD.Windows;
|
||||||
|
|
||||||
|
public partial class MainWindow : Window
|
||||||
|
{
|
||||||
|
private const string WebViewProfileFolder = "WebView2";
|
||||||
|
private static readonly Uri WebViewRuntimePage = new("https://developer.microsoft.com/microsoft-edge/webview2/");
|
||||||
|
private static readonly Lazy<Task<CoreWebView2Environment>> SharedWebViewEnvironment = new(CreateWebViewEnvironmentAsync);
|
||||||
|
private readonly List<BrowserTab> _tabs = [];
|
||||||
|
private readonly ShdEnvironment _initialEnvironment;
|
||||||
|
private CoreWebView2Environment? _webViewEnvironment;
|
||||||
|
private ShdEnvironment _selectedEnvironment;
|
||||||
|
private PendingDesktopAuth? _authAttempt;
|
||||||
|
private MemoryStream? _authPostData;
|
||||||
|
private WebView2? _authPostTarget;
|
||||||
|
private ulong? _authNavigationId;
|
||||||
|
private bool _suppressEnvironmentChange;
|
||||||
|
private bool _initialized;
|
||||||
|
private bool _closing;
|
||||||
|
private bool _isFullScreen;
|
||||||
|
private WindowStyle _previousWindowStyle;
|
||||||
|
private WindowState _previousWindowState;
|
||||||
|
private Rect _previousBounds;
|
||||||
|
|
||||||
|
public MainWindow(ShdEnvironment? initialEnvironment = null)
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
_initialEnvironment = initialEnvironment ?? SettingsStore.LoadEnvironment();
|
||||||
|
_selectedEnvironment = _initialEnvironment;
|
||||||
|
EnvironmentPicker.IsEnabled = false;
|
||||||
|
Tabs.IsEnabled = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private BrowserTab? ActiveTab => Tabs.SelectedItem is TabItem item
|
||||||
|
? _tabs.FirstOrDefault(tab => ReferenceEquals(tab.Item, item))
|
||||||
|
: null;
|
||||||
|
|
||||||
|
private async void Window_Loaded(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_initialized) return;
|
||||||
|
_initialized = true;
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_webViewEnvironment = await SharedWebViewEnvironment.Value;
|
||||||
|
SetEnvironmentPicker(_initialEnvironment);
|
||||||
|
EnvironmentPicker.IsEnabled = true;
|
||||||
|
Tabs.IsEnabled = true;
|
||||||
|
await AddTabAsync(_initialEnvironment);
|
||||||
|
StatusText.Text = "Готово";
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
var result = MessageBox.Show(
|
||||||
|
this,
|
||||||
|
"Не удалось запустить встроенный браузер SHD. Установите Microsoft Edge WebView2 Runtime и запустите приложение ещё раз.\n\nОткрыть страницу загрузки Runtime?",
|
||||||
|
"SHD — встроенный браузер",
|
||||||
|
MessageBoxButton.YesNo,
|
||||||
|
MessageBoxImage.Warning);
|
||||||
|
if (result == MessageBoxResult.Yes) OpenExternalUri(WebViewRuntimePage);
|
||||||
|
Close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<CoreWebView2Environment> CreateWebViewEnvironmentAsync()
|
||||||
|
{
|
||||||
|
var userDataFolder = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"SHDDesktop",
|
||||||
|
WebViewProfileFolder);
|
||||||
|
Directory.CreateDirectory(userDataFolder);
|
||||||
|
return await CoreWebView2Environment.CreateAsync(userDataFolder: userDataFolder);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<BrowserTab> AddTabAsync(ShdEnvironment environment, Uri? initialUri = null)
|
||||||
|
{
|
||||||
|
if (_webViewEnvironment is null)
|
||||||
|
throw new InvalidOperationException("WebView2 environment is not ready.");
|
||||||
|
|
||||||
|
var view = new WebView2
|
||||||
|
{
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
|
VerticalAlignment = VerticalAlignment.Stretch,
|
||||||
|
DefaultBackgroundColor = DrawingColor.FromArgb(18, 18, 18)
|
||||||
|
};
|
||||||
|
var progress = new ProgressBar
|
||||||
|
{
|
||||||
|
Height = 2,
|
||||||
|
IsIndeterminate = true,
|
||||||
|
Visibility = Visibility.Collapsed,
|
||||||
|
VerticalAlignment = VerticalAlignment.Top,
|
||||||
|
HorizontalAlignment = HorizontalAlignment.Stretch,
|
||||||
|
IsHitTestVisible = false,
|
||||||
|
Foreground = new SolidColorBrush(Color.FromRgb(84, 156, 255)),
|
||||||
|
Background = Brushes.Transparent
|
||||||
|
};
|
||||||
|
var content = new Grid { Background = new SolidColorBrush(Color.FromRgb(18, 18, 18)) };
|
||||||
|
content.Children.Add(view);
|
||||||
|
Panel.SetZIndex(progress, 1);
|
||||||
|
content.Children.Add(progress);
|
||||||
|
|
||||||
|
var item = new TabItem
|
||||||
|
{
|
||||||
|
Background = new SolidColorBrush(Color.FromRgb(34, 34, 34)),
|
||||||
|
Foreground = Brushes.White,
|
||||||
|
Content = content
|
||||||
|
};
|
||||||
|
var headerTitle = new TextBlock
|
||||||
|
{
|
||||||
|
Text = "SHD",
|
||||||
|
VerticalAlignment = VerticalAlignment.Center,
|
||||||
|
TextTrimming = TextTrimming.CharacterEllipsis,
|
||||||
|
MaxWidth = 210,
|
||||||
|
Margin = new Thickness(2, 0, 7, 0)
|
||||||
|
};
|
||||||
|
var closeButton = new Button
|
||||||
|
{
|
||||||
|
Content = "×",
|
||||||
|
Width = 22,
|
||||||
|
Height = 22,
|
||||||
|
Padding = new Thickness(0),
|
||||||
|
Margin = new Thickness(3, 0, 0, 0),
|
||||||
|
FontSize = 15,
|
||||||
|
ToolTip = "Закрыть вкладку",
|
||||||
|
VerticalAlignment = VerticalAlignment.Center
|
||||||
|
};
|
||||||
|
var header = new DockPanel { LastChildFill = true, Margin = new Thickness(2, 0, 2, 0) };
|
||||||
|
DockPanel.SetDock(closeButton, Dock.Right);
|
||||||
|
header.Children.Add(closeButton);
|
||||||
|
header.Children.Add(headerTitle);
|
||||||
|
item.Header = header;
|
||||||
|
|
||||||
|
var tab = new BrowserTab(view, item, progress, headerTitle, environment);
|
||||||
|
closeButton.Click += (_, _) => CloseTab(tab);
|
||||||
|
_tabs.Add(tab);
|
||||||
|
Tabs.Items.Add(item);
|
||||||
|
Tabs.SelectedItem = item;
|
||||||
|
|
||||||
|
await view.EnsureCoreWebView2Async(_webViewEnvironment);
|
||||||
|
await ConfigureBrowserAsync(tab);
|
||||||
|
view.CoreWebView2.Navigate((initialUri ?? new Uri(environment.BaseUrl())).AbsoluteUri);
|
||||||
|
UpdateWindowChrome();
|
||||||
|
return tab;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task ConfigureBrowserAsync(BrowserTab tab)
|
||||||
|
{
|
||||||
|
var core = tab.View.CoreWebView2;
|
||||||
|
core.DefaultBackgroundColor = DrawingColor.FromArgb(18, 18, 18);
|
||||||
|
core.Settings.IsStatusBarEnabled = false;
|
||||||
|
core.Settings.AreDevToolsEnabled = true;
|
||||||
|
core.Settings.IsZoomControlEnabled = true;
|
||||||
|
core.Settings.IsPasswordAutosaveEnabled = true;
|
||||||
|
core.Settings.IsGeneralAutofillEnabled = true;
|
||||||
|
core.Settings.AreDefaultScriptDialogsEnabled = false;
|
||||||
|
|
||||||
|
core.NavigationStarting += Browser_NavigationStarting;
|
||||||
|
core.NavigationCompleted += Browser_NavigationCompleted;
|
||||||
|
core.DocumentTitleChanged += Browser_DocumentTitleChanged;
|
||||||
|
core.WebMessageReceived += Browser_WebMessageReceived;
|
||||||
|
core.NewWindowRequested += Browser_NewWindowRequested;
|
||||||
|
core.PermissionRequested += Browser_PermissionRequested;
|
||||||
|
core.DownloadStarting += Browser_DownloadStarting;
|
||||||
|
core.ScriptDialogOpening += Browser_ScriptDialogOpening;
|
||||||
|
|
||||||
|
await core.AddScriptToExecuteOnDocumentCreatedAsync("""
|
||||||
|
(() => {
|
||||||
|
const darkStyle = document.createElement('style');
|
||||||
|
darkStyle.id = '__shd_desktop_dark__';
|
||||||
|
darkStyle.textContent = 'html, body { background-color: #121212 !important; color: #e0e0e0 !important; }';
|
||||||
|
const parent = document.head || document.documentElement;
|
||||||
|
if (parent) parent.appendChild(darkStyle);
|
||||||
|
window.print = function () {
|
||||||
|
if (window.chrome && window.chrome.webview) window.chrome.webview.postMessage('shd-print');
|
||||||
|
};
|
||||||
|
})();
|
||||||
|
""");
|
||||||
|
|
||||||
|
await core.AddScriptToExecuteOnDocumentCreatedAsync("""
|
||||||
|
(() => {
|
||||||
|
if (window.top !== window) return;
|
||||||
|
document.addEventListener('click', event => {
|
||||||
|
let path = window.location.pathname;
|
||||||
|
while (path.length > 1 && path.endsWith('/')) path = path.slice(0, -1);
|
||||||
|
if (path !== '/login' && path !== '/login/two-factor') return;
|
||||||
|
const 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();
|
||||||
|
if (window.chrome && window.chrome.webview) window.chrome.webview.postMessage('shd-passkey');
|
||||||
|
}, true);
|
||||||
|
})();
|
||||||
|
""");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Browser_NavigationStarting(object? sender, CoreWebView2NavigationStartingEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not CoreWebView2 core) return;
|
||||||
|
var tab = _tabs.FirstOrDefault(candidate => ReferenceEquals(candidate.View.CoreWebView2, core));
|
||||||
|
if (tab is not null) tab.Progress.Visibility = Visibility.Visible;
|
||||||
|
|
||||||
|
if (!Uri.TryCreate(e.Uri, UriKind.Absolute, out var uri)) return;
|
||||||
|
if (tab is not null && ReferenceEquals(tab.View, _authPostTarget) &&
|
||||||
|
uri.AbsolutePath.Equals("/desktop-auth/complete", StringComparison.Ordinal))
|
||||||
|
_authNavigationId = e.NavigationId;
|
||||||
|
if (uri.Scheme is not ("https" or "http"))
|
||||||
|
{
|
||||||
|
e.Cancel = true;
|
||||||
|
if (e.IsUserInitiated) OpenExternalUri(uri);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tab is not null)
|
||||||
|
{
|
||||||
|
if (ShdEnvironmentExtensions.IsDevelopmentHost(uri.Host)) tab.Environment = ShdEnvironment.Dev;
|
||||||
|
else if (ShdEnvironmentExtensions.IsProductionHost(uri.Host)) tab.Environment = ShdEnvironment.Production;
|
||||||
|
if (ReferenceEquals(tab, ActiveTab))
|
||||||
|
{
|
||||||
|
_selectedEnvironment = tab.Environment;
|
||||||
|
SetEnvironmentPicker(tab.Environment);
|
||||||
|
SettingsStore.SaveEnvironment(tab.Environment);
|
||||||
|
StatusEnvironment.Text = tab.Environment.DisplayName();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (e.IsUserInitiated && !e.IsRedirected && !ShdEnvironmentExtensions.IsInternalHost(uri.Host))
|
||||||
|
{
|
||||||
|
e.Cancel = true;
|
||||||
|
OpenExternalUri(uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Browser_NavigationCompleted(object? sender, CoreWebView2NavigationCompletedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not CoreWebView2 core) return;
|
||||||
|
var tab = _tabs.FirstOrDefault(candidate => ReferenceEquals(candidate.View.CoreWebView2, core));
|
||||||
|
if (tab is not null) tab.Progress.Visibility = Visibility.Collapsed;
|
||||||
|
UpdateWindowChrome();
|
||||||
|
|
||||||
|
if (tab is not null && ReferenceEquals(_authPostTarget, tab.View) && _authNavigationId == e.NavigationId)
|
||||||
|
{
|
||||||
|
_authPostData?.Dispose();
|
||||||
|
_authPostData = null;
|
||||||
|
_authPostTarget = null;
|
||||||
|
_authNavigationId = null;
|
||||||
|
StatusText.Text = e.IsSuccess ? "Вход завершён" : "Не удалось завершить вход";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Browser_DocumentTitleChanged(object? sender, object e)
|
||||||
|
{
|
||||||
|
if (sender is not CoreWebView2 core) return;
|
||||||
|
var tab = _tabs.FirstOrDefault(candidate => ReferenceEquals(candidate.View.CoreWebView2, core));
|
||||||
|
if (tab is null) return;
|
||||||
|
var title = CleanTitle(core.DocumentTitle);
|
||||||
|
tab.Title.Text = title;
|
||||||
|
if (ReferenceEquals(tab, ActiveTab))
|
||||||
|
{
|
||||||
|
PageTitle.Text = title;
|
||||||
|
Title = $"{title} — SHD";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Browser_WebMessageReceived(object? sender, CoreWebView2WebMessageReceivedEventArgs e)
|
||||||
|
{
|
||||||
|
if (sender is not WebView2 view || !Uri.TryCreate(e.Source, UriKind.Absolute, out var source) ||
|
||||||
|
source.Scheme != Uri.UriSchemeHttps || !ShdEnvironmentExtensions.IsInternalHost(source.Host))
|
||||||
|
return;
|
||||||
|
|
||||||
|
string message;
|
||||||
|
try { message = e.TryGetWebMessageAsString(); }
|
||||||
|
catch (InvalidOperationException) { return; }
|
||||||
|
|
||||||
|
if (message == "shd-print")
|
||||||
|
{
|
||||||
|
view.CoreWebView2.ShowPrintUI(CoreWebView2PrintDialogKind.Browser);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (message != "shd-passkey") return;
|
||||||
|
if (!(ShdEnvironmentExtensions.IsDevelopmentHost(source.Host) || ShdEnvironmentExtensions.IsProductionHost(source.Host))) return;
|
||||||
|
var tab = _tabs.FirstOrDefault(candidate => ReferenceEquals(candidate.View, view));
|
||||||
|
if (tab is null) return;
|
||||||
|
var environment = ShdEnvironmentExtensions.IsDevelopmentHost(source.Host)
|
||||||
|
? ShdEnvironment.Dev
|
||||||
|
: ShdEnvironment.Production;
|
||||||
|
await BeginPasskeyLoginAsync(tab, environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void Browser_NewWindowRequested(object? sender, CoreWebView2NewWindowRequestedEventArgs e)
|
||||||
|
{
|
||||||
|
e.Handled = true;
|
||||||
|
if (!Uri.TryCreate(e.Uri, UriKind.Absolute, out var uri)) return;
|
||||||
|
if (uri.Scheme is "http" or "https" && ShdEnvironmentExtensions.IsInternalHost(uri.Host))
|
||||||
|
{
|
||||||
|
var environment = ShdEnvironmentExtensions.IsDevelopmentHost(uri.Host)
|
||||||
|
? ShdEnvironment.Dev
|
||||||
|
: ShdEnvironmentExtensions.IsProductionHost(uri.Host)
|
||||||
|
? ShdEnvironment.Production
|
||||||
|
: _selectedEnvironment;
|
||||||
|
await AddTabAsync(environment, uri);
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
OpenExternalUri(uri);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Browser_PermissionRequested(object? sender, CoreWebView2PermissionRequestedEventArgs e)
|
||||||
|
{
|
||||||
|
if (e.PermissionKind is not (CoreWebView2PermissionKind.Camera or CoreWebView2PermissionKind.Microphone)) return;
|
||||||
|
var trusted = Uri.TryCreate(e.Uri, UriKind.Absolute, out var origin) &&
|
||||||
|
origin.Scheme == Uri.UriSchemeHttps && ShdEnvironmentExtensions.IsInternalHost(origin.Host);
|
||||||
|
if (trusted)
|
||||||
|
{
|
||||||
|
e.State = CoreWebView2PermissionState.Allow;
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var label = e.PermissionKind == CoreWebView2PermissionKind.Camera ? "камеру" : "микрофон";
|
||||||
|
var allow = MessageBox.Show(
|
||||||
|
this,
|
||||||
|
$"Разрешить сайту {e.Uri} доступ к устройству ({label})?",
|
||||||
|
"Разрешение SHD",
|
||||||
|
MessageBoxButton.YesNo,
|
||||||
|
MessageBoxImage.Question) == MessageBoxResult.Yes;
|
||||||
|
e.State = allow ? CoreWebView2PermissionState.Allow : CoreWebView2PermissionState.Deny;
|
||||||
|
}
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Browser_DownloadStarting(object? sender, CoreWebView2DownloadStartingEventArgs e)
|
||||||
|
{
|
||||||
|
var downloadsFolder = Environment.GetFolderPath(Environment.SpecialFolder.UserProfile);
|
||||||
|
downloadsFolder = Path.Combine(downloadsFolder, "Downloads");
|
||||||
|
Directory.CreateDirectory(downloadsFolder);
|
||||||
|
var suggestedName = Path.GetFileName(e.ResultFilePath);
|
||||||
|
if (string.IsNullOrWhiteSpace(suggestedName)) suggestedName = "download";
|
||||||
|
e.ResultFilePath = GetUniquePath(Path.Combine(downloadsFolder, suggestedName));
|
||||||
|
e.Handled = true;
|
||||||
|
StatusText.Text = $"Загрузка: {suggestedName}";
|
||||||
|
e.DownloadOperation.StateChanged += (_, _) =>
|
||||||
|
{
|
||||||
|
Dispatcher.InvokeAsync(() =>
|
||||||
|
{
|
||||||
|
if (e.DownloadOperation.State == CoreWebView2DownloadState.Completed)
|
||||||
|
StatusText.Text = $"Загружено: {Path.GetFileName(e.ResultFilePath)}";
|
||||||
|
else if (e.DownloadOperation.State == CoreWebView2DownloadState.Interrupted)
|
||||||
|
StatusText.Text = "Загрузка прервана";
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Browser_ScriptDialogOpening(object? sender, CoreWebView2ScriptDialogOpeningEventArgs e)
|
||||||
|
{
|
||||||
|
switch (e.Kind)
|
||||||
|
{
|
||||||
|
case CoreWebView2ScriptDialogKind.Alert:
|
||||||
|
MessageBox.Show(this, e.Message, "SHD", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
e.Accept();
|
||||||
|
break;
|
||||||
|
case CoreWebView2ScriptDialogKind.Confirm:
|
||||||
|
case CoreWebView2ScriptDialogKind.BeforeUnload:
|
||||||
|
if (MessageBox.Show(this, e.Message, "SHD", MessageBoxButton.YesNo, MessageBoxImage.Question) == MessageBoxResult.Yes)
|
||||||
|
e.Accept();
|
||||||
|
break;
|
||||||
|
case CoreWebView2ScriptDialogKind.Prompt:
|
||||||
|
var answer = ShowPrompt(e.Message, e.DefaultText);
|
||||||
|
if (answer is not null)
|
||||||
|
{
|
||||||
|
e.ResultText = answer;
|
||||||
|
e.Accept();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task BeginPasskeyLoginAsync(BrowserTab tab, ShdEnvironment environment)
|
||||||
|
{
|
||||||
|
if (_authAttempt is not null)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, "Вход уже выполняется. Завершите его в браузере или закройте окно входа.", "Вход в SHD", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (environment != ShdEnvironment.Dev)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, "Вход по passkey в приложении пока подключён только для DEV.", "Вход в SHD", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var attempt = DesktopAuthFlow.Create(environment, tab);
|
||||||
|
_authAttempt = attempt;
|
||||||
|
Process.Start(new ProcessStartInfo(attempt.AuthorizationUri.AbsoluteUri) { UseShellExecute = true });
|
||||||
|
StatusText.Text = "Продолжите вход в системном браузере";
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
_authAttempt = null;
|
||||||
|
MessageBox.Show(this, "Не удалось открыть системный браузер для входа. Попробуйте ещё раз.", "Вход в SHD", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
public bool CanHandleAuthCallback(string callbackValue)
|
||||||
|
{
|
||||||
|
if (_authAttempt is null) return false;
|
||||||
|
if (!DesktopAuthFlow.TryParseCallback(callbackValue, out var callback, out _)) return false;
|
||||||
|
return callback is not null && callback.State == _authAttempt.State;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async void HandleAuthCallback(string callbackValue)
|
||||||
|
{
|
||||||
|
if (!DesktopAuthFlow.TryParseCallback(callbackValue, out var callback, out var serverError))
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, "Сервер вернул некорректный ответ входа.", "Вход в SHD", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var attempt = _authAttempt;
|
||||||
|
if (attempt is null)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, "Этот ответ входа уже нельзя использовать. Нажмите кнопку passkey на странице и начните заново.", "Вход в SHD", MessageBoxButton.OK, MessageBoxImage.Information);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (callback is null || callback.State != attempt.State)
|
||||||
|
{
|
||||||
|
MessageBox.Show(this, "Не удалось подтвердить ответ сервера. Начните вход заново.", "Вход в SHD", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (serverError)
|
||||||
|
{
|
||||||
|
_authAttempt = null;
|
||||||
|
MessageBox.Show(this, "Сервер не завершил вход. Попробуйте ещё раз.", "Вход в SHD", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!_tabs.Contains(attempt.TargetTab) || attempt.TargetTab.View.CoreWebView2 is null)
|
||||||
|
{
|
||||||
|
_authAttempt = null;
|
||||||
|
MessageBox.Show(this, "Вкладка, из которой начался вход, уже закрыта. Начните вход заново.", "Вход в SHD", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_authAttempt = null;
|
||||||
|
var formBody = $"code={DesktopAuthFlow.FormEncode(callback.Code)}&code_verifier={DesktopAuthFlow.FormEncode(attempt.Verifier)}";
|
||||||
|
_authPostData?.Dispose();
|
||||||
|
_authPostData = new MemoryStream(Encoding.UTF8.GetBytes(formBody));
|
||||||
|
_authPostTarget = attempt.TargetTab.View;
|
||||||
|
_authNavigationId = null;
|
||||||
|
var request = _webViewEnvironment!.CreateWebResourceRequest(
|
||||||
|
DesktopAuthFlow.CompleteUri(attempt.Environment).AbsoluteUri,
|
||||||
|
"POST",
|
||||||
|
_authPostData,
|
||||||
|
"Content-Type: application/x-www-form-urlencoded; charset=utf-8\r\nCache-Control: no-store\r\n");
|
||||||
|
attempt.TargetTab.Environment = attempt.Environment;
|
||||||
|
Tabs.SelectedItem = attempt.TargetTab.Item;
|
||||||
|
SetEnvironmentPicker(attempt.Environment);
|
||||||
|
SettingsStore.SaveEnvironment(attempt.Environment);
|
||||||
|
attempt.TargetTab.View.CoreWebView2.NavigateWithWebResourceRequest(request);
|
||||||
|
StatusText.Text = "Завершаем вход…";
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
_authPostData?.Dispose();
|
||||||
|
_authPostData = null;
|
||||||
|
_authPostTarget = null;
|
||||||
|
_authNavigationId = null;
|
||||||
|
MessageBox.Show(this, "Не удалось завершить вход в выбранном контуре. Начните вход заново.", "Вход в SHD", MessageBoxButton.OK, MessageBoxImage.Warning);
|
||||||
|
}
|
||||||
|
await Task.CompletedTask;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetEnvironment(ShdEnvironment environment)
|
||||||
|
{
|
||||||
|
_selectedEnvironment = environment;
|
||||||
|
SettingsStore.SaveEnvironment(environment);
|
||||||
|
SetEnvironmentPicker(environment);
|
||||||
|
StatusEnvironment.Text = environment.DisplayName();
|
||||||
|
var tab = ActiveTab;
|
||||||
|
if (tab is null || tab.View.CoreWebView2 is null) return;
|
||||||
|
tab.Environment = environment;
|
||||||
|
tab.View.CoreWebView2.Navigate(environment.BaseUrl());
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SetEnvironmentPicker(ShdEnvironment environment)
|
||||||
|
{
|
||||||
|
_suppressEnvironmentChange = true;
|
||||||
|
EnvironmentPicker.SelectedValue = environment.ToString();
|
||||||
|
_suppressEnvironmentChange = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async void NewTab_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_webViewEnvironment is null) return;
|
||||||
|
await AddTabAsync(_selectedEnvironment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void NewWindow_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var window = new MainWindow(_selectedEnvironment);
|
||||||
|
window.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseTab_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (ActiveTab is { } tab) CloseTab(tab);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CloseTab(BrowserTab tab)
|
||||||
|
{
|
||||||
|
if (_authAttempt?.TargetTab == tab) _authAttempt = null;
|
||||||
|
if (ReferenceEquals(_authPostTarget, tab.View))
|
||||||
|
{
|
||||||
|
_authPostData?.Dispose();
|
||||||
|
_authPostData = null;
|
||||||
|
_authPostTarget = null;
|
||||||
|
_authNavigationId = null;
|
||||||
|
}
|
||||||
|
tab.View.Dispose();
|
||||||
|
_tabs.Remove(tab);
|
||||||
|
Tabs.Items.Remove(tab.Item);
|
||||||
|
if (_tabs.Count == 0 && !_closing) _ = AddTabAsync(_selectedEnvironment);
|
||||||
|
UpdateWindowChrome();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Tabs_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
var tab = ActiveTab;
|
||||||
|
if (tab is null) return;
|
||||||
|
_selectedEnvironment = tab.Environment;
|
||||||
|
SetEnvironmentPicker(tab.Environment);
|
||||||
|
StatusEnvironment.Text = tab.Environment.DisplayName();
|
||||||
|
PageTitle.Text = tab.Title.Text;
|
||||||
|
Title = $"{CleanTitle(tab.View.CoreWebView2?.DocumentTitle ?? "SHD")} — SHD";
|
||||||
|
UpdateNavigationButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void EnvironmentPicker_SelectionChanged(object sender, SelectionChangedEventArgs e)
|
||||||
|
{
|
||||||
|
if (_suppressEnvironmentChange || !Enum.TryParse<ShdEnvironment>(EnvironmentPicker.SelectedValue as string, out var environment)) return;
|
||||||
|
if (environment == _selectedEnvironment && ActiveTab?.Environment == environment) return;
|
||||||
|
SetEnvironment(environment);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Window_PreviewKeyDown(object sender, KeyEventArgs e)
|
||||||
|
{
|
||||||
|
var modifiers = Keyboard.Modifiers;
|
||||||
|
if (modifiers == ModifierKeys.Control)
|
||||||
|
{
|
||||||
|
switch (e.Key)
|
||||||
|
{
|
||||||
|
case Key.T: NewTab_Click(sender, e); e.Handled = true; break;
|
||||||
|
case Key.N: NewWindow_Click(sender, e); e.Handled = true; break;
|
||||||
|
case Key.W: CloseTab_Click(sender, e); e.Handled = true; break;
|
||||||
|
case Key.R: Reload_Click(sender, e); e.Handled = true; break;
|
||||||
|
case Key.P: Print_Click(sender, e); e.Handled = true; break;
|
||||||
|
case Key.D0: ZoomReset_Click(sender, e); e.Handled = true; break;
|
||||||
|
case Key.Add:
|
||||||
|
case Key.OemPlus: ZoomIn_Click(sender, e); e.Handled = true; break;
|
||||||
|
case Key.Subtract:
|
||||||
|
case Key.OemMinus: ZoomOut_Click(sender, e); e.Handled = true; break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else if (modifiers == (ModifierKeys.Control | ModifierKeys.Shift) && e.Key == Key.R)
|
||||||
|
{
|
||||||
|
HardReload_Click(sender, e);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
else if (e.Key == Key.F12)
|
||||||
|
{
|
||||||
|
DevTools_Click(sender, e);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
else if (e.Key == Key.F11)
|
||||||
|
{
|
||||||
|
FullScreen_Click(sender, e);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
else if (modifiers == ModifierKeys.Alt && e.Key == Key.Left)
|
||||||
|
{
|
||||||
|
Back_Click(sender, e);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
else if (modifiers == ModifierKeys.Alt && e.Key == Key.Right)
|
||||||
|
{
|
||||||
|
Forward_Click(sender, e);
|
||||||
|
e.Handled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Back_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (ActiveTab?.View.CoreWebView2?.CanGoBack == true) ActiveTab.View.CoreWebView2.GoBack();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Forward_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (ActiveTab?.View.CoreWebView2?.CanGoForward == true) ActiveTab.View.CoreWebView2.GoForward();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Reload_Click(object sender, RoutedEventArgs e) => ActiveTab?.View.CoreWebView2?.Reload();
|
||||||
|
|
||||||
|
private async void HardReload_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var core = ActiveTab?.View.CoreWebView2;
|
||||||
|
if (core is null) return;
|
||||||
|
try { await core.CallDevToolsProtocolMethodAsync("Page.reload", "{\"ignoreCache\":true}"); }
|
||||||
|
catch (InvalidOperationException) { core.Reload(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ZoomIn_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (ActiveTab is { } tab) tab.View.ZoomFactor = Math.Min(tab.View.ZoomFactor + 0.1, 3.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ZoomOut_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (ActiveTab is { } tab) tab.View.ZoomFactor = Math.Max(tab.View.ZoomFactor - 0.1, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ZoomReset_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (ActiveTab is { } tab) tab.View.ZoomFactor = 1.0;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Print_Click(object sender, RoutedEventArgs e) => ActiveTab?.View.CoreWebView2?.ShowPrintUI(CoreWebView2PrintDialogKind.Browser);
|
||||||
|
|
||||||
|
private void DevTools_Click(object sender, RoutedEventArgs e) => ActiveTab?.View.CoreWebView2?.OpenDevToolsWindow();
|
||||||
|
|
||||||
|
private void FullScreen_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (!_isFullScreen)
|
||||||
|
{
|
||||||
|
_previousBounds = RestoreBounds;
|
||||||
|
_previousWindowState = WindowState;
|
||||||
|
_previousWindowStyle = WindowStyle;
|
||||||
|
WindowState = WindowState.Normal;
|
||||||
|
WindowStyle = WindowStyle.None;
|
||||||
|
WindowState = WindowState.Maximized;
|
||||||
|
_isFullScreen = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
WindowStyle = _previousWindowStyle;
|
||||||
|
WindowState = WindowState.Normal;
|
||||||
|
Left = _previousBounds.Left;
|
||||||
|
Top = _previousBounds.Top;
|
||||||
|
Width = _previousBounds.Width;
|
||||||
|
Height = _previousBounds.Height;
|
||||||
|
WindowState = _previousWindowState;
|
||||||
|
_isFullScreen = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Downloads_Click(object sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var path = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Downloads");
|
||||||
|
Directory.CreateDirectory(path);
|
||||||
|
OpenExternalUri(new Uri(path));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void SelectProduction_Click(object sender, RoutedEventArgs e) => SetEnvironment(ShdEnvironment.Production);
|
||||||
|
|
||||||
|
private void SelectDev_Click(object sender, RoutedEventArgs e) => SetEnvironment(ShdEnvironment.Dev);
|
||||||
|
|
||||||
|
private void Exit_Click(object sender, RoutedEventArgs e) => Application.Current.Shutdown();
|
||||||
|
|
||||||
|
private void Undo_Click(object sender, RoutedEventArgs e) => ExecuteEditCommand("undo");
|
||||||
|
private void Redo_Click(object sender, RoutedEventArgs e) => ExecuteEditCommand("redo");
|
||||||
|
private void Cut_Click(object sender, RoutedEventArgs e) => ExecuteEditCommand("cut");
|
||||||
|
private void Copy_Click(object sender, RoutedEventArgs e) => ExecuteEditCommand("copy");
|
||||||
|
private void Paste_Click(object sender, RoutedEventArgs e) => ExecuteEditCommand("paste");
|
||||||
|
private void SelectAll_Click(object sender, RoutedEventArgs e) => ExecuteEditCommand("selectAll");
|
||||||
|
|
||||||
|
private void ExecuteEditCommand(string command)
|
||||||
|
{
|
||||||
|
var escaped = command.Replace("'", "\\'", StringComparison.Ordinal);
|
||||||
|
_ = ActiveTab?.View.CoreWebView2?.ExecuteScriptAsync($"document.execCommand('{escaped}')");
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateWindowChrome()
|
||||||
|
{
|
||||||
|
if (ActiveTab is { } tab)
|
||||||
|
{
|
||||||
|
PageTitle.Text = tab.Title.Text;
|
||||||
|
StatusEnvironment.Text = tab.Environment.DisplayName();
|
||||||
|
}
|
||||||
|
UpdateNavigationButtons();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void UpdateNavigationButtons()
|
||||||
|
{
|
||||||
|
BackButton.IsEnabled = ActiveTab?.View.CoreWebView2?.CanGoBack == true;
|
||||||
|
ForwardButton.IsEnabled = ActiveTab?.View.CoreWebView2?.CanGoForward == true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string CleanTitle(string? raw)
|
||||||
|
{
|
||||||
|
var title = raw ?? string.Empty;
|
||||||
|
foreach (var suffix in new[] { " — SHD Project", " - SHD Project", " | SHD" })
|
||||||
|
{
|
||||||
|
var index = title.IndexOf(suffix, StringComparison.Ordinal);
|
||||||
|
if (index >= 0) title = title[..index];
|
||||||
|
}
|
||||||
|
return string.IsNullOrWhiteSpace(title) ? "SHD" : title.Trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string GetUniquePath(string path)
|
||||||
|
{
|
||||||
|
if (!File.Exists(path)) return path;
|
||||||
|
var directory = Path.GetDirectoryName(path)!;
|
||||||
|
var name = Path.GetFileNameWithoutExtension(path);
|
||||||
|
var extension = Path.GetExtension(path);
|
||||||
|
for (var counter = 1; ; counter++)
|
||||||
|
{
|
||||||
|
var candidate = Path.Combine(directory, $"{name} ({counter}){extension}");
|
||||||
|
if (!File.Exists(candidate)) return candidate;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private string? ShowPrompt(string message, string defaultValue)
|
||||||
|
{
|
||||||
|
var dialog = new Window
|
||||||
|
{
|
||||||
|
Owner = this,
|
||||||
|
Title = "SHD",
|
||||||
|
Width = 430,
|
||||||
|
SizeToContent = SizeToContent.Height,
|
||||||
|
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||||
|
ResizeMode = ResizeMode.NoResize,
|
||||||
|
Background = new SolidColorBrush(Color.FromRgb(34, 34, 34)),
|
||||||
|
Foreground = Brushes.White,
|
||||||
|
ShowInTaskbar = false
|
||||||
|
};
|
||||||
|
var input = new TextBox { Text = defaultValue, MinWidth = 380, Margin = new Thickness(0, 9, 0, 14), Padding = new Thickness(6) };
|
||||||
|
var accept = new Button { Content = "OK", IsDefault = true, MinWidth = 80 };
|
||||||
|
var cancel = new Button { Content = "Отмена", IsCancel = true, MinWidth = 80 };
|
||||||
|
var buttons = new StackPanel { Orientation = Orientation.Horizontal, HorizontalAlignment = HorizontalAlignment.Right };
|
||||||
|
buttons.Children.Add(accept);
|
||||||
|
buttons.Children.Add(cancel);
|
||||||
|
var panel = new StackPanel { Margin = new Thickness(18) };
|
||||||
|
panel.Children.Add(new TextBlock { Text = message, TextWrapping = TextWrapping.Wrap, MaxWidth = 380 });
|
||||||
|
panel.Children.Add(input);
|
||||||
|
panel.Children.Add(buttons);
|
||||||
|
dialog.Content = panel;
|
||||||
|
string? result = null;
|
||||||
|
accept.Click += (_, _) => { result = input.Text; dialog.DialogResult = true; };
|
||||||
|
dialog.Loaded += (_, _) => { input.Focus(); input.SelectAll(); };
|
||||||
|
dialog.ShowDialog();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OpenExternalUri(Uri uri)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (uri.IsFile)
|
||||||
|
{
|
||||||
|
Process.Start(new ProcessStartInfo(uri.LocalPath) { UseShellExecute = true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (uri.Scheme is "http" or "https" or "mailto")
|
||||||
|
Process.Start(new ProcessStartInfo(uri.AbsoluteUri) { UseShellExecute = true });
|
||||||
|
}
|
||||||
|
catch (Exception)
|
||||||
|
{
|
||||||
|
StatusText.Text = "Не удалось открыть ссылку";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Window_Closing(object? sender, System.ComponentModel.CancelEventArgs e)
|
||||||
|
{
|
||||||
|
_closing = true;
|
||||||
|
_authAttempt = null;
|
||||||
|
_authPostData?.Dispose();
|
||||||
|
_authPostData = null;
|
||||||
|
foreach (var tab in _tabs.ToArray()) tab.View.Dispose();
|
||||||
|
_tabs.Clear();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<OutputType>WinExe</OutputType>
|
||||||
|
<TargetFramework>net10.0-windows</TargetFramework>
|
||||||
|
<UseWPF>true</UseWPF>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<ApplicationManifest>app.manifest</ApplicationManifest>
|
||||||
|
<AssemblyName>SHD.Windows</AssemblyName>
|
||||||
|
<RootNamespace>SHD.Windows</RootNamespace>
|
||||||
|
<Version>1.0.0</Version>
|
||||||
|
<Authors>SHD</Authors>
|
||||||
|
<Description>SHD desktop app for Windows</Description>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.4258.31" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
using System.Text.Json;
|
||||||
|
|
||||||
|
namespace SHD.Windows;
|
||||||
|
|
||||||
|
internal static class SettingsStore
|
||||||
|
{
|
||||||
|
private static readonly string SettingsPath = Path.Combine(
|
||||||
|
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||||
|
"SHDDesktop",
|
||||||
|
"settings.json");
|
||||||
|
|
||||||
|
public static ShdEnvironment LoadEnvironment()
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var settings = JsonSerializer.Deserialize<AppSettings>(File.ReadAllText(SettingsPath));
|
||||||
|
if (Enum.TryParse<ShdEnvironment>(settings?.Environment, ignoreCase: true, out var environment))
|
||||||
|
return environment;
|
||||||
|
}
|
||||||
|
catch (IOException) { }
|
||||||
|
catch (JsonException) { }
|
||||||
|
catch (UnauthorizedAccessException) { }
|
||||||
|
|
||||||
|
return ShdEnvironment.Production;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static void SaveEnvironment(ShdEnvironment environment)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
Directory.CreateDirectory(Path.GetDirectoryName(SettingsPath)!);
|
||||||
|
File.WriteAllText(SettingsPath, JsonSerializer.Serialize(new AppSettings(environment.ToString())));
|
||||||
|
}
|
||||||
|
catch (IOException) { }
|
||||||
|
catch (UnauthorizedAccessException) { }
|
||||||
|
}
|
||||||
|
|
||||||
|
private sealed record AppSettings(string Environment);
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
namespace SHD.Windows;
|
||||||
|
|
||||||
|
internal enum ShdEnvironment
|
||||||
|
{
|
||||||
|
Production,
|
||||||
|
Dev
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static class ShdEnvironmentExtensions
|
||||||
|
{
|
||||||
|
public static string BaseUrl(this ShdEnvironment environment) => environment switch
|
||||||
|
{
|
||||||
|
ShdEnvironment.Dev => "https://dev-shd.xyz.su",
|
||||||
|
_ => "https://shd.xyz.su"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static string DisplayName(this ShdEnvironment environment) => environment switch
|
||||||
|
{
|
||||||
|
ShdEnvironment.Dev => "SHD Dev",
|
||||||
|
_ => "SHD Production"
|
||||||
|
};
|
||||||
|
|
||||||
|
public static bool IsDevelopmentHost(string host) =>
|
||||||
|
host.Equals("dev-shd.xyz.su", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public static bool IsProductionHost(string host) =>
|
||||||
|
host.Equals("shd.xyz.su", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
||||||
|
public static bool IsInternalHost(string host) =>
|
||||||
|
host.Equals("xyz.su", StringComparison.OrdinalIgnoreCase) ||
|
||||||
|
host.EndsWith(".xyz.su", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||||
|
<assemblyIdentity version="1.0.0.0" name="SHD.Windows" />
|
||||||
|
<trustInfo xmlns="urn:schemas-microsoft-com:asm.v3">
|
||||||
|
<security>
|
||||||
|
<requestedPrivileges>
|
||||||
|
<requestedExecutionLevel level="asInvoker" uiAccess="false" />
|
||||||
|
</requestedPrivileges>
|
||||||
|
</security>
|
||||||
|
</trustInfo>
|
||||||
|
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||||
|
<application>
|
||||||
|
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||||
|
</application>
|
||||||
|
</compatibility>
|
||||||
|
</assembly>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$windowsRoot = Split-Path -Parent $PSScriptRoot
|
||||||
|
$project = Join-Path $windowsRoot 'SHD.Windows\SHD.Windows.csproj'
|
||||||
|
$publishDir = Join-Path $windowsRoot 'publish'
|
||||||
|
$distDir = Join-Path $windowsRoot 'dist'
|
||||||
|
$bundleDir = Join-Path $distDir 'SHD-Windows-x64'
|
||||||
|
$zipPath = Join-Path $distDir 'SHD-Windows-x64.zip'
|
||||||
|
|
||||||
|
if (Test-Path $publishDir) { Remove-Item $publishDir -Recurse -Force }
|
||||||
|
if (Test-Path $bundleDir) { Remove-Item $bundleDir -Recurse -Force }
|
||||||
|
New-Item -ItemType Directory -Path $publishDir -Force | Out-Null
|
||||||
|
New-Item -ItemType Directory -Path $bundleDir -Force | Out-Null
|
||||||
|
|
||||||
|
dotnet publish $project -c Release -r win-x64 --self-contained true -p:PublishSingleFile=false -o $publishDir
|
||||||
|
if ($LASTEXITCODE -ne 0) { throw 'dotnet publish failed.' }
|
||||||
|
|
||||||
|
Copy-Item (Join-Path $publishDir '*') $bundleDir -Recurse -Force
|
||||||
|
Copy-Item (Join-Path $PSScriptRoot 'install.ps1') $bundleDir -Force
|
||||||
|
Copy-Item (Join-Path $PSScriptRoot 'uninstall.ps1') $bundleDir -Force
|
||||||
|
Copy-Item (Join-Path $windowsRoot 'README.md') $bundleDir -Force
|
||||||
|
if (Test-Path $zipPath) { Remove-Item $zipPath -Force }
|
||||||
|
Compress-Archive -Path (Join-Path $bundleDir '*') -DestinationPath $zipPath -CompressionLevel Optimal
|
||||||
|
Write-Host "Created $zipPath"
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
param(
|
||||||
|
[switch]$NoLaunch
|
||||||
|
)
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$bundleDir = Split-Path -Parent $MyInvocation.MyCommand.Path
|
||||||
|
$sourceExe = Join-Path $bundleDir 'SHD.Windows.exe'
|
||||||
|
if (-not (Test-Path $sourceExe)) {
|
||||||
|
throw 'SHD.Windows.exe is missing. Extract the complete SHD-Windows-x64.zip before installing.'
|
||||||
|
}
|
||||||
|
|
||||||
|
$installDir = Join-Path $env:LOCALAPPDATA 'Programs\SHD'
|
||||||
|
New-Item -ItemType Directory -Path $installDir -Force | Out-Null
|
||||||
|
$bundleFullPath = [System.IO.Path]::GetFullPath($bundleDir).TrimEnd('\')
|
||||||
|
$installFullPath = [System.IO.Path]::GetFullPath($installDir).TrimEnd('\')
|
||||||
|
if (-not $bundleFullPath.Equals($installFullPath, [System.StringComparison]::OrdinalIgnoreCase)) {
|
||||||
|
Get-ChildItem -LiteralPath $bundleDir -Force | ForEach-Object {
|
||||||
|
Copy-Item -LiteralPath $_.FullName -Destination $installDir -Recurse -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$installedExe = Join-Path $installDir 'SHD.Windows.exe'
|
||||||
|
$protocolKey = 'HKCU:\Software\Classes\shd-auth'
|
||||||
|
New-Item -Path $protocolKey -Force | Out-Null
|
||||||
|
Set-Item -Path $protocolKey -Value 'URL:SHD Desktop authentication'
|
||||||
|
New-ItemProperty -Path $protocolKey -Name 'URL Protocol' -Value '' -PropertyType String -Force | Out-Null
|
||||||
|
$commandKey = Join-Path $protocolKey 'shell\open\command'
|
||||||
|
New-Item -Path $commandKey -Force | Out-Null
|
||||||
|
Set-Item -Path $commandKey -Value ('"{0}" "%1"' -f $installedExe)
|
||||||
|
|
||||||
|
$startMenuDir = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\SHD'
|
||||||
|
New-Item -ItemType Directory -Path $startMenuDir -Force | Out-Null
|
||||||
|
$shortcutPath = Join-Path $startMenuDir 'SHD.lnk'
|
||||||
|
$shell = New-Object -ComObject WScript.Shell
|
||||||
|
$shortcut = $shell.CreateShortcut($shortcutPath)
|
||||||
|
$shortcut.TargetPath = $installedExe
|
||||||
|
$shortcut.WorkingDirectory = $installDir
|
||||||
|
$shortcut.IconLocation = "$installedExe,0"
|
||||||
|
$shortcut.Save()
|
||||||
|
|
||||||
|
Write-Host "SHD installed to $installDir"
|
||||||
|
Write-Host 'The shd-auth:// callback is registered for passkey sign-in.'
|
||||||
|
if (-not $NoLaunch) { Start-Process -FilePath $installedExe }
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
$installDir = Join-Path $env:LOCALAPPDATA 'Programs\SHD'
|
||||||
|
$startMenuDir = Join-Path $env:APPDATA 'Microsoft\Windows\Start Menu\Programs\SHD'
|
||||||
|
$protocolKey = 'HKCU:\Software\Classes\shd-auth'
|
||||||
|
|
||||||
|
if (Test-Path $protocolKey) {
|
||||||
|
$commandKey = Join-Path $protocolKey 'shell\open\command'
|
||||||
|
$registeredCommand = if (Test-Path $commandKey) { (Get-Item -Path $commandKey).GetValue('') } else { '' }
|
||||||
|
if ($registeredCommand -like '*\Programs\SHD\SHD.Windows.exe*') {
|
||||||
|
Remove-Item -Path $protocolKey -Recurse -Force
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (Test-Path $startMenuDir) { Remove-Item -Path $startMenuDir -Recurse -Force }
|
||||||
|
if (Test-Path $installDir) { Remove-Item -Path $installDir -Recurse -Force -ErrorAction SilentlyContinue }
|
||||||
|
|
||||||
|
Write-Host 'SHD has been uninstalled. Your WebView profile and site data remain under %LOCALAPPDATA%\SHDDesktop.'
|
||||||
Reference in New Issue
Block a user