feat(ds-shell): 맥·iOS 웹 래퍼 앱 — document.hyungi.net WKWebView + DS 아이콘
- 맥·iOS 2타깃, WKWebView 로 웹 UI 100% 재사용(2026-06-15 결정: 맥/아이폰=웹 래퍼) - 영속 쿠키(로그인 유지), 첨부 응답 다운로드 처리, 업로드는 네이티브 피커 자동 - 맥 창 off-screen 가드(분리 모니터 좌표 저장 시 중앙 복귀) - DS 초록 라운드 아이콘(맥=라운드/iOS=풀블리드, 1024px 생성) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@@ -0,0 +1,4 @@
|
||||
DSShell.xcodeproj/
|
||||
Support/
|
||||
.build/
|
||||
*.xcuserstate
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 569 B |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 1.0 KiB |
|
After Width: | Height: | Size: 19 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"info" : { "author" : "xcode", "version" : 1 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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 }
|
||||