diff --git a/clients/ds-shell/.gitignore b/clients/ds-shell/.gitignore new file mode 100644 index 0000000..8ba2f41 --- /dev/null +++ b/clients/ds-shell/.gitignore @@ -0,0 +1,4 @@ +DSShell.xcodeproj/ +Support/ +.build/ +*.xcuserstate diff --git a/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json b/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..f168558 --- /dev/null +++ b/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,74 @@ +{ + "info" : { + "version" : 1, + "author" : "xcode" + }, + "images" : [ + { + "scale" : "1x", + "filename" : "mac_16.png", + "idiom" : "mac", + "size" : "16x16" + }, + { + "idiom" : "mac", + "size" : "16x16", + "scale" : "2x", + "filename" : "mac_32.png" + }, + { + "filename" : "mac_32.png", + "size" : "32x32", + "scale" : "1x", + "idiom" : "mac" + }, + { + "scale" : "2x", + "idiom" : "mac", + "size" : "32x32", + "filename" : "mac_64.png" + }, + { + "idiom" : "mac", + "size" : "128x128", + "filename" : "mac_128.png", + "scale" : "1x" + }, + { + "size" : "128x128", + "idiom" : "mac", + "scale" : "2x", + "filename" : "mac_256.png" + }, + { + "filename" : "mac_256.png", + "scale" : "1x", + "idiom" : "mac", + "size" : "256x256" + }, + { + "filename" : "mac_512.png", + "scale" : "2x", + "size" : "256x256", + "idiom" : "mac" + }, + { + "filename" : "mac_512.png", + "size" : "512x512", + "idiom" : "mac", + "scale" : "1x" + }, + { + "filename" : "mac_1024.png", + "size" : "512x512", + "scale" : "2x", + "idiom" : "mac" + }, + { + "idiom" : "universal", + "filename" : "ios_1024.png", + "size" : "1024x1024", + "platform" : "ios" + } + ] +} \ No newline at end of file diff --git a/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/ios_1024.png b/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/ios_1024.png new file mode 100644 index 0000000..9a824bc Binary files /dev/null and b/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/ios_1024.png differ diff --git a/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/mac_1024.png b/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/mac_1024.png new file mode 100644 index 0000000..ab43df1 Binary files /dev/null and b/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/mac_1024.png differ diff --git a/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/mac_128.png b/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/mac_128.png new file mode 100644 index 0000000..2a1f8c8 Binary files /dev/null and b/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/mac_128.png differ diff --git a/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/mac_16.png b/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/mac_16.png new file mode 100644 index 0000000..620904c Binary files /dev/null and b/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/mac_16.png differ diff --git a/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/mac_256.png b/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/mac_256.png new file mode 100644 index 0000000..5afa9c8 Binary files /dev/null and b/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/mac_256.png differ diff --git a/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/mac_32.png b/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/mac_32.png new file mode 100644 index 0000000..cb046be Binary files /dev/null and b/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/mac_32.png differ diff --git a/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/mac_512.png b/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/mac_512.png new file mode 100644 index 0000000..aaed03e Binary files /dev/null and b/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/mac_512.png differ diff --git a/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/mac_64.png b/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/mac_64.png new file mode 100644 index 0000000..ca1af7d Binary files /dev/null and b/clients/ds-shell/Sources/Assets.xcassets/AppIcon.appiconset/mac_64.png differ diff --git a/clients/ds-shell/Sources/Assets.xcassets/Contents.json b/clients/ds-shell/Sources/Assets.xcassets/Contents.json new file mode 100644 index 0000000..d8b757a --- /dev/null +++ b/clients/ds-shell/Sources/Assets.xcassets/Contents.json @@ -0,0 +1,3 @@ +{ + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/clients/ds-shell/Sources/DSShellApp.swift b/clients/ds-shell/Sources/DSShellApp.swift new file mode 100644 index 0000000..9ca4277 --- /dev/null +++ b/clients/ds-shell/Sources/DSShellApp.swift @@ -0,0 +1,29 @@ +import SwiftUI + +/// DS 웹 래퍼 — document.hyungi.net 을 네이티브 창에 로드. 로그인은 WKWebsiteDataStore.default() +/// 영속 쿠키로 유지(브라우저처럼). 맥·iOS 공용 @main. +@main +struct DSShellApp: App { + private let url = URL(string: "https://document.hyungi.net")! + + var body: some Scene { + WindowGroup { + RootWeb(url: url) + } + #if os(macOS) + .windowStyle(.automatic) + #endif + } +} + +struct RootWeb: View { + let url: URL + var body: some View { + WebView(url: url) + .ignoresSafeArea() + #if os(macOS) + .frame(minWidth: 900, minHeight: 600) + .background(WindowOnScreenGuard()) // 분리된 모니터 좌표 저장 시 화면 밖 방지 + #endif + } +} diff --git a/clients/ds-shell/Sources/WebView.swift b/clients/ds-shell/Sources/WebView.swift new file mode 100644 index 0000000..a092e6a --- /dev/null +++ b/clients/ds-shell/Sources/WebView.swift @@ -0,0 +1,96 @@ +import SwiftUI +import WebKit + +#if os(macOS) +import AppKit +#else +import UIKit +#endif + +/// document.hyungi.net 을 로드하는 WKWebView 래퍼 (맥=NSViewRepresentable / iOS=UIViewRepresentable). +/// 영속 데이터스토어 = 로그인 쿠키 유지. 첨부(Content-Disposition: attachment) 응답은 다운로드 처리. +/// 파일 업로드(file input)는 WKWebView 가 네이티브 피커로 자동 처리. +struct WebView { + let url: URL + + func makeCoordinator() -> Coordinator { Coordinator() } + + @MainActor + fileprivate func makeWebView(coordinator: Coordinator) -> WKWebView { + let cfg = WKWebViewConfiguration() + cfg.websiteDataStore = .default() // 영속 쿠키 → 로그인 유지(브라우저처럼) + let wv = WKWebView(frame: .zero, configuration: cfg) + wv.navigationDelegate = coordinator + wv.allowsBackForwardNavigationGestures = true + wv.load(URLRequest(url: url)) + return wv + } + + final class Coordinator: NSObject, WKNavigationDelegate, WKDownloadDelegate { + // 첨부 응답이면 다운로드, 아니면 일반 표시(PDF 등 인라인). + func webView(_ webView: WKWebView, + decidePolicyFor navigationResponse: WKNavigationResponse, + decisionHandler: @escaping (WKNavigationResponsePolicy) -> Void) { + if let http = navigationResponse.response as? HTTPURLResponse, + let cd = http.value(forHTTPHeaderField: "Content-Disposition"), + cd.lowercased().contains("attachment") { + decisionHandler(.download) + } else { + decisionHandler(.allow) + } + } + + func webView(_ webView: WKWebView, navigationResponse: WKNavigationResponse, didBecome download: WKDownload) { + download.delegate = self + } + func webView(_ webView: WKWebView, navigationAction: WKNavigationAction, didBecome download: WKDownload) { + download.delegate = self + } + + func download(_ download: WKDownload, + decideDestinationUsing response: URLResponse, + suggestedFilename: String) async -> URL? { + #if os(macOS) + let folder = FileManager.default.urls(for: .downloadsDirectory, in: .userDomainMask).first + #else + let folder = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first + #endif + let dir = folder ?? FileManager.default.temporaryDirectory + var dest = dir.appendingPathComponent(suggestedFilename.isEmpty ? "download" : suggestedFilename) + // 충돌 회피 (name_1.ext …) + let base = dest.deletingPathExtension().lastPathComponent + let ext = dest.pathExtension + var n = 1 + while FileManager.default.fileExists(atPath: dest.path) { + let name = ext.isEmpty ? "\(base)_\(n)" : "\(base)_\(n).\(ext)" + dest = dir.appendingPathComponent(name); n += 1 + } + return dest + } + } +} + +#if os(macOS) +extension WebView: NSViewRepresentable { + func makeNSView(context: Context) -> WKWebView { makeWebView(coordinator: context.coordinator) } + func updateNSView(_ nsView: WKWebView, context: Context) {} +} + +/// 창이 어느 화면과도 안 겹치면(분리된 외부모니터 좌표 저장 등) 메인 화면 중앙으로 복귀 — "창 안 뜸" 방지. +struct WindowOnScreenGuard: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { OnScreenView() } + func updateNSView(_ nsView: NSView, context: Context) {} + final class OnScreenView: NSView { + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + guard let win = window else { return } + if !NSScreen.screens.contains(where: { $0.visibleFrame.intersects(win.frame) }) { win.center() } + } + } +} +#else +extension WebView: UIViewRepresentable { + func makeUIView(context: Context) -> WKWebView { makeWebView(coordinator: context.coordinator) } + func updateUIView(_ uiView: WKWebView, context: Context) {} +} +#endif diff --git a/clients/ds-shell/project.yml b/clients/ds-shell/project.yml new file mode 100644 index 0000000..3f17bda --- /dev/null +++ b/clients/ds-shell/project.yml @@ -0,0 +1,88 @@ +# DS 웹 래퍼 — document.hyungi.net 을 WKWebView 로 감싼 네이티브 앱(맥 + iOS). +# 웹 UI 100% 재사용·항상 최신·코드 1벌(2026-06-15 결정). 순수 네이티브는 워치(clients/ds-watch)만. +# project.yml = source of truth, *.xcodeproj/Support = 생성물(gitignore). +name: DSShell +options: + bundleIdPrefix: net.hyungi + deploymentTarget: + macOS: "14.0" + iOS: "17.0" + createIntermediateGroups: true + minimumXcodeGenVersion: "2.40.0" + +settings: + base: + SWIFT_VERSION: "6.0" + CODE_SIGN_STYLE: Automatic + CODE_SIGNING_ALLOWED: "NO" + CODE_SIGNING_REQUIRED: "NO" + GENERATE_INFOPLIST_FILE: "NO" + +targets: + DSShellMac: + type: application + platform: macOS + deploymentTarget: "14.0" + sources: + - path: Sources + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: net.hyungi.dsshell + PRODUCT_NAME: DS + MARKETING_VERSION: "0.1" + CURRENT_PROJECT_VERSION: "1" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + info: + path: Support/Mac-Info.plist + properties: + CFBundleName: DS + CFBundleDisplayName: DS + CFBundleShortVersionString: "0.1" + CFBundleVersion: "1" + CFBundlePackageType: APPL + LSMinimumSystemVersion: "14.0" + LSApplicationCategoryType: public.app-category.productivity + entitlements: + path: Support/Mac.entitlements + properties: + com.apple.security.app-sandbox: true + com.apple.security.network.client: true + com.apple.security.files.downloads.read-write: true # 원본 다운로드 저장 + com.apple.security.files.user-selected.read-write: true # 업로드 파일 선택 + + DSShelliOS: + type: application + platform: iOS + deploymentTarget: "17.0" + sources: + - path: Sources + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: net.hyungi.dsshell + PRODUCT_NAME: DS + MARKETING_VERSION: "0.1" + CURRENT_PROJECT_VERSION: "1" + TARGETED_DEVICE_FAMILY: "1,2" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + info: + path: Support/iOS-Info.plist + properties: + CFBundleName: DS + CFBundleDisplayName: DS + CFBundleShortVersionString: "0.1" + CFBundleVersion: "1" + UILaunchScreen: {} + UISupportedInterfaceOrientations: + - UIInterfaceOrientationPortrait + - UIInterfaceOrientationLandscapeLeft + - UIInterfaceOrientationLandscapeRight + +schemes: + DSShellMac: + build: + targets: { DSShellMac: all } + run: { config: Debug } + DSShelliOS: + build: + targets: { DSShelliOS: all } + run: { config: Debug }