clauderipple 0.9.4 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/CHANGELOG.md +80 -0
  2. package/README.md +31 -0
  3. package/dist/app/package.json +1 -1
  4. package/dist/cli/src/hooks/agent-title.js +7 -4
  5. package/dist/cli/src/index.js +43 -0
  6. package/dist/cli/src/remote.js +263 -0
  7. package/dist/cli/src/schtasks.js +48 -40
  8. package/dist/cli/src/settings.js +2 -1
  9. package/dist/mod/hooks/i18n.ts +252 -6
  10. package/dist/mod/hooks/judge.ts +35 -17
  11. package/dist/mod/hooks/modes.ts +56 -1
  12. package/dist/mod/hooks/register.tsx +238 -32
  13. package/dist/mod/hooks/session.ts +98 -1
  14. package/dist/mod/hooks/settings.js +7 -3
  15. package/dist/mod/hooks/settings.ts +9 -4
  16. package/dist/mod/hooks/stats.ts +25 -1
  17. package/dist/mod/hooks/ui/agents.tsx +858 -79
  18. package/dist/mod/hooks/ui/alerts.tsx +20 -17
  19. package/dist/mod/hooks/ui/art.ts +33 -7
  20. package/dist/mod/hooks/ui/band.tsx +262 -81
  21. package/dist/mod/hooks/ui/flat.ts +265 -0
  22. package/dist/mod/hooks/ui/kit.tsx +70 -55
  23. package/dist/mod/hooks/ui/look.ts +129 -0
  24. package/dist/mod/hooks/ui/settings.tsx +71 -46
  25. package/dist/mod/hooks/ui/setup.tsx +15 -13
  26. package/dist/mod/hooks/ui/theme.ts +3 -0
  27. package/dist/mod/hooks/ui/tools.ts +124 -0
  28. package/dist/mod/hooks/ui/view.ts +116 -3
  29. package/dist/mod/hooks/workers.ts +33 -6
  30. package/dist/mod/tests/register.test.tsx +155 -11
  31. package/dist/mod/tests/ui.test.tsx +375 -27
  32. package/dist/mod/types/index.d.ts +68 -2
  33. package/dist/remote/package.json +28 -0
  34. package/dist/remote/src/away.js +85 -0
  35. package/dist/remote/src/bridge-client.js +295 -0
  36. package/dist/remote/src/composer-menu.js +11 -0
  37. package/dist/remote/src/config.js +102 -0
  38. package/dist/remote/src/desktop.js +1018 -0
  39. package/dist/remote/src/e2ee.js +126 -0
  40. package/dist/remote/src/e2ee.ts +126 -0
  41. package/dist/remote/src/eventlog.js +298 -0
  42. package/dist/remote/src/expo-push.js +127 -0
  43. package/dist/remote/src/folders.js +79 -0
  44. package/dist/remote/src/fsutil.js +39 -0
  45. package/dist/remote/src/handoff.js +38 -0
  46. package/dist/remote/src/host-service.js +168 -0
  47. package/dist/remote/src/images.js +215 -0
  48. package/dist/remote/src/local-control.js +138 -0
  49. package/dist/remote/src/main.js +264 -0
  50. package/dist/remote/src/permissions.js +124 -0
  51. package/dist/remote/src/power.js +76 -0
  52. package/dist/remote/src/processes.js +24 -0
  53. package/dist/remote/src/protocol.js +32 -0
  54. package/dist/remote/src/protocol.ts +284 -0
  55. package/dist/remote/src/push-content.js +32 -0
  56. package/dist/remote/src/push.js +134 -0
  57. package/dist/remote/src/receipts.js +111 -0
  58. package/dist/remote/src/relay.js +438 -0
  59. package/dist/remote/src/router.js +208 -0
  60. package/dist/remote/src/sdk-launcher.js +18 -0
  61. package/dist/remote/src/server.js +706 -0
  62. package/dist/remote/src/service.js +157 -0
  63. package/dist/remote/src/sessions.js +1800 -0
  64. package/dist/remote/src/sidebar-storage.js +211 -0
  65. package/dist/remote/src/sidebar.js +173 -0
  66. package/dist/remote/src/tool-details.js +205 -0
  67. package/dist/remote/src/tool-details.ts +153 -0
  68. package/dist/remote/src/worktree.js +99 -0
  69. package/dist/remote/web/app.ts +746 -0
  70. package/dist/remote/web/conn.ts +193 -0
  71. package/dist/remote/web/demo.ts +134 -0
  72. package/dist/remote/web/dom.ts +124 -0
  73. package/dist/remote/web/esc.ts +4 -0
  74. package/dist/remote/web/i18n.ts +374 -0
  75. package/dist/remote/web/icon.svg +1 -0
  76. package/dist/remote/web/index.html +24 -0
  77. package/dist/remote/web/manifest.webmanifest +10 -0
  78. package/dist/remote/web/mascot.ts +60 -0
  79. package/dist/remote/web/md.ts +78 -0
  80. package/dist/remote/web/notify.ts +55 -0
  81. package/dist/remote/web/pushstate.ts +27 -0
  82. package/dist/remote/web/secure.ts +109 -0
  83. package/dist/remote/web/sidebar.ts +106 -0
  84. package/dist/remote/web/style.css +311 -0
  85. package/dist/remote/web/sw.ts +48 -0
  86. package/dist/remote/web/timeline.ts +224 -0
  87. package/dist/remote/web/views.ts +323 -0
  88. package/dist/router/src/admin.js +227 -4
  89. package/dist/router/src/config.js +30 -0
  90. package/dist/router/src/index.js +39 -0
  91. package/dist/router/src/ingress/translate.js +6 -2
  92. package/dist/router/src/phone.js +82 -0
  93. package/dist/router/src/presets.js +3 -0
  94. package/dist/router/src/providers/anthropic-usage.js +33 -1
  95. package/dist/router/src/providers/chatgpt/index.js +41 -11
  96. package/dist/router/src/providers/chatgpt/translate.js +147 -7
  97. package/dist/router/src/providers/google/index.js +26 -0
  98. package/dist/router/src/providers/google/prompt-refusal.js +29 -0
  99. package/dist/router/src/providers/google/usage.js +257 -0
  100. package/dist/router/src/providers/grok/index.js +18 -0
  101. package/dist/router/src/providers/grok/usage.js +77 -0
  102. package/dist/router/src/providers/openai/index.js +29 -5
  103. package/dist/router/src/providers/openai/translate.js +260 -27
  104. package/dist/router/src/providers/opencode-usage.js +116 -0
  105. package/dist/router/src/proxy.js +339 -258
  106. package/dist/router/src/reasoning.js +157 -0
  107. package/dist/router/src/remote-host.js +320 -0
  108. package/dist/router/src/remote-tunnel.js +320 -0
  109. package/dist/router/src/requestlog.js +48 -39
  110. package/dist/router/src/thinking.js +20 -9
  111. package/dist/router/src/usage-history.js +227 -0
  112. package/dist/router/src/version.js +1 -1
  113. package/dist/ui/app.js +436 -59
  114. package/dist/ui/i18n.js +184 -0
  115. package/dist/ui/index.html +24 -5
  116. package/dist/ui/style.css +39 -0
  117. package/docs/ARCHITECTURE.md +164 -12
  118. package/package.json +7 -1
