@kennofizet/apphub-frontend 0.1.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 (90) hide show
  1. package/README.md +84 -0
  2. package/package.json +31 -0
  3. package/src/api/coreApi.js +25 -0
  4. package/src/api/index.js +80 -0
  5. package/src/composables/createZoneContext.js +156 -0
  6. package/src/composables/useAppHubHostApi.js +24 -0
  7. package/src/composables/useAppHubZoneContext.js +11 -0
  8. package/src/composables/useDevOriginToggle.js +40 -0
  9. package/src/i18n/index.js +16 -0
  10. package/src/i18n/resolveLang.js +6 -0
  11. package/src/i18n/resolveTheme.js +30 -0
  12. package/src/i18n/translations/en.js +303 -0
  13. package/src/i18n/translations/vi.js +302 -0
  14. package/src/index.js +427 -0
  15. package/src/moduleStore.js +10 -0
  16. package/src/modules/app-store/components/AppHubAppStoreApp.vue +210 -0
  17. package/src/modules/app-store/components/AppHubAppStoreCard.vue +88 -0
  18. package/src/modules/app-store/components/AppHubAppStoreSettingsPanel.vue +266 -0
  19. package/src/modules/app-store/components/AppHubAppVersionHistory.vue +77 -0
  20. package/src/modules/app-store/components/AppHubDevReviewPanel.vue +206 -0
  21. package/src/modules/app-store/components/AppHubDraftStoreApp.vue +184 -0
  22. package/src/modules/app-store/components/AppHubDraftStoreCard.vue +116 -0
  23. package/src/modules/app-store/composables/useAppStore.js +206 -0
  24. package/src/modules/app-store/composables/useCatalogInfiniteScroll.js +47 -0
  25. package/src/modules/app-store/constants/catalogModes.js +2 -0
  26. package/src/modules/app-store/data/defaultCatalog.js +19 -0
  27. package/src/modules/app-store/index.js +9 -0
  28. package/src/modules/app-store/utils/normalizeCatalogApp.js +37 -0
  29. package/src/modules/desktop/components/AppHubDesktop.vue +1510 -0
  30. package/src/modules/desktop/components/AppHubDesktopDevOriginBar.vue +57 -0
  31. package/src/modules/desktop/components/AppHubDesktopDropLayer.vue +15 -0
  32. package/src/modules/desktop/components/AppHubDesktopDropTarget.vue +32 -0
  33. package/src/modules/desktop/components/AppHubDesktopIconContextMenu.vue +74 -0
  34. package/src/modules/desktop/components/AppHubDesktopIconFolder.vue +60 -0
  35. package/src/modules/desktop/components/AppHubDesktopIconGroup.vue +58 -0
  36. package/src/modules/desktop/components/AppHubDesktopIconInfoDialog.vue +33 -0
  37. package/src/modules/desktop/components/AppHubDesktopIconRenameDialog.vue +62 -0
  38. package/src/modules/desktop/components/AppHubDesktopSettings.vue +28 -0
  39. package/src/modules/desktop/components/AppHubDropInstallBadge.vue +65 -0
  40. package/src/modules/desktop/components/AppHubDuplicateAppDialog.vue +38 -0
  41. package/src/modules/desktop/components/AppHubGuideApp.vue +278 -0
  42. package/src/modules/desktop/components/AppHubOriginBlockScreen.vue +105 -0
  43. package/src/modules/desktop/components/AppHubOriginLoadingScreen.vue +23 -0
  44. package/src/modules/desktop/components/AppHubPlaceholderApp.vue +14 -0
  45. package/src/modules/desktop/components/AppHubSettingsApp.vue +319 -0
  46. package/src/modules/desktop/components/AppHubStartButton.vue +24 -0
  47. package/src/modules/desktop/components/AppHubStartMenu.vue +182 -0
  48. package/src/modules/desktop/components/AppHubTaskbarPins.vue +23 -0
  49. package/src/modules/desktop/components/settings/AppHubSettingsKeyboardPanel.vue +82 -0
  50. package/src/modules/desktop/components/settings/AppHubSettingsScreenPanel.vue +41 -0
  51. package/src/modules/desktop/components/settings/AppHubSettingsStartMenuPanel.vue +95 -0
  52. package/src/modules/desktop/composables/simulateInstallProgress.js +15 -0
  53. package/src/modules/desktop/composables/useDesktopDropInstall.js +272 -0
  54. package/src/modules/desktop/composables/useDesktopHubSettings.js +51 -0
  55. package/src/modules/desktop/composables/useDesktopIconDrag.js +207 -0
  56. package/src/modules/desktop/composables/useDesktopShell.js +335 -0
  57. package/src/modules/desktop/data/builtinApps.js +77 -0
  58. package/src/modules/desktop/index.js +12 -0
  59. package/src/modules/desktop/styles/desktop.css +3104 -0
  60. package/src/modules/desktop/styles/theme.css +616 -0
  61. package/src/modules/desktop/utils/desktopGrid.js +43 -0
  62. package/src/modules/desktop/utils/desktopIconGroups.js +103 -0
  63. package/src/modules/desktop/utils/desktopSession.js +40 -0
  64. package/src/modules/desktop/utils/desktopSettings.js +37 -0
  65. package/src/modules/desktop/utils/dropPackageParser.js +140 -0
  66. package/src/modules/desktop/utils/duplicateAppUtils.js +28 -0
  67. package/src/modules/desktop/utils/hubKeyboardSettings.js +63 -0
  68. package/src/modules/desktop/utils/recentApps.js +148 -0
  69. package/src/modules/desktop/utils/startMenuFavorites.js +100 -0
  70. package/src/modules/desktop/utils/startMenuPins.js +90 -0
  71. package/src/modules/notifications/components/AppHubDesktopNotifications.vue +54 -0
  72. package/src/modules/notifications/composables/createDesktopNotifications.js +86 -0
  73. package/src/modules/notifications/index.js +9 -0
  74. package/src/modules/notifications/styles/notifications.css +118 -0
  75. package/src/modules/notifications/utils/parseApiError.js +29 -0
  76. package/src/modules/runner/components/AppHubRunner.vue +292 -0
  77. package/src/modules/runner/index.js +1 -0
  78. package/src/modules/window-manager/components/AppHubWindowFrame.vue +224 -0
  79. package/src/modules/window-manager/composables/useWindowManager.js +652 -0
  80. package/src/modules/window-manager/index.js +7 -0
  81. package/src/modules/window-manager/utils/sessionLayout.js +28 -0
  82. package/src/modules/window-manager/utils/windowLayout.js +236 -0
  83. package/src/modules/window-manager/utils/windowSnap.js +146 -0
  84. package/src/utils/bootstrapCache.js +47 -0
  85. package/src/utils/devOriginSettings.js +22 -0
  86. package/src/utils/launchUrl.js +111 -0
  87. package/src/utils/originSafety.js +267 -0
  88. package/src/utils/safeStorage.js +191 -0
  89. package/src/utils/semver.js +30 -0
  90. package/src/utils/zoneContext.js +38 -0
