@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,90 @@
1
+ import { safeParseJson } from '../../../utils/safeStorage.js'
2
+ import {
3
+ BUILTIN_APP_STORE_ID,
4
+ BUILTIN_GUIDE_ID,
5
+ BUILTIN_SETTINGS_ID,
6
+ } from '../data/builtinApps.js'
7
+
8
+ const STORAGE_KEY = 'apphub-start-menu-pins'
9
+ const MAX_PINS = 64
10
+
11
+ const DEFAULT_BUILTIN_PIN_IDS = [
12
+ BUILTIN_APP_STORE_ID,
13
+ BUILTIN_GUIDE_ID,
14
+ BUILTIN_SETTINGS_ID,
15
+ ]
16
+
17
+ function defaultPins() {
18
+ const pins = {}
19
+ for (const id of DEFAULT_BUILTIN_PIN_IDS) {
20
+ pins[id] = { visible: true }
21
+ }
22
+ return pins
23
+ }
24
+
25
+ function sanitizePins(parsed) {
26
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
27
+ const raw = parsed.pins ?? parsed
28
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
29
+
30
+ const pins = {}
31
+ let count = 0
32
+ for (const [key, value] of Object.entries(raw)) {
33
+ if (count >= MAX_PINS) break
34
+ const id = typeof key === 'string' ? key.slice(0, 80) : ''
35
+ if (!id) continue
36
+ pins[id] = {
37
+ visible: value && typeof value === 'object' && value.visible === false ? false : true,
38
+ }
39
+ count += 1
40
+ }
41
+ return pins
42
+ }
43
+
44
+ export function loadStartMenuPins() {
45
+ try {
46
+ const raw = localStorage.getItem(STORAGE_KEY)
47
+ const parsed = safeParseJson(raw, 16 * 1024)
48
+ const pins = sanitizePins(parsed)
49
+ if (pins && Object.keys(pins).length) return pins
50
+ } catch {
51
+ /* ignore */
52
+ }
53
+ return defaultPins()
54
+ }
55
+
56
+ export function saveStartMenuPins(pins) {
57
+ try {
58
+ localStorage.setItem(STORAGE_KEY, JSON.stringify({ pins }))
59
+ } catch {
60
+ /* ignore */
61
+ }
62
+ }
63
+
64
+ export function isAppPinned(pins, appId) {
65
+ return !!(appId && pins && Object.prototype.hasOwnProperty.call(pins, appId))
66
+ }
67
+
68
+ export function isAppVisibleInStart(pins, appId) {
69
+ if (!isAppPinned(pins, appId)) return false
70
+ return pins[appId].visible !== false
71
+ }
72
+
73
+ export function pinApp(pins, appId) {
74
+ if (!appId) return
75
+ pins[appId] = { visible: true }
76
+ }
77
+
78
+ export function unpinApp(pins, appId) {
79
+ if (!appId || !pins) return
80
+ delete pins[appId]
81
+ }
82
+
83
+ export function setPinVisible(pins, appId, visible) {
84
+ if (!isAppPinned(pins, appId)) return
85
+ pins[appId].visible = !!visible
86
+ }
87
+
88
+ export function listPinnedAppIds(pins) {
89
+ return Object.keys(pins ?? {})
90
+ }
@@ -0,0 +1,54 @@
1
+ <template>
2
+ <div
3
+ class="apphub-notifications"
4
+ role="region"
5
+ :aria-label="labels.region"
6
+ aria-live="polite"
7
+ aria-relevant="additions"
8
+ >
9
+ <TransitionGroup name="apphub-notifications__item">
10
+ <article
11
+ v-for="item in items"
12
+ :key="item.id"
13
+ class="apphub-notifications__toast"
14
+ :class="`apphub-notifications__toast--${item.type}`"
15
+ >
16
+ <div class="apphub-notifications__accent" aria-hidden="true" />
17
+ <div class="apphub-notifications__body">
18
+ <header v-if="item.title" class="apphub-notifications__title">{{ item.title }}</header>
19
+ <p class="apphub-notifications__message">{{ item.message || item.title }}</p>
20
+ </div>
21
+ <button
22
+ type="button"
23
+ class="apphub-notifications__close"
24
+ :aria-label="labels.close"
25
+ @click="dismiss(item.id)"
26
+ >
27
+ ×
28
+ </button>
29
+ </article>
30
+ </TransitionGroup>
31
+ </div>
32
+ </template>
33
+
34
+ <script setup>
35
+ import { computed, inject } from 'vue'
36
+ import { DESKTOP_NOTIFICATIONS_KEY } from '../composables/createDesktopNotifications.js'
37
+ import { t } from '../../../i18n/index.js'
38
+ import { resolveLang } from '../../../i18n/resolveLang.js'
39
+
40
+ const notifications = inject(DESKTOP_NOTIFICATIONS_KEY, null)
41
+ const moduleOptions = inject('apphubOptions', {})
42
+ const lang = computed(() => resolveLang(moduleOptions?.language, 'vi'))
43
+
44
+ const labels = computed(() => ({
45
+ region: t('notif_region', lang.value),
46
+ close: t('notif_close', lang.value),
47
+ }))
48
+
49
+ const items = computed(() => notifications?.state?.items ?? [])
50
+
51
+ function dismiss(id) {
52
+ notifications?.dismiss?.(id)
53
+ }
54
+ </script>
@@ -0,0 +1,86 @@
1
+ import { inject, reactive } from 'vue'
2
+
3
+ export const DESKTOP_NOTIFICATIONS_KEY = Symbol('apphubDesktopNotifications')
4
+
5
+ /**
6
+ * OS-style toast stack for the Hub desktop (reusable by shell, drop install, bridge later).
7
+ */
8
+ export function createDesktopNotificationsState() {
9
+ const state = reactive({
10
+ items: [],
11
+ })
12
+
13
+ let seq = 0
14
+
15
+ function dismiss(id) {
16
+ const idx = state.items.findIndex((item) => item.id === id)
17
+ if (idx !== -1) state.items.splice(idx, 1)
18
+ }
19
+
20
+ /**
21
+ * @param {{
22
+ * title?: string,
23
+ * message: string,
24
+ * type?: 'info' | 'success' | 'error' | 'warning',
25
+ * duration?: number,
26
+ * }} payload
27
+ */
28
+ function push(payload) {
29
+ const message = String(payload?.message ?? '').trim()
30
+ const title = String(payload?.title ?? '').trim()
31
+ if (!message && !title) return null
32
+
33
+ const type = payload?.type === 'success'
34
+ || payload?.type === 'error'
35
+ || payload?.type === 'warning'
36
+ ? payload.type
37
+ : 'info'
38
+
39
+ const id = `notif-${++seq}`
40
+ const item = {
41
+ id,
42
+ title,
43
+ message,
44
+ type,
45
+ duration: typeof payload?.duration === 'number' ? payload.duration : 6000,
46
+ }
47
+
48
+ state.items.push(item)
49
+
50
+ if (item.duration > 0) {
51
+ setTimeout(() => dismiss(id), item.duration)
52
+ }
53
+
54
+ return id
55
+ }
56
+
57
+ function success(message, title = '') {
58
+ return push({ type: 'success', title, message })
59
+ }
60
+
61
+ function error(message, title = '') {
62
+ return push({ type: 'error', title, message })
63
+ }
64
+
65
+ function info(message, title = '') {
66
+ return push({ type: 'info', title, message })
67
+ }
68
+
69
+ function warning(message, title = '') {
70
+ return push({ type: 'warning', title, message })
71
+ }
72
+
73
+ return {
74
+ state,
75
+ push,
76
+ dismiss,
77
+ success,
78
+ error,
79
+ info,
80
+ warning,
81
+ }
82
+ }
83
+
84
+ export function useDesktopNotifications() {
85
+ return inject(DESKTOP_NOTIFICATIONS_KEY, null)
86
+ }
@@ -0,0 +1,9 @@
1
+ import './styles/notifications.css'
2
+
3
+ export {
4
+ createDesktopNotificationsState,
5
+ useDesktopNotifications,
6
+ DESKTOP_NOTIFICATIONS_KEY,
7
+ } from './composables/createDesktopNotifications.js'
8
+ export { default as AppHubDesktopNotifications } from './components/AppHubDesktopNotifications.vue'
9
+ export { parseApiError } from './utils/parseApiError.js'
@@ -0,0 +1,118 @@
1
+ .apphub-notifications {
2
+ position: absolute;
3
+ right: 12px;
4
+ bottom: 52px;
5
+ z-index: 10050;
6
+ display: flex;
7
+ flex-direction: column;
8
+ gap: 8px;
9
+ width: min(360px, calc(100vw - 24px));
10
+ pointer-events: none;
11
+ }
12
+
13
+ .apphub-notifications__toast {
14
+ display: flex;
15
+ align-items: stretch;
16
+ gap: 0;
17
+ min-height: 64px;
18
+ border-radius: 8px;
19
+ background: #1e293b;
20
+ border: 1px solid #334155;
21
+ box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
22
+ overflow: hidden;
23
+ pointer-events: auto;
24
+ }
25
+
26
+ .apphub-notifications__accent {
27
+ width: 4px;
28
+ flex-shrink: 0;
29
+ background: #64748b;
30
+ }
31
+
32
+ .apphub-notifications__toast--info .apphub-notifications__accent {
33
+ background: #3b82f6;
34
+ }
35
+
36
+ .apphub-notifications__toast--success .apphub-notifications__accent {
37
+ background: #22c55e;
38
+ }
39
+
40
+ .apphub-notifications__toast--warning .apphub-notifications__accent {
41
+ background: #f59e0b;
42
+ }
43
+
44
+ .apphub-notifications__toast--error .apphub-notifications__accent {
45
+ background: #ef4444;
46
+ }
47
+
48
+ .apphub-notifications__body {
49
+ flex: 1;
50
+ min-width: 0;
51
+ padding: 10px 8px 10px 12px;
52
+ }
53
+
54
+ .apphub-notifications__title {
55
+ margin: 0 0 2px;
56
+ font-size: 0.8rem;
57
+ font-weight: 700;
58
+ color: #f1f5f9;
59
+ }
60
+
61
+ .apphub-notifications__message {
62
+ margin: 0;
63
+ font-size: 0.78rem;
64
+ line-height: 1.4;
65
+ color: #cbd5e1;
66
+ word-break: break-word;
67
+ }
68
+
69
+ .apphub-notifications__close {
70
+ flex-shrink: 0;
71
+ width: 36px;
72
+ border: none;
73
+ background: transparent;
74
+ color: #94a3b8;
75
+ font-size: 1.25rem;
76
+ line-height: 1;
77
+ cursor: pointer;
78
+ align-self: stretch;
79
+ }
80
+
81
+ .apphub-notifications__close:hover {
82
+ color: #f1f5f9;
83
+ background: rgba(255, 255, 255, 0.06);
84
+ }
85
+
86
+ .apphub-notifications__item-enter-active,
87
+ .apphub-notifications__item-leave-active {
88
+ transition: all 0.28s ease;
89
+ }
90
+
91
+ .apphub-notifications__item-enter-from,
92
+ .apphub-notifications__item-leave-to {
93
+ opacity: 0;
94
+ transform: translateX(24px);
95
+ }
96
+
97
+ .apphub-desktop--light .apphub-notifications__toast {
98
+ background: #fff;
99
+ border-color: #e2e8f0;
100
+ box-shadow: 0 8px 24px rgba(15, 23, 42, 0.12);
101
+ }
102
+
103
+ .apphub-desktop--light .apphub-notifications__title {
104
+ color: #0f172a;
105
+ }
106
+
107
+ .apphub-desktop--light .apphub-notifications__message {
108
+ color: #475569;
109
+ }
110
+
111
+ .apphub-desktop--light .apphub-notifications__close {
112
+ color: #64748b;
113
+ }
114
+
115
+ .apphub-desktop--light .apphub-notifications__close:hover {
116
+ color: #0f172a;
117
+ background: rgba(15, 23, 42, 0.06);
118
+ }
@@ -0,0 +1,29 @@
1
+ /**
2
+ * Extract a user-facing message from an API or client error.
3
+ * @param {unknown} err
4
+ * @param {string} [fallback]
5
+ */
6
+ export function parseApiError(err, fallback = '') {
7
+ if (err && typeof err === 'object' && err.code === 'no_api') {
8
+ return fallback
9
+ }
10
+
11
+ if (err && typeof err === 'object' && err.message === 'no_api') {
12
+ return fallback
13
+ }
14
+
15
+ const data = err?.response?.data
16
+ if (data && typeof data.error === 'string' && data.error.trim()) {
17
+ return data.error.trim()
18
+ }
19
+
20
+ if (data && typeof data.message === 'string' && data.message.trim()) {
21
+ return data.message.trim()
22
+ }
23
+
24
+ if (typeof err?.message === 'string' && err.message.trim() && err.message !== 'no_api') {
25
+ return err.message.trim()
26
+ }
27
+
28
+ return fallback
29
+ }
@@ -0,0 +1,292 @@
1
+ <template>
2
+ <div class="apphub-runner">
3
+ <div v-if="showPreflight" class="apphub-runner__preflight">
4
+ <header class="apphub-runner__preflight-head">
5
+ <span class="apphub-runner__preflight-icon" aria-hidden="true">{{ icon }}</span>
6
+ <div>
7
+ <h3 class="apphub-runner__preflight-title">{{ slug }}</h3>
8
+ <span class="apphub-runner__preflight-badge">{{ labels.draft_badge }}</span>
9
+ <span v-if="isHosted" class="apphub-runner__preflight-badge apphub-runner__preflight-badge--hosted">
10
+ {{ labels.hosted_badge }}
11
+ </span>
12
+ </div>
13
+ </header>
14
+
15
+ <dl class="apphub-runner__preflight-dl">
16
+ <div class="apphub-runner__preflight-row">
17
+ <dt>{{ isHosted ? labels.hosted_bundle : labels.entry_url }}</dt>
18
+ <dd>{{ preflightTargetLabel }}</dd>
19
+ </div>
20
+ <div v-if="!isHosted" class="apphub-runner__preflight-row">
21
+ <dt>{{ labels.health_url }}</dt>
22
+ <dd>{{ healthcheckUrl || labels.not_configured }}</dd>
23
+ </div>
24
+ </dl>
25
+
26
+ <div class="apphub-runner__preflight-actions">
27
+ <button
28
+ v-if="!isHosted && healthcheckUrl"
29
+ type="button"
30
+ class="apphub-runner__btn apphub-runner__btn--secondary"
31
+ :disabled="pinging"
32
+ @click="onPing"
33
+ >
34
+ {{ pinging ? labels.ping_pinging : labels.ping_btn }}
35
+ </button>
36
+ <button
37
+ v-if="!isHosted && entryUrl"
38
+ type="button"
39
+ class="apphub-runner__btn apphub-runner__btn--secondary"
40
+ @click="onCheckSafe"
41
+ >
42
+ {{ labels.safe_check }}
43
+ </button>
44
+ <button
45
+ type="button"
46
+ class="apphub-runner__btn apphub-runner__btn--primary"
47
+ :disabled="launching"
48
+ @click="onLaunchClick"
49
+ >
50
+ {{ launching ? labels.loading : labels.launch_btn }}
51
+ </button>
52
+ </div>
53
+
54
+ <p
55
+ v-if="pingResult"
56
+ class="apphub-runner__preflight-msg"
57
+ :class="pingResult.ok ? 'apphub-runner__preflight-msg--ok' : 'apphub-runner__preflight-msg--bad'"
58
+ >
59
+ {{ pingLabel }}
60
+ </p>
61
+ <p
62
+ v-if="safeResult !== null"
63
+ class="apphub-runner__preflight-msg"
64
+ :class="safeResult ? 'apphub-runner__preflight-msg--ok' : 'apphub-runner__preflight-msg--bad'"
65
+ >
66
+ {{ safeResult ? labels.safe_ok : labels.safe_fail }}
67
+ </p>
68
+ <p v-if="preflightError" class="apphub-runner__preflight-msg apphub-runner__preflight-msg--bad">
69
+ {{ preflightError }}
70
+ </p>
71
+ </div>
72
+
73
+ <template v-else>
74
+ <p v-if="loading" class="apphub-runner__msg">{{ labels.loading }}</p>
75
+ <p v-else-if="error" class="apphub-runner__error">{{ error }}</p>
76
+ <iframe
77
+ v-else-if="launchUrl"
78
+ :src="launchUrl"
79
+ class="apphub-runner__frame"
80
+ :title="slug"
81
+ :sandbox="iframeSandbox"
82
+ referrerpolicy="strict-origin-when-cross-origin"
83
+ />
84
+ </template>
85
+ </div>
86
+ </template>
87
+
88
+ <script setup>
89
+ import { computed, inject, ref, watch } from 'vue'
90
+ import { getAppHubStore } from '../../../moduleStore.js'
91
+ import { useAppHubHostApi } from '../../../composables/useAppHubHostApi.js'
92
+ import { t } from '../../../i18n/index.js'
93
+ import { resolveLang } from '../../../i18n/resolveLang.js'
94
+ import {
95
+ RUNTIME_HOSTED,
96
+ iframeSandboxAttrs,
97
+ isAllowedLaunchUrl,
98
+ isEntryUrlAllowed,
99
+ resolveLaunchUrl,
100
+ } from '../../../utils/launchUrl.js'
101
+
102
+ const props = defineProps({
103
+ slug: { type: String, required: true },
104
+ installedVersion: { type: String, default: null },
105
+ status: { type: String, default: 'active' },
106
+ runtimeType: { type: String, default: 'iframe' },
107
+ entryUrl: { type: String, default: null },
108
+ healthcheckUrl: { type: String, default: null },
109
+ language: { type: String, default: 'vi' },
110
+ icon: { type: String, default: '📦' },
111
+ })
112
+
113
+ const api = useAppHubHostApi()
114
+ const moduleOptions = inject('apphubOptions', {})
115
+ const allowedOrigins = computed(() => moduleOptions?.allowedRuntimeOrigins ?? [])
116
+
117
+ const backendUrl = computed(() => {
118
+ const fromOptions = moduleOptions?.backendUrl
119
+ if (fromOptions) return fromOptions
120
+ return getAppHubStore()?.credentials?.backendUrl ?? ''
121
+ })
122
+
123
+ const lang = computed(() => resolveLang(moduleOptions?.language, props.language))
124
+
125
+ const isHosted = computed(() => props.runtimeType === RUNTIME_HOSTED || props.runtimeType === 'hosted')
126
+
127
+ const labels = computed(() => ({
128
+ loading: t('runner_loading', lang.value),
129
+ launch_btn: t('runner_launch_btn', lang.value),
130
+ draft_badge: t('app_store_status_draft', lang.value),
131
+ hosted_badge: t('runner_hosted_badge', lang.value),
132
+ hosted_bundle: t('runner_hosted_bundle', lang.value),
133
+ entry_url: t('draft_ping_entry_url', lang.value),
134
+ health_url: t('draft_ping_health_url', lang.value),
135
+ not_configured: t('draft_ping_no_health_url', lang.value),
136
+ ping_btn: t('draft_ping_btn', lang.value),
137
+ ping_pinging: t('draft_ping_pinging', lang.value),
138
+ ping_ok: t('draft_ping_ok', lang.value),
139
+ ping_fail: t('draft_ping_fail', lang.value),
140
+ safe_check: t('draft_ping_safe_check', lang.value),
141
+ safe_ok: t('draft_ping_safe_ok', lang.value),
142
+ safe_fail: t('draft_ping_safe_fail', lang.value),
143
+ err_no_entry: t('runner_no_entry_url', lang.value),
144
+ err_no_bundle: t('runner_no_bundle', lang.value),
145
+ err_generic: t('error_generic', lang.value),
146
+ }))
147
+
148
+ const preflightTargetLabel = computed(() => {
149
+ if (isHosted.value) return props.slug
150
+ return props.entryUrl || labels.value.not_configured
151
+ })
152
+
153
+ const isDraft = computed(() => props.status === 'draft')
154
+ const showPreflight = computed(() => isDraft.value && !launched.value)
155
+
156
+ const iframeSandbox = computed(() =>
157
+ iframeSandboxAttrs(isHosted.value ? RUNTIME_HOSTED : props.runtimeType, {
158
+ hostedSandboxSameOrigin: moduleOptions?.hostedSandboxSameOrigin === true,
159
+ }),
160
+ )
161
+
162
+ const loading = ref(false)
163
+ const launching = ref(false)
164
+ const launched = ref(false)
165
+ const error = ref('')
166
+ const preflightError = ref('')
167
+ const launchUrl = ref('')
168
+ const launchRuntimeType = ref(props.runtimeType)
169
+ const pinging = ref(false)
170
+ const pingResult = ref(null)
171
+ const safeResult = ref(null)
172
+
173
+ const pingLabel = computed(() => {
174
+ if (!pingResult.value) return ''
175
+ if (pingResult.value.ok) {
176
+ const ms = pingResult.value.latency_ms != null ? ` · ${pingResult.value.latency_ms} ms` : ''
177
+ return `${labels.value.ping_ok}${ms}`
178
+ }
179
+ return labels.value.ping_fail
180
+ })
181
+
182
+ function launchOptions() {
183
+ return {
184
+ backendUrl: backendUrl.value,
185
+ runtimePublicUrl: moduleOptions?.runtimePublicUrl ?? '',
186
+ runtimeType: launchRuntimeType.value,
187
+ }
188
+ }
189
+
190
+ async function onPing() {
191
+ if (!api?.ping || !props.slug) return
192
+ pinging.value = true
193
+ pingResult.value = null
194
+ preflightError.value = ''
195
+ try {
196
+ const res = await api.ping(props.slug)
197
+ pingResult.value = res?.data?.data ?? { ok: false }
198
+ } catch {
199
+ pingResult.value = { ok: false }
200
+ } finally {
201
+ pinging.value = false
202
+ }
203
+ }
204
+
205
+ function onCheckSafe() {
206
+ safeResult.value = null
207
+ preflightError.value = ''
208
+ if (!props.entryUrl) {
209
+ safeResult.value = false
210
+ return
211
+ }
212
+ safeResult.value = isEntryUrlAllowed(props.entryUrl, allowedOrigins.value)
213
+ }
214
+
215
+ function isBackendReady() {
216
+ return Boolean(moduleOptions?.hasToken && moduleOptions?.backendUrl)
217
+ }
218
+
219
+ async function doLaunch() {
220
+ if (!props.slug) {
221
+ error.value = labels.value.err_generic
222
+ return
223
+ }
224
+
225
+ if (!isBackendReady()) {
226
+ loading.value = true
227
+ return
228
+ }
229
+
230
+ if (!api?.launch) {
231
+ error.value = labels.value.err_generic
232
+ return
233
+ }
234
+
235
+ loading.value = true
236
+ launching.value = true
237
+ error.value = ''
238
+ preflightError.value = ''
239
+
240
+ try {
241
+ const launchBody = props.installedVersion ? { version: props.installedVersion } : {}
242
+ const res = await api.launch(props.slug, launchBody)
243
+ if (!res?.data) {
244
+ error.value = labels.value.err_generic
245
+ return
246
+ }
247
+
248
+ const data = res?.data?.data ?? res?.data ?? {}
249
+ launchRuntimeType.value = data.runtime_type ?? props.runtimeType
250
+ const candidate = resolveLaunchUrl(res?.data)
251
+ const hostedRuntime = launchRuntimeType.value === RUNTIME_HOSTED || launchRuntimeType.value === 'hosted'
252
+ if (!candidate) {
253
+ error.value = hostedRuntime ? labels.value.err_no_bundle : labels.value.err_no_entry
254
+ return
255
+ }
256
+ if (!isAllowedLaunchUrl(candidate, allowedOrigins.value, launchOptions())) {
257
+ error.value = labels.value.safe_fail
258
+ return
259
+ }
260
+ launchUrl.value = candidate
261
+ launched.value = true
262
+ } catch {
263
+ error.value = labels.value.err_generic
264
+ } finally {
265
+ loading.value = false
266
+ launching.value = false
267
+ }
268
+ }
269
+
270
+ function onLaunchClick() {
271
+ if (!isHosted.value && props.entryUrl && !isEntryUrlAllowed(props.entryUrl, allowedOrigins.value)) {
272
+ safeResult.value = false
273
+ preflightError.value = labels.value.safe_fail
274
+ return
275
+ }
276
+ doLaunch()
277
+ }
278
+
279
+ watch(
280
+ () => isBackendReady(),
281
+ (ready) => {
282
+ if (isDraft.value || launchUrl.value || error.value) return
283
+ if (!ready) {
284
+ if (!isDraft.value) loading.value = true
285
+ return
286
+ }
287
+ launched.value = true
288
+ doLaunch()
289
+ },
290
+ { immediate: true },
291
+ )
292
+ </script>
@@ -0,0 +1 @@
1
+ export { default as AppHubRunner } from './components/AppHubRunner.vue'