- System tray (Linux): pure Go D-Bus StatusNotifierItem, no cgo/libappindicator. Close now offers Minimize to Tray vs Quit instead of always quitting. - New APK Installer module: batch-install from a folder or hand-picked files, live per-file progress, select all/none. - Fix Packages/App Inspector silently hiding packages disabled/uninstalled by Canta/Shizuku (pm list packages needs -u to see them). Added a state filter and per-row Restore/Enable/Disable/Uninstall actions. - 6 new theme palettes (Nord, Tokyo Night, Solarized Dark/Light, Rosé Pine, Everforest - 26 total), custom text colour, 8 bundled display fonts, adjustable font size. - Horizontal sidebar now shrinks label text progressively instead of scrolling when the window narrows; fixed a touchpad stuck-scrollbar-drag bug. - Updated README and fixed stale Go 1.23 -> 1.25 build instructions.
376 lines
14 KiB
TypeScript
376 lines
14 KiB
TypeScript
import { useState, useCallback, useMemo } from 'react'
|
|
import { Package, RefreshCw, Search, Download, Trash2, Power, PowerOff, RotateCcw, X } from 'lucide-react'
|
|
import {
|
|
ListPackages, UninstallMultiplePackages, DisableMultiplePackages,
|
|
EnableMultiplePackages, RestoreMultiplePackages, PullApk, ClearData, ForceStopPackage,
|
|
SelectFileForInstall, InstallPackage,
|
|
UninstallPackage, DisablePackage, EnablePackage, RestorePackage
|
|
} from '../../lib/wails'
|
|
import { notify } from '../../lib/notify'
|
|
import { ensureDangerUnlocked } from '../../lib/applock'
|
|
import type { PackageInfo } from '../../lib/types'
|
|
|
|
type Filter = 'all' | 'user' | 'system'
|
|
type StateFilter = 'all' | 'enabled' | 'disabled' | 'uninstalled'
|
|
|
|
export default function ViewPackages() {
|
|
const [packages, setPackages] = useState<PackageInfo[]>([])
|
|
const [loading, setLoading] = useState(false)
|
|
const [filter, setFilter] = useState<Filter>('user')
|
|
const [stateFilter, setStateFilter] = useState<StateFilter>('all')
|
|
const [search, setSearch] = useState('')
|
|
const [selected, setSelected] = useState<Set<string>>(new Set())
|
|
const [activeCtx, setActiveCtx] = useState<string | null>(null)
|
|
|
|
const load = useCallback(async (f: Filter) => {
|
|
setLoading(true)
|
|
setSelected(new Set())
|
|
setPackages([])
|
|
try {
|
|
const pkgs = await ListPackages(f)
|
|
setPackages(pkgs || [])
|
|
} catch (e: any) {
|
|
notify.error(e)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
const filtered = useMemo(() =>
|
|
packages
|
|
.filter(p => p.packageName.toLowerCase().includes(search.toLowerCase()))
|
|
.filter(p => {
|
|
switch (stateFilter) {
|
|
case 'enabled': return p.isInstalled && p.isEnabled
|
|
case 'disabled': return p.isInstalled && !p.isEnabled
|
|
case 'uninstalled': return !p.isInstalled
|
|
default: return true
|
|
}
|
|
})
|
|
.sort((a, b) => a.packageName.localeCompare(b.packageName)),
|
|
[packages, search, stateFilter]
|
|
)
|
|
|
|
const toggleSelect = (pkg: string) => {
|
|
setSelected(prev => {
|
|
const next = new Set(prev)
|
|
next.has(pkg) ? next.delete(pkg) : next.add(pkg)
|
|
return next
|
|
})
|
|
}
|
|
|
|
const selectAll = () => {
|
|
if (selected.size === filtered.length) {
|
|
setSelected(new Set())
|
|
} else {
|
|
setSelected(new Set(filtered.map(p => p.packageName)))
|
|
}
|
|
}
|
|
|
|
const batchOp = async (label: string, op: (pkgs: string[]) => Promise<string>) => {
|
|
if (selected.size === 0) { notify.error('Select packages first'); return }
|
|
if (!(await ensureDangerUnlocked())) return
|
|
const id = notify.loading(`${label} ${selected.size} package(s)...`)
|
|
try {
|
|
const out = await op([...selected])
|
|
notify.dismiss(id)
|
|
notify.success(out)
|
|
load(filter)
|
|
} catch (e: any) {
|
|
notify.dismiss(id)
|
|
notify.error(e)
|
|
}
|
|
}
|
|
|
|
const handleInstall = async () => {
|
|
const path = await SelectFileForInstall()
|
|
if (!path) return
|
|
const id = notify.loading('Installing APK...')
|
|
try {
|
|
const out = await InstallPackage(path)
|
|
notify.dismiss(id)
|
|
notify.success(out || 'Installed successfully')
|
|
load(filter)
|
|
} catch (e: any) {
|
|
notify.dismiss(id)
|
|
notify.error(e)
|
|
}
|
|
}
|
|
|
|
const handlePullApk = async (pkg: string) => {
|
|
const id = notify.loading(`Pulling APK for ${pkg}...`)
|
|
try {
|
|
const out = await PullApk(pkg)
|
|
notify.dismiss(id)
|
|
notify.success(out)
|
|
} catch (e: any) {
|
|
notify.dismiss(id)
|
|
notify.error(e)
|
|
}
|
|
setActiveCtx(null)
|
|
}
|
|
|
|
const handleClearData = async (pkg: string) => {
|
|
if (!confirm(`Clear data for ${pkg}?`)) return
|
|
try {
|
|
const out = await ClearData(pkg)
|
|
notify.success(out)
|
|
} catch (e: any) {
|
|
notify.error(e)
|
|
}
|
|
setActiveCtx(null)
|
|
}
|
|
|
|
const handleForceStop = async (pkg: string) => {
|
|
try {
|
|
const out = await ForceStopPackage(pkg)
|
|
notify.success(out)
|
|
} catch (e: any) {
|
|
notify.error(e)
|
|
}
|
|
setActiveCtx(null)
|
|
}
|
|
|
|
// Single-package state changes, mirroring the batch toolbar actions but
|
|
// reachable per-row from the "..." menu without needing to select first.
|
|
const handleUninstall = async (pkg: string) => {
|
|
setActiveCtx(null)
|
|
if (!confirm(`Uninstall ${pkg} for the current user?`)) return
|
|
if (!(await ensureDangerUnlocked())) return
|
|
const id = notify.loading(`Uninstalling ${pkg}...`)
|
|
try {
|
|
const out = await UninstallPackage(pkg)
|
|
notify.dismiss(id)
|
|
notify.success(out)
|
|
load(filter)
|
|
} catch (e: any) {
|
|
notify.dismiss(id)
|
|
notify.error(e)
|
|
}
|
|
}
|
|
|
|
const handleDisable = async (pkg: string) => {
|
|
setActiveCtx(null)
|
|
if (!(await ensureDangerUnlocked())) return
|
|
const id = notify.loading(`Disabling ${pkg}...`)
|
|
try {
|
|
const out = await DisablePackage(pkg)
|
|
notify.dismiss(id)
|
|
notify.success(out)
|
|
load(filter)
|
|
} catch (e: any) {
|
|
notify.dismiss(id)
|
|
notify.error(e)
|
|
}
|
|
}
|
|
|
|
const handleEnable = async (pkg: string) => {
|
|
setActiveCtx(null)
|
|
const id = notify.loading(`Enabling ${pkg}...`)
|
|
try {
|
|
const out = await EnablePackage(pkg)
|
|
notify.dismiss(id)
|
|
notify.success(out)
|
|
load(filter)
|
|
} catch (e: any) {
|
|
notify.dismiss(id)
|
|
notify.error(e)
|
|
}
|
|
}
|
|
|
|
const handleRestore = async (pkg: string) => {
|
|
setActiveCtx(null)
|
|
const id = notify.loading(`Restoring ${pkg}...`)
|
|
try {
|
|
const out = await RestorePackage(pkg)
|
|
notify.dismiss(id)
|
|
notify.success(out)
|
|
load(filter)
|
|
} catch (e: any) {
|
|
notify.dismiss(id)
|
|
notify.error(e)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col h-full">
|
|
{/* Toolbar */}
|
|
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-2 shrink-0 flex-wrap">
|
|
{/* Filter tabs */}
|
|
<div className="flex gap-1 bg-bg-raised rounded p-0.5">
|
|
{(['user', 'system', 'all'] as Filter[]).map(f => (
|
|
<button
|
|
key={f}
|
|
onClick={() => { setFilter(f); load(f) }}
|
|
className={`px-3 py-1 rounded text-xs font-medium transition-colors ${
|
|
filter === f ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:text-text-secondary'
|
|
}`}
|
|
>
|
|
{f.charAt(0).toUpperCase() + f.slice(1)}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
<button onClick={() => load(filter)} disabled={loading} className="btn-ghost text-xs">
|
|
<RefreshCw size={13} className={loading ? 'animate-spin' : ''} />
|
|
{loading ? 'Loading...' : 'Load'}
|
|
</button>
|
|
|
|
<div className="relative flex-1 min-w-[180px]">
|
|
<Search size={13} className="absolute left-3 top-1/2 -translate-y-1/2 text-text-muted" />
|
|
<input
|
|
className="input pl-8 text-xs"
|
|
placeholder="Filter packages..."
|
|
value={search}
|
|
onChange={e => setSearch(e.target.value)}
|
|
/>
|
|
</div>
|
|
|
|
<select
|
|
className="input text-xs"
|
|
value={stateFilter}
|
|
onChange={e => setStateFilter(e.target.value as StateFilter)}
|
|
title="Filter by state"
|
|
>
|
|
<option value="all">All states</option>
|
|
<option value="enabled">Enabled</option>
|
|
<option value="disabled">Disabled</option>
|
|
<option value="uninstalled">Uninstalled</option>
|
|
</select>
|
|
|
|
<div className="w-px h-5 bg-bg-border" />
|
|
|
|
<button onClick={handleInstall} className="btn-primary text-xs">
|
|
<Package size={13} /> Install APK
|
|
</button>
|
|
|
|
{selected.size > 0 && (
|
|
<>
|
|
<button onClick={() => batchOp('Uninstalling', UninstallMultiplePackages)} className="btn-danger text-xs">
|
|
<Trash2 size={13} /> Uninstall ({selected.size})
|
|
</button>
|
|
<button onClick={() => batchOp('Disabling', DisableMultiplePackages)} className="btn-warn text-xs">
|
|
<PowerOff size={13} /> Disable ({selected.size})
|
|
</button>
|
|
<button onClick={() => batchOp('Enabling', EnableMultiplePackages)} className="btn-ghost text-xs">
|
|
<Power size={13} /> Enable ({selected.size})
|
|
</button>
|
|
<button onClick={() => batchOp('Restoring', RestoreMultiplePackages)} className="btn-ghost text-xs text-accent-green">
|
|
<RotateCcw size={13} /> Restore ({selected.size})
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
|
|
{/* Package list header */}
|
|
<div className="grid grid-cols-[24px_1fr_80px_100px] gap-2 px-4 py-1.5 border-b border-bg-border text-text-muted text-xs shrink-0">
|
|
<input
|
|
type="checkbox"
|
|
checked={filtered.length > 0 && selected.size === filtered.length}
|
|
onChange={selectAll}
|
|
className="accent-accent-green"
|
|
/>
|
|
<span>Package</span>
|
|
<span>State</span>
|
|
<span>Actions</span>
|
|
</div>
|
|
|
|
{/* Package list */}
|
|
<div className="flex-1 overflow-auto relative">
|
|
{loading && (
|
|
<div className="flex items-center justify-center h-32">
|
|
<div className="w-5 h-5 border-2 border-accent-green border-t-transparent rounded-full animate-spin" />
|
|
</div>
|
|
)}
|
|
{!loading && packages.length === 0 && (
|
|
<div className="flex flex-col items-center justify-center h-32 gap-2">
|
|
<Package size={24} className="text-text-muted" />
|
|
<p className="text-text-muted text-sm">Click "Load" to fetch packages</p>
|
|
</div>
|
|
)}
|
|
{filtered.map(pkg => (
|
|
<div
|
|
key={pkg.packageName}
|
|
className={`
|
|
grid grid-cols-[24px_1fr_80px_100px] gap-2 px-4 py-2
|
|
border-b border-bg-border/50 items-center text-xs
|
|
hover:bg-bg-raised transition-colors
|
|
${selected.has(pkg.packageName) ? 'bg-accent-green/5' : ''}
|
|
`}
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={selected.has(pkg.packageName)}
|
|
onChange={() => toggleSelect(pkg.packageName)}
|
|
className="accent-accent-green"
|
|
/>
|
|
<span className="mono text-text-secondary truncate">{pkg.packageName}</span>
|
|
<span>
|
|
{!pkg.isInstalled ? (
|
|
<span className="badge-gray" title="Present on the system image but uninstalled for this user (e.g. via Canta/Shizuku)">uninstalled</span>
|
|
) : (
|
|
<span className={pkg.isEnabled ? 'badge-green' : 'badge-red'}>
|
|
{pkg.isEnabled ? 'enabled' : 'disabled'}
|
|
</span>
|
|
)}
|
|
</span>
|
|
<div className="relative">
|
|
<button
|
|
onClick={() => setActiveCtx(activeCtx === pkg.packageName ? null : pkg.packageName)}
|
|
className="btn-ghost text-xs py-0.5 px-2"
|
|
>
|
|
···
|
|
</button>
|
|
{activeCtx === pkg.packageName && (
|
|
<div className="absolute right-0 top-full mt-1 bg-bg-raised border border-bg-border rounded shadow-xl z-10 min-w-[160px]">
|
|
{!pkg.isInstalled && (
|
|
<>
|
|
<button onClick={() => handleRestore(pkg.packageName)} className="flex items-center gap-2 w-full px-3 py-2 text-xs text-accent-green hover:bg-bg-surface">
|
|
<RotateCcw size={12} /> Restore
|
|
</button>
|
|
<button onClick={() => handleDisable(pkg.packageName)} className="flex items-center gap-2 w-full px-3 py-2 text-xs text-warn hover:bg-bg-surface">
|
|
<PowerOff size={12} /> Disable
|
|
</button>
|
|
</>
|
|
)}
|
|
{pkg.isInstalled && (
|
|
<>
|
|
<button onClick={() => handlePullApk(pkg.packageName)} className="flex items-center gap-2 w-full px-3 py-2 text-xs text-text-secondary hover:text-text-primary hover:bg-bg-surface">
|
|
<Download size={12} /> Pull APK
|
|
</button>
|
|
<button onClick={() => handleClearData(pkg.packageName)} className="flex items-center gap-2 w-full px-3 py-2 text-xs text-warn hover:bg-bg-surface">
|
|
<X size={12} /> Clear Data
|
|
</button>
|
|
<button onClick={() => handleForceStop(pkg.packageName)} className="flex items-center gap-2 w-full px-3 py-2 text-xs text-danger hover:bg-bg-surface">
|
|
<PowerOff size={12} /> Force Stop
|
|
</button>
|
|
<div className="h-px bg-bg-border my-1" />
|
|
{pkg.isEnabled ? (
|
|
<button onClick={() => handleDisable(pkg.packageName)} className="flex items-center gap-2 w-full px-3 py-2 text-xs text-warn hover:bg-bg-surface">
|
|
<PowerOff size={12} /> Disable
|
|
</button>
|
|
) : (
|
|
<button onClick={() => handleEnable(pkg.packageName)} className="flex items-center gap-2 w-full px-3 py-2 text-xs text-accent-green hover:bg-bg-surface">
|
|
<Power size={12} /> Enable
|
|
</button>
|
|
)}
|
|
<button onClick={() => handleUninstall(pkg.packageName)} className="flex items-center gap-2 w-full px-3 py-2 text-xs text-danger hover:bg-bg-surface">
|
|
<Trash2 size={12} /> Uninstall
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
{/* Status bar */}
|
|
<div className="border-t border-bg-border px-4 py-1.5 flex items-center justify-between text-xs text-text-muted">
|
|
<span>{filtered.length} packages{search ? ` matching "${search}"` : ''}</span>
|
|
{selected.size > 0 && <span>{selected.size} selected</span>}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|