@@ -0,0 +1,236 @@
1
+ import { safeParseJson, sanitizeWindowLayout } from '../../../utils/safeStorage.js'
2
+
3
+ export const TASKBAR_HEIGHT = 48
4
+ export const WINDOW_MIN_WIDTH = 280
5
+ export const WINDOW_MIN_HEIGHT = 200
6
+ const STORAGE_PREFIX = 'apphub-window-layout:'
7
+
8
+ /** Measured from `.apphub-desktop__workarea` — not the browser viewport. */
9
+ let desktopWorkArea = { width: 0, height: 0 }
10
+
11
+ export function setDesktopWorkArea(area) {
12
+ desktopWorkArea = {
13
+ width: Math.max(0, area?.width ?? 0),
14
+ height: Math.max(0, area?.height ?? 0),
15
+ }
16
+ }
17
+
18
+ export function getWorkArea() {
19
+ if (desktopWorkArea.width > 0 && desktopWorkArea.height > 0) {
20
+ return { ...desktopWorkArea }
21
+ }
22
+ return {
23
+ width: window.innerWidth,
24
+ height: Math.max(200, window.innerHeight - TASKBAR_HEIGHT),
25
+ }
26
+ }
27
+
28
+ export function fullscreenBounds() {
29
+ const area = getWorkArea()
30
+ return { x: 0, y: 0, width: area.width, height: area.height }
31
+ }
32
+
33
+ export function centerWindow(width, height) {
34
+ const area = getWorkArea()
35
+ return {
36
+ x: Math.max(0, Math.round((area.width - width) / 2)),
37
+ y: Math.max(0, Math.round((area.height - height) / 2)),
38
+ }
39
+ }
40
+
41
+ export function clampWindowToWorkArea(win) {
42
+ const { width: areaW, height: areaH } = getWorkArea()
43
+ win.x = Math.min(Math.max(0, win.x), Math.max(0, areaW - win.width))
44
+ win.y = Math.min(Math.max(0, win.y), Math.max(0, areaH - win.height))
45
+ }
46
+
47
+ export function clampWindowDimensions(win) {
48
+ const area = getWorkArea()
49
+ const minW = Math.min(win.miniWidth ?? WINDOW_MIN_WIDTH, area.width)
50
+ const minH = Math.min(win.miniHeight ?? WINDOW_MIN_HEIGHT, area.height)
51
+
52
+ win.width = Math.max(minW, Math.min(area.width, win.width))
53
+ win.height = Math.max(minH, Math.min(area.height, win.height))
54
+ clampWindowToWorkArea(win)
55
+ }
56
+
57
+ /** @param {'n'|'s'|'e'|'w'|'ne'|'nw'|'se'|'sw'} edge */
58
+ export function applyWindowResize(win, edge, dx, dy) {
59
+ const area = getWorkArea()
60
+ const minW = Math.min(win.miniWidth ?? WINDOW_MIN_WIDTH, area.width)
61
+ const minH = Math.min(win.miniHeight ?? WINDOW_MIN_HEIGHT, area.height)
62
+
63
+ let { x, y, width, height } = win
64
+ const right = x + width
65
+ const bottom = y + height
66
+
67
+ if (edge.includes('e')) width += dx
68
+ if (edge.includes('w')) {
69
+ x += dx
70
+ width -= dx
71
+ }
72
+ if (edge.includes('s')) height += dy
73
+ if (edge.includes('n')) {
74
+ y += dy
75
+ height -= dy
76
+ }
77
+
78
+ width = Math.max(minW, Math.min(area.width, width))
79
+ height = Math.max(minH, Math.min(area.height, height))
80
+
81
+ if (edge.includes('w')) x = right - width
82
+ if (edge.includes('n')) y = bottom - height
83
+
84
+ x = Math.max(0, Math.min(x, area.width - width))
85
+ y = Math.max(0, Math.min(y, area.height - height))
86
+
87
+ win.x = x
88
+ win.y = y
89
+ win.width = width
90
+ win.height = height
91
+ }
92
+
93
+ export function loadWindowLayout(layoutKey) {
94
+ if (!layoutKey || typeof layoutKey !== 'string' || layoutKey.length > 80) return null
95
+ try {
96
+ const raw = localStorage.getItem(STORAGE_PREFIX + layoutKey)
97
+ const parsed = safeParseJson(raw, 32 * 1024)
98
+ return parsed ? sanitizeWindowLayout(parsed) : null
99
+ } catch {
100
+ return null
101
+ }
102
+ }
103
+
104
+ export function saveWindowLayout(layoutKey, layout) {
105
+ if (!layoutKey) return
106
+ try {
107
+ localStorage.setItem(STORAGE_PREFIX + layoutKey, JSON.stringify(layout))
108
+ } catch {
109
+ /* ignore quota / private mode */
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Resolve initial bounds for a new window (fullscreen default, or restored mini position).
115
+ */
116
+ export function resolveOpenLayout(definition) {
117
+ const miniWidth = definition.miniWidth ?? definition.width ?? 720
118
+ const miniHeight = definition.miniHeight ?? definition.height ?? 480
119
+ const layoutKey = definition.layoutKey ?? null
120
+
121
+ if (!layoutKey) {
122
+ const pos = definition.x != null && definition.y != null
123
+ ? { x: definition.x, y: definition.y }
124
+ : {
125
+ x: 80 + (definition.offsetIndex ?? 0) * 28,
126
+ y: 60 + (definition.offsetIndex ?? 0) * 28,
127
+ }
128
+ return {
129
+ display: 'mini',
130
+ layoutKey: null,
131
+ miniWidth,
132
+ miniHeight,
133
+ width: definition.width ?? miniWidth,
134
+ height: definition.height ?? miniHeight,
135
+ ...pos,
136
+ }
137
+ }
138
+
139
+ const saved = loadWindowLayout(layoutKey)
140
+ const defaultDisplay = definition.defaultDisplay ?? 'fullscreen'
141
+
142
+ if (saved?.display === 'mini') {
143
+ const mini = saved.mini ?? saved
144
+ const width = mini.width ?? miniWidth
145
+ const height = mini.height ?? miniHeight
146
+ const hasPosition = mini.x != null && mini.y != null
147
+ const pos = hasPosition ? { x: mini.x, y: mini.y } : centerWindow(width, height)
148
+ return {
149
+ display: 'mini',
150
+ layoutKey,
151
+ miniWidth,
152
+ miniHeight,
153
+ width,
154
+ height,
155
+ ...pos,
156
+ }
157
+ }
158
+
159
+ if (saved?.display === 'fullscreen' || defaultDisplay === 'fullscreen') {
160
+ return {
161
+ display: 'fullscreen',
162
+ layoutKey,
163
+ miniWidth,
164
+ miniHeight,
165
+ ...fullscreenBounds(),
166
+ }
167
+ }
168
+
169
+ const pos = centerWindow(miniWidth, miniHeight)
170
+ return {
171
+ display: 'mini',
172
+ layoutKey,
173
+ miniWidth,
174
+ miniHeight,
175
+ width: miniWidth,
176
+ height: miniHeight,
177
+ ...pos,
178
+ }
179
+ }
180
+
181
+ function readMiniBounds(saved, fallback) {
182
+ const mini = saved?.mini ?? saved
183
+ return {
184
+ x: mini?.x ?? null,
185
+ y: mini?.y ?? null,
186
+ width: mini?.width ?? fallback.width,
187
+ height: mini?.height ?? fallback.height,
188
+ }
189
+ }
190
+
191
+ export function layoutSnapshot(win) {
192
+ const fallback = {
193
+ width: win.miniWidth ?? win.width,
194
+ height: win.miniHeight ?? win.height,
195
+ }
196
+
197
+ if (win.display === 'fullscreen') {
198
+ const existing = loadWindowLayout(win.layoutKey)
199
+ const mini = readMiniBounds(existing, fallback)
200
+ return {
201
+ display: 'fullscreen',
202
+ mini: {
203
+ x: mini.x,
204
+ y: mini.y,
205
+ width: mini.width,
206
+ height: mini.height,
207
+ },
208
+ }
209
+ }
210
+
211
+ return {
212
+ display: 'mini',
213
+ mini: {
214
+ x: win.x,
215
+ y: win.y,
216
+ width: win.width,
217
+ height: win.height,
218
+ },
219
+ }
220
+ }
221
+
222
+ export function miniBoundsFromStorage(layoutKey, win) {
223
+ const saved = loadWindowLayout(layoutKey)
224
+ const fallback = {
225
+ width: win.miniWidth ?? 820,
226
+ height: win.miniHeight ?? 520,
227
+ }
228
+ const mini = readMiniBounds(saved, fallback)
229
+ const hasPosition = mini.x != null && mini.y != null
230
+ const pos = hasPosition ? { x: mini.x, y: mini.y } : centerWindow(mini.width, mini.height)
231
+ return {
232
+ width: mini.width,
233
+ height: mini.height,
234
+ ...pos,
235
+ }
236
+ }
@@ -0,0 +1,146 @@
1
+ import { centerWindow, fullscreenBounds, getWorkArea } from './windowLayout.js'
2
+
3
+ /** @typedef {'left'|'right'|'up'|'down'} SnapDirection */
4
+
5
+ /**
6
+ * Windows-style snap transitions (Ctrl+Alt+Arrow in browser — OS owns Win+Arrow).
7
+ * @returns {{ action: 'snap'|'fullscreen'|'restore'|'minimize'|'none', bounds?: object, snap?: string|null, display?: string }}
8
+ */
9
+ export function computeSnapAction(win, direction) {
10
+ const area = getWorkArea()
11
+ const halfW = Math.floor(area.width / 2)
12
+ const halfH = Math.floor(area.height / 2)
13
+ const snap = win.snap ?? (win.display === 'fullscreen' ? 'fullscreen' : null)
14
+
15
+ if (direction === 'left') {
16
+ return {
17
+ action: 'snap',
18
+ bounds: { x: 0, y: 0, width: halfW, height: area.height },
19
+ snap: 'left',
20
+ display: 'mini',
21
+ }
22
+ }
23
+
24
+ if (direction === 'right') {
25
+ return {
26
+ action: 'snap',
27
+ bounds: { x: halfW, y: 0, width: area.width - halfW, height: area.height },
28
+ snap: 'right',
29
+ display: 'mini',
30
+ }
31
+ }
32
+
33
+ if (direction === 'up') {
34
+ if (snap === 'left') {
35
+ return {
36
+ action: 'snap',
37
+ bounds: { x: 0, y: 0, width: halfW, height: halfH },
38
+ snap: 'top-left',
39
+ display: 'mini',
40
+ }
41
+ }
42
+ if (snap === 'right') {
43
+ return {
44
+ action: 'snap',
45
+ bounds: { x: halfW, y: 0, width: area.width - halfW, height: halfH },
46
+ snap: 'top-right',
47
+ display: 'mini',
48
+ }
49
+ }
50
+ if (snap === 'bottom-left') {
51
+ return {
52
+ action: 'snap',
53
+ bounds: { x: 0, y: 0, width: halfW, height: halfH },
54
+ snap: 'top-left',
55
+ display: 'mini',
56
+ }
57
+ }
58
+ if (snap === 'bottom-right') {
59
+ return {
60
+ action: 'snap',
61
+ bounds: { x: halfW, y: 0, width: area.width - halfW, height: halfH },
62
+ snap: 'top-right',
63
+ display: 'mini',
64
+ }
65
+ }
66
+ if (snap === 'fullscreen') return { action: 'none' }
67
+ return { action: 'fullscreen' }
68
+ }
69
+
70
+ if (direction === 'down') {
71
+ if (snap === 'fullscreen') return { action: 'restore' }
72
+ if (snap === 'left') {
73
+ return {
74
+ action: 'snap',
75
+ bounds: { x: 0, y: halfH, width: halfW, height: area.height - halfH },
76
+ snap: 'bottom-left',
77
+ display: 'mini',
78
+ }
79
+ }
80
+ if (snap === 'right') {
81
+ return {
82
+ action: 'snap',
83
+ bounds: { x: halfW, y: halfH, width: area.width - halfW, height: area.height - halfH },
84
+ snap: 'bottom-right',
85
+ display: 'mini',
86
+ }
87
+ }
88
+ if (snap === 'top-left' || snap === 'top-right' || snap === 'bottom-left' || snap === 'bottom-right') {
89
+ return { action: 'restore' }
90
+ }
91
+ return { action: 'minimize' }
92
+ }
93
+
94
+ return { action: 'none' }
95
+ }
96
+
97
+ export function captureFloatingBounds(win) {
98
+ if (win.display === 'fullscreen') return
99
+ win.floatingBounds = {
100
+ x: win.x,
101
+ y: win.y,
102
+ width: win.width,
103
+ height: win.height,
104
+ }
105
+ }
106
+
107
+ export function defaultFloatingBounds(win) {
108
+ const width = win.miniWidth ?? 820
109
+ const height = win.miniHeight ?? 520
110
+ return { width, height, ...centerWindow(width, height) }
111
+ }
112
+
113
+ export function resolveRestoreBounds(win) {
114
+ if (win.floatingBounds) {
115
+ return { ...win.floatingBounds }
116
+ }
117
+ if (win.layoutKey) {
118
+ return null
119
+ }
120
+ return defaultFloatingBounds(win)
121
+ }
122
+
123
+ export function applyFullscreen(win) {
124
+ captureFloatingBounds(win)
125
+ Object.assign(win, fullscreenBounds())
126
+ win.display = 'fullscreen'
127
+ win.snap = 'fullscreen'
128
+ }
129
+
130
+ /** Recompute snap bounds when work area size changes. */
131
+ export function getSnapBounds(snap) {
132
+ const area = getWorkArea()
133
+ const halfW = Math.floor(area.width / 2)
134
+ const halfH = Math.floor(area.height / 2)
135
+
136
+ const map = {
137
+ left: { x: 0, y: 0, width: halfW, height: area.height },
138
+ right: { x: halfW, y: 0, width: area.width - halfW, height: area.height },
139
+ 'top-left': { x: 0, y: 0, width: halfW, height: halfH },
140
+ 'top-right': { x: halfW, y: 0, width: area.width - halfW, height: halfH },
141
+ 'bottom-left': { x: 0, y: halfH, width: halfW, height: area.height - halfH },
142
+ 'bottom-right': { x: halfW, y: halfH, width: area.width - halfW, height: area.height - halfH },
143
+ }
144
+
145
+ return map[snap] ?? null
146
+ }
@@ -0,0 +1,47 @@
1
+ const KEY_PREFIX = 'apphub-bootstrap:'
2
+
3
+ function cacheKey(backendUrl) {
4
+ return KEY_PREFIX + String(backendUrl ?? '').replace(/\/$/, '')
5
+ }
6
+
7
+ /** @returns {{ savedAt: number, isDevUser: boolean, origins: object } | null} */
8
+ export function loadBootstrapCache(backendUrl) {
9
+ if (typeof localStorage === 'undefined' || !backendUrl) return null
10
+ try {
11
+ const raw = localStorage.getItem(cacheKey(backendUrl))
12
+ if (!raw) return null
13
+ const parsed = JSON.parse(raw)
14
+ if (!parsed?.origins) return null
15
+ return parsed
16
+ } catch {
17
+ return null
18
+ }
19
+ }
20
+
21
+ /** @param {import('axios').AxiosResponse|{ data?: unknown }} bootstrapResponse */
22
+ export function saveBootstrapCache(backendUrl, bootstrapResponse) {
23
+ if (typeof localStorage === 'undefined' || !backendUrl) return
24
+ const data = bootstrapResponse?.data?.data ?? bootstrapResponse?.data ?? {}
25
+ if (!data.origins) return
26
+ try {
27
+ localStorage.setItem(cacheKey(backendUrl), JSON.stringify({
28
+ savedAt: Date.now(),
29
+ isDevUser: data.is_dev_user === true,
30
+ origins: data.origins,
31
+ }))
32
+ } catch {
33
+ /* ignore quota */
34
+ }
35
+ }
36
+
37
+ /** Build axios-like bootstrap response from cache entry. */
38
+ export function bootstrapResponseFromCache(entry) {
39
+ return {
40
+ data: {
41
+ data: {
42
+ is_dev_user: entry.isDevUser === true,
43
+ origins: entry.origins,
44
+ },
45
+ },
46
+ }
47
+ }
@@ -0,0 +1,22 @@
1
+ const STORAGE_KEY = 'apphub-dev-friendly-origins'
2
+
3
+ /** Default true — relaxed origin checks on localhost until dev turns off. */
4
+ export function loadDevFriendlyOriginsPreference() {
5
+ if (typeof localStorage === 'undefined') return true
6
+ try {
7
+ const raw = localStorage.getItem(STORAGE_KEY)
8
+ if (raw === null) return true
9
+ return raw !== '0' && raw !== 'false'
10
+ } catch {
11
+ return true
12
+ }
13
+ }
14
+
15
+ export function saveDevFriendlyOriginsPreference(enabled) {
16
+ if (typeof localStorage === 'undefined') return
17
+ try {
18
+ localStorage.setItem(STORAGE_KEY, enabled ? '1' : '0')
19
+ } catch {
20
+ /* ignore */
21
+ }
22
+ }
@@ -0,0 +1,111 @@
1
+ import { resolveRuntimeApiBase } from './originSafety.js'
2
+
3
+ const BLOCKED_PROTOCOLS = new Set(['javascript:', 'data:', 'blob:', 'file:'])
4
+
5
+ export const RUNTIME_HOSTED = 'hosted'
6
+ export const RUNTIME_IFRAME = 'iframe'
7
+
8
+ /** Hub-served bundle under {runtimeBase}/apps/{slug}/runtime/ */
9
+ export function isHubHostedRuntimeUrl(url, runtimeBaseOrOptions) {
10
+ const base = typeof runtimeBaseOrOptions === 'object'
11
+ ? resolveRuntimeApiBase(runtimeBaseOrOptions)
12
+ : String(runtimeBaseOrOptions ?? '').replace(/\/$/, '')
13
+
14
+ if (!base || !url || typeof url !== 'string') return false
15
+ try {
16
+ const parsed = new URL(url)
17
+ const baseParsed = new URL(base)
18
+ if (parsed.origin !== baseParsed.origin) return false
19
+ return /\/apps\/[^/]+\/runtime\//.test(parsed.pathname)
20
+ } catch {
21
+ return false
22
+ }
23
+ }
24
+
25
+ /**
26
+ * @param {string} url
27
+ * @param {string[]} [allowedOrigins]
28
+ * @param {{ backendUrl?: string, runtimePublicUrl?: string, runtimeType?: string }} [options]
29
+ */
30
+ export function isAllowedLaunchUrl(url, allowedOrigins = [], options = {}) {
31
+ if (!url || typeof url !== 'string') return false
32
+
33
+ if (options.runtimeType === RUNTIME_HOSTED || isHubHostedRuntimeUrl(url, options)) {
34
+ return isHubHostedRuntimeUrl(url, options)
35
+ }
36
+
37
+ let parsed
38
+ try {
39
+ parsed = new URL(url)
40
+ } catch {
41
+ return false
42
+ }
43
+
44
+ if (BLOCKED_PROTOCOLS.has(parsed.protocol)) return false
45
+
46
+ if (parsed.protocol === 'http:' && (parsed.hostname === 'localhost' || parsed.hostname === '127.0.0.1')) {
47
+ return true
48
+ }
49
+
50
+ if (parsed.protocol !== 'https:') return false
51
+
52
+ if (allowedOrigins.length > 0) {
53
+ const origin = parsed.origin
54
+ return allowedOrigins.some((entry) => {
55
+ try {
56
+ return new URL(entry).origin === origin
57
+ } catch {
58
+ return false
59
+ }
60
+ })
61
+ }
62
+
63
+ return true
64
+ }
65
+
66
+ /** @param {string} entryUrl */
67
+ export function entryUrlOrigin(entryUrl) {
68
+ if (!entryUrl || typeof entryUrl !== 'string') return ''
69
+ try {
70
+ return new URL(entryUrl).origin
71
+ } catch {
72
+ return ''
73
+ }
74
+ }
75
+
76
+ /** Check catalog entry_url origin against host allowedRuntimeOrigins. */
77
+ export function isEntryUrlAllowed(entryUrl, allowedOrigins = []) {
78
+ if (!entryUrl || typeof entryUrl !== 'string') return false
79
+ return isAllowedLaunchUrl(entryUrl, allowedOrigins)
80
+ }
81
+
82
+ export function resolveLaunchUrl(responseData) {
83
+ const data = responseData?.data ?? responseData
84
+ const base = data?.runtime_url ?? data?.entry_url ?? data?.launch?.url ?? ''
85
+ const token = data?.launch_token
86
+ if (!base) return ''
87
+ if (!token || typeof token !== 'string') return base
88
+
89
+ try {
90
+ const url = new URL(base)
91
+ url.searchParams.set('launch_token', token)
92
+ return url.toString()
93
+ } catch {
94
+ return base
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Hosted apps use opaque iframe origin (no allow-same-origin) so publisher JS cannot read Hub localStorage.
100
+ * @param {string} runtimeType
101
+ * @param {{ hostedSandboxSameOrigin?: boolean }} [options]
102
+ */
103
+ export function iframeSandboxAttrs(runtimeType, options = {}) {
104
+ if (runtimeType === RUNTIME_HOSTED) {
105
+ if (options.hostedSandboxSameOrigin === true) {
106
+ return 'allow-scripts allow-forms allow-popups allow-same-origin'
107
+ }
108
+ return 'allow-scripts allow-forms allow-popups'
109
+ }
110
+ return 'allow-scripts allow-forms allow-popups'
111
+ }