식단서랍 웹의 알림 설정 화면은 열리자마자 window.RecipeDrawerApp.getPushToken() 을 부른다. 그리고 그 자리에서 문자열을 받아 서버에 "이 기기 구독 중인가" 를 묻는다. Android 에서는 아무 문제가 없던 한 줄이다. iOS 의 WKWebView 에는 이 한 줄에 그 자리에서 대답할 방법이 없다.
Android 앱을 WebView 로 다시 지으며 Chrome 이 하던 일을 앱으로 옮긴 이야기는 TWA에서 WebView로 연재에 있다. 이번에는 그 목록을 WKWebView 위에서 한 번 더 따라갔다.
이름부터 맞췄다
원칙은 하나였다. 웹을 고치지 않는다. 식단서랍 웹의 push.js 는 window.RecipeDrawerApp.requestPushToken 이 있으면 앱 안이라고 본다. 앱이 알려 줄 결과는 window.RecipeDrawerPush._onAppToken(token) 같은 콜백으로 받는다. Android 앱이 넣어 주는 이름과 메서드가 곧 웹과 앱 사이의 약속이었다.
그래서 iOS 앱도 같은 이름을 쓴다. 객체 이름 RecipeDrawerApp, 메서드 다섯 개(requestPushToken, deletePushToken, openNotificationSettings, getPushToken, getNotificationPermission), 콜백 이름까지 Android 와 한 글자도 다르지 않다. 웹은 상대가 Android 인지 iPhone 인지 몰라도 된다. 이름을 새로 지었다면 웹에 분기가 생겼을 것이다.
실제 코드는 업무용 A/S 웹 서비스 앱에서 먼저 만들었다. 식단서랍은 그 코드를 복사해서 객체 이름과 콜백 이름, 도메인을 바꿨다. 식단서랍에만 있는 openNotificationSettings() 를 하나 더 붙였고, A/S 앱에만 있던 위치 브리지와 새 창 뷰어는 뺐다.
미리 넣어 두는 대답
Android 의 addJavascriptInterface 는 자바 메서드를 JS 함수처럼 보이게 하고, 반환값이 그대로 JS 로 돌아온다. 동기 호출이다.
WKWebView 에서 JS 가 앱을 부르는 길은 window.webkit.messageHandlers.<이름>.postMessage(...) 다. 보내기만 있고 돌려받는 값이 없다. 그러니 getPushToken() 이 "지금 앱이 가진 토큰" 을 그 자리에서 돌려주려면, 그 값이 이미 JS 쪽에 있어야 한다.
사실 Android 에서도 같은 고민을 한 번 했다. 남의 프레임에서 브리지를 지키려고 WebMessageListener 로 갈아탔을 때, 그 채널도 보내기만 있었다. 그때 쓴 방법이 앱이 상태를 미리 밀어 넣고 심(shim)이 그 값을 돌려주는 것이었다. iOS 는 처음부터 그 방식으로 갔다.
var st = {token: "<캐시된 값>", permission: "default"};
function post(a){ window.webkit.messageHandlers.recipePush.postMessage({action: a}); }
window.RecipeDrawerApp = {
requestPushToken: function(){ post('requestPushToken'); },
deletePushToken: function(){ post('deletePushToken'); },
openNotificationSettings: function(){ post('openNotificationSettings'); },
getPushToken: function(){ return st.token || ''; },
getNotificationPermission: function(){ return st.permission || 'default'; }
};
window.__recipeSetPushState = function(t, p){ /* 앱이 값을 바꿀 때 부른다 */ };
이 심은 WKUserScript 로 문서 시작 시점에 넣는다. 페이지의 첫 스크립트가 돌기 전에 객체가 있어야 push.js 가 앱으로 알아본다. 값은 스크립트 문자열에 박혀 들어간다. 값이 바뀌면 두 군데를 고친다. 다음에 열릴 페이지를 위해 사용자 스크립트를 지우고 새 값으로 다시 등록하고, 이미 열린 페이지에는 evaluateJavaScript 로 __recipeSetPushState(...) 를 부른다.
알림 권한 값에는 순서 문제가 있었다. iOS 의 알림 권한 조회는 비동기다. 첫 페이지가 먼저 열려 버리면 웹은 실제 상태와 상관없이 기본값 default 를 보게 된다. 그래서 앱은 권한 상태를 먼저 받아 심에 넣은 뒤에 시작 주소를 연다. 설정 앱에서 알림을 껐다가 돌아오는 경우도 있으니, 앱이 앞으로 나올 때마다 다시 읽어 갱신한다.
남의 프레임 문제도 다시 따졌다. Android 의 허용 목록은 출처로 걸렀다. 앱 도메인이면 iframe 이어도 브리지를 받고, 외부 출처 프레임은 받지 못한다. iOS 도 같게 하려고 심을 모든 프레임에 넣되, 심 첫머리에 출처 검사를 두었다. location.origin 이 https:// + 앱 도메인이나 그 하위 도메인이 아니면 아무것도 정의하지 않고 끝난다. Android 의 출처 허용 목록과 같은 판정이다. 앱 쪽에서 메시지를 받을 때도 한 번 더 본다.
let origin = message.frameInfo.securityOrigin
guard origin.`protocol` == "https", URLRouter.isAppHostName(origin.host) else { return }
JS 검사는 페이지가 우회할 수도 있다고 보고, 최종 판단은 WebKit 이 알려 주는 보낸 프레임의 출처로 한다. 푸시 요청은 여기에 더해 메인 프레임 주소가 앱 도메인일 때만 받는다.
작은 함정도 하나 있었다. WKUserContentController 는 등록한 메시지 핸들러를 강하게 잡는다. 브리지 객체는 스크립트를 다시 등록하려고 WKUserContentController 를 들고 있으니, 브리지를 핸들러로 바로 넣으면 둘이 서로를 잡아 화면을 닫아도 풀리지 않는다. 약한 참조만 들고 메시지를 넘겨주는 작은 래퍼 클래스를 사이에 끼웠다.
푸시 토큰을 실제로 받아 오는 과정은 Android 와 꽤 달라서 뒤쪽 푸시 편에서 따로 다룬다.
이 주소는 어디서 여는가
주소를 나누는 규칙은 Android 와 같다. 앱 도메인의 https 는 앱 안에서 연다. http:// 로 적힌 앱 도메인은 https 로 바꿔 다시 연다. 그 밖의 http(s) 는 밖으로 보낸다. Android 에서 Custom Tabs 로 보내던 자리를 iOS 에서는 SFSafariViewController 가 맡는다. Safari 를 따로 띄우지 않고 앱 위에 Safari 화면을 올렸다가 닫으면 돌아오는 방식이다. tel: 같은 스킴은 처리할 수 있는 앱에 넘긴다.
규칙이 하나 달라졌다. 카카오 로그인이다. Android 에서는 카카오 인증 페이지를 Custom Tabs 로 내보냈다. 로그인이 끝나면 카카오가 식단서랍의 /signin-kakao 로 돌려보내고, 그 주소는 App Links 로 앱에 돌아온다. Digital Asset Links 로 앱과 도메인이 한 몸이라는 걸 증명해 둔 덕이다.
iOS 에서 같은 일을 하려면 도메인에 apple-app-site-association 을 두고 유니버설 링크를 설정해야 하는데, 웹에는 그게 없다. 웹을 고치지 않기로 했으니, 밖으로 내보낸 로그인은 돌아올 길이 없다. 돌아온다 해도 SFSafariViewController 의 쿠키는 WebView 와 따로 놀아서, 로그인 세션은 앱 밖에 남는다. 그래서 카카오 인증 도메인 몇 개를 "앱 안에서 여는 외부 도메인" 목록에 넣었다. 로그인 화면이 앱 안의 WebView 에서 열리고, 끝나면 같은 WebView 가 /signin-kakao 로 돌아온다. 같은 이유로 Apple 로그인 도메인도 이 목록에 들어 있는데, 그 흐름은 뒤쪽 편에서 다룬다.
iframe 은 이 규칙을 타지 않는다. 레시피 상세의 YouTube 영상은 외부 도메인 iframe 이라, 규칙을 그대로 적용하면 영상이 Safari 로 튀어 나간다. 하위 프레임은 웹 주소를 그대로 허용한다.
새 창은 두 앱이 다르게 갔다. A/S 앱의 Android 판에는 target="_blank" 와 window.open 을 받는 팝업 뷰어가 있었다. 첨부 사진이 새 창으로 열리며 작업 화면을 덮어쓰던 문제를 막으려고 만든 것이다. iOS 에서도 같은 뷰어를 만들었다. createWebViewWith 에서 새 WKWebView 를 만들어 돌려주는데, 이때 반드시 WebKit 이 넘겨준 configuration 으로 만들어야 한다. 그래야 부모 페이지와 세션을 공유하고 window.opener 관계가 이어진다. 첫 이동이 앱 도메인이면 제목과 닫기 버튼이 있는 전체 화면 모달로 띄우고, 외부 도메인이면 뷰어 없이 Safari 화면으로 넘긴다. 다운로드만 하는 새 창이면 뷰어를 띄우지 않고 다운로드만 처리한다.
식단서랍 Android 판은 새 창을 지원하지 않았다. 새 창 링크는 같은 WebView 의 이동으로 처리됐다. iOS 도 그대로 맞췄다. createWebViewWith 에서 새 WebView 를 만들지 않고 nil 을 돌려주되, 그 주소를 같은 규칙으로 보낸다. 앱 도메인이면 메인 WebView 에서 열고, 레시피의 영상 링크 같은 외부 주소는 Safari 화면으로 연다.
받은 파일은 Documents 로
다운로드는 Android 보다 쉬웠다. WKDownload 가 있어서다. 웹이 a[download] 링크를 누르면 navigationAction.shouldPerformDownload 가 켜지고, 서버가 Content-Disposition: attachment 를 보내거나 WebView 가 보여 줄 수 없는 형식이면 응답 단계에서 다운로드로 돌릴 수 있다. 정책 콜백에서 .download 를 돌려주면 끝이다. 같은 WebView 세션으로 받으니 로그인 쿠키도 그대로 실린다. Android 에서 DownloadManager 에 쿠키를 손으로 옮겨 싣던 수고가 없다. 앱 도메인이 아닌 주소의 다운로드는 Android 와 같이 앱 안에서 받지 않고 밖으로 넘긴다.
blob: 과 data: 는 여전히 손이 갔다. blob 주소는 그것을 만든 페이지 안에서만 읽힌다. 웹이 click() 직후 revokeObjectURL 을 부르면 앱이 따라가 받을 때는 이미 사라져 있다. Android 에서 쓴 훅 스크립트를 그대로 옮겼다. 문서 시작 시점에 URL.createObjectURL 을 감싸 Blob 참조를 붙잡아 두고, revokeObjectURL 뒤에도 60초 동안 지우지 않는다. a[download] 클릭을 가로채 FileReader 로 data URL 을 만든다. 바뀐 건 마지막 한 줄, 앱으로 보내는 통로가 webkit.messageHandlers 라는 것뿐이다.
받은 파일은 앱의 Documents 폴더에 둔다. Info.plist 에 UIFileSharingEnabled 와 LSSupportsOpeningDocumentsInPlace 를 켜 두면 이 폴더가 "파일" 앱의 "나의 iPhone > 식단서랍" 으로 보인다. 같은 이름이 있으면 이름 (1).확장자 로 피한다. 파일 이름은 Content-Disposition 의 filename*=UTF-8''... 를 먼저 읽는다. 이 값을 보지 않으면 한글 파일 이름이 WebKit 이 추천한 이상한 문자열로 바뀐다. 저장이 끝나면 QuickLook 으로 바로 미리보기를 띄우고, 미리보기가 안 되는 형식이면 공유 창을 띄운다.
당기면 새로고침, 모달에서는 아니고
당겨서 새로고침은 WKWebView 의 스크롤 뷰에 UIRefreshControl 을 붙이면 생긴다. 붙이는 건 한 줄이다. 문제는 Android 에서 겪은 그대로였다. 모달 안의 긴 목록을 올리던 손가락에 화면 전체가 새로고침된다. 스크롤 뷰는 문서가 맨 위라는 것만 알고, 손가락 밑에 모달이 있는지는 모른다.
그건 웹만 안다. Android 에서 쓴 터치 판정 스크립트를 그대로 넣었다. touchstart 때 손가락이 닿은 요소부터 부모로 올라가며, 안쪽으로 스크롤이 내려간 영역이나 position: fixed 레이어, overscroll-behavior 로 막은 영역이 있는지 본다. 결과를 메시지로 앱에 보낸다.
받는 쪽은 더 단순해졌다. Android 에서는 SwipeRefreshLayout 이 "지금 막을까" 를 매 순간 물어 와서, 판정이 아직 도착하지 않은 짧은 순간을 시각으로 재며 기다려야 했다. iOS 에서는 판정이 오면 새로고침 컨트롤을 아예 떼어 버린다.
func webBridge(touchBlocksRefresh blocked: Bool, from webView: WKWebView) {
guard !refreshControl.isRefreshing else { return }
let control: UIRefreshControl? = blocked ? nil : refreshControl
if webView.scrollView.refreshControl !== control {
webView.scrollView.refreshControl = control
}
}
touchstart 직후 오는 메시지라 손가락이 당김을 시작하기 전에 도착한다. 막아야 할 곳이면 컨트롤이 없으니 당겨도 아무 일이 없고, 아니면 다시 붙어 있다. 이미 새로고침 중일 때는 건드리지 않는다. 새 페이지로 넘어가면 아직 판정을 보낸 적이 없으니 didCommit 에서 컨트롤을 다시 붙인다.
뒤로가기 버튼이 없는 대신 화면 가장자리를 밀어 웹 히스토리를 되짚게 했다. 웹 프로세스가 죽으면 같은 페이지를 다시 열고 10초 안에 또 죽으면 시작 주소로 가는 규칙도 Android 에서 그대로 옮겼다.
지금까지 맥 미니의 시뮬레이터에서 빌드하고 첫 화면이 뜨는 것까지 확인했다. 카카오 로그인 왕복, 다운로드 후 "파일" 앱에서 보이는지, 모달에서 당겼을 때의 동작은 TestFlight 로 실기기에 올린 뒤 확인할 차례다.
옮겨 보니 WKWebView 는 Android WebView 보다 많은 걸 기본으로 해 줬다. 다운로드가 그랬고, 사진 첨부의 파일 선택과 카메라도 별도 코드 없이 WKWebView 의 기본 동작에 맡겼다. 대신 해 주지 않는 것은 확실히 해 주지 않았다. 그 자리에서 대답하는 브리지가 없었고, 앱 밖으로 나간 로그인을 데려올 방법이 없었다. 웹을 고치지 않는다는 원칙은 결국 앱이 그 차이를 다 떠안는다는 뜻이었다. 다음 편은 WebView 가 아닌 앱, Unity 게임을 iOS 로 빌드하는 이야기다.