@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.
- package/README.md +84 -0
- package/package.json +31 -0
- package/src/api/coreApi.js +25 -0
- package/src/api/index.js +80 -0
- package/src/composables/createZoneContext.js +156 -0
- package/src/composables/useAppHubHostApi.js +24 -0
- package/src/composables/useAppHubZoneContext.js +11 -0
- package/src/composables/useDevOriginToggle.js +40 -0
- package/src/i18n/index.js +16 -0
- package/src/i18n/resolveLang.js +6 -0
- package/src/i18n/resolveTheme.js +30 -0
- package/src/i18n/translations/en.js +303 -0
- package/src/i18n/translations/vi.js +302 -0
- package/src/index.js +427 -0
- package/src/moduleStore.js +10 -0
- package/src/modules/app-store/components/AppHubAppStoreApp.vue +210 -0
- package/src/modules/app-store/components/AppHubAppStoreCard.vue +88 -0
- package/src/modules/app-store/components/AppHubAppStoreSettingsPanel.vue +266 -0
- package/src/modules/app-store/components/AppHubAppVersionHistory.vue +77 -0
- package/src/modules/app-store/components/AppHubDevReviewPanel.vue +206 -0
- package/src/modules/app-store/components/AppHubDraftStoreApp.vue +184 -0
- package/src/modules/app-store/components/AppHubDraftStoreCard.vue +116 -0
- package/src/modules/app-store/composables/useAppStore.js +206 -0
- package/src/modules/app-store/composables/useCatalogInfiniteScroll.js +47 -0
- package/src/modules/app-store/constants/catalogModes.js +2 -0
- package/src/modules/app-store/data/defaultCatalog.js +19 -0
- package/src/modules/app-store/index.js +9 -0
- package/src/modules/app-store/utils/normalizeCatalogApp.js +37 -0
- package/src/modules/desktop/components/AppHubDesktop.vue +1510 -0
- package/src/modules/desktop/components/AppHubDesktopDevOriginBar.vue +57 -0
- package/src/modules/desktop/components/AppHubDesktopDropLayer.vue +15 -0
- package/src/modules/desktop/components/AppHubDesktopDropTarget.vue +32 -0
- package/src/modules/desktop/components/AppHubDesktopIconContextMenu.vue +74 -0
- package/src/modules/desktop/components/AppHubDesktopIconFolder.vue +60 -0
- package/src/modules/desktop/components/AppHubDesktopIconGroup.vue +58 -0
- package/src/modules/desktop/components/AppHubDesktopIconInfoDialog.vue +33 -0
- package/src/modules/desktop/components/AppHubDesktopIconRenameDialog.vue +62 -0
- package/src/modules/desktop/components/AppHubDesktopSettings.vue +28 -0
- package/src/modules/desktop/components/AppHubDropInstallBadge.vue +65 -0
- package/src/modules/desktop/components/AppHubDuplicateAppDialog.vue +38 -0
- package/src/modules/desktop/components/AppHubGuideApp.vue +278 -0
- package/src/modules/desktop/components/AppHubOriginBlockScreen.vue +105 -0
- package/src/modules/desktop/components/AppHubOriginLoadingScreen.vue +23 -0
- package/src/modules/desktop/components/AppHubPlaceholderApp.vue +14 -0
- package/src/modules/desktop/components/AppHubSettingsApp.vue +319 -0
- package/src/modules/desktop/components/AppHubStartButton.vue +24 -0
- package/src/modules/desktop/components/AppHubStartMenu.vue +182 -0
- package/src/modules/desktop/components/AppHubTaskbarPins.vue +23 -0
- package/src/modules/desktop/components/settings/AppHubSettingsKeyboardPanel.vue +82 -0
- package/src/modules/desktop/components/settings/AppHubSettingsScreenPanel.vue +41 -0
- package/src/modules/desktop/components/settings/AppHubSettingsStartMenuPanel.vue +95 -0
- package/src/modules/desktop/composables/simulateInstallProgress.js +15 -0
- package/src/modules/desktop/composables/useDesktopDropInstall.js +272 -0
- package/src/modules/desktop/composables/useDesktopHubSettings.js +51 -0
- package/src/modules/desktop/composables/useDesktopIconDrag.js +207 -0
- package/src/modules/desktop/composables/useDesktopShell.js +335 -0
- package/src/modules/desktop/data/builtinApps.js +77 -0
- package/src/modules/desktop/index.js +12 -0
- package/src/modules/desktop/styles/desktop.css +3104 -0
- package/src/modules/desktop/styles/theme.css +616 -0
- package/src/modules/desktop/utils/desktopGrid.js +43 -0
- package/src/modules/desktop/utils/desktopIconGroups.js +103 -0
- package/src/modules/desktop/utils/desktopSession.js +40 -0
- package/src/modules/desktop/utils/desktopSettings.js +37 -0
- package/src/modules/desktop/utils/dropPackageParser.js +140 -0
- package/src/modules/desktop/utils/duplicateAppUtils.js +28 -0
- package/src/modules/desktop/utils/hubKeyboardSettings.js +63 -0
- package/src/modules/desktop/utils/recentApps.js +148 -0
- package/src/modules/desktop/utils/startMenuFavorites.js +100 -0
- package/src/modules/desktop/utils/startMenuPins.js +90 -0
- package/src/modules/notifications/components/AppHubDesktopNotifications.vue +54 -0
- package/src/modules/notifications/composables/createDesktopNotifications.js +86 -0
- package/src/modules/notifications/index.js +9 -0
- package/src/modules/notifications/styles/notifications.css +118 -0
- package/src/modules/notifications/utils/parseApiError.js +29 -0
- package/src/modules/runner/components/AppHubRunner.vue +292 -0
- package/src/modules/runner/index.js +1 -0
- package/src/modules/window-manager/components/AppHubWindowFrame.vue +224 -0
- package/src/modules/window-manager/composables/useWindowManager.js +652 -0
- package/src/modules/window-manager/index.js +7 -0
- package/src/modules/window-manager/utils/sessionLayout.js +28 -0
- package/src/modules/window-manager/utils/windowLayout.js +236 -0
- package/src/modules/window-manager/utils/windowSnap.js +146 -0
- package/src/utils/bootstrapCache.js +47 -0
- package/src/utils/devOriginSettings.js +22 -0
- package/src/utils/launchUrl.js +111 -0
- package/src/utils/originSafety.js +267 -0
- package/src/utils/safeStorage.js +191 -0
- package/src/utils/semver.js +30 -0
- package/src/utils/zoneContext.js +38 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/** Grid cell key for grouping apps at the same desktop position. */
|
|
2
|
+
export function cellKey(x, y) {
|
|
3
|
+
return `${x},${y}`
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
/** Apps sharing the same snapped desktop cell. */
|
|
7
|
+
export function findAppsAtCell(apps, x, y, excludeIds = []) {
|
|
8
|
+
const excluded = new Set(excludeIds)
|
|
9
|
+
return (apps ?? []).filter(
|
|
10
|
+
(a) =>
|
|
11
|
+
a.desktopX === x &&
|
|
12
|
+
a.desktopY === y &&
|
|
13
|
+
!excluded.has(a.id),
|
|
14
|
+
)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Desktop layout: single icons or groups (2+ apps in one cell). */
|
|
18
|
+
export function buildDesktopItems(apps) {
|
|
19
|
+
const byCell = new Map()
|
|
20
|
+
|
|
21
|
+
for (const app of apps ?? []) {
|
|
22
|
+
if (app.desktopX == null || app.desktopY == null) continue
|
|
23
|
+
const key = cellKey(app.desktopX, app.desktopY)
|
|
24
|
+
if (!byCell.has(key)) byCell.set(key, [])
|
|
25
|
+
byCell.get(key).push(app)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const items = []
|
|
29
|
+
for (const [key, cellApps] of byCell) {
|
|
30
|
+
const [x, y] = key.split(',').map(Number)
|
|
31
|
+
if (cellApps.length >= 2) {
|
|
32
|
+
items.push({ type: 'group', id: `group-${key}`, apps: cellApps, x, y })
|
|
33
|
+
} else {
|
|
34
|
+
items.push({ type: 'single', app: cellApps[0], id: cellApps[0].id, x, y })
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return items
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function occupiedCells(apps) {
|
|
41
|
+
const cells = new Set()
|
|
42
|
+
for (const app of apps ?? []) {
|
|
43
|
+
if (app.desktopX == null || app.desktopY == null) continue
|
|
44
|
+
cells.add(cellKey(app.desktopX, app.desktopY))
|
|
45
|
+
}
|
|
46
|
+
return cells
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Move one or more apps to a grid cell (creates or joins a group). */
|
|
50
|
+
export function moveAppsToCell(apps, x, y) {
|
|
51
|
+
for (const app of apps) {
|
|
52
|
+
app.desktopX = x
|
|
53
|
+
app.desktopY = y
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Preview apps at drop target after a drag merge. */
|
|
58
|
+
export function previewGroupAtCell(allApps, draggedApps, x, y) {
|
|
59
|
+
const draggedIds = new Set(draggedApps.map((a) => a.id))
|
|
60
|
+
const existing = findAppsAtCell(allApps, x, y, [...draggedIds])
|
|
61
|
+
const merged = [...existing]
|
|
62
|
+
for (const app of draggedApps) {
|
|
63
|
+
if (!merged.some((a) => a.id === app.id)) merged.push(app)
|
|
64
|
+
}
|
|
65
|
+
return merged
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Find layout item (single or group) at a grid cell. */
|
|
69
|
+
export function findLayoutItemAt(items, x, y) {
|
|
70
|
+
return (items ?? []).find((item) => item.x === x && item.y === y) ?? null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function defaultGroupLabel(labels, count) {
|
|
74
|
+
return count > 1 ? `${labels.group_label} (${count})` : labels.group_label
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function getGroupDisplayName(settings, x, y, labels, count) {
|
|
78
|
+
const key = cellKey(x, y)
|
|
79
|
+
const custom = settings?.groupNames?.[key]
|
|
80
|
+
if (custom) return custom
|
|
81
|
+
return defaultGroupLabel(labels, count)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function setGroupDisplayName(settings, x, y, name, labels, count) {
|
|
85
|
+
if (!settings.groupNames) settings.groupNames = {}
|
|
86
|
+
const key = cellKey(x, y)
|
|
87
|
+
const trimmed = String(name ?? '').trim()
|
|
88
|
+
const fallback = defaultGroupLabel(labels, count)
|
|
89
|
+
if (!trimmed || trimmed === fallback) {
|
|
90
|
+
delete settings.groupNames[key]
|
|
91
|
+
return
|
|
92
|
+
}
|
|
93
|
+
settings.groupNames[key] = trimmed
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function migrateGroupDisplayName(settings, fromX, fromY, toX, toY) {
|
|
97
|
+
const fromKey = cellKey(fromX, fromY)
|
|
98
|
+
const toKey = cellKey(toX, toY)
|
|
99
|
+
if (fromKey === toKey || !settings?.groupNames?.[fromKey]) return
|
|
100
|
+
if (!settings.groupNames) settings.groupNames = {}
|
|
101
|
+
settings.groupNames[toKey] = settings.groupNames[fromKey]
|
|
102
|
+
delete settings.groupNames[fromKey]
|
|
103
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { safeParseJson, sanitizeDesktopSession } from '../../../utils/safeStorage.js'
|
|
2
|
+
|
|
3
|
+
const SESSION_KEY = 'apphub-desktop-session'
|
|
4
|
+
|
|
5
|
+
export function loadDesktopSession() {
|
|
6
|
+
try {
|
|
7
|
+
const raw = localStorage.getItem(SESSION_KEY)
|
|
8
|
+
const parsed = safeParseJson(raw, 512 * 1024)
|
|
9
|
+
return parsed ? sanitizeDesktopSession(parsed) : null
|
|
10
|
+
} catch {
|
|
11
|
+
return null
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function saveDesktopSession(session) {
|
|
16
|
+
try {
|
|
17
|
+
localStorage.setItem(SESSION_KEY, JSON.stringify(session))
|
|
18
|
+
} catch {
|
|
19
|
+
/* ignore */
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function buildDesktopSession(shell, wm, appStore, settings = null) {
|
|
24
|
+
return {
|
|
25
|
+
windows: wm.state.windows.map((win) => ({
|
|
26
|
+
appId: win.id.replace(/^win-/, ''),
|
|
27
|
+
minimized: !!win.minimized,
|
|
28
|
+
display: win.display ?? 'mini',
|
|
29
|
+
x: win.x,
|
|
30
|
+
y: win.y,
|
|
31
|
+
width: win.width,
|
|
32
|
+
height: win.height,
|
|
33
|
+
zIndex: win.zIndex,
|
|
34
|
+
})),
|
|
35
|
+
activeId: wm.state.activeId,
|
|
36
|
+
userApps: shell.state.userApps.map((app) => ({ ...app })),
|
|
37
|
+
installedSlugs: [...(appStore?.state?.installedSlugs ?? [])],
|
|
38
|
+
settings: settings ? { ...settings } : undefined,
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { safeParseJson, sanitizeDesktopSettings } from '../../../utils/safeStorage.js'
|
|
2
|
+
|
|
3
|
+
const SETTINGS_KEY = 'apphub-desktop-settings'
|
|
4
|
+
|
|
5
|
+
const defaults = {
|
|
6
|
+
snapToGrid: true,
|
|
7
|
+
theme: 'dark',
|
|
8
|
+
groupNames: {},
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function loadDesktopSettings() {
|
|
12
|
+
try {
|
|
13
|
+
const raw = localStorage.getItem(SETTINGS_KEY)
|
|
14
|
+
const parsed = safeParseJson(raw, 64 * 1024)
|
|
15
|
+
const sanitized = parsed ? sanitizeDesktopSettings(parsed) : null
|
|
16
|
+
return { ...defaults, ...sanitized }
|
|
17
|
+
} catch {
|
|
18
|
+
return { ...defaults }
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function applyDesktopSettings(target, source) {
|
|
23
|
+
const sanitized = sanitizeDesktopSettings(source)
|
|
24
|
+
if (!sanitized) return
|
|
25
|
+
if (sanitized.snapToGrid !== undefined) target.snapToGrid = sanitized.snapToGrid
|
|
26
|
+
if (sanitized.theme !== undefined) target.theme = sanitized.theme
|
|
27
|
+
if (sanitized.groupNames) target.groupNames = sanitized.groupNames
|
|
28
|
+
if (sanitized.builtinPositions) target.builtinPositions = sanitized.builtinPositions
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function saveDesktopSettings(settings) {
|
|
32
|
+
try {
|
|
33
|
+
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings))
|
|
34
|
+
} catch {
|
|
35
|
+
/* ignore */
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { clampString, isValidSlug } from '../../../utils/safeStorage.js'
|
|
2
|
+
|
|
3
|
+
const MAX_MANIFEST_BYTES = 64 * 1024
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Parse dropped files into an install intent.
|
|
7
|
+
* Hosted publish: drop .zip (manifest.json inside zip) → draft submit, await DEV approval.
|
|
8
|
+
*/
|
|
9
|
+
export async function parseDropFiles(dataTransfer) {
|
|
10
|
+
let files = [...(dataTransfer?.files ?? [])]
|
|
11
|
+
|
|
12
|
+
if (!files.length && dataTransfer?.items?.length) {
|
|
13
|
+
for (const item of dataTransfer.items) {
|
|
14
|
+
if (item.kind === 'file') {
|
|
15
|
+
const file = item.getAsFile()
|
|
16
|
+
if (file) files.push(file)
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!files.length) return null
|
|
22
|
+
|
|
23
|
+
const zipFile = files.find((f) => /\.zip$/i.test(f.name))
|
|
24
|
+
const jsonFile = files.find((f) =>
|
|
25
|
+
f.name.endsWith('.apphub.json') || f.name === 'manifest.json' || f.type === 'application/json',
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if (zipFile) {
|
|
29
|
+
let manifest = null
|
|
30
|
+
if (jsonFile) {
|
|
31
|
+
manifest = await readManifestFile(jsonFile)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (!manifest || isHostedPublishManifest(manifest)) {
|
|
35
|
+
return buildPublishIntent(zipFile, manifest)
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (jsonFile) {
|
|
40
|
+
const manifest = await readManifestFile(jsonFile)
|
|
41
|
+
if (!manifest) return null
|
|
42
|
+
|
|
43
|
+
if (manifest.source === 'appstore' && manifest.slug) {
|
|
44
|
+
const slug = normalizeSlug(manifest.slug)
|
|
45
|
+
if (!slug) return null
|
|
46
|
+
return {
|
|
47
|
+
method: 'appstore',
|
|
48
|
+
slug,
|
|
49
|
+
name: clampString(manifest.name) || slug,
|
|
50
|
+
icon: clampString(manifest.icon, 16) || '🛒',
|
|
51
|
+
description: clampString(manifest.description, 500),
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (manifest.source === 'local' || (manifest.name && !isHostedPublishManifest(manifest))) {
|
|
56
|
+
const slug = normalizeSlug(manifest.slug ?? manifest.name ?? stripExt(jsonFile.name))
|
|
57
|
+
if (!slug) return null
|
|
58
|
+
return {
|
|
59
|
+
method: 'local',
|
|
60
|
+
slug,
|
|
61
|
+
name: clampString(manifest.name) || stripExt(jsonFile.name),
|
|
62
|
+
icon: clampString(manifest.icon, 16) || pickIcon(files),
|
|
63
|
+
description: clampString(manifest.description, 500),
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const primary = files[0]
|
|
69
|
+
if (primary.size > 50 * 1024 * 1024) return null
|
|
70
|
+
|
|
71
|
+
return {
|
|
72
|
+
method: 'local',
|
|
73
|
+
slug: slugify(stripExt(primary.name)),
|
|
74
|
+
name: stripExt(primary.name),
|
|
75
|
+
icon: pickIcon(files),
|
|
76
|
+
description: '',
|
|
77
|
+
fileName: primary.name,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isHostedPublishManifest(manifest) {
|
|
82
|
+
if (!manifest || typeof manifest !== 'object') return false
|
|
83
|
+
const runtime = typeof manifest.runtime_type === 'string' ? manifest.runtime_type.toLowerCase() : ''
|
|
84
|
+
if (runtime === 'hosted') return true
|
|
85
|
+
if (manifest.source === 'publish' || manifest.source === 'hosted') return true
|
|
86
|
+
return Boolean(manifest.slug && manifest.name && !manifest.entry_url)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function buildPublishIntent(zipFile, manifest) {
|
|
90
|
+
const slug = manifest?.slug ? normalizeSlug(manifest.slug) : null
|
|
91
|
+
const name = manifest?.name
|
|
92
|
+
? clampString(manifest.name)
|
|
93
|
+
: stripExt(zipFile.name)
|
|
94
|
+
|
|
95
|
+
return {
|
|
96
|
+
method: 'publish',
|
|
97
|
+
zipFile,
|
|
98
|
+
manifest,
|
|
99
|
+
slug,
|
|
100
|
+
name: name || stripExt(zipFile.name),
|
|
101
|
+
icon: clampString(manifest?.icon, 16) || '📦',
|
|
102
|
+
description: clampString(manifest?.description ?? manifest?.short_description, 500),
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async function readManifestFile(jsonFile) {
|
|
107
|
+
if (!jsonFile || jsonFile.size > MAX_MANIFEST_BYTES) return null
|
|
108
|
+
try {
|
|
109
|
+
const text = await jsonFile.text()
|
|
110
|
+
if (text.length > MAX_MANIFEST_BYTES) return null
|
|
111
|
+
const manifest = JSON.parse(text)
|
|
112
|
+
if (!manifest || typeof manifest !== 'object' || Array.isArray(manifest)) return null
|
|
113
|
+
return manifest
|
|
114
|
+
} catch {
|
|
115
|
+
return null
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function stripExt(name) {
|
|
120
|
+
return name.replace(/\.[^.]+$/, '')
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function normalizeSlug(value) {
|
|
124
|
+
const slug = slugify(value)
|
|
125
|
+
return isValidSlug(slug) ? slug : null
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function slugify(value) {
|
|
129
|
+
return String(value)
|
|
130
|
+
.toLowerCase()
|
|
131
|
+
.replace(/[^a-z0-9]+/g, '-')
|
|
132
|
+
.replace(/^-|-$/g, '') || 'dropped-app'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function pickIcon(files) {
|
|
136
|
+
const img = files.find((f) => f.type.startsWith('image/'))
|
|
137
|
+
if (img) return '🖼️'
|
|
138
|
+
if (files.some((f) => /\.zip$/i.test(f.name))) return '📦'
|
|
139
|
+
return '📄'
|
|
140
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export function normalizeAppName(name) {
|
|
2
|
+
return String(name ?? '').trim()
|
|
3
|
+
}
|
|
4
|
+
|
|
5
|
+
export function findAppByName(name, apps) {
|
|
6
|
+
const target = normalizeAppName(name).toLowerCase()
|
|
7
|
+
if (!target) return null
|
|
8
|
+
return (apps ?? []).find((a) => normalizeAppName(a.name).toLowerCase() === target) ?? null
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** Next name: "App" → "App 2", existing "App 2" → "App 3" */
|
|
12
|
+
export function nextDuplicateName(baseName, apps) {
|
|
13
|
+
const base = normalizeAppName(baseName)
|
|
14
|
+
const names = new Set((apps ?? []).map((a) => normalizeAppName(a.name)))
|
|
15
|
+
if (!names.has(base)) return base
|
|
16
|
+
let n = 2
|
|
17
|
+
while (names.has(`${base} ${n}`)) n += 1
|
|
18
|
+
return `${base} ${n}`
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function nextDuplicateSlug(baseSlug, apps) {
|
|
22
|
+
const base = String(baseSlug ?? '').trim() || 'app'
|
|
23
|
+
const slugs = new Set((apps ?? []).map((a) => a.slug))
|
|
24
|
+
if (!slugs.has(base)) return base
|
|
25
|
+
let n = 2
|
|
26
|
+
while (slugs.has(`${base}-${n}`)) n += 1
|
|
27
|
+
return `${base}-${n}`
|
|
28
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { safeParseJson } from '../../../utils/safeStorage.js'
|
|
2
|
+
|
|
3
|
+
const STORAGE_KEY = 'apphub-keyboard-settings'
|
|
4
|
+
|
|
5
|
+
/** Browser-safe modifier — Windows (⊞)+arrow is captured by the OS, not the page. */
|
|
6
|
+
export const KEYBOARD_MODIFIER = 'ctrl-alt'
|
|
7
|
+
|
|
8
|
+
const defaults = {
|
|
9
|
+
enabled: true,
|
|
10
|
+
modifier: KEYBOARD_MODIFIER,
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function loadHubKeyboardSettings() {
|
|
14
|
+
try {
|
|
15
|
+
const raw = localStorage.getItem(STORAGE_KEY)
|
|
16
|
+
const parsed = safeParseJson(raw, 8 * 1024)
|
|
17
|
+
const merged = { ...defaults, ...sanitizeHubKeyboardSettings(parsed) }
|
|
18
|
+
return normalizeHubKeyboardSettings(merged)
|
|
19
|
+
} catch {
|
|
20
|
+
return { ...defaults }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function sanitizeHubKeyboardSettings(parsed) {
|
|
25
|
+
if (!parsed || typeof parsed !== 'object') return {}
|
|
26
|
+
return {
|
|
27
|
+
enabled: typeof parsed.enabled === 'boolean' ? parsed.enabled : undefined,
|
|
28
|
+
modifier: parsed.modifier === KEYBOARD_MODIFIER ? KEYBOARD_MODIFIER : undefined,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Coerce legacy Win-key preference saved before browser limitation was enforced. */
|
|
33
|
+
export function normalizeHubKeyboardSettings(settings) {
|
|
34
|
+
return {
|
|
35
|
+
enabled: settings.enabled !== false,
|
|
36
|
+
modifier: KEYBOARD_MODIFIER,
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function saveHubKeyboardSettings(settings) {
|
|
41
|
+
try {
|
|
42
|
+
const normalized = normalizeHubKeyboardSettings(settings)
|
|
43
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(normalized))
|
|
44
|
+
} catch {
|
|
45
|
+
/* ignore */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** @returns {'left'|'right'|'up'|'down'|null} */
|
|
50
|
+
export function matchSnapShortcut(event, settings) {
|
|
51
|
+
if (!settings?.enabled) return null
|
|
52
|
+
if (!event.ctrlKey || !event.altKey) return null
|
|
53
|
+
if (event.metaKey) return null
|
|
54
|
+
|
|
55
|
+
const map = {
|
|
56
|
+
ArrowLeft: 'left',
|
|
57
|
+
ArrowRight: 'right',
|
|
58
|
+
ArrowUp: 'up',
|
|
59
|
+
ArrowDown: 'down',
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
return map[event.key] ?? null
|
|
63
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
const STORAGE_KEY = 'apphub-recent-apps'
|
|
2
|
+
export const MAX_RECENT = 10
|
|
3
|
+
export const MAX_SUGGESTED = 6
|
|
4
|
+
const MAX_OPEN_LOG = 48
|
|
5
|
+
const MAX_OPEN_COUNT = 999_999
|
|
6
|
+
|
|
7
|
+
function clampOpenCount(value) {
|
|
8
|
+
if (typeof value !== 'number' || !Number.isFinite(value) || value < 1) return 1
|
|
9
|
+
return Math.min(Math.floor(value), MAX_OPEN_COUNT)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
function clampOpenedAt(value, fallback = Date.now()) {
|
|
13
|
+
if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value
|
|
14
|
+
return fallback
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sanitizeEntry(entry, fallbackOpenedAt = Date.now()) {
|
|
18
|
+
if (typeof entry === 'string' && entry.length > 0) {
|
|
19
|
+
return { id: entry.slice(0, 80), openCount: 1, openedAt: fallbackOpenedAt }
|
|
20
|
+
}
|
|
21
|
+
if (entry && typeof entry === 'object' && typeof entry.id === 'string' && entry.id.length > 0) {
|
|
22
|
+
const openCount =
|
|
23
|
+
typeof entry.openCount === 'number' ? clampOpenCount(entry.openCount) : 1
|
|
24
|
+
const openedAt = clampOpenedAt(entry.openedAt, fallbackOpenedAt)
|
|
25
|
+
return { id: entry.id.slice(0, 80), openCount, openedAt }
|
|
26
|
+
}
|
|
27
|
+
return null
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function mergeEntries(a, b) {
|
|
31
|
+
return {
|
|
32
|
+
id: a.id,
|
|
33
|
+
openCount: clampOpenCount(Math.max(a.openCount, b.openCount)),
|
|
34
|
+
openedAt: Math.max(a.openedAt, b.openedAt),
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** One entry per app with merged openCount and latest openedAt. */
|
|
39
|
+
export function normalizeOpenLog(log) {
|
|
40
|
+
if (!Array.isArray(log) || !log.length) return []
|
|
41
|
+
const byId = new Map()
|
|
42
|
+
for (const entry of log) {
|
|
43
|
+
const sanitized = sanitizeEntry(entry)
|
|
44
|
+
if (!sanitized) continue
|
|
45
|
+
const prev = byId.get(sanitized.id)
|
|
46
|
+
byId.set(sanitized.id, prev ? mergeEntries(prev, sanitized) : sanitized)
|
|
47
|
+
}
|
|
48
|
+
return [...byId.values()]
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Most recently opened first. */
|
|
52
|
+
export function sortOpenLogByTime(log) {
|
|
53
|
+
return [...normalizeOpenLog(log)].sort((a, b) => b.openedAt - a.openedAt)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Most opened first; ties broken by most recent open. */
|
|
57
|
+
export function sortOpenLogByCount(log) {
|
|
58
|
+
return [...normalizeOpenLog(log)].sort(
|
|
59
|
+
(a, b) => b.openCount - a.openCount || b.openedAt - a.openedAt,
|
|
60
|
+
)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function loadRecentOpenLog() {
|
|
64
|
+
try {
|
|
65
|
+
const raw = localStorage.getItem(STORAGE_KEY)
|
|
66
|
+
if (!raw || typeof raw !== 'string' || raw.length > 16 * 1024) return []
|
|
67
|
+
const parsed = JSON.parse(raw)
|
|
68
|
+
if (!Array.isArray(parsed)) return []
|
|
69
|
+
|
|
70
|
+
const now = Date.now()
|
|
71
|
+
if (parsed.length && typeof parsed[0] === 'string') {
|
|
72
|
+
return normalizeOpenLog(
|
|
73
|
+
parsed.map((id, index) => sanitizeEntry(id, now - index * 1000)),
|
|
74
|
+
).slice(0, MAX_OPEN_LOG)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
return normalizeOpenLog(parsed.map((entry) => sanitizeEntry(entry))).slice(0, MAX_OPEN_LOG)
|
|
78
|
+
} catch {
|
|
79
|
+
return []
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function saveRecentOpenLog(log) {
|
|
84
|
+
try {
|
|
85
|
+
const normalized = normalizeOpenLog(log).slice(0, MAX_OPEN_LOG)
|
|
86
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify(normalized))
|
|
87
|
+
return normalized
|
|
88
|
+
} catch {
|
|
89
|
+
return Array.isArray(log) ? log : []
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function recordRecentApp(appId, currentLog = null) {
|
|
94
|
+
if (!appId || typeof appId !== 'string') {
|
|
95
|
+
return normalizeOpenLog(Array.isArray(currentLog) ? currentLog : loadRecentOpenLog())
|
|
96
|
+
}
|
|
97
|
+
const base = normalizeOpenLog(Array.isArray(currentLog) ? currentLog : loadRecentOpenLog())
|
|
98
|
+
const existing = base.find((entry) => entry.id === appId)
|
|
99
|
+
const now = Date.now()
|
|
100
|
+
const bumped = {
|
|
101
|
+
id: appId,
|
|
102
|
+
openCount: (existing?.openCount ?? 0) + 1,
|
|
103
|
+
openedAt: now,
|
|
104
|
+
}
|
|
105
|
+
const merged = [bumped, ...base.filter((entry) => entry.id !== appId)]
|
|
106
|
+
return saveRecentOpenLog(merged)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function getRecentAppIds(log = loadRecentOpenLog()) {
|
|
110
|
+
return sortOpenLogByTime(log)
|
|
111
|
+
.map((entry) => entry.id)
|
|
112
|
+
.slice(0, MAX_RECENT)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** @deprecated use loadRecentOpenLog */
|
|
116
|
+
export function loadRecentAppIds() {
|
|
117
|
+
return getRecentAppIds()
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function resolveRecentApps(catalog, log = loadRecentOpenLog()) {
|
|
121
|
+
if (!Array.isArray(catalog) || !catalog.length) return []
|
|
122
|
+
const byId = new Map(catalog.map((app) => [app.id, app]))
|
|
123
|
+
return sortOpenLogByTime(log)
|
|
124
|
+
.map((entry) => byId.get(entry.id))
|
|
125
|
+
.filter(Boolean)
|
|
126
|
+
.slice(0, MAX_RECENT)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function resolveSuggestedApps(catalog, log = loadRecentOpenLog(), options = {}) {
|
|
130
|
+
const limit = options.limit ?? MAX_SUGGESTED
|
|
131
|
+
const excludeIds = options.excludeIds ?? new Set()
|
|
132
|
+
const includeBuiltins = options.includeBuiltins === true
|
|
133
|
+
if (!Array.isArray(catalog) || !catalog.length || !Array.isArray(log) || !log.length) return []
|
|
134
|
+
|
|
135
|
+
const byId = new Map(catalog.map((app) => [app.id, app]))
|
|
136
|
+
const result = []
|
|
137
|
+
|
|
138
|
+
for (const entry of sortOpenLogByCount(log)) {
|
|
139
|
+
if (result.length >= limit) break
|
|
140
|
+
if (excludeIds.has(entry.id)) continue
|
|
141
|
+
const app = byId.get(entry.id)
|
|
142
|
+
if (!app) continue
|
|
143
|
+
if (!includeBuiltins && app.builtin) continue
|
|
144
|
+
result.push(app)
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
return result
|
|
148
|
+
}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import { safeParseJson } from '../../../utils/safeStorage.js'
|
|
2
|
+
import { sortOpenLogByTime } from './recentApps.js'
|
|
3
|
+
|
|
4
|
+
const STORAGE_KEY = 'apphub-start-menu-favorites'
|
|
5
|
+
const MAX_FAVORITES = 32
|
|
6
|
+
|
|
7
|
+
export const START_MENU_LIST_MAX = 10
|
|
8
|
+
export const START_MENU_FAVORITES_MAX = 5
|
|
9
|
+
|
|
10
|
+
function sanitizeFavorites(parsed) {
|
|
11
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) return null
|
|
12
|
+
const raw = parsed.favorites ?? parsed
|
|
13
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
|
|
14
|
+
|
|
15
|
+
const favorites = {}
|
|
16
|
+
let count = 0
|
|
17
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
18
|
+
if (count >= MAX_FAVORITES) break
|
|
19
|
+
const id = typeof key === 'string' ? key.slice(0, 80) : ''
|
|
20
|
+
if (!id) continue
|
|
21
|
+
favorites[id] = {
|
|
22
|
+
visible: value && typeof value === 'object' && value.visible === false ? false : true,
|
|
23
|
+
}
|
|
24
|
+
count += 1
|
|
25
|
+
}
|
|
26
|
+
return favorites
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function loadStartMenuFavorites() {
|
|
30
|
+
try {
|
|
31
|
+
const raw = localStorage.getItem(STORAGE_KEY)
|
|
32
|
+
const parsed = safeParseJson(raw, 16 * 1024)
|
|
33
|
+
const favorites = sanitizeFavorites(parsed)
|
|
34
|
+
if (favorites) return favorites
|
|
35
|
+
} catch {
|
|
36
|
+
/* ignore */
|
|
37
|
+
}
|
|
38
|
+
return {}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function saveStartMenuFavorites(favorites) {
|
|
42
|
+
try {
|
|
43
|
+
localStorage.setItem(STORAGE_KEY, JSON.stringify({ favorites }))
|
|
44
|
+
} catch {
|
|
45
|
+
/* ignore */
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isAppFavorite(favorites, appId) {
|
|
50
|
+
return !!(appId && favorites && Object.prototype.hasOwnProperty.call(favorites, appId))
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function isAppVisibleAsFavorite(favorites, appId) {
|
|
54
|
+
if (!isAppFavorite(favorites, appId)) return false
|
|
55
|
+
return favorites[appId].visible !== false
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function favoriteApp(favorites, appId) {
|
|
59
|
+
if (!appId) return
|
|
60
|
+
favorites[appId] = { visible: true }
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function unfavoriteApp(favorites, appId) {
|
|
64
|
+
if (!appId || !favorites) return
|
|
65
|
+
delete favorites[appId]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function setFavoriteVisible(favorites, appId, visible) {
|
|
69
|
+
if (!isAppFavorite(favorites, appId)) return
|
|
70
|
+
favorites[appId].visible = !!visible
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function listFavoriteAppIds(favorites) {
|
|
74
|
+
return Object.keys(favorites ?? {})
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function resolveVisibleFavoriteApps(catalog, favorites) {
|
|
78
|
+
if (!Array.isArray(catalog) || !catalog.length) return []
|
|
79
|
+
const byId = new Map(catalog.map((app) => [app.id, app]))
|
|
80
|
+
return listFavoriteAppIds(favorites)
|
|
81
|
+
.filter((id) => isAppVisibleAsFavorite(favorites, id))
|
|
82
|
+
.map((id) => byId.get(id))
|
|
83
|
+
.filter(Boolean)
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function resolveStartMenuFavoriteApps(catalog, favorites) {
|
|
87
|
+
return resolveVisibleFavoriteApps(catalog, favorites).slice(0, START_MENU_FAVORITES_MAX)
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export function resolveStartMenuRecentApps(catalog, favorites, openLog) {
|
|
91
|
+
const favoriteApps = resolveStartMenuFavoriteApps(catalog, favorites)
|
|
92
|
+
const favoriteIdSet = new Set(favoriteApps.map((app) => app.id))
|
|
93
|
+
const recentSlots = Math.max(0, START_MENU_LIST_MAX - favoriteApps.length)
|
|
94
|
+
const byId = new Map((catalog ?? []).map((app) => [app.id, app]))
|
|
95
|
+
return sortOpenLogByTime(openLog ?? [])
|
|
96
|
+
.filter((entry) => !favoriteIdSet.has(entry.id))
|
|
97
|
+
.slice(0, recentSlots)
|
|
98
|
+
.map((entry) => byId.get(entry.id))
|
|
99
|
+
.filter(Boolean)
|
|
100
|
+
}
|