UwU пре 4 дана
родитељ
комит
a0667b1a63

+ 12 - 1
src/main/ipc/lwe.handlers.ts

@@ -1,4 +1,4 @@
-import { ipcMain, type BrowserWindow } from 'electron'
+import { BrowserWindow, dialog, ipcMain } from 'electron'
 import { IpcChannels } from '@shared/ipc-channels'
 import { getLweStatus, detectDistro, installLweDeps, installLwe, uninstallLwe, launchLweAsync, isLweRunning, killAllLweProcesses, listLweObjects, hotswapLweSettings, listLweProperties, listLweAudioObjects, listLweEffects, type HotswapOptions } from '../services/lwe.service'
 import { stopDisplay } from '../services/display.service'
@@ -6,6 +6,7 @@ import { stopPlaylist } from '../services/playlist-player.service'
 import type { ScreenTarget } from '@shared/types'
 import { invalidateEnvCache } from '../services/wallpaper.service'
 import { getConnectedScreens } from '../utils/platform'
+import { listDesktopApplications } from '../services/shortcuts.service'
 
 export function registerLweHandlers(win: BrowserWindow): void {
   ipcMain.handle(IpcChannels.LWE_STATUS, () => {
@@ -67,6 +68,16 @@ export function registerLweHandlers(win: BrowserWindow): void {
     return getConnectedScreens()
   })
 
+  ipcMain.handle(IpcChannels.LWE_LIST_APPLICATIONS, () => listDesktopApplications())
+
+  ipcMain.handle(IpcChannels.LWE_PICK_SHORTCUT_PATH, async (_e, kind: 'file' | 'directory') => {
+    const result = await dialog.showOpenDialog(BrowserWindow.getFocusedWindow() ?? win, {
+      properties: [kind === 'directory' ? 'openDirectory' : 'openFile'],
+      title: kind === 'directory' ? 'Choose a folder to open' : 'Choose a file to open'
+    })
+    return result.canceled || result.filePaths.length === 0 ? null : result.filePaths[0]
+  })
+
   ipcMain.handle(IpcChannels.LWE_LIST_PROPERTIES, async (_e, wallpaperPath: string) => {
     // Same reasoning as list-objects: some wallpapers have properties linux-wallpaperengine
     // itself can't parse, treat that as "no properties" rather than a hard error.

+ 1 - 1
src/main/services/lwe.service.ts

@@ -1602,7 +1602,7 @@ export async function listLweAudioObjects(wallpaperPath: string): Promise<LweAud
 // Combo option values (before " = ") can contain spaces (e.g. font names), so that
 // line can't be split on whitespace - only on the literal " = " separator.
 const PROPERTY_HEADER_RE =
-  /^(\S+) - (slider|boolean|color|combo|text|scene texture|file|textinput)$/
+  /^(\S+) - (slider|boolean|color|combo|text|scene texture|file|textinput|usershortcut)$/
 const PROPERTY_DETAIL_RE = /^\t(Text|Value|Min|Max|Step): (.*)$/
 const PROPERTY_COMBO_OPTION_RE = /^\t\t(.+?) = (.*)$/
 

+ 47 - 0
src/main/services/shortcuts.service.test.ts

@@ -0,0 +1,47 @@
+import fs from 'fs'
+import os from 'os'
+import path from 'path'
+import { afterEach, beforeEach, describe, expect, it } from 'vitest'
+import { listDesktopApplications } from './shortcuts.service'
+
+let root: string
+const savedEnv = { home: process.env.XDG_DATA_HOME, dirs: process.env.XDG_DATA_DIRS }
+
+function writeEntry(dataDir: string, name: string, body: string): void {
+  const dir = path.join(root, dataDir, 'applications')
+  fs.mkdirSync(dir, { recursive: true })
+  fs.writeFileSync(path.join(dir, name), `[Desktop Entry]\n${body}\n`)
+}
+
+beforeEach(() => {
+  root = fs.mkdtempSync(path.join(os.tmpdir(), 'wem-apps-'))
+  process.env.XDG_DATA_HOME = path.join(root, 'home')
+  process.env.XDG_DATA_DIRS = path.join(root, 'system')
+})
+
+afterEach(() => {
+  fs.rmSync(root, { recursive: true, force: true })
+  process.env.XDG_DATA_HOME = savedEnv.home
+  process.env.XDG_DATA_DIRS = savedEnv.dirs
+})
+
+describe('listDesktopApplications', () => {
+  it('lists visible apps sorted by name', () => {
+    writeEntry('system', 'zed.desktop', 'Type=Application\nName=Zed\nExec=zed')
+    writeEntry('system', 'alpha.desktop', 'Type=Application\nName=Alpha\nName[de]=Alpha DE\nExec=alpha')
+    writeEntry('system', 'hidden.desktop', 'Type=Application\nName=Hidden\nNoDisplay=true')
+    writeEntry('system', 'link.desktop', 'Type=Link\nName=Link\nURL=https://example.org')
+
+    expect(listDesktopApplications().map((a) => a.name)).toEqual(['Alpha', 'Zed'])
+  })
+
+  it('lets the user dir hide a system app with the same id', () => {
+    writeEntry('system', 'editor.desktop', 'Type=Application\nName=System Editor\nExec=editor')
+    writeEntry('home', 'editor.desktop', 'Type=Application\nName=My Editor\nExec=editor')
+
+    const apps = listDesktopApplications()
+    expect(apps).toHaveLength(1)
+    expect(apps[0].name).toBe('My Editor')
+    expect(apps[0].file).toBe(path.join(root, 'home', 'applications', 'editor.desktop'))
+  })
+})

+ 62 - 0
src/main/services/shortcuts.service.ts

@@ -0,0 +1,62 @@
+import fs from 'fs'
+import os from 'os'
+import path from 'path'
+import type { DesktopApplication } from '@shared/types'
+
+function applicationDirs(): string[] {
+  const dataHome = process.env.XDG_DATA_HOME || path.join(os.homedir(), '.local/share')
+  const dataDirs = (process.env.XDG_DATA_DIRS || '/usr/local/share:/usr/share').split(':').filter(Boolean)
+  return [dataHome, ...dataDirs].map((dir) => path.join(dir, 'applications'))
+}
+
+function readDesktopEntry(file: string): Record<string, string> {
+  const entry: Record<string, string> = {}
+  let inEntry = false
+
+  for (const line of fs.readFileSync(file, 'utf8').split('\n')) {
+    if (line.startsWith('[')) {
+      inEntry = line.trim() === '[Desktop Entry]'
+      continue
+    }
+    const separator = line.indexOf('=')
+    if (!inEntry || separator < 0 || line.slice(0, separator).includes('[')) continue
+    const key = line.slice(0, separator).trim()
+    if (!(key in entry)) entry[key] = line.slice(separator + 1).trim()
+  }
+  return entry
+}
+
+/**
+ * Apps the user could pick as a wallpaper shortcut, the way a launcher menu lists them: an id earlier in the
+ * XDG data dirs hides the same id further down, hidden and NoDisplay entries are left out.
+ */
+export function listDesktopApplications(): DesktopApplication[] {
+  const seen = new Set<string>()
+  const result: DesktopApplication[] = []
+
+  for (const dir of applicationDirs()) {
+    let files: string[]
+    try {
+      files = fs.readdirSync(dir, { recursive: true }) as string[]
+    } catch {
+      continue
+    }
+
+    for (const relative of files) {
+      if (!relative.endsWith('.desktop')) continue
+      const id = relative.split(path.sep).join('-')
+      if (seen.has(id)) continue
+      seen.add(id)
+
+      try {
+        const entry = readDesktopEntry(path.join(dir, relative))
+        if (entry.Type !== 'Application' || entry.NoDisplay === 'true' || entry.Hidden === 'true' || !entry.Name) continue
+        result.push({ name: entry.Name, file: path.join(dir, relative) })
+      } catch {
+        // unreadable entries are skipped
+      }
+    }
+  }
+
+  return result.sort((a, b) => a.name.localeCompare(b.name))
+}

+ 6 - 1
src/preload/index.ts

@@ -19,6 +19,7 @@ import type {
   LweProperty,
   LweAudioObject,
   LweSceneEffect,
+  DesktopApplication,
   BackupProgressEvent,
   Playlist,
   PlaylistSettings,
@@ -323,7 +324,11 @@ const api = {
     listAudioObjects: (wallpaperPath: string): Promise<LweAudioObject[]> =>
       ipcRenderer.invoke(IpcChannels.LWE_LIST_AUDIO_OBJECTS, wallpaperPath),
     listEffects: (wallpaperPath: string): Promise<LweSceneEffect[]> =>
-      ipcRenderer.invoke(IpcChannels.LWE_LIST_EFFECTS, wallpaperPath)
+      ipcRenderer.invoke(IpcChannels.LWE_LIST_EFFECTS, wallpaperPath),
+    listApplications: (): Promise<DesktopApplication[]> =>
+      ipcRenderer.invoke(IpcChannels.LWE_LIST_APPLICATIONS),
+    pickShortcutPath: (kind: 'file' | 'directory'): Promise<string | null> =>
+      ipcRenderer.invoke(IpcChannels.LWE_PICK_SHORTCUT_PATH, kind)
   },
 
   schedule: {

+ 7 - 2
src/renderer/src/components/common/DetailSidebar.tsx

@@ -48,6 +48,7 @@ import { getPreviewSrc } from '../../utils/preview'
 import EngineFlagsSection from './sidebar/EngineFlagsSection'
 import CompatSection from './sidebar/CompatSection'
 import ThumbnailSection from './sidebar/ThumbnailSection'
+import { ShortcutRow } from './ShortcutRow'
 import { useDisplayTargets, useScreenAssignments } from '../../hooks/useDisplayTargets'
 import type {
   LweSceneObject,
@@ -193,7 +194,7 @@ function EffectRow({
   )
 }
 
-// Only booleans/sliders/combos are user-facing toggles in the WE customize UI - color/
+// Only booleans/sliders/combos and user shortcuts are user-facing in the WE customize UI - color/
 // text/file/scene-texture/textinput properties are left out of the sidebar entirely.
 function PropertyRow({
   property,
@@ -209,6 +210,10 @@ function PropertyRow({
   const [draft, setDraft] = useState<number | null>(null)
   const label = property.text || property.name
 
+  if (property.type === 'usershortcut') {
+    return <ShortcutRow property={property} value={value} busy={busy} onCommit={onCommit} />
+  }
+
   if (property.type === 'boolean') {
     const on = value === '1' || value === 'true'
     return (
@@ -507,7 +512,7 @@ export default function DetailSidebar({
   })
   // Only these types have an actual editable control in the WE customize UI
   const properties = allProperties.filter(
-    (p) => p.type === 'boolean' || p.type === 'slider' || p.type === 'combo'
+    (p) => p.type === 'boolean' || p.type === 'slider' || p.type === 'combo' || p.type === 'usershortcut'
   )
   const propertyOverrides = libraryMeta?.propertyOverrides ?? {}
 

+ 222 - 0
src/renderer/src/components/common/ShortcutRow.tsx

@@ -0,0 +1,222 @@
+import { useMemo, useState } from 'react'
+import { useQuery } from '@tanstack/react-query'
+import { AppWindow, File, Folder, Globe, Pencil, Terminal, X } from 'lucide-react'
+import clsx from 'clsx'
+import type { DesktopApplication, LweProperty } from '@shared/types'
+
+type ShortcutType = 'application' | 'file' | 'directory' | 'web' | 'command'
+
+interface Shortcut {
+  type: ShortcutType
+  file: string
+  arguments: string
+}
+
+const TYPES: { type: ShortcutType; label: string; icon: typeof File }[] = [
+  { type: 'application', label: 'App', icon: AppWindow },
+  { type: 'file', label: 'File', icon: File },
+  { type: 'directory', label: 'Folder', icon: Folder },
+  { type: 'web', label: 'Website', icon: Globe },
+  { type: 'command', label: 'Command', icon: Terminal }
+]
+
+// the engine's format (the real Wallpaper Engine's, plus "application" for a .desktop file)
+function parseShortcut(value: string): Shortcut | null {
+  if (!value.trim().startsWith('{')) return null
+  try {
+    const json = JSON.parse(value) as { commandtype?: string; file?: string; arguments?: string }
+    if (!json.file || !TYPES.some((t) => t.type === json.commandtype)) return null
+    return { type: json.commandtype as ShortcutType, file: json.file, arguments: json.arguments ?? '' }
+  } catch {
+    return null
+  }
+}
+
+function serializeShortcut(shortcut: Shortcut): string {
+  return JSON.stringify({
+    isbound: true,
+    commandtype: shortcut.type,
+    file: shortcut.file.trim(),
+    ...(shortcut.arguments.trim() ? { arguments: shortcut.arguments.trim() } : {})
+  })
+}
+
+function describe(shortcut: Shortcut, apps: DesktopApplication[]): string {
+  if (shortcut.type === 'application') {
+    return apps.find((a) => a.file === shortcut.file)?.name ?? shortcut.file.split('/').pop() ?? shortcut.file
+  }
+  if (shortcut.type === 'file' || shortcut.type === 'directory') return shortcut.file.split('/').pop() || shortcut.file
+  return shortcut.file
+}
+
+export function ShortcutRow({
+  property,
+  value,
+  busy,
+  onCommit
+}: {
+  property: LweProperty
+  value: string
+  busy: boolean
+  onCommit: (value: string) => void
+}) {
+  const current = parseShortcut(value)
+  const [draft, setDraft] = useState<Shortcut | null>(null)
+  const [search, setSearch] = useState('')
+  const label = property.text || property.name
+
+  const { data: apps = [] } = useQuery({
+    queryKey: ['desktop-applications'],
+    queryFn: () => window.electronAPI.lwe.listApplications(),
+    staleTime: 5 * 60_000
+  })
+
+  const filteredApps = useMemo(() => {
+    const needle = search.trim().toLowerCase()
+    return needle ? apps.filter((a) => a.name.toLowerCase().includes(needle)) : apps
+  }, [apps, search])
+
+  const CurrentIcon = TYPES.find((t) => t.type === current?.type)?.icon
+
+  const pick = async (kind: 'file' | 'directory') => {
+    const picked = await window.electronAPI.lwe.pickShortcutPath(kind)
+    if (picked && draft) setDraft({ ...draft, file: picked })
+  }
+
+  const save = () => {
+    if (!draft || !draft.file.trim()) return
+    onCommit(serializeShortcut(draft))
+    setDraft(null)
+  }
+
+  return (
+    <div className="px-1.5 py-1 text-xs">
+      <div className="flex items-center gap-2">
+        <span className="flex-1 truncate text-gray-300" title={property.name}>
+          {label}
+        </span>
+        <span className="flex max-w-[45%] items-center gap-1 truncate text-gray-500">
+          {current && CurrentIcon && <CurrentIcon size={12} className="shrink-0" />}
+          <span className="truncate">{current ? describe(current, apps) : 'Not assigned'}</span>
+        </span>
+        <button
+          onClick={() => setDraft(draft ? null : (current ?? { type: 'application', file: '', arguments: '' }))}
+          disabled={busy}
+          title={current ? 'Change shortcut' : 'Assign shortcut'}
+          className="rounded p-0.5 text-gray-400 hover:bg-white/10 hover:text-gray-200 disabled:opacity-40"
+        >
+          <Pencil size={12} />
+        </button>
+        {current && (
+          <button
+            onClick={() => onCommit('')}
+            disabled={busy}
+            title="Remove shortcut"
+            className="rounded p-0.5 text-gray-400 hover:bg-white/10 hover:text-gray-200 disabled:opacity-40"
+          >
+            <X size={12} />
+          </button>
+        )}
+      </div>
+
+      {draft && (
+        <div className="mt-1.5 space-y-1.5 rounded bg-white/5 p-1.5">
+          <div className="flex gap-1">
+            {TYPES.map(({ type, label: typeLabel, icon: Icon }) => (
+              <button
+                key={type}
+                onClick={() => setDraft({ type, file: '', arguments: '' })}
+                className={clsx(
+                  'flex flex-1 items-center justify-center gap-1 rounded px-1 py-0.5',
+                  draft.type === type ? 'bg-indigo-500/30 text-indigo-200' : 'text-gray-400 hover:bg-white/5'
+                )}
+              >
+                <Icon size={11} />
+                {typeLabel}
+              </button>
+            ))}
+          </div>
+
+          {draft.type === 'application' && (
+            <>
+              <input
+                value={search}
+                onChange={(e) => setSearch(e.target.value)}
+                placeholder="Search apps"
+                className="w-full rounded bg-black/20 px-1.5 py-0.5 text-gray-200 outline-none"
+              />
+              <div className="max-h-40 overflow-y-auto">
+                {filteredApps.map((app) => (
+                  <button
+                    key={app.file}
+                    onClick={() => setDraft({ ...draft, file: app.file })}
+                    title={app.file}
+                    className={clsx(
+                      'block w-full truncate rounded px-1.5 py-0.5 text-left',
+                      draft.file === app.file ? 'bg-indigo-500/30 text-indigo-200' : 'text-gray-300 hover:bg-white/5'
+                    )}
+                  >
+                    {app.name}
+                  </button>
+                ))}
+              </div>
+            </>
+          )}
+
+          {(draft.type === 'file' || draft.type === 'directory') && (
+            <div className="flex gap-1">
+              <input
+                value={draft.file}
+                onChange={(e) => setDraft({ ...draft, file: e.target.value })}
+                placeholder={draft.type === 'directory' ? '/path/to/folder' : '/path/to/file'}
+                className="min-w-0 flex-1 rounded bg-black/20 px-1.5 py-0.5 text-gray-200 outline-none"
+              />
+              <button
+                onClick={() => pick(draft.type === 'directory' ? 'directory' : 'file')}
+                className="rounded bg-white/5 px-1.5 text-gray-300 hover:bg-white/10"
+              >
+                Browse
+              </button>
+            </div>
+          )}
+
+          {draft.type === 'web' && (
+            <input
+              value={draft.file}
+              onChange={(e) => setDraft({ ...draft, file: e.target.value })}
+              placeholder="https://"
+              className="w-full rounded bg-black/20 px-1.5 py-0.5 text-gray-200 outline-none"
+            />
+          )}
+
+          {draft.type === 'command' && (
+            <>
+              <input
+                value={draft.file}
+                onChange={(e) => setDraft({ ...draft, file: e.target.value })}
+                placeholder="Command"
+                className="w-full rounded bg-black/20 px-1.5 py-0.5 font-mono text-gray-200 outline-none"
+              />
+              <p className="text-[10px] leading-snug text-amber-300/80">
+                Runs through the shell when clicked. Only enter commands you understand.
+              </p>
+            </>
+          )}
+
+          <div className="flex justify-end gap-1">
+            <button onClick={() => setDraft(null)} className="rounded px-2 py-0.5 text-gray-400 hover:bg-white/5">
+              Cancel
+            </button>
+            <button
+              onClick={save}
+              disabled={!draft.file.trim() || busy}
+              className="rounded bg-indigo-500/80 px-2 py-0.5 text-white hover:bg-indigo-500 disabled:opacity-40"
+            >
+              Save
+            </button>
+          </div>
+        </div>
+      )}
+    </div>
+  )
+}

+ 2 - 0
src/shared/ipc-channels.ts

@@ -141,6 +141,8 @@ export const IpcChannels = {
   LWE_LIST_SCREENS: 'lwe:list-screens',
   LWE_LIST_AUDIO_OBJECTS: 'lwe:list-audio-objects',
   LWE_LIST_EFFECTS: 'lwe:list-effects',
+  LWE_LIST_APPLICATIONS: 'lwe:list-applications',
+  LWE_PICK_SHORTCUT_PATH: 'lwe:pick-shortcut-path',
 
   // Desktop icons overlay
   DESKTOP_ICONS_SET_ENABLED: 'desktop-icons:set-enabled',

+ 16 - 1
src/shared/types.ts

@@ -232,7 +232,17 @@ export interface LweAudioObject {
 
 export interface LweProperty {
   name: string
-  type: 'slider' | 'boolean' | 'color' | 'combo' | 'text' | 'scene-texture' | 'file' | 'textinput' | 'unknown'
+  type:
+    | 'slider'
+    | 'boolean'
+    | 'color'
+    | 'combo'
+    | 'text'
+    | 'scene-texture'
+    | 'file'
+    | 'textinput'
+    | 'usershortcut'
+    | 'unknown'
   text?: string
   value: string
   min?: number
@@ -241,6 +251,11 @@ export interface LweProperty {
   options?: { value: string; label: string }[]
 }
 
+export interface DesktopApplication {
+  name: string
+  file: string
+}
+
 export interface WorkshopAuthorInfo {
   steamId: string
   name: string