@@ -0,0 +1,374 @@
1
+ // Korean is the source language; English mirrors every key. A missing key shows the key itself,
2
+ // which the i18n test catches before a person does.
3
+
4
+ export type Lang = "ko" | "en";
5
+
6
+ const ko = {
7
+ "app.name": "리플 리모트",
8
+ "image.view": "이미지 크게 보기",
9
+ "image.unavailable": "이미지를 표시할 수 없습니다",
10
+ /** What copy calls the connected computer before its name is known (and on the pair screen). */
11
+ "host.fallback": "컴퓨터",
12
+ "conn.open": "연결됨",
13
+ "conn.connecting": "연결 중…",
14
+ "conn.retryIn": "{s}초 후 재연결",
15
+ "conn.retrying": "재연결 중…",
16
+ "conn.offline": "오프라인 상태",
17
+ "conn.lost.title": "{host} 연결 끊김",
18
+ "conn.lost.body": "{ago} 동안 응답이 없습니다. {host}에서 세션이 계속 실행 중일 수 있으며, 다시 연결되면 놓친 내용을 모두 동기화합니다.",
19
+ "conn.offline.title": "기기 오프라인",
20
+ "conn.offline.body": "네트워크가 복구되면 자동으로 다시 연결합니다. 대기 중인 메시지는 보관 후 전송합니다.",
21
+ "conn.retryNow": "지금 다시 시도",
22
+ "conn.lastSeen": "마지막 확인 {time}",
23
+ "conn.denied.title": "기기 연결 해제됨",
24
+ "conn.denied.body": "{host}에서 새 QR 코드를 생성해 다시 스캔하세요.",
25
+ "conn.router": "{host}에 연결되었으나 ClaudeRipple 라우터가 응답하지 않습니다. 새 요청은 라우터가 복구될 때까지 실패합니다.",
26
+ "conn.desktop": "Claude 앱 세션에는 지금 보내기·승인이 되지 않습니다. {problem}",
27
+ "conn.stale": "현재 내용은 {time} 기준입니다",
28
+ "pair.title": "{host}에 연결",
29
+ "pair.step1": "{host}에서 ClaudeRipple 대시보드를 열고 이 메뉴로 가세요",
30
+ "pair.where": "휴대폰 › QR 코드 보기",
31
+ "pair.step2": "표시된 QR 코드를 카메라로 스캔하세요",
32
+ "pair.step3": "열린 페이지를 홈 화면에 추가하면 앱처럼 실행됩니다",
33
+ "pair.paste": "홈 화면 앱에서는 QR로 연 Safari의 연결이 이어지지 않습니다. 대시보드의 휴대폰 화면에서 링크를 복사해 여기에 붙여 넣으세요.",
34
+ "pair.connect": "연결",
35
+ "pair.badLink": "연결 링크가 아닙니다. 대시보드의 휴대폰 화면에서 복사한 #pair=… 링크 전체를 붙여 넣으세요.",
36
+ "pair.note": "릴레이를 통해 어디서나 연결되고 알림도 받습니다. 대화는 종단간 암호화됩니다.",
37
+ "home.title": "세션",
38
+ "home.waiting": "승인 대기",
39
+ "home.active": "진행 중",
40
+ "home.recent": "최근",
41
+ "home.desktop": "Claude 앱 세션",
42
+ "session.readOnly": "{host}에서 작성한 세션입니다. 읽기 전용으로 표시합니다.",
43
+ "home.empty.title": "첫 세션 시작",
44
+ "home.empty.body": "폴더와 모델을 선택하고 작업을 입력하면 {host}에서 즉시 시작합니다.",
45
+ "home.more": "더 보기",
46
+ "group.input": "입력 필요",
47
+ "group.failed": "실패",
48
+ "group.review": "검토 대기",
49
+ "group.working": "작업 중",
50
+ "group.done": "완료",
51
+ "home.byStatus": "상태별",
52
+ "home.byFolder": "폴더별",
53
+ "home.showArchived": "보관됨",
54
+ "home.markUnread": "안 읽음으로 표시",
55
+ "home.archive": "보관",
56
+ "home.unarchive": "보관 해제",
57
+ "home.pending": "{n}건 대기",
58
+ "folder.new": "새 폴더",
59
+ "folder.newName": "폴더 이름",
60
+ "folder.create": "만들기",
61
+ "isolation.title": "격리",
62
+ "isolation.local": "로컬",
63
+ "isolation.worktree": "새 worktree",
64
+ "isolation.base": "분기 기준",
65
+ "composer.new": "이 폴더에서 무엇을 할까요?",
66
+ "composer.reply": "이어서 입력",
67
+ "composer.send": "보내기",
68
+ "composer.stop": "중지",
69
+ "chip.folder": "폴더",
70
+ "chip.model": "모델",
71
+ "state.starting": "시작 중",
72
+ "state.running": "작업 중",
73
+ "state.waiting": "승인 대기",
74
+ "state.idle": "완료",
75
+ "state.stopped": "일시 중지",
76
+ "state.error": "오류",
77
+ "time.now": "방금",
78
+ "time.min": "{n}분 전",
79
+ "time.hour": "{n}시간 전",
80
+ "time.day": "{n}일 전",
81
+ "dur.s": "{n}초",
82
+ "dur.m": "{n}분",
83
+ "dur.ms": "{m}분 {s}초",
84
+ "dur.h": "{n}시간",
85
+ "dur.hm": "{h}시간 {m}분",
86
+ "band.effort": "추론 강도",
87
+ "band.cache": "캐시",
88
+ "band.context": "컨텍스트",
89
+ "band.fiveHour": "5시간 한도",
90
+ "band.workers": "워커",
91
+ "band.workersNone": "이 세션에서 실행된 워커가 없습니다",
92
+ "band.workerLine": "{n}회",
93
+ "band.resetsAt": "{time} 초기화",
94
+ "band.updated": "{time} 기준",
95
+ "perm.title": "{tool} 실행을 허용하시겠습니까?",
96
+ "perm.allow": "허용",
97
+ "perm.deny": "거부",
98
+ "perm.always": "이 세션에서 항상 허용",
99
+ "perm.of": "{i}/{n}",
100
+ "perm.allowed": "허용됨",
101
+ "perm.allowedAlways": "항상 허용됨",
102
+ "perm.denied": "거부됨",
103
+ "perm.expired": "만료됨",
104
+ "perm.reason.host_restarted": "{host}의 호스트가 재시작되어 요청이 종료되었습니다. 메시지를 다시 보내면 재확인합니다.",
105
+ "perm.reason.aborted": "작업이 중단되어 요청이 종료되었습니다.",
106
+ "perm.showAll": "전체 보기",
107
+ "tool.failed": "실패",
108
+ "tool.more": "결과 보기",
109
+ "tool.less": "접기",
110
+ "tool.group": "도구 {n}개 사용",
111
+ "tool.groupWorkers": "도구 {n}개 · 워커 {w}개",
112
+ "tool.loading": "불러오는 중…",
113
+ "sub.worker": "워커",
114
+ "sub.steps": "{n}단계",
115
+ "sub.running": "실행 중",
116
+ "sub.done": "완료",
117
+ "sub.failed": "실패",
118
+ "sub.open": "기록 보기",
119
+ "turn.footer": "{model} · {effort} · {dur}",
120
+ "turn.failed": "이번 턴이 실패했습니다: {error}",
121
+ "queue.pending": "연결 시 전송",
122
+ "queue.sending": "보내는 중…",
123
+ "notice.host_restarted": "호스트가 다시 시작되어 이 턴이 중단되었습니다.",
124
+ "notice.interrupted": "중단되었습니다.",
125
+ "notice.resumed": "이전 대화를 다시 시작했습니다.",
126
+ "notice.desktop_open": "Claude 앱에서 열려 있는 세션이라 지금은 휴대폰에서 입력할 수 없습니다.",
127
+ "notice.desktop_blocked": "Claude 앱에서 열려 있는 세션이라 지금은 휴대폰에서 입력할 수 없습니다. {text}",
128
+ "notice.desktop_bridge": "Remote Control로 Claude 앱과 같은 대화를 이어갑니다.",
129
+ "perm.reason.cancelled": "{host}에서 요청이 취소되었거나 이미 응답되었습니다.",
130
+ "notice.desktop_resumed": "Claude 앱에서 이 대화를 다시 열면 휴대폰에서 이어 간 내용이 보입니다.",
131
+ "notice.model_changed": "모델이 변경되었습니다: {text}",
132
+ "notice.api_retry": "모델 서버 응답이 지연되어 재시도 중입니다.",
133
+ "notice.router_down": "라우터가 응답하지 않습니다.",
134
+ "folder.title": "폴더",
135
+ "folder.recent": "최근 폴더",
136
+ "folder.browse": "찾아보기",
137
+ "folder.here": "이 폴더에서 시작",
138
+ "folder.filter": "이름으로 필터링",
139
+ "folder.empty": "하위 폴더가 없습니다",
140
+ "folder.up": "상위 폴더",
141
+ "folder.truncated": "폴더 수가 많아 상위 {n}개만 표시합니다",
142
+ "model.title": "모델",
143
+ "model.effort": "추론 강도",
144
+ "model.recent": "최근",
145
+ "model.unusable": "로그인 필요",
146
+ "model.done": "이 모델 선택",
147
+ "model.noEffort": "이 모델은 추론 강도를 지원하지 않습니다",
148
+ "history.loading": "이전 대화 불러오는 중…",
149
+ "history.start": "대화 시작",
150
+ "tasks.running": "실행 중인 작업 {n}개",
151
+ "running.for": "{t} 동안 작업 중",
152
+ "running.lastSeen": "{time} 마지막 확인 시 작업 중이었습니다",
153
+ "effort.none": "없음",
154
+ "effort.minimal": "최소",
155
+ "effort.low": "낮음",
156
+ "effort.medium": "중간",
157
+ "effort.high": "높음",
158
+ "effort.xhigh": "매우 높음",
159
+ "effort.max": "최대",
160
+ "effort.ultra": "울트라",
161
+ "a11y.back": "뒤로",
162
+ "a11y.settings": "설정",
163
+ "a11y.close": "닫기",
164
+ "error.cwd": "해당 폴더를 열 수 없습니다",
165
+ "error.generic": "오류가 발생했습니다: {text}",
166
+ "push.title": "알림 설정",
167
+ "push.body": "승인이 필요하거나 작업이 완료되면 앱이 닫혀 있어도 휴대폰으로 알림을 보냅니다.",
168
+ "push.on": "알림 켜기",
169
+ "push.later": "나중에",
170
+ "push.install": "iPhone에서는 홈 화면에 추가된 웹 앱에서만 웹 푸시 알림을 받을 수 있습니다. Safari 공유 버튼 → ‘홈 화면에 추가’ 후, 홈 화면의 Ripple로 여세요.",
171
+ "push.denied": "기기에서 알림이 차단되어 있습니다. 설정 → 알림 → Ripple에서 허용해 주세요.",
172
+ "push.insecure": "알림 기능은 HTTPS 주소에서만 동작합니다. {host}에서 릴레이 페어링 링크(QR)로 여세요.",
173
+ "push.done": "알림이 활성화되었습니다",
174
+ "push.failed": "알림 설정 실패: {text}",
175
+ } as const;
176
+
177
+ export type Key = keyof typeof ko;
178
+
179
+ const en: Record<Key, string> = {
180
+ "app.name": "Ripple Remote",
181
+ "image.view": "View full image",
182
+ "image.unavailable": "Image unavailable",
183
+ /** What copy calls the connected computer before its name is known (and on the pair screen). */
184
+ "host.fallback": "computer",
185
+ "conn.open": "Connected",
186
+ "conn.connecting": "Connecting…",
187
+ "conn.retryIn": "Reconnecting in {s}s",
188
+ "conn.retrying": "Reconnecting…",
189
+ "conn.offline": "Phone offline",
190
+ "conn.lost.title": "Can't reach your {host}",
191
+ "conn.lost.body": "No answer for {ago}. Sessions may still be running on your {host}; everything you missed arrives once it's back.",
192
+ "conn.offline.title": "This phone is offline",
193
+ "conn.offline.body": "It reconnects as soon as the network is back. Messages you write meanwhile are kept and sent then.",
194
+ "conn.retryNow": "Retry now",
195
+ "conn.lastSeen": "Last seen {time}",
196
+ "conn.denied.title": "This phone was unpaired",
197
+ "conn.denied.body": "Show a new QR on your {host} and scan it again.",
198
+ "conn.router": "Your {host} answers but ClaudeRipple's router does not. New requests fail until it is back.",
199
+ "conn.desktop": "Sending to and approving in Claude app sessions does not work right now. {problem}",
200
+ "conn.stale": "What you see is as of {time}",
201
+ "pair.title": "Connect to your {host}",
202
+ "pair.step1": "On your {host}, open the ClaudeRipple dashboard and go to",
203
+ "pair.where": "Phone › Show QR code",
204
+ "pair.step2": "Scan the QR it shows with this phone's camera",
205
+ "pair.step3": "Add the page to your Home Screen to open it like an app",
206
+ "pair.paste": "A Home Screen app does not share Safari's pairing from the QR. Copy the link from the dashboard's Phone view and paste it here.",
207
+ "pair.connect": "Connect",
208
+ "pair.badLink": "That is not a pairing link. Paste the whole #pair=… link copied from the dashboard's Phone view.",
209
+ "pair.note": "It connects through the relay from anywhere, with notifications. Conversations are end-to-end encrypted.",
210
+ "home.title": "Sessions",
211
+ "home.waiting": "Needs you",
212
+ "home.active": "Running",
213
+ "home.recent": "Recent",
214
+ "home.desktop": "Claude app sessions",
215
+ "session.readOnly": "This session was written on your {host}. It is read-only.",
216
+ "home.empty.title": "Start your first session",
217
+ "home.empty.body": "Pick a folder and a model, write what to do below, and it starts on your {host}.",
218
+ "home.more": "Show more",
219
+ "group.input": "Needs input",
220
+ "group.failed": "Failed",
221
+ "group.review": "To review",
222
+ "group.working": "Working",
223
+ "group.done": "Done",
224
+ "home.byStatus": "By status",
225
+ "home.byFolder": "By folder",
226
+ "home.showArchived": "Archived",
227
+ "home.markUnread": "Mark unread",
228
+ "home.archive": "Archive",
229
+ "home.unarchive": "Unarchive",
230
+ "home.pending": "{n} waiting",
231
+ "folder.new": "New folder",
232
+ "folder.newName": "Folder name",
233
+ "folder.create": "Create",
234
+ "isolation.title": "Isolation",
235
+ "isolation.local": "Local",
236
+ "isolation.worktree": "New worktree",
237
+ "isolation.base": "Branch from",
238
+ "composer.new": "What should it do in this folder?",
239
+ "composer.reply": "Reply",
240
+ "composer.send": "Send",
241
+ "composer.stop": "Stop",
242
+ "chip.folder": "Folder",
243
+ "chip.model": "Model",
244
+ "state.starting": "Starting",
245
+ "state.running": "Working",
246
+ "state.waiting": "Needs approval",
247
+ "state.idle": "Done",
248
+ "state.stopped": "Paused",
249
+ "state.error": "Error",
250
+ "time.now": "just now",
251
+ "time.min": "{n}m ago",
252
+ "time.hour": "{n}h ago",
253
+ "time.day": "{n}d ago",
254
+ "dur.s": "{n}s",
255
+ "dur.m": "{n}m",
256
+ "dur.ms": "{m}m {s}s",
257
+ "dur.h": "{n}h",
258
+ "dur.hm": "{h}h {m}m",
259
+ "band.effort": "Effort",
260
+ "band.cache": "Cache",
261
+ "band.context": "Context",
262
+ "band.fiveHour": "5h limit",
263
+ "band.workers": "Workers",
264
+ "band.workersNone": "No other model has worked in this session yet",
265
+ "band.workerLine": "{n}×",
266
+ "band.resetsAt": "resets {time}",
267
+ "band.updated": "as of {time}",
268
+ "perm.title": "Allow {tool}?",
269
+ "perm.allow": "Allow",
270
+ "perm.deny": "Deny",
271
+ "perm.always": "Keep allowing in this session",
272
+ "perm.of": "{i}/{n}",
273
+ "perm.allowed": "Allowed",
274
+ "perm.allowedAlways": "Always allowed",
275
+ "perm.denied": "Denied",
276
+ "perm.expired": "Expired",
277
+ "perm.reason.host_restarted": "The host on your {host} restarted, so this request ended. Send again and it asks again.",
278
+ "perm.reason.aborted": "The work stopped, so this request ended.",
279
+ "perm.showAll": "Show all",
280
+ "tool.failed": "failed",
281
+ "tool.more": "Show result",
282
+ "tool.less": "Hide",
283
+ "tool.group": "Used {n} {n|tool|tools}",
284
+ "tool.groupWorkers": "{n} {n|tool|tools} · {w} {w|worker|workers}",
285
+ "tool.loading": "Loading…",
286
+ "sub.worker": "Worker",
287
+ "sub.steps": "{n} {n|step|steps}",
288
+ "sub.running": "working",
289
+ "sub.done": "done",
290
+ "sub.failed": "failed",
291
+ "sub.open": "Show log",
292
+ "turn.footer": "{model} · {effort} · {dur}",
293
+ "turn.failed": "This turn failed: {error}",
294
+ "queue.pending": "Sends when connected",
295
+ "queue.sending": "Sending…",
296
+ "notice.host_restarted": "The host restarted, so this turn was stopped.",
297
+ "notice.interrupted": "Stopped.",
298
+ "notice.resumed": "Resumed the earlier conversation.",
299
+ "notice.desktop_open": "This session is open in the Claude app and cannot currently accept phone input.",
300
+ "notice.desktop_blocked": "This session is open in the Claude app and cannot currently accept phone input. {text}",
301
+ "notice.desktop_bridge": "Remote Control continues the same conversation in the Claude app.",
302
+ "perm.reason.cancelled": "The request was cancelled or already answered on your {host}.",
303
+ "notice.desktop_resumed": "When you reopen this conversation in the Claude app, it will show what you continue on your phone.",
304
+ "notice.model_changed": "Model changed: {text}",
305
+ "notice.api_retry": "The model server didn't answer; retrying.",
306
+ "notice.router_down": "The router isn't answering.",
307
+ "folder.title": "Folder",
308
+ "folder.recent": "Recent folders",
309
+ "folder.browse": "Browse",
310
+ "folder.here": "Start in this folder",
311
+ "folder.filter": "Filter by name",
312
+ "folder.empty": "No folders inside",
313
+ "folder.up": "Up",
314
+ "folder.truncated": "Many folders; showing the first {n}",
315
+ "model.title": "Model",
316
+ "model.effort": "Effort",
317
+ "model.recent": "Recent",
318
+ "model.unusable": "Sign-in needed",
319
+ "model.done": "Use this model",
320
+ "model.noEffort": "This model takes no effort level",
321
+ "history.loading": "Loading earlier messages…",
322
+ "history.start": "Start of conversation",
323
+ "tasks.running": "{n} {n|task|tasks} running",
324
+ "running.for": "Working for {t}",
325
+ "running.lastSeen": "Was working when last seen at {time}",
326
+ "effort.none": "none",
327
+ "effort.minimal": "minimal",
328
+ "effort.low": "low",
329
+ "effort.medium": "medium",
330
+ "effort.high": "high",
331
+ "effort.xhigh": "x-high",
332
+ "effort.max": "max",
333
+ "effort.ultra": "ultra",
334
+ "a11y.back": "Back",
335
+ "a11y.settings": "Settings",
336
+ "a11y.close": "Close",
337
+ "error.cwd": "That folder can't be opened",
338
+ "error.generic": "Something went wrong: {text}",
339
+ "push.title": "Get notified",
340
+ "push.body": "When a session needs your OK or finishes, your phone tells you — even with the app closed.",
341
+ "push.on": "Turn on notifications",
342
+ "push.later": "Later",
343
+ "push.install": "On iPhone, notifications only reach an app added to the Home Screen. Tap Share → ‘Add to Home Screen’ in Safari, then open Ripple from the Home Screen.",
344
+ "push.denied": "Notifications are blocked on this phone. Allow them in Settings → Notifications → Ripple.",
345
+ "push.insecure": "Notifications need an https address. Open the relay pairing link (QR) from your {host}.",
346
+ "push.done": "Notifications are on",
347
+ "push.failed": "Couldn't turn on notifications: {text}",
348
+ };
349
+
350
+ export const dictionaries: Record<Lang, Record<Key, string>> = { ko, en };
351
+
352
+ let current: Lang = "ko";
353
+
354
+ export function pickLang(navLangs: readonly string[], stored: string | null): Lang {
355
+ if (stored === "ko" || stored === "en") return stored;
356
+ for (const l of navLangs) {
357
+ if (l.toLowerCase().startsWith("ko")) return "ko";
358
+ if (l.toLowerCase().startsWith("en")) return "en";
359
+ }
360
+ return "ko";
361
+ }
362
+
363
+ export function setLang(lang: Lang): void { current = lang; }
364
+ export function lang(): Lang { return current; }
365
+
366
+ export function t(key: Key, vars?: Record<string, string | number>): string {
367
+ let s: string = dictionaries[current][key] ?? key;
368
+ // `{host}` is the connected computer's name. Every call site that has a HostInfo passes it;
369
+ // the rest get the neutral fallback so no copy ever hardcodes a platform.
370
+ if (s.includes("{host}")) s = s.replaceAll("{host}", String(vars?.host ?? dictionaries[current]["host.fallback"]));
371
+ if (vars) for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, String(v));
372
+ // `{n|tool|tools}` picks the singular when that count is 1 (English needs it; Korean never uses it).
373
+ return s.replace(/\{(\w+)\|([^|}]*)\|([^}]*)\}/g, (_, k: string, one: string, other: string) => (Number(vars?.[k]) === 1 ? one : other));
374
+ }
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 56 56"><rect width="56" height="56" rx="12.6" fill="#6a56d6"/><g transform="translate(9.8 10.6) scale(0.65)"><path d="M24.7 12.29C24.2 6.2 29.2 2.4 35 2.6C39.6 2.8 42.6 6.2 41.6 9.8C40.6 8 38.6 7 36.8 7.6C35 8.2 35 11 36.33 13.92Z" fill="#fff"/><circle cx="28" cy="31" r="16.5" fill="none" stroke="#fff" stroke-width="5"/><path d="M49.47 21.44A23.5 23.5 0 0 1 49.47 40.56M6.53 40.56A23.5 23.5 0 0 1 6.53 21.44" fill="none" stroke="#fff" stroke-width="2.4" stroke-linecap="round"/><rect x="20.6" y="26.2" width="3.6" height="5.6" rx="1.8" fill="#fff"/><rect x="31.8" y="26.2" width="3.6" height="5.6" rx="1.8" fill="#fff"/><path d="M24.8 36.2q3.2 2.6 6.4 0" fill="none" stroke="#fff" stroke-width="2.2" stroke-linecap="round"/></g></svg>
@@ -0,0 +1,24 @@
1
+ <!doctype html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover, interactive-widget=resizes-content">
6
+ <meta name="theme-color" content="#f5f4f1" media="(prefers-color-scheme: light)">
7
+ <meta name="theme-color" content="#141413" media="(prefers-color-scheme: dark)">
8
+ <meta name="apple-mobile-web-app-capable" content="yes">
9
+ <meta name="mobile-web-app-capable" content="yes">
10
+ <meta name="apple-mobile-web-app-status-bar-style" content="default">
11
+ <meta name="apple-mobile-web-app-title" content="Ripple">
12
+ <meta name="referrer" content="no-referrer">
13
+ <title>Ripple Remote</title>
14
+ <link rel="manifest" href="manifest.webmanifest">
15
+ <link rel="icon" href="icon.svg" type="image/svg+xml">
16
+ <link rel="apple-touch-icon" href="icon.svg">
17
+ <link rel="stylesheet" href="style.css">
18
+ </head>
19
+ <body>
20
+ <div class="app" id="app"></div>
21
+ <script src="vendor/nacl-fast.min.js"></script>
22
+ <script type="module" src="app.ts"></script>
23
+ </body>
24
+ </html>
@@ -0,0 +1,10 @@
1
+ {
2
+ "name": "Ripple Remote",
3
+ "short_name": "Ripple",
4
+ "start_url": "./",
5
+ "scope": "./",
6
+ "display": "standalone",
7
+ "background_color": "#f5f4f1",
8
+ "theme_color": "#6a56d6",
9
+ "icons": [{ "src": "icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any maskable" }]
10
+ }
@@ -0,0 +1,60 @@
1
+ // 리플이 v2 (물마루): ring body + wave-crest forelock, on the 56-unit grid of
2
+ // design-drafts/ripple-char-v2.html. The face is always ink and the tone lives in the body, so
3
+ // light and dark differ only by tokens. Used sparingly: empty and connection states.
4
+
5
+ export type Face = "calm" | "pleased" | "sleepy" | "uneasy" | "going" | "sorry";
6
+
7
+ const CREST_UP = "M24.7 12.29C24.2 6.2 29.2 2.4 35 2.6C39.6 2.8 42.6 6.2 41.6 9.8C40.6 8 38.6 7 36.8 7.6C35 8.2 35 11 36.33 13.92Z";
8
+ const CREST_SHORT = "M24.7 12.29C25 8.4 28.4 6.4 32 6.6C34.8 6.8 36.8 8.4 36.8 10.2C36 9.6 35.2 9.8 34.9 10.6C34.7 11.6 35.4 12.8 36.33 13.92Z";
9
+ const CREST_WIND = "M24.7 12.29C25.6 6.4 31.6 3.6 38 4C42 4.2 44.6 6 45.2 8.4C43.4 7.4 40.8 7.4 38.8 8.4C37.2 9.4 36 11.6 36.33 13.92Z";
10
+ const CREST_WILT = "M24.7 12.29C25.4 6 31.4 3.8 36.6 4.6C41.6 5.4 44.4 9.6 43.6 14.6C42.4 12 40.2 10.6 38.2 11C37.2 11.4 36.6 12.6 36.33 13.92Z";
11
+
12
+ const line = (d: string, color: string, w: number, extra = ""): string =>
13
+ `<path d="${d}" fill="none" stroke="var(--${color})" stroke-width="${w}" stroke-linecap="round" stroke-linejoin="round"${extra}/>`;
14
+ const ears = (tone: string, dim: boolean): string => {
15
+ const o = dim ? ` stroke-opacity="0.4"` : "";
16
+ return line("M49.47 21.44A23.5 23.5 0 0 1 49.47 40.56", tone, 2.4, o) + line("M6.53 40.56A23.5 23.5 0 0 1 6.53 21.44", tone, 2.4, o);
17
+ };
18
+ const eyes = (x1: number, x2: number, y: number, h: number): string =>
19
+ `<rect x="${x1}" y="${y}" width="3.6" height="${h}" rx="1.8" fill="var(--ink)"/><rect x="${x2}" y="${y}" width="3.6" height="${h}" rx="1.8" fill="var(--ink)"/>`;
20
+ const ring = (tone: string, extra = ""): string =>
21
+ `<circle cx="28" cy="31" r="16.5" fill="none" stroke="var(--${tone})" stroke-width="5"${extra}/>`;
22
+ const crest = (d: string, tone: string): string => `<path d="${d}" fill="var(--${tone})"/>`;
23
+ const drop = (d: string): string => `<path d="${d}" fill="var(--muted)"/>`;
24
+
25
+ const FACES: Record<Face, string> = {
26
+ calm: crest(CREST_UP, "accent") + ring("accent") + ears("accent", false) + eyes(20.6, 31.8, 26.2, 5.6) + line("M24.8 36.2q3.2 2.6 6.4 0", "ink", 2.2),
27
+ pleased: crest(CREST_UP, "ok") + ring("ok") + ears("ok", false) + line("M20 30q2.4 -3.4 4.8 0", "ink", 2.2) + line("M31.2 30q2.4 -3.4 4.8 0", "ink", 2.2) +
28
+ `<path d="M23.8 35.4h8.4a4.2 4.2 0 0 1 -8.4 0z" fill="var(--ink)"/>`,
29
+ sleepy: `<g opacity="0.45">${crest(CREST_SHORT, "accent")}${ring("accent")}</g>` + line("M20 29q2.4 2.2 4.8 0", "ink", 2.2) + line("M31.2 29q2.4 2.2 4.8 0", "ink", 2.2) +
30
+ line("M26.6 37.2q1.4 1 2.8 0", "ink", 2.2) + line("M44.4 4.6h4l-4 4.4h4", "muted", 1.6) + line("M50 10.6h2.8l-2.8 3h2.8", "muted", 1.3, ` opacity=".7"`),
31
+ uneasy: crest(CREST_UP, "warn") + ring("faint") + line("M28 14.5A16.5 16.5 0 1 1 13.07 23.97", "warn", 5) + ears("warn", true) + eyes(20.6, 31.8, 26.9, 5) +
32
+ line("M19.4 24.4L25 22.4", "ink", 2) + line("M36.6 24.4L31 22.4", "ink", 2) + line("M22.9 37.2q1.275 -1.7 2.55 0t2.55 0t2.55 0t2.55 0", "ink", 2.2) +
33
+ drop("M46.6 13.8q-2.3 3.1 -2.3 4.5a2.3 2.3 0 0 0 4.6 0q0 -1.4 -2.3 -4.5z"),
34
+ going: crest(CREST_WIND, "accent") + ring("accent", ` stroke-dasharray="9.2 3.76" transform="rotate(-99 28 31)"`) + ears("accent", true) + eyes(22.2, 33.4, 26.2, 5.6) +
35
+ line("M26.6 37.2q1.4 1 2.8 0", "ink", 2.2),
36
+ sorry: crest(CREST_WILT, "bad") + ring("bad") + eyes(20.6, 31.8, 27.6, 4.4) + line("M19.2 24.8L24.6 22.2", "ink", 2) + line("M36.8 24.8L31.4 22.2", "ink", 2) +
37
+ line("M24.8 38.4q3.2 -2.6 6.4 0", "ink", 2.2) + drop("M36.4 33.2q-1.84 2.48 -1.84 3.6a1.84 1.84 0 0 0 3.68 0q0 -1.12 -1.84 -3.6z"),
38
+ };
39
+
40
+ export function ripple(face: Face, size: number, label = ""): string {
41
+ const a11y = label ? `role="img" aria-label="${label}"` : `aria-hidden="true"`;
42
+ return `<svg class="ripple" width="${size}" height="${size}" viewBox="0 0 56 56" ${a11y}>${FACES[face]}</svg>`;
43
+ }
44
+
45
+ /**
46
+ * The small state glyph in lists and headers: the same ring, no face. Dashed = in progress,
47
+ * warn = needs you, ok = done, faint = paused, bad = error (the mascot's own prop rules).
48
+ */
49
+ export function stateRing(state: string, size = 22): string {
50
+ // Background work only (subagents, shells): the app's dark blinking dot.
51
+ if (state === "background") return `<svg class="ring ring-background" width="${size}" height="${size}" viewBox="0 0 56 56" role="img" aria-label="Background work"><circle cx="28" cy="28" r="9" fill="var(--ink)"/></svg>`;
52
+ if (state === "unread" || state === "read") return `<svg class="ring ring-${state}" width="${size}" height="${size}" viewBox="0 0 56 56" role="img" aria-label="${state === "unread" ? "Unread activity" : "Read"}"><circle cx="28" cy="28" r="${state === "unread" ? 9 : 8}" fill="${state === "unread" ? "var(--warn)" : "none"}" stroke="var(--${state === "unread" ? "warn" : "faint"})" stroke-width="3"/></svg>`;
53
+ const tone = state === "waiting" ? "warn" : state === "error" ? "bad" : state === "idle" ? "ok" : state === "stopped" ? "faint" : "accent";
54
+ const dashed = state === "running" || state === "starting";
55
+ const body = dashed
56
+ ? `<circle cx="28" cy="31" r="16.5" fill="none" stroke="var(--${tone})" stroke-width="6" stroke-dasharray="9.2 3.76" transform="rotate(-99 28 31)"/>`
57
+ : `<circle cx="28" cy="31" r="16.5" fill="none" stroke="var(--${tone})" stroke-width="6"/>`;
58
+ const dot = state === "waiting" ? `<circle cx="28" cy="31" r="6" fill="var(--warn)"/>` : "";
59
+ return `<svg class="ring ring-${state}" width="${size}" height="${size}" viewBox="0 0 56 56" aria-hidden="true">${crest(state === "stopped" ? CREST_SHORT : CREST_UP, tone)}${body}${dot}</svg>`;
60
+ }
@@ -0,0 +1,78 @@
1
+ // A small Markdown subset for model answers: fenced code, inline code, bold/italic, headings,
2
+ // lists, quotes, tables and http(s) links. Everything is escaped first; nothing a model writes
3
+ // becomes markup we did not choose.
4
+ import { esc } from "./esc.ts";
5
+
6
+ function inline(src: string): string {
7
+ const codes: string[] = [];
8
+ let s = src.replace(/`([^`\n]+)`/g, (_, c: string) => `\u0000${codes.push(c) - 1}\u0000`);
9
+ s = esc(s);
10
+ s = s.replace(/\*\*([^*\n]+)\*\*/g, "<strong>$1</strong>");
11
+ s = s.replace(/(^|[\s(])\*([^*\n]+)\*(?=[\s).,!?:;]|$)/g, "$1<em>$2</em>");
12
+ s = s.replace(/\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/g, (_, text: string, url: string) => `<a href="${url}" target="_blank" rel="noopener noreferrer">${text}</a>`);
13
+ return s.replace(/\u0000(\d+)\u0000/g, (_, i: string) => `<code>${esc(codes[Number(i)] ?? "")}</code>`);
14
+ }
15
+
16
+ function table(rows: string[]): string {
17
+ const cells = (r: string): string[] => r.replace(/^\s*\|/, "").replace(/\|\s*$/, "").split("|").map((c) => c.trim());
18
+ const head = cells(rows[0] ?? "");
19
+ const body = rows.slice(2).map(cells);
20
+ return `<div class="md-table"><table><thead><tr>${head.map((h) => `<th>${inline(h)}</th>`).join("")}</tr></thead><tbody>${body
21
+ .map((r) => `<tr>${r.map((c) => `<td>${inline(c)}</td>`).join("")}</tr>`)
22
+ .join("")}</tbody></table></div>`;
23
+ }
24
+
25
+ export function markdown(src: string): string {
26
+ const lines = src.replace(/\r\n?/g, "\n").split("\n");
27
+ const out: string[] = [];
28
+ let i = 0;
29
+ while (i < lines.length) {
30
+ const line = lines[i]!;
31
+ const fence = /^\s*(```+|~~~+)\s*([\w+-]*)/.exec(line);
32
+ if (fence) {
33
+ const close = fence[1]!;
34
+ const body: string[] = [];
35
+ i++;
36
+ while (i < lines.length && !lines[i]!.trim().startsWith(close)) body.push(lines[i++]!);
37
+ i++;
38
+ out.push(`<pre class="md-code"${fence[2] ? ` data-lang="${esc(fence[2])}"` : ""}><code>${esc(body.join("\n"))}</code></pre>`);
39
+ continue;
40
+ }
41
+ if (/^\s*\|.*\|\s*$/.test(line) && /^\s*\|?\s*:?-{2,}/.test(lines[i + 1] ?? "")) {
42
+ const rows: string[] = [];
43
+ while (i < lines.length && /^\s*\|.*\|\s*$/.test(lines[i]!)) rows.push(lines[i++]!);
44
+ out.push(table(rows));
45
+ continue;
46
+ }
47
+ const h = /^(#{1,4})\s+(.*)$/.exec(line);
48
+ if (h) {
49
+ const level = Math.min(4, h[1]!.length + 1);
50
+ out.push(`<h${level}>${inline(h[2]!)}</h${level}>`);
51
+ i++;
52
+ continue;
53
+ }
54
+ if (/^\s*([-*+]|\d+[.)])\s+/.test(line)) {
55
+ const ordered = /^\s*\d/.test(line);
56
+ const items: string[] = [];
57
+ while (i < lines.length && /^\s*([-*+]|\d+[.)])\s+/.test(lines[i]!)) {
58
+ let item = lines[i++]!.replace(/^\s*([-*+]|\d+[.)])\s+/, "");
59
+ while (i < lines.length && /^\s{2,}\S/.test(lines[i]!) && !/^\s*([-*+]|\d+[.)])\s+/.test(lines[i]!)) item += "\n" + lines[i++]!.trim();
60
+ items.push(`<li>${item.split("\n").map(inline).join("<br>")}</li>`);
61
+ }
62
+ out.push(ordered ? `<ol>${items.join("")}</ol>` : `<ul>${items.join("")}</ul>`);
63
+ continue;
64
+ }
65
+ if (/^\s*>/.test(line)) {
66
+ const quote: string[] = [];
67
+ while (i < lines.length && /^\s*>/.test(lines[i]!)) quote.push(lines[i++]!.replace(/^\s*>\s?/, ""));
68
+ out.push(`<blockquote>${markdown(quote.join("\n"))}</blockquote>`);
69
+ continue;
70
+ }
71
+ if (!line.trim()) { i++; continue; }
72
+ const para: string[] = [];
73
+ while (i < lines.length && lines[i]!.trim() && !/^\s*(```|~~~|#{1,4}\s|>|([-*+]|\d+[.)])\s)/.test(lines[i]!)) para.push(lines[i++]!);
74
+ if (para.length === 0) para.push(lines[i++]!);
75
+ out.push(`<p>${para.map(inline).join("<br>")}</p>`);
76
+ }
77
+ return out.join("");
78
+ }
@@ -0,0 +1,55 @@
1
+ // Notifications on the phone, the browser half: reading what this browser offers (the decision is
2
+ // pushstate.ts) and subscribing. Permission may only be asked from a tap.
3
+ import type { PushEnv } from "./pushstate.ts";
4
+
5
+ export function browserPushEnv(subscribed: boolean): PushEnv {
6
+ const nav = navigator as Navigator & { standalone?: boolean };
7
+ const ios = /iPhone|iPad|iPod/.test(nav.userAgent) || (nav.platform === "MacIntel" && nav.maxTouchPoints > 1);
8
+ return {
9
+ secure: window.isSecureContext,
10
+ hasServiceWorker: "serviceWorker" in nav,
11
+ hasPush: "PushManager" in window,
12
+ ios,
13
+ standalone: matchMedia("(display-mode: standalone)").matches || nav.standalone === true,
14
+ permission: "Notification" in window ? Notification.permission : null,
15
+ subscribed,
16
+ };
17
+ }
18
+
19
+ export function keyBytes(b64u: string): Uint8Array<ArrayBuffer> {
20
+ const s = atob(b64u.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (b64u.length % 4)) % 4));
21
+ const out = new Uint8Array(new ArrayBuffer(s.length));
22
+ for (let i = 0; i < s.length; i++) out[i] = s.charCodeAt(i);
23
+ return out;
24
+ }
25
+
26
+ /** Whether a subscription was made with this host's key; a host with a new key needs a new one. */
27
+ export function sameKey(sub: PushSubscription, publicKey: string): boolean {
28
+ const k = sub.options?.applicationServerKey;
29
+ if (!k) return true; // some browsers do not expose it; resubscribing every time would be worse
30
+ const a = new Uint8Array(k);
31
+ const b = keyBytes(publicKey);
32
+ return a.length === b.length && a.every((v, i) => v === b[i]);
33
+ }
34
+
35
+ export async function currentSubscription(): Promise<PushSubscription | null> {
36
+ if (!window.isSecureContext || !("serviceWorker" in navigator) || !("PushManager" in window)) return null;
37
+ const reg = await navigator.serviceWorker.getRegistration();
38
+ return (await reg?.pushManager.getSubscription()) ?? null;
39
+ }
40
+
41
+ /**
42
+ * Turn notifications on. Call straight from a tap: the permission prompt must be the first thing
43
+ * that awaits, or iOS refuses it. `hostKey` is fetched before (the host's VAPID public key).
44
+ */
45
+ export async function enablePush(hostKey: () => Promise<string>, register: (sub: PushSubscriptionJSON) => Promise<void>): Promise<"on" | "denied"> {
46
+ const permission = await Notification.requestPermission();
47
+ if (permission !== "granted") return "denied";
48
+ const reg = await navigator.serviceWorker.ready;
49
+ const key = await hostKey();
50
+ let sub = await reg.pushManager.getSubscription();
51
+ if (sub && !sameKey(sub, key)) { await sub.unsubscribe(); sub = null; }
52
+ sub ??= await reg.pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: keyBytes(key) });
53
+ await register(sub.toJSON());
54
+ return "on";
55
+ }
@@ -0,0 +1,27 @@
1
+ // Whether notifications can work on this phone — the decision only, no browser APIs, so it is
2
+ // tested in Node (notify.ts reads the browser and does the subscribing). The rules come from the
3
+ // platforms, not from us: a service worker (so push) needs a secure context — https, which the
4
+ // relay gives; on iPhone, web push exists only for a site added to the Home Screen and opened from
5
+ // there (iOS 16.4+).
6
+
7
+ export type PushAvail = "on" | "off" | "denied" | "install" | "insecure" | "unsupported";
8
+
9
+ export interface PushEnv {
10
+ secure: boolean;
11
+ hasServiceWorker: boolean;
12
+ hasPush: boolean;
13
+ ios: boolean;
14
+ standalone: boolean;
15
+ permission: "default" | "granted" | "denied" | null;
16
+ subscribed: boolean;
17
+ }
18
+
19
+ export function pushAvailability(e: PushEnv): PushAvail {
20
+ if (!e.secure) return "insecure";
21
+ // iOS Safari outside the Home Screen app has no PushManager at all; say what to do, not "unsupported".
22
+ if (e.ios && !e.standalone) return "install";
23
+ if (!e.hasServiceWorker || !e.hasPush) return "unsupported";
24
+ if (e.permission === "denied") return "denied";
25
+ if (e.permission === "granted" && e.subscribed) return "on";
26
+ return "off";
27
+ }