Publish full source including logcat visual map engine
This commit is contained in:
parent
10902567d8
commit
ac609bc80c
132 changed files with 19071 additions and 2759 deletions
|
|
@ -4,9 +4,6 @@
|
|||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>ATK — Android Toolkit</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Sans:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -9,10 +9,15 @@
|
|||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fontsource/ibm-plex-sans": "^5.2.8",
|
||||
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||
"@types/three": "^0.184.1",
|
||||
"lucide-react": "^0.383.0",
|
||||
"pixi.js": "^8.18.1",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"sonner": "^1.7.4"
|
||||
"sonner": "^1.7.4",
|
||||
"three": "^0.184.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/react": "^18.3.28",
|
||||
|
|
@ -22,6 +27,7 @@
|
|||
"postcss": "^8.5.8",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"typescript": "^5.9.3",
|
||||
"vite": "^5.4.21"
|
||||
"vite": "^5.4.21",
|
||||
"vite-plugin-javascript-obfuscator": "^3.1.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +1 @@
|
|||
d772c5ee4d5ec9453e4b361871c1c91f
|
||||
bfb47127747332de1e5119ef153cca76
|
||||
1047
frontend/pnpm-lock.yaml
generated
1047
frontend/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load diff
|
|
@ -1,48 +1,67 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Toaster } from 'sonner'
|
||||
import Sidebar from './components/layout/Sidebar'
|
||||
import TitleBar from './components/layout/TitleBar'
|
||||
import DismissibleBanner from './components/DismissibleBanner'
|
||||
import LockGate from './components/LockGate'
|
||||
import DangerGate from './components/DangerGate'
|
||||
import ViewDashboard from './components/views/ViewDashboard'
|
||||
import ViewFiles from './components/views/ViewFiles'
|
||||
import ViewScreenMirror from './components/views/ViewScreenMirror'
|
||||
import ViewPackages from './components/views/ViewPackages'
|
||||
import ViewDebloater from './components/views/ViewDebloater'
|
||||
import ViewShell from './components/views/ViewShell'
|
||||
import ViewLogcat from './components/views/ViewLogcat'
|
||||
import ViewAppInspect from './components/views/ViewAppInspect'
|
||||
import ViewApkAudit from './components/views/ViewApkAudit'
|
||||
import ViewCerts from './components/views/ViewCerts'
|
||||
import ViewBackup from './components/views/ViewBackup'
|
||||
import ViewProps from './components/views/ViewProps'
|
||||
import ViewFlasher from './components/views/ViewFlasher'
|
||||
import ViewPixelFlasher from './components/views/ViewPixelFlasher'
|
||||
import ViewUtilities from './components/views/ViewUtilities'
|
||||
import ViewSettings from './components/views/ViewSettings'
|
||||
import { CheckSystemRequirements } from './lib/wails'
|
||||
import { getSidebarPosition, onSidebarPositionChange, getSidebarLabels, onSidebarLabelsChange } from './lib/layout'
|
||||
import { refreshAppLockStatus } from './lib/applock'
|
||||
import type { View } from './lib/types'
|
||||
|
||||
export default function App() {
|
||||
const [view, setView] = useState<View>('dashboard')
|
||||
const [ready, setReady] = useState(false)
|
||||
const [initError, setInitError] = useState('')
|
||||
const [sidebarPos, setSidebarPos] = useState(getSidebarPosition())
|
||||
const [sidebarLabels, setSidebarLabels] = useState(getSidebarLabels())
|
||||
const [locked, setLocked] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
CheckSystemRequirements()
|
||||
.then(() => setReady(true))
|
||||
.catch((err: string) => { setInitError(err); setReady(true) })
|
||||
// Resolve the lock status before anything else so the gate can show.
|
||||
refreshAppLockStatus()
|
||||
.then(s => setLocked(s.enabled))
|
||||
.finally(() => {
|
||||
CheckSystemRequirements()
|
||||
.then(() => setReady(true))
|
||||
.catch((err: string) => { setInitError(err); setReady(true) })
|
||||
})
|
||||
}, [])
|
||||
|
||||
useEffect(() => onSidebarPositionChange(setSidebarPos), [])
|
||||
useEffect(() => onSidebarLabelsChange(setSidebarLabels), [])
|
||||
|
||||
const renderView = () => {
|
||||
switch (view) {
|
||||
case 'dashboard': return <ViewDashboard />
|
||||
case 'files': return <ViewFiles />
|
||||
case 'mirror': return <ViewScreenMirror />
|
||||
case 'packages': return <ViewPackages />
|
||||
case 'debloater': return <ViewDebloater />
|
||||
case 'shell': return <ViewShell />
|
||||
case 'logcat': return <ViewLogcat />
|
||||
case 'appinspect': return <ViewAppInspect />
|
||||
case 'apkaudit': return <ViewApkAudit />
|
||||
case 'certs': return <ViewCerts />
|
||||
case 'backup': return <ViewBackup />
|
||||
case 'props': return <ViewProps />
|
||||
case 'flasher': return <ViewFlasher />
|
||||
case 'pixelflasher': return <ViewPixelFlasher />
|
||||
case 'utilities': return <ViewUtilities />
|
||||
case 'settings': return <ViewSettings />
|
||||
default: return <ViewDashboard />
|
||||
|
|
@ -58,25 +77,36 @@ export default function App() {
|
|||
</div>
|
||||
)
|
||||
|
||||
if (locked) return <LockGate onUnlock={() => setLocked(false)} />
|
||||
|
||||
const sidebar = <Sidebar activeView={view} onViewChange={setView} position={sidebarPos} showLabels={sidebarLabels} />
|
||||
|
||||
return (
|
||||
<div className="flex h-full bg-bg-base overflow-hidden">
|
||||
<Sidebar activeView={view} onViewChange={setView} />
|
||||
<main className="flex-1 overflow-hidden flex flex-col">
|
||||
{initError && (
|
||||
<div className="bg-danger/10 border-b border-danger/20 px-4 py-2 text-danger text-sm flex items-center gap-2">
|
||||
<span className="font-mono">⚠</span>
|
||||
<span>{initError}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto">{renderView()}</div>
|
||||
</main>
|
||||
<div className="flex flex-col h-full bg-bg-base overflow-hidden rounded-[10px]">
|
||||
<DangerGate />
|
||||
<TitleBar />
|
||||
<div className={`flex-1 flex overflow-hidden ${sidebarPos === 'left' ? 'flex-row' : 'flex-col'}`}>
|
||||
{sidebarPos !== 'bottom' && sidebar}
|
||||
<main className="flex-1 overflow-hidden flex flex-col">
|
||||
{initError && (
|
||||
<DismissibleBanner
|
||||
id={`init-error:${initError}`}
|
||||
className="bg-danger/10 border-b border-danger/20 px-4 py-2 text-danger text-sm"
|
||||
>
|
||||
<span className="font-mono">⚠</span>
|
||||
<span>{initError}</span>
|
||||
</DismissibleBanner>
|
||||
)}
|
||||
<div className="flex-1 overflow-auto">{renderView()}</div>
|
||||
</main>
|
||||
{sidebarPos === 'bottom' && sidebar}
|
||||
</div>
|
||||
<Toaster
|
||||
position="bottom-right"
|
||||
theme="dark"
|
||||
toastOptions={{
|
||||
style: {
|
||||
background: '#18181f', border: '1px solid #252530',
|
||||
color: '#e8e8f0', fontFamily: "'IBM Plex Sans', sans-serif", fontSize: '13px',
|
||||
background: 'rgb(var(--bg-raised))', border: '1px solid rgb(var(--bg-border))',
|
||||
color: 'rgb(var(--text-primary))', fontFamily: "'IBM Plex Sans', sans-serif", fontSize: '13px',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
|
|
|||
76
frontend/src/components/DangerGate.tsx
Normal file
76
frontend/src/components/DangerGate.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { useEffect, useState } from 'react'
|
||||
import { ShieldAlert } from 'lucide-react'
|
||||
import { _registerDangerHost, tryUnlockDanger, type DangerRequest } from '../lib/applock'
|
||||
|
||||
// Modal host for the destructive-action re-auth prompt. Mounted once in App.tsx.
|
||||
// ensureDangerUnlocked() (lib/applock) drives it: when a destructive action
|
||||
// needs re-auth, it hands us a request whose `resolve` we call with the outcome.
|
||||
export default function DangerGate() {
|
||||
const [req, setReq] = useState<DangerRequest | null>(null)
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
useEffect(() => _registerDangerHost(r => {
|
||||
setPassword('')
|
||||
setError('')
|
||||
setReq(r)
|
||||
}), [])
|
||||
|
||||
if (!req) return null
|
||||
|
||||
const close = (ok: boolean) => {
|
||||
req.resolve(ok)
|
||||
setReq(null)
|
||||
}
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!password || busy) return
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const ok = await tryUnlockDanger(password)
|
||||
if (ok) { close(true); return }
|
||||
setError('Incorrect password')
|
||||
setPassword('')
|
||||
} catch (err: any) {
|
||||
setError(String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||
onMouseDown={e => { if (e.target === e.currentTarget) close(false) }}
|
||||
>
|
||||
<form onSubmit={submit} className="card p-5 w-80 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldAlert size={18} className="text-warn shrink-0" />
|
||||
<p className="text-sm font-medium text-text-primary">Confirm with password</p>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
This is a destructive action. Re-enter your app password to continue. You won't be
|
||||
asked again for a few minutes.
|
||||
</p>
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
className="input text-sm w-full"
|
||||
placeholder="App password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button type="button" onClick={() => close(false)} className="btn-ghost text-xs">Cancel</button>
|
||||
<button type="submit" disabled={!password || busy} className="btn-primary text-xs">
|
||||
{busy ? 'Verifying…' : 'Confirm'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
frontend/src/components/DismissibleBanner.tsx
Normal file
32
frontend/src/components/DismissibleBanner.tsx
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import { useState, type ReactNode } from 'react'
|
||||
import { X } from 'lucide-react'
|
||||
import { isDismissed, dismiss } from '../lib/dismissible'
|
||||
|
||||
interface Props {
|
||||
/** Stable unique id - dismissal is remembered against this. */
|
||||
id: string
|
||||
/** Container classes (background, border, padding, text colour). */
|
||||
className?: string
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* A banner the user can permanently hide with the ✕ button. The dismissal is
|
||||
* remembered across restarts (keyed by `id`). Renders nothing once dismissed.
|
||||
*/
|
||||
export default function DismissibleBanner({ id, className = '', children }: Props) {
|
||||
const [hidden, setHidden] = useState(() => isDismissed(id))
|
||||
if (hidden) return null
|
||||
return (
|
||||
<div className={`flex items-start gap-2 ${className}`}>
|
||||
<div className="flex-1 flex items-start gap-2 min-w-0">{children}</div>
|
||||
<button
|
||||
onClick={() => { dismiss(id); setHidden(true) }}
|
||||
title="Hide this message"
|
||||
className="shrink-0 -my-0.5 -mr-1 p-1 rounded opacity-50 hover:opacity-100 transition-opacity"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
55
frontend/src/components/LockGate.tsx
Normal file
55
frontend/src/components/LockGate.tsx
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { useState } from 'react'
|
||||
import { Lock } from 'lucide-react'
|
||||
import { VerifyAppPassword } from '../lib/wails'
|
||||
|
||||
// Full-window launch gate. Rendered in place of the app when the lock is
|
||||
// enabled and the session hasn't been unlocked yet. The backend stores only a
|
||||
// salted scrypt hash; this just verifies and reveals the UI.
|
||||
export default function LockGate({ onUnlock }: { onUnlock: () => void }) {
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!password || busy) return
|
||||
setBusy(true)
|
||||
setError('')
|
||||
try {
|
||||
const ok = await VerifyAppPassword(password)
|
||||
if (ok) { onUnlock(); return }
|
||||
setError('Incorrect password')
|
||||
setPassword('')
|
||||
} catch (err: any) {
|
||||
setError(String(err))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full items-center justify-center bg-bg-base rounded-[10px]">
|
||||
<form onSubmit={submit} className="card p-6 w-80 space-y-4 text-center">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="w-12 h-12 rounded-full bg-bg-raised flex items-center justify-center">
|
||||
<Lock size={22} className="text-accent-green" />
|
||||
</div>
|
||||
<p className="text-sm font-medium text-text-primary">ATK is locked</p>
|
||||
<p className="text-xs text-text-muted">Enter your app password to continue</p>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
className="input text-sm w-full text-center"
|
||||
placeholder="Password"
|
||||
value={password}
|
||||
onChange={e => setPassword(e.target.value)}
|
||||
/>
|
||||
{error && <p className="text-xs text-danger">{error}</p>}
|
||||
<button type="submit" disabled={!password || busy} className="btn-primary text-sm w-full justify-center">
|
||||
{busy ? 'Unlocking…' : 'Unlock'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,77 +1,167 @@
|
|||
import {
|
||||
LayoutDashboard, FolderOpen, Package, Terminal,
|
||||
Zap, Wrench, Settings, Radio, Shield, Smartphone,
|
||||
ScrollText, Search, Lock, Archive, SlidersHorizontal
|
||||
Zap, Wrench, Settings, Shield,
|
||||
ScrollText, Search, Lock, Archive, SlidersHorizontal, ScanSearch, MonitorSmartphone
|
||||
} from 'lucide-react'
|
||||
import { useState, useEffect, useMemo, useRef } from 'react'
|
||||
import type { View } from '../../lib/types'
|
||||
import type { SidebarPosition } from '../../lib/layout'
|
||||
import { getHiddenViews, onHiddenViewsChange, getNavOrder, setNavOrder } from '../../lib/featureflags'
|
||||
|
||||
interface Props {
|
||||
activeView: View
|
||||
onViewChange: (v: View) => void
|
||||
position: SidebarPosition
|
||||
showLabels: boolean
|
||||
}
|
||||
|
||||
const navItems: { view: View; icon: React.ReactNode; label: string; dividerBefore?: boolean }[] = [
|
||||
interface NavItem { view: View; icon: React.ReactNode; label: string; dividerBefore?: boolean }
|
||||
|
||||
const navItems: NavItem[] = [
|
||||
{ view: 'dashboard', icon: <LayoutDashboard size={17} />, label: 'Dashboard' },
|
||||
{ view: 'files', icon: <FolderOpen size={17} />, label: 'Files' },
|
||||
{ view: 'mirror', icon: <MonitorSmartphone size={17} />, label: 'Screen Mirror' },
|
||||
{ view: 'packages', icon: <Package size={17} />, label: 'Packages' },
|
||||
{ view: 'debloater', icon: <Shield size={17} />, label: 'Debloater' },
|
||||
{ view: 'shell', icon: <Terminal size={17} />, label: 'Shell' },
|
||||
{ view: 'logcat', icon: <ScrollText size={17} />, label: 'Logcat', dividerBefore: true },
|
||||
{ view: 'appinspect', icon: <Search size={17} />, label: 'App Inspector' },
|
||||
{ view: 'apkaudit', icon: <ScanSearch size={17} />, label: 'APK Audit' },
|
||||
{ view: 'certs', icon: <Lock size={17} />, label: 'Certificates' },
|
||||
{ view: 'backup', icon: <Archive size={17} />, label: 'Backup' },
|
||||
{ view: 'props', icon: <SlidersHorizontal size={17}/>, label: 'Prop Editor' },
|
||||
{ view: 'utilities', icon: <Wrench size={17} />, label: 'Utilities', dividerBefore: true },
|
||||
{ view: 'flasher', icon: <Zap size={17} />, label: 'Flasher' },
|
||||
{ view: 'pixelflasher', icon: <Smartphone size={17} />, label: 'Pixel Flash' },
|
||||
]
|
||||
|
||||
export default function Sidebar({ activeView, onViewChange }: Props) {
|
||||
return (
|
||||
<aside className="w-[52px] flex flex-col bg-bg-surface border-r border-bg-border shrink-0">
|
||||
<div className="h-12 flex items-center justify-center border-b border-bg-border shrink-0">
|
||||
<Radio size={18} className="text-accent-green" />
|
||||
</div>
|
||||
interface DragProps {
|
||||
onDragStart: (e: React.DragEvent) => void
|
||||
onDragOver: (e: React.DragEvent) => void
|
||||
onDragLeave: () => void
|
||||
onDrop: (e: React.DragEvent) => void
|
||||
onDragEnd: () => void
|
||||
over: boolean
|
||||
}
|
||||
|
||||
<nav className="flex-1 flex flex-col gap-0.5 p-1 pt-1.5 overflow-auto">
|
||||
{navItems.map(({ view, icon, label, dividerBefore }) => (
|
||||
<div key={view}>
|
||||
{dividerBefore && <div className="w-full h-px bg-bg-border my-1" />}
|
||||
<button
|
||||
onClick={() => onViewChange(view)}
|
||||
title={label}
|
||||
className={`
|
||||
w-full flex items-center justify-center h-8 rounded
|
||||
transition-all duration-150 relative
|
||||
${activeView === view
|
||||
? 'bg-accent-green/10 text-accent-green'
|
||||
: 'text-text-muted hover:text-text-secondary hover:bg-bg-raised'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{icon}
|
||||
{activeView === view && (
|
||||
<span className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 bg-accent-green rounded-r" />
|
||||
)}
|
||||
</button>
|
||||
export default function Sidebar({ activeView, onViewChange, position, showLabels }: Props) {
|
||||
const horizontal = position !== 'left'
|
||||
|
||||
const [hidden, setHidden] = useState<string[]>(getHiddenViews())
|
||||
useEffect(() => onHiddenViewsChange(setHidden), [])
|
||||
|
||||
// Drag-to-reorder (dock style). Saved order first, then any new defaults.
|
||||
const [order, setOrder] = useState<string[]>(getNavOrder())
|
||||
const dragRef = useRef<string | null>(null)
|
||||
const [overView, setOverView] = useState<string | null>(null)
|
||||
|
||||
const ordered = useMemo(() => {
|
||||
const map = new Map(navItems.map(i => [i.view as string, i]))
|
||||
const seen = new Set<string>()
|
||||
const res: NavItem[] = []
|
||||
for (const v of order) {
|
||||
const it = map.get(v)
|
||||
if (it) { res.push(it); seen.add(v) }
|
||||
}
|
||||
for (const it of navItems) if (!seen.has(it.view)) res.push(it)
|
||||
return res
|
||||
}, [order])
|
||||
|
||||
const visibleItems = ordered.filter(i => !hidden.includes(i.view))
|
||||
|
||||
const handleDrop = (target: string) => {
|
||||
const from = dragRef.current
|
||||
dragRef.current = null
|
||||
setOverView(null)
|
||||
if (!from || from === target) return
|
||||
const base = ordered.map(i => i.view as string)
|
||||
const fi = base.indexOf(from)
|
||||
const ti = base.indexOf(target)
|
||||
if (fi < 0 || ti < 0) return
|
||||
base.splice(fi, 1)
|
||||
base.splice(ti, 0, from)
|
||||
setOrder(base)
|
||||
setNavOrder(base)
|
||||
}
|
||||
|
||||
const edgeBorder =
|
||||
position === 'left' ? 'border-r' : position === 'top' ? 'border-b' : 'border-t'
|
||||
|
||||
const asideCls = horizontal
|
||||
? `${showLabels ? 'h-[68px]' : 'h-[52px]'} w-full flex flex-row items-center bg-bg-surface ${edgeBorder} border-bg-border shrink-0`
|
||||
: `${showLabels ? 'w-[84px]' : 'w-[52px]'} flex flex-col bg-bg-surface ${edgeBorder} border-bg-border shrink-0`
|
||||
|
||||
const navCls = horizontal
|
||||
? 'flex-1 flex flex-row items-center justify-center gap-0.5 px-1 overflow-x-auto'
|
||||
: 'flex-1 flex flex-col gap-0.5 p-1 pt-1.5 overflow-auto'
|
||||
|
||||
const dividerCls = horizontal ? 'h-7 w-px bg-bg-border mx-1' : 'w-full h-px bg-bg-border my-1'
|
||||
|
||||
const settingsWrapCls = horizontal
|
||||
? 'px-1 h-full flex items-center border-l border-bg-border shrink-0'
|
||||
: 'p-1 pb-1.5 border-t border-bg-border shrink-0'
|
||||
|
||||
const btnSizing = !showLabels
|
||||
? 'w-8 h-8'
|
||||
: horizontal
|
||||
? 'flex-col gap-1 px-2 py-1.5 min-w-[3.25rem] h-full justify-center'
|
||||
: 'flex-col gap-1 px-1 py-1.5 w-full'
|
||||
|
||||
const labelCls = `text-[10px] leading-tight text-center ${horizontal ? 'whitespace-nowrap' : ''}`
|
||||
|
||||
const renderButton = (view: View | 'settings', icon: React.ReactNode, label: string, drag?: DragProps) => {
|
||||
const active = activeView === view
|
||||
return (
|
||||
<button
|
||||
draggable={!!drag}
|
||||
onDragStart={drag?.onDragStart}
|
||||
onDragOver={drag?.onDragOver}
|
||||
onDragLeave={drag?.onDragLeave}
|
||||
onDrop={drag?.onDrop}
|
||||
onDragEnd={drag?.onDragEnd}
|
||||
onClick={() => onViewChange(view as View)}
|
||||
title={label}
|
||||
className={`
|
||||
flex items-center justify-center rounded transition-all duration-150 relative
|
||||
${btnSizing}
|
||||
${drag ? 'cursor-grab active:cursor-grabbing' : ''}
|
||||
${drag?.over ? 'ring-1 ring-accent-green ring-inset' : ''}
|
||||
${active
|
||||
? 'bg-accent-green/10 text-accent-green'
|
||||
: 'text-text-muted hover:text-text-secondary hover:bg-bg-raised'
|
||||
}
|
||||
`}
|
||||
>
|
||||
{icon}
|
||||
{showLabels && <span className={labelCls}>{label}</span>}
|
||||
{active && (
|
||||
horizontal
|
||||
? <span className="absolute bottom-0 left-1/2 -translate-x-1/2 h-0.5 w-5 bg-accent-green rounded-t" />
|
||||
: <span className="absolute left-0 top-1/2 -translate-y-1/2 w-0.5 h-5 bg-accent-green rounded-r" />
|
||||
)}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<aside className={asideCls}>
|
||||
<nav className={navCls}>
|
||||
{visibleItems.map(({ view, icon, label, dividerBefore }, idx) => (
|
||||
<div key={view} className={horizontal ? 'flex items-center' : undefined}>
|
||||
{dividerBefore && idx > 0 && <div className={dividerCls} />}
|
||||
{renderButton(view, icon, label, {
|
||||
onDragStart: e => { dragRef.current = view; e.dataTransfer.setData('text/plain', view); e.dataTransfer.effectAllowed = 'move' },
|
||||
onDragOver: e => { e.preventDefault(); if (overView !== view) setOverView(view) },
|
||||
onDragLeave: () => setOverView(s => (s === view ? null : s)),
|
||||
onDrop: e => { e.preventDefault(); handleDrop(view) },
|
||||
onDragEnd: () => { dragRef.current = null; setOverView(null) },
|
||||
over: overView === view && dragRef.current !== view,
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="p-1 pb-1.5 border-t border-bg-border shrink-0">
|
||||
<button
|
||||
onClick={() => onViewChange('settings')}
|
||||
title="Settings"
|
||||
className={`
|
||||
w-full flex items-center justify-center h-8 rounded transition-all duration-150
|
||||
${activeView === 'settings'
|
||||
? 'bg-accent-green/10 text-accent-green'
|
||||
: 'text-text-muted hover:text-text-secondary hover:bg-bg-raised'
|
||||
}
|
||||
`}
|
||||
>
|
||||
<Settings size={17} />
|
||||
</button>
|
||||
<div className={settingsWrapCls}>
|
||||
{renderButton('settings', <Settings size={17} />, 'Settings')}
|
||||
</div>
|
||||
</aside>
|
||||
)
|
||||
|
|
|
|||
38
frontend/src/components/layout/TitleBar.tsx
Normal file
38
frontend/src/components/layout/TitleBar.tsx
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
// Custom frameless title bar. The window is Frameless (main.go), which on GTK
|
||||
// also removes the native title (so no app name shows). This thin bar provides
|
||||
// the drag region via Wails' `--wails-draggable:drag` CSS hint, plus macOS-style
|
||||
// traffic-light controls tinted in Catppuccin pastels. No app name by design.
|
||||
|
||||
// Runtime is injected by Wails on window['runtime'] (same access pattern as
|
||||
// ViewLogcat.tsx); guarded with ?. so a browser dev session won't crash.
|
||||
const rt = () => (window as any)['runtime']
|
||||
|
||||
function TrafficLight({ color, hover, title, onClick }: {
|
||||
color: string; hover: string; title: string; onClick: () => void
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
title={title}
|
||||
style={{ backgroundColor: color }}
|
||||
className={`w-3 h-3 rounded-full transition-colors ${hover}`}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export default function TitleBar() {
|
||||
return (
|
||||
<div
|
||||
className="titlebar h-8 shrink-0 flex items-center gap-2 px-3 bg-bg-surface border-b border-bg-border"
|
||||
style={{ '--wails-draggable': 'drag' } as React.CSSProperties}
|
||||
>
|
||||
{/* Catppuccin Frappé: green #a6d189, peach/yellow #e5c890, red #e78284.
|
||||
Right-aligned (ml-auto), close at the far edge. */}
|
||||
<div className="flex items-center gap-2 ml-auto" style={{ '--wails-draggable': 'no-drag' } as React.CSSProperties}>
|
||||
<TrafficLight color="#e5c890" hover="hover:brightness-110" title="Minimise" onClick={() => rt()?.WindowMinimise?.()} />
|
||||
<TrafficLight color="#a6d189" hover="hover:brightness-110" title="Maximise" onClick={() => rt()?.WindowToggleMaximise?.()} />
|
||||
<TrafficLight color="#e78284" hover="hover:brightness-110" title="Close" onClick={() => rt()?.Quit?.()} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
2004
frontend/src/components/views/LogcatMap.tsx
Normal file
2004
frontend/src/components/views/LogcatMap.tsx
Normal file
File diff suppressed because it is too large
Load diff
584
frontend/src/components/views/ViewApkAudit.tsx
Normal file
584
frontend/src/components/views/ViewApkAudit.tsx
Normal file
|
|
@ -0,0 +1,584 @@
|
|||
import { useState, useMemo } from 'react'
|
||||
import {
|
||||
ScanSearch, FileUp, Package, Shield, AlertTriangle, FileCode,
|
||||
Lock, FolderTree, Search, ChevronRight, Activity, Radar,
|
||||
Download, X,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
SelectAPKForAudit, AuditAPK, AuditInstalledApp, ListPackages,
|
||||
ReadAPKEntry, ExportAudit,
|
||||
} from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import type { APKAudit, APKAuditFinding, APKEntryContent, PackageInfo } from '../../lib/types'
|
||||
|
||||
type Tab = 'overview' | 'findings' | 'manifest' | 'components' | 'cert' | 'explorer'
|
||||
type Severity = 'critical' | 'high' | 'medium' | 'low' | 'info'
|
||||
|
||||
const SEV_ORDER: Severity[] = ['critical', 'high', 'medium', 'low', 'info']
|
||||
|
||||
function sevText(s: string): string {
|
||||
switch (s) {
|
||||
case 'critical': return 'text-danger'
|
||||
case 'high': return 'text-danger'
|
||||
case 'medium': return 'text-warn'
|
||||
case 'low': return 'text-text-secondary'
|
||||
default: return 'text-text-muted'
|
||||
}
|
||||
}
|
||||
|
||||
function sevBadge(s: string): string {
|
||||
switch (s) {
|
||||
case 'critical': return 'bg-danger/20 text-danger border border-danger/30'
|
||||
case 'high': return 'bg-danger/10 text-danger border border-danger/20'
|
||||
case 'medium': return 'bg-warn/15 text-warn border border-warn/25'
|
||||
case 'low': return 'bg-bg-raised text-text-secondary border border-bg-border'
|
||||
default: return 'bg-bg-raised text-text-muted border border-bg-border'
|
||||
}
|
||||
}
|
||||
|
||||
function scoreColor(score: number): string {
|
||||
if (score >= 75) return 'text-accent-green'
|
||||
if (score >= 40) return 'text-warn'
|
||||
return 'text-danger'
|
||||
}
|
||||
|
||||
function formatBytes(n: number): string {
|
||||
if (!n) return '0 B'
|
||||
const u = ['B', 'KB', 'MB', 'GB']
|
||||
const i = Math.floor(Math.log(n) / Math.log(1024))
|
||||
return `${(n / Math.pow(1024, i)).toFixed(i ? 1 : 0)} ${u[i]}`
|
||||
}
|
||||
|
||||
export default function ViewApkAudit() {
|
||||
const [result, setResult] = useState<APKAudit | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [tab, setTab] = useState<Tab>('overview')
|
||||
|
||||
// package picker
|
||||
const [search, setSearch] = useState('')
|
||||
const [packages, setPackages] = useState<PackageInfo[]>([])
|
||||
const [pkgsLoaded, setPkgsLoaded] = useState(false)
|
||||
const [showPicker, setShowPicker] = useState(false)
|
||||
|
||||
// findings controls
|
||||
const [findFilter, setFindFilter] = useState<Severity | 'all'>('all')
|
||||
const [findSearch, setFindSearch] = useState('')
|
||||
const [openFinding, setOpenFinding] = useState<string | null>(null)
|
||||
|
||||
// explorer
|
||||
const [fileSearch, setFileSearch] = useState('')
|
||||
const [entry, setEntry] = useState<APKEntryContent | null>(null)
|
||||
const [entryPath, setEntryPath] = useState('')
|
||||
const [entryLoading, setEntryLoading] = useState(false)
|
||||
|
||||
// export
|
||||
const [showExport, setShowExport] = useState(false)
|
||||
const [exporting, setExporting] = useState(false)
|
||||
|
||||
const loadPackages = async () => {
|
||||
if (pkgsLoaded) return
|
||||
try {
|
||||
const pkgs = await ListPackages('all')
|
||||
setPackages(pkgs || [])
|
||||
setPkgsLoaded(true)
|
||||
} catch { /* device may be offline; ignore */ }
|
||||
}
|
||||
|
||||
const run = async (fn: () => Promise<APKAudit>) => {
|
||||
setLoading(true); setResult(null); setTab('overview'); setShowPicker(false)
|
||||
setFindFilter('all'); setFindSearch(''); setOpenFinding(null)
|
||||
setEntry(null); setEntryPath(''); setFileSearch(''); setShowExport(false)
|
||||
try {
|
||||
setResult(await fn())
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const auditFile = async () => {
|
||||
const path = await SelectAPKForAudit()
|
||||
if (path) run(() => AuditAPK(path))
|
||||
}
|
||||
|
||||
const openEntry = async (path: string) => {
|
||||
if (!result) return
|
||||
setEntryPath(path); setEntry(null); setEntryLoading(true)
|
||||
try {
|
||||
setEntry(await ReadAPKEntry(result.localPath, path))
|
||||
} catch (e: any) {
|
||||
notify.error(e); setEntryPath('')
|
||||
} finally {
|
||||
setEntryLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const doExport = async (format: 'json' | 'csv' | 'sarif') => {
|
||||
if (!result) return
|
||||
setShowExport(false); setExporting(true)
|
||||
try {
|
||||
const path = await ExportAudit(result, format)
|
||||
if (path) notify.success(`Exported to ${path}`)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
} finally {
|
||||
setExporting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const filteredPkgs = packages
|
||||
.filter(p => p.packageName.toLowerCase().includes(search.toLowerCase()))
|
||||
|
||||
const findings = result?.findings ?? []
|
||||
const visibleFindings = useMemo(() => findings.filter(f => {
|
||||
if (findFilter !== 'all' && f.severity !== findFilter) return false
|
||||
if (findSearch) {
|
||||
const q = findSearch.toLowerCase()
|
||||
return (f.title + f.category + f.cwe + f.masvs).toLowerCase().includes(q)
|
||||
}
|
||||
return true
|
||||
}), [findings, findFilter, findSearch])
|
||||
|
||||
const visibleFiles = useMemo(() => (result?.files ?? []).filter(f =>
|
||||
!fileSearch || f.path.toLowerCase().includes(fileSearch.toLowerCase())
|
||||
).slice(0, 2000), [result, fileSearch])
|
||||
|
||||
const dangerousPerms = (result?.permissions ?? []).filter(p => p.dangerous)
|
||||
|
||||
const tabs: { id: Tab; label: string; icon: React.ReactNode }[] = [
|
||||
{ id: 'overview', label: 'Overview', icon: <Package size={12} /> },
|
||||
{ id: 'findings', label: `Findings (${findings.length})`, icon: <AlertTriangle size={12} /> },
|
||||
{ id: 'manifest', label: 'Manifest', icon: <FileCode size={12} /> },
|
||||
{ id: 'components', label: `Components (${result?.components?.length || 0})`, icon: <Activity size={12} /> },
|
||||
{ id: 'cert', label: 'Cert', icon: <Lock size={12} /> },
|
||||
{ id: 'explorer', label: `Explorer (${result?.files?.length || 0})`, icon: <FolderTree size={12} /> },
|
||||
]
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden">
|
||||
{/* Source bar */}
|
||||
<div className="border-b border-bg-border px-4 py-3 flex items-center gap-3 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<ScanSearch size={16} className="text-accent-green" />
|
||||
<span className="section-title">APK Audit</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 ml-2">
|
||||
<button onClick={auditFile} disabled={loading} className="btn-primary text-xs">
|
||||
<FileUp size={12} /> Browse APK…
|
||||
</button>
|
||||
<div className="relative">
|
||||
<button
|
||||
onClick={() => { setShowPicker(v => !v); loadPackages() }}
|
||||
disabled={loading}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
<Package size={12} /> Installed app…
|
||||
</button>
|
||||
{showPicker && (
|
||||
<div className="absolute z-20 mt-1 w-72 bg-bg-surface border border-bg-border rounded shadow-lg">
|
||||
<div className="p-2 border-b border-bg-border">
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
autoFocus
|
||||
className="input pl-7 text-xs w-full"
|
||||
placeholder="Filter packages…"
|
||||
value={search}
|
||||
onChange={e => setSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="max-h-64 overflow-auto">
|
||||
{!pkgsLoaded && <p className="text-text-muted text-xs p-3 text-center">Loading… (device must be connected)</p>}
|
||||
{pkgsLoaded && filteredPkgs.length === 0 && (
|
||||
<p className="text-text-muted text-xs p-3 text-center">No matching packages</p>
|
||||
)}
|
||||
{filteredPkgs.map(p => (
|
||||
<button
|
||||
key={p.packageName}
|
||||
onClick={() => run(() => AuditInstalledApp(p.packageName))}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-text-secondary hover:bg-bg-raised hover:text-text-primary border-b border-bg-border/30 truncate mono"
|
||||
>
|
||||
{p.packageName}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Empty / loading */}
|
||||
{!result && !loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3 text-text-muted">
|
||||
<ScanSearch size={36} className="opacity-20" />
|
||||
<p className="text-sm">Browse for an APK file or pick an installed app to audit</p>
|
||||
<p className="text-xs opacity-70">Static analysis: manifest, signing, permissions, components, secrets & trackers</p>
|
||||
</div>
|
||||
)}
|
||||
{loading && (
|
||||
<div className="flex flex-col items-center justify-center h-full gap-3">
|
||||
<div className="w-6 h-6 border-2 border-accent-green border-t-transparent rounded-full animate-spin" />
|
||||
<p className="text-text-muted text-xs">Auditing… (pulling & parsing DEX, this can take a few seconds)</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<>
|
||||
{/* Header */}
|
||||
<div className="border-b border-bg-border px-4 py-3 flex items-center gap-4 shrink-0">
|
||||
<div className={`text-3xl font-bold ${scoreColor(result.score)}`}>{result.grade}</div>
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm text-text-primary truncate">
|
||||
{result.appLabel || result.fileName} <span className="text-text-muted mono text-xs">({result.packageName})</span>
|
||||
</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
v{result.versionName} (code {result.versionCode}) · SDK {result.minSdk}–{result.targetSdk} · {formatBytes(result.fileSize)} · score {result.score}/100
|
||||
</p>
|
||||
</div>
|
||||
<div className="ml-auto flex items-center gap-3">
|
||||
<div className="flex gap-1.5 flex-wrap justify-end">
|
||||
{SEV_ORDER.map(s => (result.counts?.[s] ? (
|
||||
<span key={s} className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${sevBadge(s)}`}>
|
||||
{result.counts[s]} {s}
|
||||
</span>
|
||||
) : null))}
|
||||
</div>
|
||||
<div className="relative shrink-0">
|
||||
<button onClick={() => setShowExport(v => !v)} disabled={exporting} className="btn-ghost text-xs">
|
||||
<Download size={12} /> {exporting ? 'Exporting…' : 'Export'}
|
||||
</button>
|
||||
{showExport && (
|
||||
<div className="absolute right-0 z-20 mt-1 w-32 bg-bg-surface border border-bg-border rounded shadow-lg">
|
||||
{(['json', 'csv', 'sarif'] as const).map(f => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => doExport(f)}
|
||||
className="w-full text-left px-3 py-1.5 text-xs text-text-secondary hover:bg-bg-raised hover:text-text-primary uppercase mono border-b border-bg-border/30 last:border-0"
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-bg-border flex shrink-0 overflow-x-auto">
|
||||
{tabs.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => setTab(t.id)}
|
||||
className={`flex items-center gap-1.5 px-3 py-2 text-xs whitespace-nowrap border-b-2 transition-colors ${
|
||||
tab === t.id ? 'border-accent-green text-accent-green'
|
||||
: 'border-transparent text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{t.icon} {t.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto p-4">
|
||||
{/* OVERVIEW */}
|
||||
{tab === 'overview' && (
|
||||
<div className="space-y-5">
|
||||
<div className="grid grid-cols-2 gap-x-8 gap-y-2">
|
||||
{[
|
||||
{ label: 'Package', value: result.packageName },
|
||||
{ label: 'Version', value: `${result.versionName} (${result.versionCode})` },
|
||||
{ label: 'SDK', value: `min ${result.minSdk} · target ${result.targetSdk} · compile ${result.compileSdk}` },
|
||||
{ label: 'Source', value: result.source === 'device' ? 'Installed app' : result.path },
|
||||
{ label: 'SHA-256', value: result.sha256 },
|
||||
{ label: 'Size', value: formatBytes(result.fileSize) },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label} className="flex gap-2 min-w-0">
|
||||
<span className="text-text-muted text-xs w-24 shrink-0">{label}</span>
|
||||
<span className="text-xs text-text-primary mono truncate" title={value}>{value || 'N/A'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* manifest flags */}
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{result.debuggable && <span className="badge-red">debuggable</span>}
|
||||
{result.allowBackup && <span className="badge-yellow">allowBackup</span>}
|
||||
{result.usesCleartext && <span className="badge-yellow">cleartext traffic</span>}
|
||||
{result.hasNetworkSecurityConfig && <span className="badge-green">network-security-config</span>}
|
||||
{result.cert.verified
|
||||
? <span className="badge-green">signature verified</span>
|
||||
: <span className="badge-red">unsigned / unverified</span>}
|
||||
{result.cert.v3 && <span className="badge-gray">v3 sig</span>}
|
||||
{result.cert.v2 && <span className="badge-gray">v2 sig</span>}
|
||||
{result.cert.v1 && <span className="badge-gray">v1 sig</span>}
|
||||
</div>
|
||||
|
||||
{/* dangerous perms */}
|
||||
<div>
|
||||
<p className="section-title mb-2">Dangerous permissions ({dangerousPerms.length})</p>
|
||||
{dangerousPerms.length === 0 && <p className="text-text-muted text-xs">None of the runtime-dangerous permissions are requested.</p>}
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{dangerousPerms.map(p => (
|
||||
<span key={p.name} className="px-1.5 py-0.5 rounded text-[10px] bg-warn/10 text-warn border border-warn/20 mono">
|
||||
{p.name.replace('android.permission.', '')}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* trackers */}
|
||||
<div>
|
||||
<p className="section-title mb-2 flex items-center gap-1.5"><Radar size={12} /> Trackers / SDKs ({result.trackers?.length || 0})</p>
|
||||
{(!result.trackers || result.trackers.length === 0) && <p className="text-text-muted text-xs">No known tracker SDK signatures detected.</p>}
|
||||
<div className="space-y-1">
|
||||
{result.trackers?.map(tr => (
|
||||
<div key={tr.name} className="flex items-center gap-2 text-xs py-0.5">
|
||||
<span className="text-text-primary w-44 truncate">{tr.name}</span>
|
||||
<span className="text-text-muted w-32">{tr.category}</span>
|
||||
<span className="text-text-muted">×{tr.matches}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* FINDINGS */}
|
||||
{tab === 'findings' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{(['all', ...SEV_ORDER] as const).map(s => (
|
||||
<button
|
||||
key={s}
|
||||
onClick={() => setFindFilter(s)}
|
||||
className={`px-2 py-0.5 rounded text-[10px] capitalize ${
|
||||
findFilter === s ? 'bg-accent-green/15 text-accent-green border border-accent-green/30'
|
||||
: 'bg-bg-raised text-text-muted border border-bg-border'
|
||||
}`}
|
||||
>
|
||||
{s}{s !== 'all' && result.counts?.[s] ? ` ${result.counts[s]}` : ''}
|
||||
</button>
|
||||
))}
|
||||
<div className="relative ml-auto">
|
||||
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
className="input pl-7 text-xs w-48"
|
||||
placeholder="Search findings…"
|
||||
value={findSearch}
|
||||
onChange={e => setFindSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{visibleFindings.length === 0 && (
|
||||
<p className="text-text-muted text-xs py-6 text-center">No findings match.</p>
|
||||
)}
|
||||
{visibleFindings.map(f => (
|
||||
<FindingRow
|
||||
key={f.id}
|
||||
f={f}
|
||||
open={openFinding === f.id}
|
||||
onToggle={() => setOpenFinding(openFinding === f.id ? null : f.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MANIFEST */}
|
||||
{tab === 'manifest' && (
|
||||
<div className="space-y-4">
|
||||
<div>
|
||||
<p className="section-title mb-2">Permissions ({result.permissions?.length || 0})</p>
|
||||
<div className="space-y-0.5">
|
||||
{result.permissions?.map(p => (
|
||||
<div key={p.name} className="flex items-center gap-2 py-0.5 border-b border-bg-border/30">
|
||||
<Shield size={11} className={p.dangerous ? 'text-warn shrink-0' : 'text-text-muted shrink-0'} />
|
||||
<span className="mono text-xs text-text-secondary">{p.name}</span>
|
||||
{p.dangerous && <span className="badge-yellow ml-auto">dangerous</span>}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="section-title mb-2">Decoded AndroidManifest.xml</p>
|
||||
<pre className="mono text-xs text-text-secondary whitespace-pre-wrap break-words leading-relaxed bg-bg-raised rounded p-3 border border-bg-border max-h-[55vh] overflow-auto">
|
||||
{result.manifestXml || 'Not available'}
|
||||
</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* COMPONENTS */}
|
||||
{tab === 'components' && (
|
||||
<div className="space-y-4">
|
||||
{['activity', 'service', 'receiver', 'provider'].map(type => {
|
||||
const items = result.components.filter(c => c.type === type)
|
||||
return (
|
||||
<div key={type}>
|
||||
<p className="section-title mb-2 capitalize">{type} ({items.length})</p>
|
||||
{items.length === 0 && <p className="text-text-muted text-xs">None</p>}
|
||||
{items.map((c, i) => (
|
||||
<div key={c.name + i} className="py-1 border-b border-bg-border/30">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="mono text-xs text-text-secondary truncate">{c.name}</span>
|
||||
{c.exported && <span className="badge-red shrink-0">exported</span>}
|
||||
{!c.exported && c.exportedImplicit && <span className="badge-yellow shrink-0">implicit export</span>}
|
||||
{c.permission && <span className="badge-gray shrink-0" title={c.permission}>protected</span>}
|
||||
</div>
|
||||
{c.intentFilters?.filter(Boolean).length > 0 && (
|
||||
<p className="text-[10px] text-text-muted mt-0.5 pl-1">↳ {c.intentFilters.filter(Boolean).join(' · ')}</p>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* CERT */}
|
||||
{tab === 'cert' && (
|
||||
<div className="space-y-3">
|
||||
<div className="flex gap-1.5 flex-wrap">
|
||||
{result.cert.verified ? <span className="badge-green">verified</span> : <span className="badge-red">does not verify</span>}
|
||||
{result.cert.v1 && <span className="badge-gray">v1 scheme</span>}
|
||||
{result.cert.v2 && <span className="badge-gray">v2 scheme</span>}
|
||||
{result.cert.v3 && <span className="badge-gray">v3 scheme</span>}
|
||||
{result.cert.isDebug && <span className="badge-red">debug cert</span>}
|
||||
{result.cert.expired && <span className="badge-yellow">expired</span>}
|
||||
{result.cert.weakAlgo && <span className="badge-red">weak algorithm</span>}
|
||||
</div>
|
||||
{result.cert.error && (
|
||||
<p className="text-xs text-danger bg-danger/10 border border-danger/20 rounded px-3 py-1.5">{result.cert.error}</p>
|
||||
)}
|
||||
{[
|
||||
{ label: 'Subject', value: result.cert.subject },
|
||||
{ label: 'Issuer', value: result.cert.issuer },
|
||||
{ label: 'Algorithm', value: result.cert.sigAlgo },
|
||||
{ label: 'Serial', value: result.cert.serial },
|
||||
{ label: 'Valid from', value: result.cert.validFrom },
|
||||
{ label: 'Valid to', value: result.cert.validTo },
|
||||
{ label: 'SHA-256', value: result.cert.sha256 },
|
||||
{ label: 'SHA-1', value: result.cert.sha1 },
|
||||
].map(({ label, value }) => (
|
||||
<div key={label}>
|
||||
<p className="text-xs text-text-muted mb-0.5">{label}</p>
|
||||
<p className="mono text-xs text-text-primary bg-bg-raised rounded px-3 py-1.5 break-all">{value || 'N/A'}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* EXPLORER */}
|
||||
{tab === 'explorer' && (
|
||||
<div className="flex gap-3 h-full min-h-0">
|
||||
{/* file list */}
|
||||
<div className="w-72 shrink-0 flex flex-col min-h-0">
|
||||
<div className="relative mb-2">
|
||||
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
className="input pl-7 text-xs w-full"
|
||||
placeholder="Filter files…"
|
||||
value={fileSearch}
|
||||
onChange={e => setFileSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="border border-bg-border rounded overflow-auto flex-1">
|
||||
{visibleFiles.map(f => (
|
||||
<button
|
||||
key={f.path}
|
||||
onClick={() => openEntry(f.path)}
|
||||
className={`w-full flex items-center gap-2 px-2.5 py-1 text-xs border-b border-bg-border/30 text-left ${
|
||||
entryPath === f.path ? 'bg-accent-green/10' : 'hover:bg-bg-raised'
|
||||
}`}
|
||||
>
|
||||
<FileCode size={11} className="text-text-muted shrink-0" />
|
||||
<span className="mono text-text-secondary truncate flex-1">{f.path}</span>
|
||||
<span className="text-text-muted shrink-0">{formatBytes(f.size)}</span>
|
||||
</button>
|
||||
))}
|
||||
{(result.files?.length || 0) > visibleFiles.length && (
|
||||
<p className="text-text-muted text-[10px] p-2 text-center">Showing {visibleFiles.length} of {result.files.length} — refine the filter.</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* viewer */}
|
||||
<div className="flex-1 min-w-0 flex flex-col border border-bg-border rounded overflow-hidden">
|
||||
{!entryPath && (
|
||||
<div className="flex items-center justify-center h-full text-text-muted text-xs">Select a file to view its contents</div>
|
||||
)}
|
||||
{entryPath && (
|
||||
<>
|
||||
<div className="flex items-center gap-2 px-3 py-1.5 border-b border-bg-border bg-bg-surface shrink-0">
|
||||
<span className="mono text-xs text-text-primary truncate flex-1">{entryPath}</span>
|
||||
{entry && <span className="text-[10px] text-text-muted shrink-0">{entry.kind} · {formatBytes(entry.size)}{entry.truncated ? ' · truncated' : ''}</span>}
|
||||
<button onClick={() => { setEntry(null); setEntryPath('') }} className="text-text-muted hover:text-text-primary shrink-0"><X size={13} /></button>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{entryLoading && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="w-5 h-5 border-2 border-accent-green border-t-transparent rounded-full animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
{entry?.kind === 'image' && (
|
||||
<div className="p-4 flex items-center justify-center bg-bg-base">
|
||||
<img src={`data:${entry.mime};base64,${entry.base64}`} alt={entry.name} className="max-w-full max-h-[55vh] object-contain" />
|
||||
</div>
|
||||
)}
|
||||
{entry?.kind === 'text' && (
|
||||
<pre className="mono text-[11px] text-text-secondary whitespace-pre-wrap break-words leading-relaxed p-3">{entry.text}</pre>
|
||||
)}
|
||||
{entry?.kind === 'binary' && (
|
||||
<pre className="mono text-[11px] text-text-secondary whitespace-pre p-3 leading-snug">{entry.hex}</pre>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function FindingRow({ f, open, onToggle }: { f: APKAuditFinding; open: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<div className="border border-bg-border rounded overflow-hidden">
|
||||
<button onClick={onToggle} className="w-full flex items-center gap-2 px-3 py-2 text-left hover:bg-bg-raised">
|
||||
<ChevronRight size={13} className={`text-text-muted shrink-0 transition-transform ${open ? 'rotate-90' : ''}`} />
|
||||
<span className={`px-1.5 py-0.5 rounded text-[10px] font-medium uppercase shrink-0 ${sevBadge(f.severity)}`}>{f.severity}</span>
|
||||
<span className="text-xs text-text-primary flex-1">{f.title}</span>
|
||||
{f.matches?.length > 0 && <span className="text-[10px] text-text-muted shrink-0">{f.matches.length} match{f.matches.length > 1 ? 'es' : ''}</span>}
|
||||
</button>
|
||||
{open && (
|
||||
<div className="px-3 pb-3 pt-1 space-y-2 bg-bg-base/50">
|
||||
<p className="text-xs text-text-secondary">{f.description}</p>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{f.cwe && <span className="badge-gray">{f.cwe}</span>}
|
||||
{f.masvs && <span className="badge-gray">{f.masvs}</span>}
|
||||
<span className="badge-gray">{f.category}</span>
|
||||
<span className="badge-gray">confidence {f.confidence}%</span>
|
||||
</div>
|
||||
{f.matches?.length > 0 && (
|
||||
<div className="space-y-0.5 mt-1">
|
||||
{f.matches.map((m, i) => (
|
||||
<div key={i} className="flex gap-2 text-[11px] mono bg-bg-raised rounded px-2 py-1">
|
||||
{m.file && <span className="text-text-muted shrink-0">{m.file}</span>}
|
||||
<span className="text-text-secondary break-all">{m.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -13,6 +13,32 @@ export default function ViewAppInspect() {
|
|||
const [pinning, setPinning] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('overview')
|
||||
const [showManifest, setShowManifest] = useState(false)
|
||||
// Width of the package picker rail. Draggable so long package names (which
|
||||
// truncate at the old fixed 256px) can be read in full. Persisted.
|
||||
const [panelW, setPanelW] = useState(() => {
|
||||
const v = parseInt(localStorage.getItem('atk-appinspect-w') || '', 10)
|
||||
return Number.isFinite(v) ? Math.min(560, Math.max(200, v)) : 256
|
||||
})
|
||||
|
||||
const startResize = (e: React.MouseEvent) => {
|
||||
e.preventDefault()
|
||||
const startX = e.clientX
|
||||
const startW = panelW
|
||||
let latest = startW
|
||||
document.body.style.userSelect = 'none'
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
latest = Math.min(560, Math.max(200, startW + ev.clientX - startX))
|
||||
setPanelW(latest)
|
||||
}
|
||||
const onUp = () => {
|
||||
window.removeEventListener('mousemove', onMove)
|
||||
window.removeEventListener('mouseup', onUp)
|
||||
document.body.style.userSelect = ''
|
||||
localStorage.setItem('atk-appinspect-w', String(latest))
|
||||
}
|
||||
window.addEventListener('mousemove', onMove)
|
||||
window.addEventListener('mouseup', onUp)
|
||||
}
|
||||
|
||||
const loadPackages = async () => {
|
||||
if (pkgsLoaded) return
|
||||
|
|
@ -51,7 +77,7 @@ export default function ViewAppInspect() {
|
|||
|
||||
const filtered = packages.filter(p =>
|
||||
p.packageName.toLowerCase().includes(search.toLowerCase())
|
||||
).slice(0, 20)
|
||||
)
|
||||
|
||||
const tabs = [
|
||||
{ id: 'overview', label: 'Overview', icon: <Package size={12} /> },
|
||||
|
|
@ -64,8 +90,8 @@ export default function ViewAppInspect() {
|
|||
|
||||
return (
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* Left: package picker */}
|
||||
<div className="w-64 shrink-0 border-r border-bg-border flex flex-col overflow-hidden">
|
||||
{/* Left: package picker (resizable) */}
|
||||
<div className="shrink-0 border-r border-bg-border flex flex-col overflow-hidden relative" style={{ width: panelW }}>
|
||||
<div className="p-3 border-b border-bg-border space-y-2 shrink-0">
|
||||
<p className="section-title">App Inspector</p>
|
||||
<div className="relative">
|
||||
|
|
@ -99,6 +125,12 @@ export default function ViewAppInspect() {
|
|||
<p className="text-text-muted text-xs text-center p-4">Type to search or focus to load package list</p>
|
||||
)}
|
||||
</div>
|
||||
{/* Drag handle to widen the rail when package names get cut off */}
|
||||
<div
|
||||
onMouseDown={startResize}
|
||||
title="Drag to resize"
|
||||
className="absolute top-0 right-0 h-full w-1.5 cursor-col-resize hover:bg-accent-green/40 active:bg-accent-green/60"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: inspection results */}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useState } from 'react'
|
||||
import { Archive, RotateCcw, AlertTriangle, Package, Check } from 'lucide-react'
|
||||
import { StartBackup, RestoreBackup, SelectBackupFile, ListPackages } from '../../lib/wails'
|
||||
import { Archive, RotateCcw, AlertTriangle, Check, FolderDown, X, Eye, EyeOff } from 'lucide-react'
|
||||
import { StartBackup, RestoreBackup, SelectBackupFile, ListPackages, PullPathsWithProgress } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import DismissibleBanner from '../DismissibleBanner'
|
||||
import type { PackageInfo } from '../../lib/types'
|
||||
|
||||
export default function ViewBackup() {
|
||||
|
|
@ -14,6 +15,28 @@ export default function ViewBackup() {
|
|||
const [backing, setBacking] = useState(false)
|
||||
const [search, setSearch] = useState('')
|
||||
const [result, setResult] = useState('')
|
||||
const [tipsHidden, setTipsHidden] = useState(localStorage.getItem('atk-backup-tips') === 'hidden')
|
||||
const [folders, setFolders] = useState<string[]>([])
|
||||
const [folderInput, setFolderInput] = useState('')
|
||||
|
||||
const toggleTips = () => {
|
||||
const v = !tipsHidden
|
||||
setTipsHidden(v)
|
||||
localStorage.setItem('atk-backup-tips', v ? 'hidden' : 'shown')
|
||||
}
|
||||
const addFolder = (p: string) => {
|
||||
const v = p.trim()
|
||||
if (v && !folders.includes(v)) setFolders([...folders, v])
|
||||
setFolderInput('')
|
||||
}
|
||||
const backupFolders = async () => {
|
||||
if (folders.length === 0) { notify.error('Add at least one folder to back up'); return }
|
||||
const id = notify.loading('Folder backup — choose a destination folder…')
|
||||
try {
|
||||
const out = await PullPathsWithProgress(folders)
|
||||
notify.dismiss(id); notify.success(out)
|
||||
} catch (e: any) { notify.dismiss(id); notify.error(e) }
|
||||
}
|
||||
|
||||
const loadPackages = async () => {
|
||||
if (pkgsLoaded) return
|
||||
|
|
@ -91,15 +114,21 @@ export default function ViewBackup() {
|
|||
return (
|
||||
<div className="flex flex-col h-full overflow-hidden p-4 gap-4">
|
||||
{/* Warning */}
|
||||
<div className="flex items-start gap-3 bg-warn/5 border border-warn/20 rounded-lg px-4 py-3 shrink-0">
|
||||
<DismissibleBanner id="warn-backup" className="bg-warn/5 border border-warn/20 rounded-lg px-4 py-3 shrink-0 text-warn">
|
||||
<AlertTriangle size={15} className="text-warn shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-warn/80 space-y-1">
|
||||
<p className="font-medium">Android 12+ heavily restricts adb backup</p>
|
||||
<p>Apps must opt-in via <span className="mono">android:allowBackup="true"</span> and the <span className="mono">ALLOW_ADB_BACKUP</span> flag. Many modern apps will not be backed up. For full backup, use a rooted device with Titanium Backup or Swift Backup.</p>
|
||||
</div>
|
||||
</DismissibleBanner>
|
||||
|
||||
<div className="flex justify-end shrink-0 -mt-2">
|
||||
<button onClick={toggleTips} className="btn-ghost text-xs">
|
||||
{tipsHidden ? <><Eye size={12} /> Show tips</> : <><EyeOff size={12} /> Hide tips</>}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4 flex-1 overflow-hidden">
|
||||
<div className={`grid grid-cols-1 ${tipsHidden ? '' : 'xl:grid-cols-2'} gap-4 flex-1 overflow-hidden`}>
|
||||
{/* Backup config */}
|
||||
<div className="card p-4 space-y-4 overflow-auto">
|
||||
<p className="section-title">Backup Configuration</p>
|
||||
|
|
@ -182,9 +211,44 @@ export default function ViewBackup() {
|
|||
{result}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Folder / file backup (no app-opt-in needed — straight adb pull) */}
|
||||
<div className="border-t border-bg-border pt-4 space-y-2">
|
||||
<p className="section-title">Folder backup</p>
|
||||
<p className="text-xs text-text-muted">Pull device folders/files straight to your computer — works regardless of an app's backup flags.</p>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{['/sdcard/DCIM', '/sdcard/Download', '/sdcard/Pictures', '/sdcard/Documents', '/sdcard'].map(p => (
|
||||
<button key={p} onClick={() => addFolder(p)} className="btn-ghost text-xs py-0.5 px-1.5">+ {p.replace('/sdcard/', '') || '/sdcard'}</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input text-xs flex-1 mono"
|
||||
placeholder="/sdcard/path/to/folder"
|
||||
value={folderInput}
|
||||
onChange={e => setFolderInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addFolder(folderInput)}
|
||||
/>
|
||||
<button onClick={() => addFolder(folderInput)} className="btn-ghost text-xs shrink-0">Add</button>
|
||||
</div>
|
||||
{folders.length > 0 && (
|
||||
<div className="space-y-1">
|
||||
{folders.map(f => (
|
||||
<div key={f} className="flex items-center justify-between bg-bg-raised rounded px-2 py-1">
|
||||
<span className="mono text-xs text-text-secondary truncate">{f}</span>
|
||||
<button onClick={() => setFolders(folders.filter(x => x !== f))} className="text-text-muted hover:text-danger shrink-0"><X size={12} /></button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<button onClick={backupFolders} disabled={folders.length === 0} className="btn-ghost w-full justify-center text-xs">
|
||||
<FolderDown size={13} /> Back up {folders.length || ''} folder(s) → computer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Info panel */}
|
||||
{!tipsHidden && (
|
||||
<div className="card p-4 space-y-4 overflow-auto">
|
||||
<p className="section-title">How adb backup works</p>
|
||||
<div className="space-y-3 text-xs text-text-muted">
|
||||
|
|
@ -219,6 +283,7 @@ export default function ViewBackup() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'
|
|||
import { Shield, RefreshCw, Plus, Trash2, AlertTriangle, Check, Lock } from 'lucide-react'
|
||||
import { ListSystemCerts, ListUserCerts, InstallUserCert, RemoveUserCert, SelectCertFile } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import DismissibleBanner from '../DismissibleBanner'
|
||||
import type { CertInfo } from '../../lib/types'
|
||||
|
||||
export default function ViewCerts() {
|
||||
|
|
@ -70,15 +71,13 @@ export default function ViewCerts() {
|
|||
</div>
|
||||
|
||||
{/* Burp/MITM info banner */}
|
||||
<div className="border-b border-bg-border/50 bg-accent-green/5 px-4 py-2 shrink-0">
|
||||
<div className="flex items-start gap-2">
|
||||
<Shield size={13} className="text-accent-green shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-text-secondary space-y-0.5">
|
||||
<p className="font-medium text-accent-green">HTTPS Interception Setup (Burp Suite / mitmproxy)</p>
|
||||
<p>1. Export your proxy CA cert as DER/PEM 2. Click "Install User CA" above 3. Set device proxy to your machine IP 4. For Android 7+ apps with pinning — use Magisk TrustUserCerts module or patch the APK</p>
|
||||
</div>
|
||||
<DismissibleBanner id="info-certs-burp" className="border-b border-bg-border/50 bg-accent-green/5 px-4 py-2 shrink-0 text-accent-green">
|
||||
<Shield size={13} className="text-accent-green shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-text-secondary space-y-0.5">
|
||||
<p className="font-medium text-accent-green">HTTPS Interception Setup (Burp Suite / mitmproxy)</p>
|
||||
<p>1. Export your proxy CA cert as DER/PEM 2. Click "Install User CA" above 3. Set device proxy to your machine IP 4. For Android 7+ apps with pinning — use Magisk TrustUserCerts module or patch the APK</p>
|
||||
</div>
|
||||
</div>
|
||||
</DismissibleBanner>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="border-b border-bg-border flex shrink-0">
|
||||
|
|
@ -99,13 +98,13 @@ export default function ViewCerts() {
|
|||
|
||||
{/* Warning for user certs */}
|
||||
{activeTab === 'user' && (
|
||||
<div className="border-b border-warn/20 bg-warn/5 px-4 py-2 flex items-start gap-2 shrink-0">
|
||||
<DismissibleBanner id="warn-certs-user" className="border-b border-warn/20 bg-warn/5 px-4 py-2 shrink-0 text-warn">
|
||||
<AlertTriangle size={13} className="text-warn shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-warn/80">
|
||||
<span className="font-medium">Android 7+ restricts user certs</span> — apps targeting API 24+ won't trust them by default.
|
||||
Use <span className="mono">TrustUserCerts</span> Magisk module or recompile the app's network security config to include user certs.
|
||||
</p>
|
||||
</div>
|
||||
</DismissibleBanner>
|
||||
)}
|
||||
|
||||
{/* Cert list */}
|
||||
|
|
|
|||
|
|
@ -1,15 +1,22 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { RefreshCw, Wifi, WifiOff, RotateCcw, Shield, Cpu, Battery, HardDrive, Monitor } from 'lucide-react'
|
||||
import {
|
||||
GetDevices, GetDeviceInfo, EnableWirelessAdb,
|
||||
GetDevices, GetDeviceInfo, GetSecurityOverview, EnableWirelessAdb,
|
||||
ConnectWirelessAdb, DisconnectWirelessAdb, Reboot
|
||||
} from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import type { Device, DeviceInfo } from '../../lib/types'
|
||||
|
||||
interface SecurityOverview {
|
||||
root: string; selinux: string; verifiedBoot: string; bootloaderLocked: string
|
||||
encryption: string; securityPatch: string; dmVerity: string; debuggable: string
|
||||
secure: string; buildType: string; buildTags: string; adbEnabled: string; devOptions: string
|
||||
}
|
||||
|
||||
export default function ViewDashboard() {
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [info, setInfo] = useState<DeviceInfo | null>(null)
|
||||
const [sec, setSec] = useState<SecurityOverview | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [infoLoading, setInfoLoading] = useState(false)
|
||||
const [wirelessIp, setWirelessIp] = useState('')
|
||||
|
|
@ -30,9 +37,11 @@ export default function ViewDashboard() {
|
|||
const loadDeviceInfo = useCallback(async () => {
|
||||
setInfoLoading(true)
|
||||
setInfo(null)
|
||||
setSec(null)
|
||||
try {
|
||||
const i = await GetDeviceInfo()
|
||||
const [i, s] = await Promise.all([GetDeviceInfo(), GetSecurityOverview().catch(() => null)])
|
||||
setInfo(i)
|
||||
setSec(s)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
} finally {
|
||||
|
|
@ -99,6 +108,26 @@ export default function ViewDashboard() {
|
|||
|
||||
const connectedDevices = devices.filter(d => d.status === 'device')
|
||||
|
||||
type Tone = 'good' | 'warn' | 'bad' | 'none'
|
||||
const toneCls: Record<Tone, string> = {
|
||||
good: 'text-accent-green', warn: 'text-warn', bad: 'text-danger', none: 'text-text-primary',
|
||||
}
|
||||
const secRows: { label: string; value: string; tone: Tone }[] = sec ? [
|
||||
{ label: 'Bootloader', value: sec.bootloaderLocked, tone: sec.bootloaderLocked === 'Locked' ? 'good' : sec.bootloaderLocked === 'Unlocked' ? 'warn' : 'none' },
|
||||
{ label: 'Root', value: sec.root, tone: sec.root.includes('su') ? 'warn' : 'good' },
|
||||
{ label: 'SELinux', value: sec.selinux, tone: /enforc/i.test(sec.selinux) ? 'good' : /permiss/i.test(sec.selinux) ? 'bad' : 'none' },
|
||||
{ label: 'Verified boot', value: sec.verifiedBoot, tone: sec.verifiedBoot === 'green' ? 'good' : (sec.verifiedBoot === 'orange' || sec.verifiedBoot === 'yellow') ? 'warn' : sec.verifiedBoot === 'red' ? 'bad' : 'none' },
|
||||
{ label: 'dm-verity', value: sec.dmVerity, tone: /enforc/i.test(sec.dmVerity) ? 'good' : /disabled|logging/i.test(sec.dmVerity) ? 'warn' : 'none' },
|
||||
{ label: 'Encryption', value: sec.encryption, tone: /^encrypted/i.test(sec.encryption) ? 'good' : /unencrypted/i.test(sec.encryption) ? 'bad' : 'none' },
|
||||
{ label: 'Security patch', value: sec.securityPatch, tone: 'none' },
|
||||
{ label: 'Build type', value: sec.buildType, tone: sec.buildType === 'user' ? 'good' : (sec.buildType === 'userdebug' || sec.buildType === 'eng') ? 'warn' : 'none' },
|
||||
{ label: 'Build tags', value: sec.buildTags, tone: /release-keys/.test(sec.buildTags) ? 'good' : /test-keys/.test(sec.buildTags) ? 'warn' : 'none' },
|
||||
{ label: 'ro.debuggable', value: sec.debuggable, tone: sec.debuggable === '1' ? 'bad' : sec.debuggable === '0' ? 'good' : 'none' },
|
||||
{ label: 'ro.secure', value: sec.secure, tone: sec.secure === '0' ? 'bad' : sec.secure === '1' ? 'good' : 'none' },
|
||||
{ label: 'ADB enabled', value: sec.adbEnabled, tone: sec.adbEnabled === '1' ? 'warn' : 'none' },
|
||||
{ label: 'Dev options', value: sec.devOptions, tone: sec.devOptions === '1' ? 'warn' : 'none' },
|
||||
] : []
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4 h-full overflow-auto">
|
||||
{/* Header */}
|
||||
|
|
@ -189,6 +218,25 @@ export default function ViewDashboard() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Security Overview — quick audit */}
|
||||
{sec && (
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield size={14} className="text-accent-green" />
|
||||
<p className="section-title">Security Overview</p>
|
||||
<span className="text-[11px] text-text-muted ml-1">quick device audit</span>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 xl:grid-cols-4 gap-x-6 gap-y-2">
|
||||
{secRows.map(r => (
|
||||
<div key={r.label} className="flex items-start gap-2 min-w-0">
|
||||
<span className="text-text-muted text-xs w-24 shrink-0 pt-0.5">{r.label}</span>
|
||||
<span className={`text-xs truncate ${toneCls[r.tone]}`}>{r.value || 'N/A'}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
{/* Wireless ADB */}
|
||||
<div className="card p-4 space-y-3">
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import { useState, useEffect, useMemo } from 'react'
|
||||
import { Shield, RefreshCw, Search, Trash2, PowerOff, AlertTriangle, Check, X, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { ListPackages, DisableMultiplePackages, UninstallMultiplePackages } from '../../lib/wails'
|
||||
import { Shield, RefreshCw, Search, Trash2, PowerOff, Zap, RotateCcw, AlertTriangle, Check, X, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { ListPackages, DisableMultiplePackages, UninstallMultiplePackages, UninstallAndDisableMultiplePackages, RestoreMultiplePackages } from '../../lib/wails'
|
||||
import { ensureDangerUnlocked } from '../../lib/applock'
|
||||
import { notify } from '../../lib/notify'
|
||||
import DismissibleBanner from '../DismissibleBanner'
|
||||
import { DEBLOAT_CATEGORIES } from '../../lib/debloat_db'
|
||||
import type { Safety } from '../../lib/debloat_db'
|
||||
import type { PackageInfo } from '../../lib/types'
|
||||
|
|
@ -14,6 +16,7 @@ const SAFETY_CONFIG: Record<Safety, { label: string; cls: string; icon: React.Re
|
|||
|
||||
export default function ViewDebloater() {
|
||||
const [installed, setInstalled] = useState<Set<string>>(new Set())
|
||||
const [disabled, setDisabled] = useState<Set<string>>(new Set())
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
const [search, setSearch] = useState('')
|
||||
|
|
@ -21,16 +24,18 @@ export default function ViewDebloater() {
|
|||
const [mfrFilter, setMfrFilter] = useState('all')
|
||||
const [openCats, setOpenCats] = useState<Set<string>>(new Set())
|
||||
const [operating, setOperating] = useState(false)
|
||||
const [showNotInstalled, setShowNotInstalled] = useState(false)
|
||||
const [stateFilter, setStateFilter] = useState<'installed' | 'enabled' | 'disabled' | 'notinstalled' | 'all'>('installed')
|
||||
|
||||
const loadInstalled = async () => {
|
||||
setLoading(true)
|
||||
setInstalled(new Set())
|
||||
setDisabled(new Set())
|
||||
setSelected(new Set())
|
||||
try {
|
||||
const pkgs = await ListPackages('all')
|
||||
const names = new Set<string>((pkgs || []).map((p: PackageInfo) => p.packageName))
|
||||
setInstalled(names)
|
||||
setDisabled(new Set<string>((pkgs || []).filter((p: PackageInfo) => !p.isEnabled).map((p: PackageInfo) => p.packageName)))
|
||||
// Auto-open categories that have installed packages
|
||||
const withInstalled = new Set<string>()
|
||||
DEBLOAT_CATEGORIES.forEach(cat => {
|
||||
|
|
@ -55,7 +60,15 @@ export default function ViewDebloater() {
|
|||
...cat,
|
||||
packages: cat.packages.filter(p => {
|
||||
if (safetyFilter !== 'all' && p.safety !== safetyFilter) return false
|
||||
if (!showNotInstalled && !installed.has(p.pkg)) return false
|
||||
const inst = installed.has(p.pkg)
|
||||
const dis = disabled.has(p.pkg)
|
||||
switch (stateFilter) {
|
||||
case 'installed': if (!inst) return false; break
|
||||
case 'enabled': if (!inst || dis) return false; break
|
||||
case 'disabled': if (!dis) return false; break
|
||||
case 'notinstalled': if (inst) return false; break
|
||||
// 'all' → no state restriction
|
||||
}
|
||||
if (search) {
|
||||
const q = search.toLowerCase()
|
||||
return p.pkg.toLowerCase().includes(q) || p.label.toLowerCase().includes(q) || p.description.toLowerCase().includes(q)
|
||||
|
|
@ -64,7 +77,7 @@ export default function ViewDebloater() {
|
|||
})
|
||||
}))
|
||||
.filter(cat => cat.packages.length > 0)
|
||||
}, [search, safetyFilter, mfrFilter, installed, showNotInstalled])
|
||||
}, [search, safetyFilter, mfrFilter, installed, disabled, stateFilter])
|
||||
|
||||
const totalInstalled = useMemo(() =>
|
||||
DEBLOAT_CATEGORIES.reduce((n, cat) => n + cat.packages.filter(p => installed.has(p.pkg)).length, 0),
|
||||
|
|
@ -98,6 +111,7 @@ export default function ViewDebloater() {
|
|||
const batchOp = async (label: string, op: (pkgs: string[]) => Promise<string>, confirm_msg: string) => {
|
||||
if (selected.size === 0) { notify.error('Select packages first'); return }
|
||||
if (!confirm(confirm_msg)) return
|
||||
if (!(await ensureDangerUnlocked())) return
|
||||
setOperating(true)
|
||||
const id = notify.loading(`${label} ${selected.size} package(s)...`)
|
||||
try {
|
||||
|
|
@ -150,15 +164,18 @@ export default function ViewDebloater() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-1.5 text-xs text-text-muted cursor-pointer">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showNotInstalled}
|
||||
onChange={e => setShowNotInstalled(e.target.checked)}
|
||||
className="accent-accent-green"
|
||||
/>
|
||||
Show not installed
|
||||
</label>
|
||||
<select
|
||||
className="input text-xs"
|
||||
value={stateFilter}
|
||||
onChange={e => setStateFilter(e.target.value as typeof stateFilter)}
|
||||
title="Filter by device state"
|
||||
>
|
||||
<option value="installed">On device</option>
|
||||
<option value="enabled">Enabled</option>
|
||||
<option value="disabled">Disabled</option>
|
||||
<option value="notinstalled">Not installed</option>
|
||||
<option value="all">All</option>
|
||||
</select>
|
||||
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
|
|
@ -177,12 +194,12 @@ export default function ViewDebloater() {
|
|||
</div>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="flex items-start gap-2 bg-warn/5 border-b border-warn/20 px-4 py-2 shrink-0">
|
||||
<DismissibleBanner id="warn-debloater" className="bg-warn/5 border-b border-warn/20 px-4 py-2 shrink-0 text-warn">
|
||||
<AlertTriangle size={13} className="text-warn shrink-0 mt-0.5" />
|
||||
<p className="text-xs text-warn/80">
|
||||
<span className="font-medium">Always prefer Disable over Uninstall.</span> Never remove packages marked <span className="text-danger font-medium">Keep</span> — they will break your device. Source: Universal Android Debloater (UAD-ng), 2157 packages.
|
||||
<span className="font-medium">Always prefer Disable over Uninstall.</span> Never remove packages marked <span className="text-danger font-medium">Keep</span> — they will break your device. Source: Universal Android Debloater (UAD-ng), 5362 packages.
|
||||
</p>
|
||||
</div>
|
||||
</DismissibleBanner>
|
||||
|
||||
{/* Action bar */}
|
||||
{selected.size > 0 && (
|
||||
|
|
@ -199,12 +216,28 @@ export default function ViewDebloater() {
|
|||
</button>
|
||||
<button
|
||||
onClick={() => batchOp('Uninstalling', UninstallMultiplePackages,
|
||||
`Uninstall ${selected.size} package(s) for current user?\n\nUses pm uninstall -k --user 0. Package stays on system but is removed for your user.\nReversible via re-enable or factory reset.`)}
|
||||
`Uninstall ${selected.size} package(s) for current user?\n\nUses pm uninstall --user 0 (protected system apps fall back to a privileged on-device helper).\nReversible via re-enable or factory reset.`)}
|
||||
disabled={operating}
|
||||
className="btn-danger text-xs"
|
||||
>
|
||||
<Trash2 size={12} /> Uninstall for user ({selected.size})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => batchOp('Disabling + uninstalling', UninstallAndDisableMultiplePackages,
|
||||
`Disable AND uninstall ${selected.size} package(s)?\n\nForce-stops + disables each app (pm disable-user --user 0), then uninstalls it (privileged fallback for protected system apps).\nIf an app can't be removed it is left disabled.\nReversible via re-enable or factory reset.`)}
|
||||
disabled={operating}
|
||||
className="btn-danger text-xs"
|
||||
>
|
||||
<Zap size={12} /> Disable + Uninstall ({selected.size})
|
||||
</button>
|
||||
<button
|
||||
onClick={() => batchOp('Restoring', RestoreMultiplePackages,
|
||||
`Restore ${selected.size} package(s)?\n\nReinstalls for your user (cmd package install-existing --user 0) and re-enables (pm enable --user 0).\nBrings back apps that were disabled or uninstalled-for-user.`)}
|
||||
disabled={operating}
|
||||
className="btn-ghost text-xs text-accent-green"
|
||||
>
|
||||
<RotateCcw size={12} /> Restore ({selected.size})
|
||||
</button>
|
||||
<button onClick={() => setSelected(new Set())} className="btn-ghost text-xs">
|
||||
Clear
|
||||
</button>
|
||||
|
|
@ -222,7 +255,7 @@ export default function ViewDebloater() {
|
|||
<div className="flex flex-col items-center justify-center h-32 gap-2 text-text-muted">
|
||||
<Shield size={24} className="opacity-30" />
|
||||
<p className="text-sm">No packages match current filters</p>
|
||||
{!showNotInstalled && totalInstalled === 0 && (
|
||||
{stateFilter !== 'notinstalled' && totalInstalled === 0 && (
|
||||
<p className="text-xs">Try clicking "Scan" to detect installed packages</p>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -256,6 +289,7 @@ export default function ViewDebloater() {
|
|||
{/* Packages */}
|
||||
{isOpen && cat.packages.map(p => {
|
||||
const isInst = installed.has(p.pkg)
|
||||
const isDisabled = disabled.has(p.pkg)
|
||||
const isSel = selected.has(p.pkg)
|
||||
const safety = SAFETY_CONFIG[p.safety]
|
||||
|
||||
|
|
@ -264,15 +298,16 @@ export default function ViewDebloater() {
|
|||
key={p.pkg}
|
||||
className={`
|
||||
flex items-start gap-3 px-4 py-2 border-t border-bg-border/30 transition-colors
|
||||
${isInst ? 'hover:bg-bg-raised cursor-pointer' : 'opacity-40'}
|
||||
${p.safety !== 'keep' ? 'hover:bg-bg-raised cursor-pointer' : ''}
|
||||
${!isInst ? 'opacity-60' : ''}
|
||||
${isSel ? 'bg-accent-green/5' : ''}
|
||||
`}
|
||||
onClick={() => isInst && p.safety !== 'keep' && toggleSelect(p.pkg)}
|
||||
onClick={() => p.safety !== 'keep' && toggleSelect(p.pkg)}
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isSel}
|
||||
disabled={!isInst || p.safety === 'keep'}
|
||||
disabled={p.safety === 'keep'}
|
||||
onChange={() => toggleSelect(p.pkg)}
|
||||
className="accent-accent-green mt-0.5 shrink-0"
|
||||
onClick={e => e.stopPropagation()}
|
||||
|
|
@ -284,6 +319,7 @@ export default function ViewDebloater() {
|
|||
{safety.icon} {safety.label}
|
||||
</span>
|
||||
{!isInst && <span className="badge-gray text-xs">not on device</span>}
|
||||
{isInst && isDisabled && <span className="badge-yellow text-xs">disabled</span>}
|
||||
{p.deps && p.deps.length > 0 && (
|
||||
<span className="badge-gray text-xs" title={`Depends on: ${p.deps.join(', ')}`}>has deps</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -1,18 +1,30 @@
|
|||
import { useState, useEffect, useCallback } from 'react'
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
import {
|
||||
FolderOpen, File, ArrowLeft, RefreshCw, Upload,
|
||||
Download, Trash2, FolderPlus, Edit3, Copy
|
||||
FolderOpen, File, ArrowLeft, ArrowRight, ArrowUp, RefreshCw, Upload,
|
||||
Download, Trash2, FolderPlus, Edit3, Copy, FolderInput, Smartphone, Monitor,
|
||||
Image as ImageIcon, X, ChevronLeft, ChevronRight
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
ListFiles, PushFile, PullMultipleFiles, DeleteMultipleFiles,
|
||||
CreateFolder, RenameFile, CopyFile, SelectFileForPush, CancelOperation
|
||||
ListFiles, ListLocalFiles, HomeDir, PushWithProgress, PushPathsWithProgress,
|
||||
PullPathsWithProgress, DeleteMultipleFiles, CreateFolder, RenameFile,
|
||||
SelectFileForPush, CancelOperation
|
||||
} from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import type { FileEntry } from '../../lib/types'
|
||||
|
||||
// Wails runtime is injected on window['runtime'] (same access as ViewLogcat).
|
||||
const rt = () => (window as any)['runtime']
|
||||
|
||||
type Source = 'device' | 'local'
|
||||
interface Transfer { kind: string; label: string; percent: number }
|
||||
interface Menu { x: number; y: number; entry: FileEntry }
|
||||
interface Nav { stack: string[]; idx: number }
|
||||
|
||||
export default function ViewFiles() {
|
||||
const [path, setPath] = useState('/sdcard')
|
||||
const [pathInput, setPathInput] = useState('/sdcard')
|
||||
const [source, setSource] = useState<Source>('device')
|
||||
const [nav, setNav] = useState<Nav>({ stack: ['/sdcard'], idx: 0 })
|
||||
const path = nav.stack[nav.idx]
|
||||
const [pathInput, setPathInput] = useState(path)
|
||||
const [files, setFiles] = useState<FileEntry[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [selected, setSelected] = useState<Set<string>>(new Set())
|
||||
|
|
@ -20,12 +32,29 @@ export default function ViewFiles() {
|
|||
const [renameValue, setRenameValue] = useState('')
|
||||
const [newFolder, setNewFolder] = useState(false)
|
||||
const [newFolderName, setNewFolderName] = useState('')
|
||||
const [menu, setMenu] = useState<Menu | null>(null)
|
||||
const [moving, setMoving] = useState<FileEntry | null>(null)
|
||||
const [moveDest, setMoveDest] = useState('')
|
||||
const [pushStaged, setPushStaged] = useState<string[] | null>(null)
|
||||
const [transfer, setTransfer] = useState<Transfer | null>(null)
|
||||
const [eta, setEta] = useState('')
|
||||
const [viewer, setViewer] = useState<string | null>(null) // image filename being viewed
|
||||
const [imgLoading, setImgLoading] = useState(false)
|
||||
const [imgError, setImgError] = useState(false)
|
||||
const progRef = useRef<{ label: string; t0: number } | null>(null)
|
||||
// Remembered path per source + the last device dir (push destination default).
|
||||
const remembered = useRef<Record<Source, string>>({ device: '/sdcard', local: '' })
|
||||
|
||||
const loadFiles = useCallback(async (p: string) => {
|
||||
const fullPath = useCallback(
|
||||
(name: string) => (path.endsWith('/') ? path + name : path + '/' + name),
|
||||
[path]
|
||||
)
|
||||
|
||||
const loadFiles = useCallback(async (p: string, src: Source) => {
|
||||
setLoading(true)
|
||||
setSelected(new Set())
|
||||
try {
|
||||
const result = await ListFiles(p)
|
||||
const result = await (src === 'device' ? ListFiles(p) : ListLocalFiles(p))
|
||||
setFiles(result || [])
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
|
|
@ -35,29 +64,68 @@ export default function ViewFiles() {
|
|||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { loadFiles(path) }, [path, loadFiles])
|
||||
useEffect(() => { loadFiles(path, source) }, [path, source, loadFiles])
|
||||
useEffect(() => { setPathInput(path) }, [path])
|
||||
|
||||
// Live push/pull progress + ETA, computed from percent over elapsed time.
|
||||
useEffect(() => {
|
||||
const onProgress = (t: Transfer) => {
|
||||
const now = performance.now()
|
||||
if (!progRef.current || progRef.current.label !== t.label || t.percent === 0) {
|
||||
progRef.current = { label: t.label, t0: now }
|
||||
}
|
||||
const elapsed = now - progRef.current.t0
|
||||
if (t.percent > 0 && t.percent < 100) {
|
||||
const total = elapsed / (t.percent / 100)
|
||||
setEta(formatEta(Math.max(0, total - elapsed)))
|
||||
} else {
|
||||
setEta('')
|
||||
}
|
||||
setTransfer(t)
|
||||
}
|
||||
const onDone = () => { setTransfer(null); setEta(''); progRef.current = null }
|
||||
const off1 = rt()?.EventsOn?.('transfer:progress', onProgress)
|
||||
const off2 = rt()?.EventsOn?.('transfer:done', onDone)
|
||||
return () => { off1?.(); off2?.() }
|
||||
}, [])
|
||||
|
||||
// Seed the Computer browser's starting path with the user's home directory.
|
||||
useEffect(() => {
|
||||
HomeDir().then((h: string) => { if (h) remembered.current.local = h }).catch(() => {})
|
||||
}, [])
|
||||
|
||||
// Navigate to a new path (pushes onto history, truncating any forward entries).
|
||||
const go = (to: string) => {
|
||||
setNav(n => {
|
||||
if (n.stack[n.idx] === to) return n
|
||||
const stack = n.stack.slice(0, n.idx + 1)
|
||||
stack.push(to)
|
||||
return { stack, idx: stack.length - 1 }
|
||||
})
|
||||
}
|
||||
const back = () => setNav(n => (n.idx > 0 ? { ...n, idx: n.idx - 1 } : n))
|
||||
const forward = () => setNav(n => (n.idx < n.stack.length - 1 ? { ...n, idx: n.idx + 1 } : n))
|
||||
|
||||
const switchSource = (s: Source) => {
|
||||
if (s === source) return
|
||||
remembered.current[source] = path
|
||||
const target = remembered.current[s] || (s === 'local' ? '/' : '/sdcard')
|
||||
setSource(s)
|
||||
setNav({ stack: [target], idx: 0 })
|
||||
}
|
||||
|
||||
const navigate = (entry: FileEntry) => {
|
||||
if (entry.type === 'Directory') {
|
||||
const next = path.endsWith('/') ? path + entry.name : path + '/' + entry.name
|
||||
setPath(next)
|
||||
setPathInput(next)
|
||||
}
|
||||
if (entry.type === 'Directory' || entry.type === 'Symlink') go(fullPath(entry.name))
|
||||
}
|
||||
|
||||
const goUp = () => {
|
||||
const parts = path.split('/').filter(Boolean)
|
||||
if (parts.length === 0) return
|
||||
parts.pop()
|
||||
const next = '/' + parts.join('/')
|
||||
setPath(next || '/')
|
||||
setPathInput(next || '/')
|
||||
go('/' + parts.join('/') || '/')
|
||||
}
|
||||
|
||||
const navigatePath = () => {
|
||||
setPath(pathInput)
|
||||
loadFiles(pathInput)
|
||||
}
|
||||
const navigatePath = () => go(pathInput)
|
||||
|
||||
const toggleSelect = (name: string) => {
|
||||
setSelected(prev => {
|
||||
|
|
@ -68,71 +136,78 @@ export default function ViewFiles() {
|
|||
}
|
||||
|
||||
const selectAll = () => {
|
||||
if (selected.size === files.length) {
|
||||
setSelected(new Set())
|
||||
} else {
|
||||
setSelected(new Set(files.map(f => f.name)))
|
||||
}
|
||||
if (selected.size === files.length) setSelected(new Set())
|
||||
else setSelected(new Set(files.map(f => f.name)))
|
||||
}
|
||||
|
||||
// ── Device-mode actions ──
|
||||
const handlePush = async () => {
|
||||
const local = await SelectFileForPush()
|
||||
if (!local) return
|
||||
const id = notify.loading('Pushing file...')
|
||||
try {
|
||||
const out = await PushFile(local, path)
|
||||
notify.dismiss(id)
|
||||
const out = await PushWithProgress(local, path)
|
||||
notify.success(out || 'File pushed')
|
||||
loadFiles(path)
|
||||
loadFiles(path, source)
|
||||
} catch (e: any) {
|
||||
notify.dismiss(id)
|
||||
notify.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handlePull = async () => {
|
||||
if (selected.size === 0) { notify.error('Select files to pull'); return }
|
||||
const paths = [...selected].map(name =>
|
||||
path.endsWith('/') ? path + name : path + '/' + name
|
||||
)
|
||||
const id = notify.loading(`Pulling ${paths.length} item(s)...`)
|
||||
const pull = async (paths: string[]) => {
|
||||
if (paths.length === 0) { notify.error('Select files to pull'); return }
|
||||
try {
|
||||
const out = await PullMultipleFiles(paths)
|
||||
notify.dismiss(id)
|
||||
const out = await PullPathsWithProgress(paths)
|
||||
notify.success(out)
|
||||
} catch (e: any) {
|
||||
notify.dismiss(id)
|
||||
notify.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (selected.size === 0) { notify.error('Select files to delete'); return }
|
||||
const paths = [...selected].map(name =>
|
||||
path.endsWith('/') ? path + name : path + '/' + name
|
||||
)
|
||||
const del = async (paths: string[]) => {
|
||||
if (paths.length === 0) { notify.error('Select files to delete'); return }
|
||||
if (!confirm(`Delete ${paths.length} item(s)?`)) return
|
||||
const id = notify.loading('Deleting...')
|
||||
try {
|
||||
const out = await DeleteMultipleFiles(paths)
|
||||
notify.dismiss(id)
|
||||
notify.success(out)
|
||||
loadFiles(path)
|
||||
loadFiles(path, source)
|
||||
} catch (e: any) {
|
||||
notify.dismiss(id)
|
||||
notify.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Local→device push: stage the files, then flip to the Device browser so
|
||||
// the user picks the destination folder visually and clicks "Push here". ──
|
||||
const startPush = (localPaths: string[]) => {
|
||||
if (localPaths.length === 0) { notify.error('Select files to push'); return }
|
||||
setPushStaged(localPaths)
|
||||
remembered.current.local = path
|
||||
setSource('device')
|
||||
setNav({ stack: [remembered.current.device || '/sdcard'], idx: 0 })
|
||||
}
|
||||
const handlePushHere = async () => {
|
||||
if (!pushStaged) return
|
||||
const files = pushStaged
|
||||
setPushStaged(null)
|
||||
try {
|
||||
const out = await PushPathsWithProgress(files, path)
|
||||
notify.success(out)
|
||||
loadFiles(path, source)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreateFolder = async () => {
|
||||
if (!newFolderName.trim()) return
|
||||
const fullPath = path.endsWith('/') ? path + newFolderName : path + '/' + newFolderName
|
||||
try {
|
||||
await CreateFolder(fullPath)
|
||||
await CreateFolder(fullPath(newFolderName))
|
||||
notify.success('Folder created')
|
||||
setNewFolder(false)
|
||||
setNewFolderName('')
|
||||
loadFiles(path)
|
||||
loadFiles(path, source)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
}
|
||||
|
|
@ -148,18 +223,73 @@ export default function ViewFiles() {
|
|||
setRenaming(null)
|
||||
return
|
||||
}
|
||||
const oldPath = path.endsWith('/') ? path + renaming : path + '/' + renaming
|
||||
const newPath = path.endsWith('/') ? path + renameValue : path + '/' + renameValue
|
||||
try {
|
||||
await RenameFile(oldPath, newPath)
|
||||
await RenameFile(fullPath(renaming), fullPath(renameValue))
|
||||
notify.success('Renamed')
|
||||
setRenaming(null)
|
||||
loadFiles(path)
|
||||
loadFiles(path, source)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const handleMove = async () => {
|
||||
if (!moving) return
|
||||
const destDir = moveDest.trim().replace(/\/+$/, '')
|
||||
if (!destDir) { setMoving(null); return }
|
||||
try {
|
||||
await RenameFile(fullPath(moving.name), destDir + '/' + moving.name)
|
||||
notify.success(`Moved to ${destDir}`)
|
||||
setMoving(null)
|
||||
loadFiles(path, source)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const copyPath = (entry: FileEntry) => {
|
||||
navigator.clipboard?.writeText(fullPath(entry.name))
|
||||
notify.success('Path copied')
|
||||
}
|
||||
|
||||
// ── Image viewer — streams bytes via the /__file asset-server route (no
|
||||
// base64 size limits). The <img> loads the URL itself. ──
|
||||
const fileURL = useCallback(
|
||||
(name: string) => `/__file?src=${source}&p=${encodeURIComponent(fullPath(name))}`,
|
||||
[source, fullPath]
|
||||
)
|
||||
const openViewer = (name: string) => { setViewer(name); setImgLoading(true); setImgError(false) }
|
||||
|
||||
const stepViewer = useCallback((delta: number) => {
|
||||
setViewer(cur => {
|
||||
if (!cur) return cur
|
||||
const imgs = files.filter(f => isImage(f.name)).map(f => f.name)
|
||||
const i = imgs.indexOf(cur)
|
||||
if (i < 0) return cur
|
||||
setImgLoading(true)
|
||||
setImgError(false)
|
||||
return imgs[(i + delta + imgs.length) % imgs.length]
|
||||
})
|
||||
}, [files])
|
||||
|
||||
// Esc to close, arrows to step through images while the viewer is open.
|
||||
useEffect(() => {
|
||||
if (!viewer) return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setViewer(null)
|
||||
if (e.key === 'ArrowRight') stepViewer(1)
|
||||
if (e.key === 'ArrowLeft') stepViewer(-1)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [viewer, stepViewer])
|
||||
|
||||
// Double-click / Open: directories navigate, images open the viewer.
|
||||
const open = (entry: FileEntry) => {
|
||||
if (entry.type === 'Directory' || entry.type === 'Symlink') navigate(entry)
|
||||
else if (isImage(entry.name)) openViewer(entry.name)
|
||||
}
|
||||
|
||||
const formatSize = (size: string) => {
|
||||
const n = parseInt(size)
|
||||
if (isNaN(n)) return size
|
||||
|
|
@ -168,43 +298,90 @@ export default function ViewFiles() {
|
|||
return `${(n / 1048576).toFixed(1)} MB`
|
||||
}
|
||||
|
||||
const isDevice = source === 'device'
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="flex flex-col h-full" onClick={() => menu && setMenu(null)}>
|
||||
{/* Toolbar */}
|
||||
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-2 shrink-0">
|
||||
<button onClick={goUp} className="btn-ghost p-1.5" title="Go up">
|
||||
{/* Source toggle */}
|
||||
<div className="flex gap-1 bg-bg-raised rounded p-0.5 shrink-0">
|
||||
<button
|
||||
onClick={() => switchSource('device')}
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium flex items-center gap-1 transition-colors ${
|
||||
isDevice ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Smartphone size={12} /> Device
|
||||
</button>
|
||||
<button
|
||||
onClick={() => switchSource('local')}
|
||||
className={`px-2 py-0.5 rounded text-xs font-medium flex items-center gap-1 transition-colors ${
|
||||
!isDevice ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
<Monitor size={12} /> Computer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button onClick={back} disabled={nav.idx === 0} className="btn-ghost p-1.5" title="Back">
|
||||
<ArrowLeft size={14} />
|
||||
</button>
|
||||
<button onClick={forward} disabled={nav.idx === nav.stack.length - 1} className="btn-ghost p-1.5" title="Forward">
|
||||
<ArrowRight size={14} />
|
||||
</button>
|
||||
<button onClick={goUp} disabled={path === '/'} className="btn-ghost p-1.5" title="Up">
|
||||
<ArrowUp size={14} />
|
||||
</button>
|
||||
<input
|
||||
className="input flex-1 text-xs mono"
|
||||
value={pathInput}
|
||||
onChange={e => setPathInput(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && navigatePath()}
|
||||
placeholder="/sdcard"
|
||||
placeholder={isDevice ? '/sdcard' : '/home'}
|
||||
/>
|
||||
<button onClick={() => loadFiles(path)} disabled={loading} className="btn-ghost p-1.5">
|
||||
<button onClick={() => loadFiles(path, source)} disabled={loading} className="btn-ghost p-1.5">
|
||||
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
|
||||
</button>
|
||||
|
||||
<div className="w-px h-5 bg-bg-border mx-1" />
|
||||
|
||||
<button onClick={handlePush} className="btn-ghost text-xs">
|
||||
<Upload size={13} /> Push
|
||||
</button>
|
||||
<button onClick={handlePull} disabled={selected.size === 0} className="btn-ghost text-xs">
|
||||
<Download size={13} /> Pull {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
</button>
|
||||
<button onClick={() => setNewFolder(true)} className="btn-ghost text-xs">
|
||||
<FolderPlus size={13} /> New Folder
|
||||
</button>
|
||||
<button onClick={handleDelete} disabled={selected.size === 0} className="btn-danger text-xs">
|
||||
<Trash2 size={13} /> Delete {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
</button>
|
||||
{loading && (
|
||||
<button onClick={() => CancelOperation()} className="btn-warn text-xs">Cancel</button>
|
||||
{isDevice ? (
|
||||
<>
|
||||
<button onClick={handlePush} className="btn-ghost text-xs">
|
||||
<Upload size={13} /> Push
|
||||
</button>
|
||||
<button onClick={() => pull([...selected].map(fullPath))} disabled={selected.size === 0} className="btn-ghost text-xs">
|
||||
<Download size={13} /> Pull {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
</button>
|
||||
<button onClick={() => setNewFolder(true)} className="btn-ghost text-xs">
|
||||
<FolderPlus size={13} /> New Folder
|
||||
</button>
|
||||
<button onClick={() => del([...selected].map(fullPath))} disabled={selected.size === 0} className="btn-danger text-xs">
|
||||
<Trash2 size={13} /> Delete {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button onClick={() => startPush([...selected].map(fullPath))} disabled={selected.size === 0} className="btn-ghost text-xs">
|
||||
<Upload size={13} /> Push to device {selected.size > 0 ? `(${selected.size})` : ''}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Transfer progress bar */}
|
||||
{transfer && (
|
||||
<div className="border-b border-bg-border px-4 py-2 bg-bg-raised flex items-center gap-3 shrink-0">
|
||||
{transfer.kind === 'pull' ? <Download size={13} className="text-accent-green shrink-0" /> : <Upload size={13} className="text-accent-green shrink-0" />}
|
||||
<span className="text-xs text-text-secondary truncate max-w-[200px]" title={transfer.label}>{transfer.label}</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-bg-border overflow-hidden">
|
||||
<div className="h-full bg-accent-green transition-all duration-200" style={{ width: `${transfer.percent}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-text-muted mono w-10 text-right">{transfer.percent}%</span>
|
||||
{eta && <span className="text-xs text-text-muted w-20 text-right">~{eta} left</span>}
|
||||
<button onClick={() => CancelOperation()} className="btn-warn text-xs">Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New folder input */}
|
||||
{newFolder && (
|
||||
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-2 bg-bg-raised">
|
||||
|
|
@ -225,6 +402,40 @@ export default function ViewFiles() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Move dialog (device) */}
|
||||
{moving && (
|
||||
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-2 bg-bg-raised">
|
||||
<FolderInput size={13} className="text-accent-green" />
|
||||
<span className="text-xs text-text-muted shrink-0">Move <span className="text-text-secondary">{moving.name}</span> to:</span>
|
||||
<input
|
||||
autoFocus
|
||||
className="input flex-1 text-xs mono"
|
||||
placeholder="/sdcard/Destination"
|
||||
value={moveDest}
|
||||
onChange={e => setMoveDest(e.target.value)}
|
||||
onKeyDown={e => {
|
||||
if (e.key === 'Enter') handleMove()
|
||||
if (e.key === 'Escape') setMoving(null)
|
||||
}}
|
||||
/>
|
||||
<button onClick={handleMove} className="btn-primary text-xs">Move</button>
|
||||
<button onClick={() => setMoving(null)} className="btn-ghost text-xs">Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Push destination picker — shown after staging local files for push */}
|
||||
{pushStaged && isDevice && (
|
||||
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-2 bg-accent-green/10">
|
||||
<Upload size={13} className="text-accent-green shrink-0" />
|
||||
<span className="text-xs text-text-secondary flex-1">
|
||||
Pushing {pushStaged.length} item(s) — browse to a destination folder, then push.
|
||||
</span>
|
||||
<span className="text-xs text-text-muted mono truncate max-w-[260px]">→ {path}</span>
|
||||
<button onClick={handlePushHere} className="btn-primary text-xs">Push here</button>
|
||||
<button onClick={() => setPushStaged(null)} className="btn-ghost text-xs">Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File list header */}
|
||||
<div className="grid grid-cols-[24px_1fr_80px_100px_120px] gap-2 px-4 py-1.5 border-b border-bg-border text-text-muted text-xs">
|
||||
<input
|
||||
|
|
@ -254,6 +465,7 @@ export default function ViewFiles() {
|
|||
{files.map(f => (
|
||||
<div
|
||||
key={f.name}
|
||||
onContextMenu={e => { e.preventDefault(); setMenu({ x: e.clientX, y: e.clientY, entry: f }) }}
|
||||
className={`
|
||||
grid grid-cols-[24px_1fr_80px_100px_120px] gap-2 px-4 py-1.5
|
||||
text-xs border-b border-bg-border/50 items-center
|
||||
|
|
@ -272,7 +484,9 @@ export default function ViewFiles() {
|
|||
<div className="flex items-center gap-2 min-w-0">
|
||||
{f.type === 'Directory'
|
||||
? <FolderOpen size={13} className="text-accent-green shrink-0" />
|
||||
: <File size={13} className="text-text-muted shrink-0" />
|
||||
: isImage(f.name)
|
||||
? <ImageIcon size={13} className="text-accent-green/70 shrink-0" />
|
||||
: <File size={13} className="text-text-muted shrink-0" />
|
||||
}
|
||||
{renaming === f.name ? (
|
||||
<input
|
||||
|
|
@ -288,19 +502,22 @@ export default function ViewFiles() {
|
|||
/>
|
||||
) : (
|
||||
<span
|
||||
className={`truncate cursor-pointer ${f.type === 'Directory' ? 'text-text-primary' : 'text-text-secondary'}`}
|
||||
onDoubleClick={() => navigate(f)}
|
||||
className={`truncate cursor-pointer select-none hover:underline ${f.type === 'Directory' ? 'text-text-primary' : 'text-text-secondary'}`}
|
||||
onClick={() => open(f)}
|
||||
title={f.type === 'Directory' || f.type === 'Symlink' ? 'Open' : (isImage(f.name) ? 'Preview' : undefined)}
|
||||
>
|
||||
{f.name}
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
onClick={() => startRename(f.name)}
|
||||
className="opacity-0 group-hover:opacity-100 text-text-muted hover:text-text-secondary ml-auto shrink-0"
|
||||
title="Rename"
|
||||
>
|
||||
<Edit3 size={11} />
|
||||
</button>
|
||||
{isDevice && (
|
||||
<button
|
||||
onClick={() => startRename(f.name)}
|
||||
className="opacity-0 group-hover:opacity-100 text-text-muted hover:text-text-secondary ml-auto shrink-0"
|
||||
title="Rename"
|
||||
>
|
||||
<Edit3 size={11} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-right text-text-muted mono">
|
||||
|
|
@ -312,11 +529,112 @@ export default function ViewFiles() {
|
|||
))}
|
||||
</div>
|
||||
|
||||
{/* Right-click context menu */}
|
||||
{menu && (
|
||||
<div
|
||||
className="fixed z-50 min-w-[170px] py-1 rounded-md border border-bg-border bg-bg-surface shadow-lg text-xs"
|
||||
style={{ top: menu.y, left: menu.x }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
>
|
||||
{(menu.entry.type === 'Directory' || menu.entry.type === 'Symlink') && (
|
||||
<MenuItem icon={<FolderOpen size={13} />} label="Open" onClick={() => { navigate(menu.entry); setMenu(null) }} />
|
||||
)}
|
||||
{isImage(menu.entry.name) && (
|
||||
<MenuItem icon={<ImageIcon size={13} />} label="Preview" onClick={() => { openViewer(menu.entry.name); setMenu(null) }} />
|
||||
)}
|
||||
{isDevice ? (
|
||||
<>
|
||||
<MenuItem icon={<Download size={13} />} label="Pull to folder…" onClick={() => { pull([fullPath(menu.entry.name)]); setMenu(null) }} />
|
||||
<MenuItem icon={<Edit3 size={13} />} label="Rename" onClick={() => { startRename(menu.entry.name); setMenu(null) }} />
|
||||
<MenuItem icon={<FolderInput size={13} />} label="Move to…" onClick={() => { setMoving(menu.entry); setMoveDest(path); setMenu(null) }} />
|
||||
<MenuItem icon={<Copy size={13} />} label="Copy path" onClick={() => { copyPath(menu.entry); setMenu(null) }} />
|
||||
<div className="my-1 h-px bg-bg-border" />
|
||||
<MenuItem icon={<Trash2 size={13} />} label="Delete" danger onClick={() => { del([fullPath(menu.entry.name)]); setMenu(null) }} />
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{menu.entry.type === 'File' && (
|
||||
<MenuItem icon={<Upload size={13} />} label="Push to device…" onClick={() => { startPush([fullPath(menu.entry.name)]); setMenu(null) }} />
|
||||
)}
|
||||
<MenuItem icon={<Copy size={13} />} label="Copy path" onClick={() => { copyPath(menu.entry); setMenu(null) }} />
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Image viewer — click anywhere (except the image or buttons) to close */}
|
||||
{viewer && (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex flex-col bg-black/85 backdrop-blur-sm"
|
||||
onClick={() => setViewer(null)}
|
||||
>
|
||||
<div className="flex items-center justify-between px-4 py-2 text-xs text-text-secondary shrink-0">
|
||||
<span className="mono truncate">{viewer}</span>
|
||||
<button onClick={e => { e.stopPropagation(); setViewer(null) }} className="btn-ghost p-1.5" title="Close (Esc)">
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex-1 flex items-center justify-center gap-3 overflow-hidden px-2 pb-4">
|
||||
<button onClick={e => { e.stopPropagation(); stepViewer(-1) }} className="btn-ghost p-2 shrink-0" title="Previous (←)">
|
||||
<ChevronLeft size={20} />
|
||||
</button>
|
||||
<div className="relative flex-1 h-full flex items-center justify-center overflow-hidden">
|
||||
{imgLoading && !imgError && (
|
||||
<div className="absolute w-6 h-6 border-2 border-accent-green border-t-transparent rounded-full animate-spin" />
|
||||
)}
|
||||
{imgError ? (
|
||||
<p className="text-text-muted text-sm">Couldn't load this image</p>
|
||||
) : (
|
||||
<img
|
||||
src={fileURL(viewer)}
|
||||
alt={viewer}
|
||||
onClick={e => e.stopPropagation()}
|
||||
onLoad={() => setImgLoading(false)}
|
||||
onError={() => { setImgLoading(false); setImgError(true) }}
|
||||
className="max-h-full max-w-full object-contain rounded"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={e => { e.stopPropagation(); stepViewer(1) }} className="btn-ghost p-2 shrink-0" title="Next (→)">
|
||||
<ChevronRight size={20} />
|
||||
</button>
|
||||
</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 className="mono">{path}</span>
|
||||
<span className="mono flex items-center gap-1.5">
|
||||
{isDevice ? <Smartphone size={12} /> : <Monitor size={12} />}
|
||||
{path}
|
||||
</span>
|
||||
<span>{files.length} items{selected.size > 0 ? `, ${selected.size} selected` : ''}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function MenuItem({ icon, label, onClick, danger }: {
|
||||
icon: React.ReactNode; label: string; onClick: () => void; danger?: boolean
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`w-full flex items-center gap-2 px-3 py-1.5 text-left hover:bg-bg-raised ${
|
||||
danger ? 'text-danger' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{icon}{label}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function formatEta(ms: number): string {
|
||||
const s = Math.round(ms / 1000)
|
||||
if (s < 60) return `${s}s`
|
||||
return `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`
|
||||
}
|
||||
|
||||
function isImage(name: string): boolean {
|
||||
return /\.(png|jpe?g|gif|webp|bmp|ico)$/i.test(name)
|
||||
}
|
||||
|
|
|
|||
309
frontend/src/components/views/ViewFirmware.tsx
Normal file
309
frontend/src/components/views/ViewFirmware.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import { useState, useEffect, useRef } from 'react'
|
||||
import { Download, Search, ShieldCheck, X, PackageOpen, FileCheck2 } from 'lucide-react'
|
||||
import { ListFirmware, DownloadFirmware, CancelOperation, SelectFileForFlash, ListPayloadPartitions, ExtractPayloadPartition, SelectAnyFile, HashFile } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
|
||||
const rt = () => (window as any)['runtime']
|
||||
|
||||
interface Firmware { version: string; url: string; sha256: string }
|
||||
interface PayloadPartition { name: string; sizeMB: number }
|
||||
interface FileHashes { sha256: string; sha1: string; sizeBytes: number }
|
||||
|
||||
// Pixel device codenames (newest first). Value = codename used by Google's images.
|
||||
const PIXEL_DEVICES: { name: string; cn: string }[] = [
|
||||
{ name: 'Pixel 10 Pro Fold', cn: 'rango' },
|
||||
{ name: 'Pixel 10 Pro XL', cn: 'mustang' },
|
||||
{ name: 'Pixel 10 Pro', cn: 'blazer' },
|
||||
{ name: 'Pixel 10', cn: 'frankel' },
|
||||
{ name: 'Pixel 9a', cn: 'tegu' },
|
||||
{ name: 'Pixel 9 Pro Fold', cn: 'comet' },
|
||||
{ name: 'Pixel 9 Pro XL', cn: 'komodo' },
|
||||
{ name: 'Pixel 9 Pro', cn: 'caiman' },
|
||||
{ name: 'Pixel 9', cn: 'tokay' },
|
||||
{ name: 'Pixel 8a', cn: 'akita' },
|
||||
{ name: 'Pixel 8 Pro', cn: 'husky' },
|
||||
{ name: 'Pixel 8', cn: 'shiba' },
|
||||
{ name: 'Pixel Fold', cn: 'felix' },
|
||||
{ name: 'Pixel Tablet', cn: 'tangorpro' },
|
||||
{ name: 'Pixel 7a', cn: 'lynx' },
|
||||
{ name: 'Pixel 7 Pro', cn: 'cheetah' },
|
||||
{ name: 'Pixel 7', cn: 'panther' },
|
||||
{ name: 'Pixel 6a', cn: 'bluejay' },
|
||||
{ name: 'Pixel 6 Pro', cn: 'raven' },
|
||||
{ name: 'Pixel 6', cn: 'oriole' },
|
||||
{ name: 'Pixel 5a', cn: 'barbet' },
|
||||
{ name: 'Pixel 5', cn: 'redfin' },
|
||||
{ name: 'Pixel 4a 5G', cn: 'bramble' },
|
||||
{ name: 'Pixel 4a', cn: 'sunfish' },
|
||||
{ name: 'Pixel 4 XL', cn: 'coral' },
|
||||
{ name: 'Pixel 4', cn: 'flame' },
|
||||
{ name: 'Pixel 3a XL', cn: 'bonito' },
|
||||
{ name: 'Pixel 3a', cn: 'sargo' },
|
||||
{ name: 'Pixel 3 XL', cn: 'crosshatch' },
|
||||
{ name: 'Pixel 3', cn: 'blueline' },
|
||||
]
|
||||
|
||||
export default function ViewFirmware({ codename }: { codename?: string }) {
|
||||
const [cn, setCn] = useState(codename || 'husky')
|
||||
const [custom, setCustom] = useState(false)
|
||||
const [kind, setKind] = useState<'factory' | 'ota'>('factory')
|
||||
const [list, setList] = useState<Firmware[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [downloading, setDownloading] = useState(false)
|
||||
const [percent, setPercent] = useState(0)
|
||||
const [eta, setEta] = useState('')
|
||||
const t0 = useRef(0)
|
||||
// payload.bin extraction
|
||||
const [otaZip, setOtaZip] = useState('')
|
||||
const [parts, setParts] = useState<PayloadPartition[] | null>(null)
|
||||
const [partsBusy, setPartsBusy] = useState(false)
|
||||
const [extracting, setExtracting] = useState('')
|
||||
const [extractPct, setExtractPct] = useState(0)
|
||||
// verify a file
|
||||
const [vName, setVName] = useState('')
|
||||
const [vHashes, setVHashes] = useState<FileHashes | null>(null)
|
||||
const [vExpected, setVExpected] = useState('')
|
||||
const [vBusy, setVBusy] = useState(false)
|
||||
|
||||
useEffect(() => { if (codename) setCn(codename) }, [codename]) // prefill from connected device
|
||||
|
||||
// If the detected codename isn't a known Pixel, still offer it in the list.
|
||||
const known = PIXEL_DEVICES.some(d => d.cn === cn)
|
||||
|
||||
useEffect(() => {
|
||||
const onProg = (p: { percent: number }) => {
|
||||
setPercent(p.percent)
|
||||
const now = performance.now()
|
||||
if (p.percent <= 1 || !t0.current) t0.current = now
|
||||
const elapsed = now - t0.current
|
||||
if (p.percent > 1 && p.percent < 100) {
|
||||
const total = elapsed / (p.percent / 100)
|
||||
const s = Math.round((total - elapsed) / 1000)
|
||||
setEta(s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${String(s % 60).padStart(2, '0')}s`)
|
||||
} else setEta('')
|
||||
}
|
||||
const onDone = () => { setDownloading(false); setPercent(0); setEta(''); t0.current = 0 }
|
||||
const off1 = rt()?.EventsOn?.('firmware:progress', onProg)
|
||||
const off2 = rt()?.EventsOn?.('firmware:done', onDone)
|
||||
const off3 = rt()?.EventsOn?.('payload:progress', (p: { percent: number }) => setExtractPct(p.percent))
|
||||
const off4 = rt()?.EventsOn?.('payload:done', () => { setExtracting(''); setExtractPct(0) })
|
||||
return () => { off1?.(); off2?.(); off3?.(); off4?.() }
|
||||
}, [])
|
||||
|
||||
const pickOta = async () => {
|
||||
const z = await SelectFileForFlash()
|
||||
if (!z) return
|
||||
setOtaZip(z)
|
||||
setParts(null)
|
||||
setPartsBusy(true)
|
||||
try {
|
||||
setParts(await ListPayloadPartitions(z) || [])
|
||||
} catch (e: any) { notify.error(e); setParts([]) }
|
||||
finally { setPartsBusy(false) }
|
||||
}
|
||||
|
||||
const verifyFile = async () => {
|
||||
const f = await SelectAnyFile()
|
||||
if (!f) return
|
||||
setVName(f.split('/').pop() || f)
|
||||
setVHashes(null)
|
||||
setVBusy(true)
|
||||
try { setVHashes(await HashFile(f)) }
|
||||
catch (e: any) { notify.error(e) }
|
||||
finally { setVBusy(false) }
|
||||
}
|
||||
|
||||
const extractPart = async (name: string) => {
|
||||
setExtracting(name)
|
||||
setExtractPct(0)
|
||||
try {
|
||||
notify.success(await ExtractPayloadPartition(otaZip, name))
|
||||
} catch (e: any) { notify.error(e) }
|
||||
finally { setExtracting('') }
|
||||
}
|
||||
|
||||
const search = async () => {
|
||||
setLoading(true)
|
||||
setList([])
|
||||
try {
|
||||
setList(await ListFirmware(cn, kind) || [])
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
const download = async (fw: Firmware) => {
|
||||
setDownloading(true)
|
||||
setPercent(0)
|
||||
t0.current = 0
|
||||
try {
|
||||
const out = await DownloadFirmware(fw.url, fw.sha256)
|
||||
notify.success(out)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
} finally {
|
||||
setDownloading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="card p-4 space-y-3">
|
||||
<p className="section-title">Download Firmware</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Official Google Pixel images. Pick your device (auto-selected from the connected phone when possible). Files are large (2–3 GB) and verified by SHA-256 automatically after download.
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex gap-1 bg-bg-raised rounded p-0.5 shrink-0">
|
||||
{(['factory', 'ota'] as const).map(k => (
|
||||
<button key={k} onClick={() => setKind(k)}
|
||||
className={`px-3 py-0.5 rounded text-xs font-medium transition-colors ${kind === k ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:text-text-secondary'}`}>
|
||||
{k === 'factory' ? 'Factory' : 'OTA'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<select className="input text-xs flex-1" value={custom ? '__other__' : cn}
|
||||
onChange={e => {
|
||||
if (e.target.value === '__other__') { setCustom(true); setCn('') }
|
||||
else { setCustom(false); setCn(e.target.value) }
|
||||
}}>
|
||||
{!known && cn && !custom && <option value={cn}>{cn} (detected)</option>}
|
||||
{PIXEL_DEVICES.map(d => (
|
||||
<option key={d.cn} value={d.cn}>{d.name} ({d.cn})</option>
|
||||
))}
|
||||
<option value="__other__">Other (type codename)…</option>
|
||||
</select>
|
||||
{custom && (
|
||||
<input className="input text-xs w-32 mono shrink-0" value={cn} placeholder="codename" autoFocus
|
||||
onChange={e => setCn(e.target.value.trim())} onKeyDown={e => e.key === 'Enter' && search()} />
|
||||
)}
|
||||
<button onClick={search} disabled={loading} className="btn-ghost text-xs shrink-0">
|
||||
<Search size={13} /> {loading ? 'Searching…' : 'List builds'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{downloading && (
|
||||
<div className="card p-3 flex items-center gap-3">
|
||||
<Download size={14} className="text-accent-green shrink-0" />
|
||||
<div className="flex-1 h-1.5 rounded-full bg-bg-border overflow-hidden">
|
||||
<div className="h-full bg-accent-green transition-all duration-200" style={{ width: `${percent}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-text-muted mono w-10 text-right">{percent}%</span>
|
||||
{eta && <span className="text-xs text-text-muted w-20 text-right">~{eta} left</span>}
|
||||
<button onClick={() => CancelOperation()} className="btn-warn text-xs">Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{list.length > 0 && (
|
||||
<div className="card divide-y divide-bg-border/50">
|
||||
{list.map(fw => (
|
||||
<div key={fw.url} className="flex items-center gap-3 px-4 py-2.5">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-text-primary leading-snug break-words">{fw.version}</p>
|
||||
{fw.sha256 ? (
|
||||
<>
|
||||
<p className="text-[10px] text-accent-green flex items-center gap-1 mt-0.5">
|
||||
<ShieldCheck size={10} className="shrink-0" /> verified on download
|
||||
</p>
|
||||
<p className="text-[10px] text-text-muted mono break-all leading-snug">{fw.sha256}</p>
|
||||
</>
|
||||
) : (
|
||||
<p className="text-[10px] text-text-muted mt-0.5">no checksum listed</p>
|
||||
)}
|
||||
</div>
|
||||
<button onClick={() => download(fw)} disabled={downloading} className="btn-ghost text-xs shrink-0">
|
||||
<Download size={13} /> Download
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && list.length === 0 && (
|
||||
<div className="flex flex-col items-center justify-center py-8 text-text-muted gap-2">
|
||||
<X size={24} className="opacity-20" />
|
||||
<p className="text-sm">Pick a device and list builds.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Extract from an existing OTA (payload.bin) */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<PackageOpen size={14} className="text-accent-green" />
|
||||
<p className="section-title">Extract from OTA (payload.bin)</p>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Pull individual partition images (e.g. <span className="mono">init_boot</span>, <span className="mono">boot</span>, <span className="mono">system</span>) out of an A/B OTA zip — for patching, reverting, or analysis. Full OTAs only.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input className="input text-xs flex-1 mono" value={otaZip} readOnly placeholder="Select an OTA .zip..." />
|
||||
<button onClick={pickOta} disabled={partsBusy} className="btn-ghost text-xs shrink-0">
|
||||
{partsBusy ? 'Reading…' : 'Select OTA zip'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{extracting && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-xs text-text-secondary shrink-0 mono">{extracting}</span>
|
||||
<div className="flex-1 h-1.5 rounded-full bg-bg-border overflow-hidden">
|
||||
<div className="h-full bg-accent-green transition-all duration-200" style={{ width: `${extractPct}%` }} />
|
||||
</div>
|
||||
<span className="text-xs text-text-muted mono w-10 text-right">{extractPct}%</span>
|
||||
<button onClick={() => CancelOperation()} className="btn-warn text-xs">Cancel</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{parts !== null && parts.length > 0 && (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-1.5">
|
||||
{parts.map(p => (
|
||||
<button
|
||||
key={p.name}
|
||||
onClick={() => extractPart(p.name)}
|
||||
disabled={!!extracting}
|
||||
className="flex items-center justify-between gap-2 rounded border border-bg-border px-2 py-1.5 text-xs hover:bg-bg-raised disabled:opacity-50"
|
||||
>
|
||||
<span className="mono text-text-secondary truncate">{p.name}</span>
|
||||
<span className="text-text-muted shrink-0">{p.sizeMB ? `${p.sizeMB}M` : ''}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{parts !== null && parts.length === 0 && (
|
||||
<p className="text-xs text-text-muted">No partitions found — not an A/B OTA, or it's an incremental update.</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Verify a file (SHA-256) */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileCheck2 size={14} className="text-accent-green" />
|
||||
<p className="section-title">Verify a File (SHA-256)</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input className="input text-xs flex-1 mono" value={vName} readOnly placeholder="Select any file to hash..." />
|
||||
<button onClick={verifyFile} disabled={vBusy} className="btn-ghost text-xs shrink-0">{vBusy ? 'Hashing…' : 'Select file'}</button>
|
||||
</div>
|
||||
{vHashes && (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[10px] text-text-muted mono break-all">SHA-256 {vHashes.sha256}</p>
|
||||
<p className="text-[10px] text-text-muted mono break-all">SHA-1 {vHashes.sha1}</p>
|
||||
<input
|
||||
className="input text-xs w-full mono"
|
||||
placeholder="Paste expected SHA-256 to compare…"
|
||||
value={vExpected}
|
||||
onChange={e => setVExpected(e.target.value)}
|
||||
/>
|
||||
{vExpected.trim() && (
|
||||
vHashes.sha256.toLowerCase() === vExpected.trim().toLowerCase()
|
||||
? <p className="text-xs text-accent-green flex items-center gap-1"><ShieldCheck size={12} /> Match — file is authentic</p>
|
||||
: <p className="text-xs text-danger flex items-center gap-1"><X size={12} /> Mismatch — checksums differ</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,15 +1,131 @@
|
|||
import { useState, useCallback } from 'react'
|
||||
import { Zap, RefreshCw, AlertTriangle } from 'lucide-react'
|
||||
import { GetFastbootDevices, FlashPartition, FastbootGetVar, SelectFileForFlash, SideloadPackage, SelectFileForInstall } from '../../lib/wails'
|
||||
import { useState, useCallback, useEffect } from 'react'
|
||||
import {
|
||||
Zap, RefreshCw, AlertTriangle, Power, Unlock, Lock, Rocket, HardDrive, KeyRound, Download, Boxes, Trash2, FileSearch
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
GetFastbootDevices, FlashPartition, FastbootGetVar, SelectFileForFlash,
|
||||
SideloadPackage, SelectFileForInstall, FastbootBoot, FlashBootImage,
|
||||
FastbootFlashing, FastbootReboot, FlasherDeviceInfo, Reboot,
|
||||
MagiskInstalled, InstallMagisk, ExtractBootImages, PushImageToDevice, OpenMagisk, PullPatchedBoot,
|
||||
ListMagiskModules, ToggleMagiskModule, RemoveMagiskModule, AnalyzeBootImage
|
||||
} from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import { ensureDangerUnlocked } from '../../lib/applock'
|
||||
import { getRootTools } from '../../lib/featureflags'
|
||||
import DismissibleBanner from '../DismissibleBanner'
|
||||
import ViewPixelFlasher from './ViewPixelFlasher'
|
||||
import ViewFirmware from './ViewFirmware'
|
||||
import type { Device } from '../../lib/types'
|
||||
|
||||
interface BootImages { boot: string; initBoot: string; source: string }
|
||||
interface MagiskModule { id: string; name: string; version: string; author: string; description: string; enabled: boolean }
|
||||
interface BootInfo { valid: boolean; type: string; headerVersion: number; androidVersion: string; securityPatch: string; pageSize: number; kernelKB: number; ramdiskKB: number; sizeMB: number; sha1: string; sha256: string; root: string }
|
||||
|
||||
const PARTITIONS = [
|
||||
'boot', 'recovery', 'system', 'vendor', 'userdata',
|
||||
'boot', 'init_boot', 'recovery', 'system', 'vendor', 'userdata',
|
||||
'dtbo', 'vbmeta', 'super', 'product', 'odm', 'radio'
|
||||
]
|
||||
const BOOT_PARTITIONS = ['boot', 'init_boot', 'vendor_boot', 'recovery', 'dtbo', 'vbmeta']
|
||||
|
||||
interface FlasherInfo {
|
||||
connection: string
|
||||
serial: string
|
||||
slot: string
|
||||
bootloader: string
|
||||
fingerprint: string
|
||||
androidVer: string
|
||||
codename: string
|
||||
lockState: string
|
||||
verifiedBoot: string
|
||||
root: string
|
||||
}
|
||||
|
||||
// Tabbed container: all flash-related tools live here (Manual fastboot/sideload
|
||||
// + Pixel factory-image flashing) to keep the sidebar uncluttered.
|
||||
export default function ViewFlasher() {
|
||||
const [tab, setTab] = useState<'manual' | 'pixel' | 'download'>('manual')
|
||||
const [info, setInfo] = useState<FlasherInfo | null>(null)
|
||||
const [loadingInfo, setLoadingInfo] = useState(false)
|
||||
|
||||
const refreshInfo = useCallback(async () => {
|
||||
setLoadingInfo(true)
|
||||
try {
|
||||
setInfo(await FlasherDeviceInfo())
|
||||
} catch {
|
||||
setInfo(null)
|
||||
} finally {
|
||||
setLoadingInfo(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => { refreshInfo() }, [refreshInfo])
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-3 shrink-0">
|
||||
<Zap size={15} className="text-accent-green" />
|
||||
<span className="text-sm font-medium text-text-primary">Flasher</span>
|
||||
<div className="flex gap-1 bg-bg-raised rounded p-0.5 ml-1">
|
||||
{([['manual', 'Manual'], ['pixel', 'Pixel Factory'], ['download', 'Download']] as const).map(([id, label]) => (
|
||||
<button
|
||||
key={id}
|
||||
onClick={() => setTab(id)}
|
||||
className={`px-3 py-0.5 rounded text-xs font-medium transition-colors ${
|
||||
tab === id ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeviceBar info={info} loading={loadingInfo} onRefresh={refreshInfo} />
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
{tab === 'manual' && <ManualFlash info={info} refresh={refreshInfo} />}
|
||||
{tab === 'pixel' && <ViewPixelFlasher />}
|
||||
{tab === 'download' && <ViewFirmware codename={info?.codename} />}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Chip({ label, value, tone }: { label: string; value?: string; tone?: 'green' | 'red' | 'amber' }) {
|
||||
const color = tone === 'green' ? 'text-accent-green' : tone === 'red' ? 'text-danger' : tone === 'amber' ? 'text-warn' : 'text-text-secondary'
|
||||
return (
|
||||
<span className="flex items-center gap-1 whitespace-nowrap">
|
||||
<span className="text-text-muted">{label}</span>
|
||||
<span className={`mono ${color}`}>{value || '—'}</span>
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function DeviceBar({ info, loading, onRefresh }: { info: FlasherInfo | null; loading: boolean; onRefresh: () => void }) {
|
||||
const conn = info?.connection ?? 'none'
|
||||
const connTone = conn === 'none' ? 'red' : 'green'
|
||||
return (
|
||||
<div className="border-b border-bg-border bg-bg-surface px-4 py-1.5 flex items-center gap-4 text-xs overflow-x-auto shrink-0">
|
||||
<Chip label="Mode" value={conn} tone={connTone as any} />
|
||||
{conn !== 'none' && <>
|
||||
<Chip label="Serial" value={info?.serial} />
|
||||
<Chip label="Slot" value={info?.slot ? info.slot : undefined} />
|
||||
<Chip label="Bootloader" value={info?.bootloader} />
|
||||
<Chip label="Lock" value={info?.lockState} tone={info?.lockState === 'unlocked' ? 'amber' : info?.lockState === 'locked' ? 'green' : undefined} />
|
||||
{info?.codename && <Chip label="Device" value={info?.codename} />}
|
||||
{info?.androidVer && <Chip label="Android" value={info?.androidVer} />}
|
||||
{info?.root && <Chip label="Root" value={info.root} tone={info.root !== 'none' ? 'amber' : undefined} />}
|
||||
</>}
|
||||
<button onClick={onRefresh} disabled={loading} className="btn-ghost text-xs ml-auto shrink-0" title="Refresh device info">
|
||||
<RefreshCw size={12} className={loading ? 'animate-spin' : ''} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface ManualProps { info: FlasherInfo | null; refresh: () => void }
|
||||
|
||||
function ManualFlash({ info, refresh }: ManualProps) {
|
||||
const [devices, setDevices] = useState<Device[]>([])
|
||||
const [loadingDevices, setLoadingDevices] = useState(false)
|
||||
const [partition, setPartition] = useState('boot')
|
||||
|
|
@ -17,6 +133,24 @@ export default function ViewFlasher() {
|
|||
const [flashing, setFlashing] = useState(false)
|
||||
const [getvarKey, setGetvarKey] = useState('all')
|
||||
const [getvarResult, setGetvarResult] = useState('')
|
||||
// Live-boot / boot-image flashing
|
||||
const [bootFile, setBootFile] = useState('')
|
||||
const [bootPartition, setBootPartition] = useState('boot')
|
||||
const [slot, setSlot] = useState('')
|
||||
const [busy, setBusy] = useState('')
|
||||
// Magisk rooting flow (gated by Settings → Advanced)
|
||||
const rootTools = getRootTools()
|
||||
const [magiskBusy, setMagiskBusy] = useState('')
|
||||
const [extracted, setExtracted] = useState<BootImages | null>(null)
|
||||
const [patchTarget, setPatchTarget] = useState<'boot' | 'initBoot'>('initBoot')
|
||||
const [magiskPkg, setMagiskPkg] = useState('')
|
||||
const [modules, setModules] = useState<MagiskModule[] | null>(null)
|
||||
const [modulesBusy, setModulesBusy] = useState(false)
|
||||
const [dryRun, setDryRun] = useState(false)
|
||||
const [force, setForce] = useState(false)
|
||||
const [bootInfo, setBootInfo] = useState<BootInfo | null>(null)
|
||||
|
||||
const inFastboot = info?.connection === 'fastboot'
|
||||
|
||||
const refreshDevices = useCallback(async () => {
|
||||
setLoadingDevices(true)
|
||||
|
|
@ -38,72 +172,420 @@ export default function ViewFlasher() {
|
|||
|
||||
const handleFlash = async () => {
|
||||
if (!selectedFile) { notify.error('Select an image file first'); return }
|
||||
if (!confirm(`Flash ${selectedFile} to ${partition}?\n\nThis will overwrite the ${partition} partition. Make sure you know what you're doing.`)) return
|
||||
|
||||
if (dryRun) { notify.info(`[dry run] fastboot ${force ? '--force ' : ''}flash ${partition} ${selectedFile}`); return }
|
||||
if (!confirm(`Flash ${selectedFile} to ${partition}?${force ? '\n\n⚠ --force is ON (skips safety checks).' : ''}\n\nThis overwrites the ${partition} partition.`)) return
|
||||
if (!(await ensureDangerUnlocked())) return
|
||||
setFlashing(true)
|
||||
const id = notify.loading(`Flashing ${partition}...`)
|
||||
try {
|
||||
const out = await FlashPartition(partition, selectedFile)
|
||||
notify.dismiss(id)
|
||||
notify.success(out || `${partition} flashed successfully`)
|
||||
} catch (e: any) {
|
||||
notify.dismiss(id)
|
||||
notify.error(e)
|
||||
} finally {
|
||||
setFlashing(false)
|
||||
}
|
||||
const out = await FlashPartition(partition, selectedFile, force)
|
||||
notify.dismiss(id); notify.success(out || `${partition} flashed`)
|
||||
} catch (e: any) { notify.dismiss(id); notify.error(e) }
|
||||
finally { setFlashing(false) }
|
||||
}
|
||||
|
||||
const handleGetvar = async () => {
|
||||
try {
|
||||
const out = await FastbootGetVar(getvarKey)
|
||||
setGetvarResult(out)
|
||||
} catch (e: any) {
|
||||
setGetvarResult(String(e))
|
||||
}
|
||||
try { setGetvarResult(await FastbootGetVar(getvarKey)) }
|
||||
catch (e: any) { setGetvarResult(String(e)) }
|
||||
}
|
||||
|
||||
const handleSideload = async () => {
|
||||
const path = await SelectFileForInstall()
|
||||
if (!path) return
|
||||
if (!confirm('Sideload requires device to be in sideload mode (adb sideload). Continue?')) return
|
||||
if (!confirm('Sideload requires the device in sideload mode (recovery → Apply update from ADB). Continue?')) return
|
||||
if (!(await ensureDangerUnlocked())) return
|
||||
const id = notify.loading('Sideloading...')
|
||||
try {
|
||||
const out = await SideloadPackage(path)
|
||||
notify.dismiss(id); notify.success(out || 'Sideload complete')
|
||||
} catch (e: any) { notify.dismiss(id); notify.error(e) }
|
||||
}
|
||||
|
||||
const reboot = async (target: string) => {
|
||||
setBusy('reboot')
|
||||
try {
|
||||
if (inFastboot) await FastbootReboot(target)
|
||||
else await Reboot(target) // adb: '', bootloader, recovery, fastboot, sideload
|
||||
notify.success(`Reboot ${target || 'system'} sent`)
|
||||
setTimeout(refresh, 3500)
|
||||
} catch (e: any) { notify.error(e) }
|
||||
finally { setBusy('') }
|
||||
}
|
||||
|
||||
const flashing2 = async (action: 'unlock' | 'lock') => {
|
||||
if (!confirm(`fastboot flashing ${action}\n\n${action === 'unlock'
|
||||
? 'Unlocking ERASES ALL DATA and requires confirmation on the device screen.'
|
||||
: 'Locking ERASES ALL DATA. Only lock with fully stock partitions or you may brick the device.'}\n\nContinue?`)) return
|
||||
if (!(await ensureDangerUnlocked())) return
|
||||
setBusy(action)
|
||||
try {
|
||||
const out = await FastbootFlashing(action)
|
||||
notify.success(out)
|
||||
setTimeout(refresh, 1500)
|
||||
} catch (e: any) { notify.error(e) }
|
||||
finally { setBusy('') }
|
||||
}
|
||||
|
||||
const selectBootFile = async () => {
|
||||
const path = await SelectFileForFlash()
|
||||
if (path) setBootFile(path)
|
||||
}
|
||||
|
||||
const liveBoot = async () => {
|
||||
if (!bootFile) { notify.error('Select an image first'); return }
|
||||
if (dryRun) { notify.info(`[dry run] fastboot boot ${bootFile}`); return }
|
||||
setBusy('liveboot')
|
||||
const id = notify.loading('Live-booting image...')
|
||||
try {
|
||||
await FastbootBoot(bootFile)
|
||||
notify.dismiss(id); notify.success('Booting image — watch the device')
|
||||
setTimeout(refresh, 4000)
|
||||
} catch (e: any) { notify.dismiss(id); notify.error(e) }
|
||||
finally { setBusy('') }
|
||||
}
|
||||
|
||||
// ── Magisk assisted patch flow ──
|
||||
const checkMagisk = useCallback(async () => {
|
||||
try { setMagiskPkg(await MagiskInstalled()) } catch { setMagiskPkg('') }
|
||||
}, [])
|
||||
useEffect(() => { if (rootTools) checkMagisk() }, [rootTools, checkMagisk])
|
||||
|
||||
const installMagisk = async () => {
|
||||
if (!(await ensureDangerUnlocked())) return
|
||||
setMagiskBusy('install')
|
||||
const id = notify.loading('Downloading & installing Magisk (may take a moment)...')
|
||||
try {
|
||||
const out = await InstallMagisk()
|
||||
notify.dismiss(id); notify.success(out)
|
||||
checkMagisk()
|
||||
} catch (e: any) { notify.dismiss(id); notify.error(e) }
|
||||
finally { setMagiskBusy('') }
|
||||
}
|
||||
|
||||
const loadModules = async () => {
|
||||
setModulesBusy(true)
|
||||
try {
|
||||
setModules(await ListMagiskModules() || [])
|
||||
} catch (e: any) { notify.error(e); setModules([]) }
|
||||
finally { setModulesBusy(false) }
|
||||
}
|
||||
const toggleModule = async (m: MagiskModule) => {
|
||||
try {
|
||||
const out = await ToggleMagiskModule(m.id, !m.enabled)
|
||||
notify.success(out)
|
||||
setModules(mods => mods?.map(x => x.id === m.id ? { ...x, enabled: !x.enabled } : x) || null)
|
||||
} catch (e: any) { notify.error(e) }
|
||||
}
|
||||
const removeModule = async (m: MagiskModule) => {
|
||||
if (!confirm(`Flag "${m.name}" for removal on next reboot?`)) return
|
||||
try { notify.success(await RemoveMagiskModule(m.id)) }
|
||||
catch (e: any) { notify.error(e) }
|
||||
}
|
||||
|
||||
const magiskExtract = async () => {
|
||||
const zip = await SelectFileForFlash()
|
||||
if (!zip) return
|
||||
setMagiskBusy('extract')
|
||||
const id = notify.loading('Extracting boot images from factory zip...')
|
||||
try {
|
||||
const imgs: BootImages = await ExtractBootImages(zip)
|
||||
setExtracted(imgs)
|
||||
setPatchTarget(imgs.initBoot ? 'initBoot' : 'boot')
|
||||
notify.dismiss(id)
|
||||
notify.success(out || 'Sideload complete')
|
||||
} catch (e: any) {
|
||||
notify.success(`Found ${[imgs.boot && 'boot.img', imgs.initBoot && 'init_boot.img'].filter(Boolean).join(' + ')}`)
|
||||
} catch (e: any) { notify.dismiss(id); notify.error(e) }
|
||||
finally { setMagiskBusy('') }
|
||||
}
|
||||
|
||||
const magiskPushOpen = async () => {
|
||||
if (!extracted) return
|
||||
const local = patchTarget === 'initBoot' ? extracted.initBoot : extracted.boot
|
||||
if (!local) { notify.error('That image is not present in the zip'); return }
|
||||
setMagiskBusy('push')
|
||||
const id = notify.loading('Pushing image and opening Magisk...')
|
||||
try {
|
||||
await MagiskInstalled() // surfaces a clear error if Magisk isn't installed
|
||||
await PushImageToDevice(local)
|
||||
await OpenMagisk()
|
||||
notify.dismiss(id)
|
||||
notify.error(e)
|
||||
}
|
||||
notify.success('Pushed to /sdcard/Download. In Magisk: Install → Select and Patch a File → pick it → Let\'s Go.')
|
||||
} catch (e: any) { notify.dismiss(id); notify.error(e) }
|
||||
finally { setMagiskBusy('') }
|
||||
}
|
||||
|
||||
const magiskPull = async () => {
|
||||
setMagiskBusy('pull')
|
||||
const id = notify.loading('Pulling patched image...')
|
||||
try {
|
||||
const path = await PullPatchedBoot()
|
||||
notify.dismiss(id)
|
||||
setBootFile(path)
|
||||
setBootPartition(patchTarget === 'initBoot' ? 'init_boot' : 'boot')
|
||||
notify.success('Patched image loaded into "Boot Image" below — Live boot to test, or Flash to make root permanent.')
|
||||
} catch (e: any) { notify.dismiss(id); notify.error(e) }
|
||||
finally { setMagiskBusy('') }
|
||||
}
|
||||
|
||||
const flashBoot = async () => {
|
||||
if (!bootFile) { notify.error('Select an image first'); return }
|
||||
if (dryRun) { notify.info(`[dry run] fastboot ${force ? '--force ' : ''}${slot ? '--slot ' + slot + ' ' : ''}flash ${bootPartition} ${bootFile}`); return }
|
||||
const where = slot ? ` (slot ${slot})` : ''
|
||||
if (!confirm(`Flash ${bootFile}\n→ ${bootPartition}${where}?${force ? '\n\n⚠ --force is ON.' : ''}`)) return
|
||||
if (!(await ensureDangerUnlocked())) return
|
||||
setBusy('flashboot')
|
||||
const id = notify.loading(`Flashing ${bootPartition}...`)
|
||||
try {
|
||||
const out = await FlashBootImage(bootPartition, bootFile, slot, force)
|
||||
notify.dismiss(id); notify.success(out || `${bootPartition} flashed`)
|
||||
} catch (e: any) { notify.dismiss(id); notify.error(e) }
|
||||
finally { setBusy('') }
|
||||
}
|
||||
|
||||
const analyzeBoot = async () => {
|
||||
const f = await SelectFileForFlash()
|
||||
if (!f) return
|
||||
setBootInfo(null)
|
||||
try { setBootInfo(await AnalyzeBootImage(f)) }
|
||||
catch (e: any) { notify.error(e) }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4 h-full overflow-auto">
|
||||
<h1 className="text-base font-medium text-text-primary">Flasher</h1>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="flex items-start gap-3 bg-warn/5 border border-warn/20 rounded-lg px-4 py-3">
|
||||
<div className="p-4 space-y-4">
|
||||
<DismissibleBanner id="warn-flasher" className="bg-warn/5 border border-warn/20 rounded-lg px-4 py-3 text-warn">
|
||||
<AlertTriangle size={16} className="text-warn shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-warn/90">
|
||||
<p className="font-medium mb-1">Fastboot operations are destructive and irreversible.</p>
|
||||
<p className="text-warn/70">Wrong partition or wrong image = bricked device. Only partition names in the safe list are permitted. Make sure your device bootloader is unlocked before flashing.</p>
|
||||
<p className="text-warn/70">Wrong partition or wrong image = bricked device. Make sure the bootloader is unlocked before flashing.</p>
|
||||
</div>
|
||||
</DismissibleBanner>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 px-1">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-primary">Dry run</p>
|
||||
<p className="text-[11px] text-text-muted">Preview the exact fastboot command instead of running it (flash / live-boot).</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setDryRun(v => !v)}
|
||||
role="switch"
|
||||
aria-checked={dryRun}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors shrink-0 ${dryRun ? 'bg-warn' : 'bg-bg-border'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-bg-surface shadow transition-all ${dryRun ? 'left-[18px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 px-1">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-primary">Force (<span className="mono">--force</span>)</p>
|
||||
<p className="text-[11px] text-text-muted">Adds <span className="mono">--force</span> to flash commands (e.g. bootloader/radio). Skips safety checks — use only when you know it's needed.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setForce(v => !v)}
|
||||
role="switch"
|
||||
aria-checked={force}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors shrink-0 ${force ? 'bg-danger' : 'bg-bg-border'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-bg-surface shadow transition-all ${force ? 'left-[18px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
{/* Reboot */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<p className="section-title">Reboot</p>
|
||||
<p className="text-xs text-text-muted">{inFastboot ? 'Device in fastboot — uses fastboot reboot.' : 'Device in adb — uses adb reboot.'}</p>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<button onClick={() => reboot('')} disabled={!!busy} className="btn-ghost text-xs"><Power size={12} /> System</button>
|
||||
<button onClick={() => reboot('bootloader')} disabled={!!busy} className="btn-ghost text-xs">Bootloader</button>
|
||||
<button onClick={() => reboot('fastboot')} disabled={!!busy} className="btn-ghost text-xs">Fastbootd</button>
|
||||
<button onClick={() => reboot('recovery')} disabled={!!busy} className="btn-ghost text-xs">Recovery</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bootloader */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<p className="section-title">Bootloader Lock</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
{inFastboot ? `Current: ${info?.lockState ?? 'unknown'}. Both actions wipe the device.` : 'Connect a device in fastboot mode to lock/unlock.'}
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={() => flashing2('unlock')} disabled={!inFastboot || !!busy} className="btn-warn text-xs flex-1 justify-center"><Unlock size={13} /> Unlock</button>
|
||||
<button onClick={() => flashing2('lock')} disabled={!inFastboot || !!busy} className="btn-ghost text-xs flex-1 justify-center"><Lock size={13} /> Lock</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Magisk rooting flow (gated by Settings → Advanced) */}
|
||||
{rootTools && (
|
||||
<div className="card p-4 space-y-3 xl:col-span-2 border border-warn/20">
|
||||
<div className="flex items-center gap-2">
|
||||
<KeyRound size={14} className="text-warn" />
|
||||
<p className="section-title">Root with Magisk</p>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Patches the factory boot image with the Magisk app on your phone, then loads it below to Live boot (temporary root) or Flash (permanent). ATK doesn't bundle Magisk — it uses the app on your device (install it below if missing). Requires an unlocked bootloader.
|
||||
</p>
|
||||
<div className="flex items-center justify-between gap-2 text-xs border-b border-bg-border pb-3">
|
||||
<span className={magiskPkg ? 'text-accent-green' : 'text-text-muted'}>
|
||||
{magiskPkg ? `Magisk detected: ${magiskPkg}` : 'Magisk not detected on device'}
|
||||
</span>
|
||||
<button onClick={installMagisk} disabled={!!magiskBusy} className="btn-ghost text-xs shrink-0">
|
||||
<Download size={12} /> {magiskBusy === 'install' ? 'Installing…' : 'Download & install Magisk'}
|
||||
</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
||||
<button onClick={magiskExtract} disabled={!!magiskBusy} className="btn-ghost text-xs justify-center">
|
||||
1. {magiskBusy === 'extract' ? 'Extracting…' : 'Extract boot from zip'}
|
||||
</button>
|
||||
<button onClick={magiskPushOpen} disabled={!extracted || !!magiskBusy} className="btn-ghost text-xs justify-center">
|
||||
2. {magiskBusy === 'push' ? 'Pushing…' : 'Push + open Magisk'}
|
||||
</button>
|
||||
<button onClick={magiskPull} disabled={!!magiskBusy} className="btn-ghost text-xs justify-center">
|
||||
3. {magiskBusy === 'pull' ? 'Pulling…' : 'Pull patched image'}
|
||||
</button>
|
||||
</div>
|
||||
{extracted && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="text-text-muted">Patch:</span>
|
||||
{(['initBoot', 'boot'] as const).map(t => {
|
||||
const has = t === 'initBoot' ? extracted.initBoot : extracted.boot
|
||||
return (
|
||||
<button
|
||||
key={t}
|
||||
disabled={!has}
|
||||
onClick={() => setPatchTarget(t)}
|
||||
className={`px-2 py-0.5 rounded border text-xs ${
|
||||
patchTarget === t ? 'border-accent-green text-accent-green bg-accent-green/10' : 'border-bg-border text-text-muted'
|
||||
} ${!has ? 'opacity-40 cursor-not-allowed' : ''}`}
|
||||
>
|
||||
{t === 'initBoot' ? 'init_boot.img' : 'boot.img'}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
<span className="text-text-muted ml-1">(init_boot for Pixel 7+/8+, boot for older)</span>
|
||||
</div>
|
||||
)}
|
||||
<p className="text-[11px] text-text-muted">Step 2 opens Magisk on the phone — tap <span className="text-text-secondary">Install → Select and Patch a File</span>, choose the pushed image in Download, then <span className="text-text-secondary">Let's Go</span>. Then run step 3.</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Magisk module management (gated, requires root) */}
|
||||
{rootTools && (
|
||||
<div className="card p-4 space-y-3 xl:col-span-2 border border-warn/20">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Boxes size={14} className="text-warn" />
|
||||
<p className="section-title">Magisk Modules</p>
|
||||
</div>
|
||||
<button onClick={loadModules} disabled={modulesBusy} className="btn-ghost text-xs">
|
||||
<RefreshCw size={12} className={modulesBusy ? 'animate-spin' : ''} /> {modules === null ? 'Load' : 'Refresh'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">Enable/disable or remove installed modules. Requires root (grant shell root in Magisk if prompted); changes apply on reboot.</p>
|
||||
{modules !== null && (
|
||||
modules.length === 0 ? (
|
||||
<p className="text-xs text-text-muted text-center py-3">No modules installed (or device not rooted).</p>
|
||||
) : (
|
||||
<div className="divide-y divide-bg-border/50">
|
||||
{modules.map(m => (
|
||||
<div key={m.id} className="flex items-center gap-3 py-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-xs text-text-primary truncate">{m.name} <span className="text-text-muted">{m.version}</span></p>
|
||||
<p className="text-[10px] text-text-muted truncate">{m.author || m.id}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleModule(m)}
|
||||
role="switch"
|
||||
aria-checked={m.enabled}
|
||||
title={m.enabled ? 'Enabled' : 'Disabled'}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors shrink-0 ${m.enabled ? 'bg-accent-green' : 'bg-bg-border'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-bg-surface shadow transition-all ${m.enabled ? 'left-[18px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
<button onClick={() => removeModule(m)} title="Remove on reboot" className="text-text-muted hover:text-danger shrink-0">
|
||||
<Trash2 size={13} />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Live boot + boot image flashing */}
|
||||
<div className="card p-4 space-y-3 xl:col-span-2">
|
||||
<p className="section-title">Boot Image — Live Boot & Flash</p>
|
||||
<div className="flex gap-2">
|
||||
<input className="input text-xs flex-1 mono" value={bootFile} readOnly placeholder="Select a boot / init_boot image (.img)" />
|
||||
<button onClick={selectBootFile} className="btn-ghost text-xs shrink-0">Browse</button>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="block">
|
||||
<span className="text-xs text-text-muted">Partition</span>
|
||||
<select className="input text-xs mt-1" value={bootPartition} onChange={e => setBootPartition(e.target.value)}>
|
||||
{BOOT_PARTITIONS.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-text-muted">Slot</span>
|
||||
<select className="input text-xs mt-1" value={slot} onChange={e => setSlot(e.target.value)}>
|
||||
<option value="">current</option>
|
||||
<option value="a">a</option>
|
||||
<option value="b">b</option>
|
||||
<option value="all">both</option>
|
||||
</select>
|
||||
</label>
|
||||
<div className="flex gap-2 ml-auto">
|
||||
<button onClick={liveBoot} disabled={!inFastboot || !bootFile || !!busy} className="btn-ghost text-sm" title="Boot the image without flashing">
|
||||
<Rocket size={14} /> {busy === 'liveboot' ? 'Booting…' : 'Live boot'}
|
||||
</button>
|
||||
<button onClick={flashBoot} disabled={!inFastboot || !bootFile || !!busy} className="btn-danger text-sm">
|
||||
<HardDrive size={14} /> {busy === 'flashboot' ? 'Flashing…' : `Flash ${bootPartition}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{!inFastboot && <p className="text-xs text-text-muted">Connect a device in fastboot mode to live-boot or flash.</p>}
|
||||
</div>
|
||||
|
||||
{/* Boot image analyzer (local file — no device needed) */}
|
||||
<div className="card p-4 space-y-3 xl:col-span-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<FileSearch size={14} className="text-accent-green" />
|
||||
<p className="section-title">Boot Image Analyzer</p>
|
||||
</div>
|
||||
<button onClick={analyzeBoot} className="btn-ghost text-xs">Analyze a .img…</button>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">Inspect a boot / init_boot image: type, header, Android version + security patch, sizes, hashes, and whether it looks rooted. Local file only — no device needed.</p>
|
||||
{bootInfo && (
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1 text-xs">
|
||||
<Chip label="Type" value={bootInfo.type} tone={bootInfo.valid ? 'green' : 'red'} />
|
||||
<Chip label="Header" value={bootInfo.headerVersion ? `v${bootInfo.headerVersion}` : undefined} />
|
||||
<Chip label="Android" value={bootInfo.androidVersion} />
|
||||
<Chip label="Patch" value={bootInfo.securityPatch} />
|
||||
<Chip label="Kernel" value={bootInfo.kernelKB ? `${bootInfo.kernelKB} KB` : undefined} />
|
||||
<Chip label="Ramdisk" value={bootInfo.ramdiskKB ? `${bootInfo.ramdiskKB} KB` : undefined} />
|
||||
<Chip label="Root" value={bootInfo.root} tone={bootInfo.root.includes('none') ? undefined : 'amber'} />
|
||||
<Chip label="Size" value={`${bootInfo.sizeMB} MB`} />
|
||||
<div className="col-span-2 mt-1">
|
||||
<p className="text-[10px] text-text-muted mono break-all">SHA-256 {bootInfo.sha256}</p>
|
||||
<p className="text-[10px] text-text-muted mono break-all">SHA-1 {bootInfo.sha1}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fastboot devices */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<p className="section-title">Fastboot Devices</p>
|
||||
<button onClick={refreshDevices} disabled={loadingDevices} className="btn-ghost text-xs">
|
||||
<RefreshCw size={12} className={loadingDevices ? 'animate-spin' : ''} />
|
||||
Refresh
|
||||
<RefreshCw size={12} className={loadingDevices ? 'animate-spin' : ''} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
{devices.length === 0 ? (
|
||||
<p className="text-text-muted text-sm text-center py-4">
|
||||
No fastboot devices. Boot device to bootloader with:<br />
|
||||
No fastboot devices. Boot to bootloader:<br />
|
||||
<span className="mono text-xs text-text-secondary">adb reboot bootloader</span>
|
||||
</p>
|
||||
) : (
|
||||
|
|
@ -124,39 +606,20 @@ export default function ViewFlasher() {
|
|||
<div className="space-y-2">
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Partition</label>
|
||||
<select
|
||||
className="input text-xs"
|
||||
value={partition}
|
||||
onChange={e => setPartition(e.target.value)}
|
||||
>
|
||||
{PARTITIONS.map(p => (
|
||||
<option key={p} value={p}>{p}</option>
|
||||
))}
|
||||
<select className="input text-xs" value={partition} onChange={e => setPartition(e.target.value)}>
|
||||
{PARTITIONS.map(p => <option key={p} value={p}>{p}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-text-muted mb-1 block">Image file</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input text-xs flex-1 mono"
|
||||
value={selectedFile}
|
||||
readOnly
|
||||
placeholder="No file selected"
|
||||
/>
|
||||
<input className="input text-xs flex-1 mono" value={selectedFile} readOnly placeholder="No file selected" />
|
||||
<button onClick={handleSelectFile} className="btn-ghost text-xs shrink-0">Browse</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleFlash}
|
||||
disabled={flashing || !selectedFile || devices.length === 0}
|
||||
className="btn-danger w-full justify-center"
|
||||
>
|
||||
<Zap size={14} />
|
||||
{flashing ? 'Flashing...' : `Flash ${partition}`}
|
||||
<button onClick={handleFlash} disabled={flashing || !selectedFile} className="btn-danger w-full justify-center">
|
||||
<Zap size={14} /> {flashing ? 'Flashing...' : `Flash ${partition}`}
|
||||
</button>
|
||||
{devices.length === 0 && (
|
||||
<p className="text-text-muted text-xs text-center">Connect a device in fastboot mode to flash</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
|
@ -164,27 +627,18 @@ export default function ViewFlasher() {
|
|||
<div className="card p-4 space-y-3">
|
||||
<p className="section-title">Fastboot Getvar</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input text-xs flex-1"
|
||||
value={getvarKey}
|
||||
onChange={e => setGetvarKey(e.target.value)}
|
||||
placeholder="all"
|
||||
/>
|
||||
<input className="input text-xs flex-1" value={getvarKey} onChange={e => setGetvarKey(e.target.value)} placeholder="all" />
|
||||
<button onClick={handleGetvar} className="btn-ghost text-xs">Query</button>
|
||||
</div>
|
||||
{getvarResult && (
|
||||
<pre className="bg-bg-raised rounded p-3 text-xs mono text-text-secondary whitespace-pre-wrap max-h-48 overflow-auto">
|
||||
{getvarResult}
|
||||
</pre>
|
||||
<pre className="bg-bg-raised rounded p-3 text-xs mono text-text-secondary whitespace-pre-wrap max-h-48 overflow-auto">{getvarResult}</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Sideload */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<p className="section-title">ADB Sideload</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Sideload a ZIP (OTA update) to a device in sideload mode. Boot to recovery then select "Apply update from ADB".
|
||||
</p>
|
||||
<p className="text-xs text-text-muted">Sideload a ZIP (OTA update) to a device in sideload mode (recovery → Apply update from ADB).</p>
|
||||
<button onClick={handleSideload} className="btn-ghost w-full justify-center text-xs">
|
||||
<Zap size={13} /> Select ZIP and Sideload
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useState, useEffect, useRef, useCallback } from 'react'
|
||||
import { Play, Square, Trash2, Download, Filter, ChevronDown } from 'lucide-react'
|
||||
import { Play, Square, Trash2, Download, Filter, ChevronDown, List, Share2 } from 'lucide-react'
|
||||
import { StartLogcat, StopLogcat, ClearLogcat } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import LogcatMap from './LogcatMap'
|
||||
import type { LogcatLine } from '../../lib/types'
|
||||
|
||||
// @ts-ignore
|
||||
|
|
@ -23,8 +24,9 @@ const LEVEL_BG: Record<string, string> = {
|
|||
W: 'bg-warn/5',
|
||||
}
|
||||
|
||||
const BUFFERS = ['main', 'radio', 'events', 'crash', 'all']
|
||||
const MAX_LINES = 5000
|
||||
const BUFFERS = ['main', 'system', 'radio', 'events', 'crash', 'default', 'all']
|
||||
const REFRESH_OPTS: [number, string][] = [[0, 'Live'], [250, '250ms'], [500, '500ms'], [1000, '1s'], [2000, '2s']]
|
||||
const MAX_LINE_OPTS = [1000, 5000, 20000, 100000]
|
||||
|
||||
export default function ViewLogcat() {
|
||||
const [lines, setLines] = useState<LogcatLine[]>([])
|
||||
|
|
@ -33,12 +35,25 @@ export default function ViewLogcat() {
|
|||
const [tagFilter, setTagFilter] = useState('')
|
||||
const [levelFilter, setLevelFilter] = useState<string[]>([])
|
||||
const [buffer, setBuffer] = useState('main')
|
||||
const [refreshMs, setRefreshMs] = useState(0)
|
||||
const [maxLines, setMaxLines] = useState(5000)
|
||||
const pendingRef = useRef<LogcatLine[]>([])
|
||||
const [autoScroll, setAutoScroll] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [showFilters, setShowFilters] = useState(false)
|
||||
const [viewMode, setViewMode] = useState<'text' | 'map'>('text')
|
||||
const mapSinkRef = useRef<((l: LogcatLine) => void) | null>(null)
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// The map subscribes to the same stream via this sink (registered on mount).
|
||||
const registerMapSink = useCallback((fn: ((l: LogcatLine) => void) | null) => { mapSinkRef.current = fn }, [])
|
||||
const inspectEntity = useCallback((e: { kind: 'pid' | 'tag'; value: string; label: string }) => {
|
||||
if (e.kind === 'tag') { setTagFilter(e.value); setSearch('') }
|
||||
else { setSearch(e.value); setTagFilter('') }
|
||||
setViewMode('text'); setShowFilters(true)
|
||||
}, [])
|
||||
|
||||
// Wails runtime event bridge
|
||||
const useWailsEvent = (event: string, handler: (data: any) => void) => {
|
||||
useEffect(() => {
|
||||
|
|
@ -53,11 +68,28 @@ export default function ViewLogcat() {
|
|||
}
|
||||
|
||||
const handleLine = useCallback((line: LogcatLine) => {
|
||||
mapSinkRef.current?.(line) // always feed the visual map at full rate
|
||||
if (refreshMs > 0) { pendingRef.current.push(line); return } // batched flush below
|
||||
setLines(prev => {
|
||||
const next = [...prev, line]
|
||||
return next.length > MAX_LINES ? next.slice(next.length - MAX_LINES) : next
|
||||
return next.length > maxLines ? next.slice(next.length - maxLines) : next
|
||||
})
|
||||
}, [])
|
||||
}, [refreshMs, maxLines])
|
||||
|
||||
// Batched render: flush queued lines on the chosen interval instead of per-line.
|
||||
useEffect(() => {
|
||||
if (refreshMs <= 0) return
|
||||
const id = setInterval(() => {
|
||||
if (pendingRef.current.length === 0) return
|
||||
const batch = pendingRef.current
|
||||
pendingRef.current = []
|
||||
setLines(prev => {
|
||||
const next = prev.concat(batch)
|
||||
return next.length > maxLines ? next.slice(next.length - maxLines) : next
|
||||
})
|
||||
}, refreshMs)
|
||||
return () => clearInterval(id)
|
||||
}, [refreshMs, maxLines])
|
||||
|
||||
const handleStopped = useCallback(() => {
|
||||
setRunning(false)
|
||||
|
|
@ -149,11 +181,22 @@ export default function ViewLogcat() {
|
|||
value={buffer}
|
||||
onChange={e => setBuffer(e.target.value)}
|
||||
disabled={running}
|
||||
title="Log buffer"
|
||||
>
|
||||
{BUFFERS.map(b => <option key={b} value={b}>{b}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* Refresh rate (UI flush interval) */}
|
||||
<select
|
||||
className="input text-xs w-20 py-1"
|
||||
value={refreshMs}
|
||||
onChange={e => setRefreshMs(Number(e.target.value))}
|
||||
title="Refresh rate — how often the view updates"
|
||||
>
|
||||
{REFRESH_OPTS.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
|
||||
{/* Start/Stop */}
|
||||
{!running ? (
|
||||
<button onClick={start} className="btn-primary text-xs">
|
||||
|
|
@ -173,6 +216,24 @@ export default function ViewLogcat() {
|
|||
<Download size={12} /> Save
|
||||
</button>
|
||||
|
||||
{/* Text / Map view toggle */}
|
||||
<div className="flex rounded overflow-hidden border border-bg-border ml-1">
|
||||
<button
|
||||
onClick={() => setViewMode('text')}
|
||||
className={`px-2 py-1 text-xs flex items-center gap-1 ${viewMode === 'text' ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:bg-bg-raised'}`}
|
||||
title="Text log"
|
||||
>
|
||||
<List size={12} /> Text
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('map')}
|
||||
className={`px-2 py-1 text-xs flex items-center gap-1 ${viewMode === 'map' ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:bg-bg-raised'}`}
|
||||
title="Live visual map"
|
||||
>
|
||||
<Share2 size={12} /> Map
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="w-px h-5 bg-bg-border" />
|
||||
|
||||
{/* Search */}
|
||||
|
|
@ -209,11 +270,12 @@ export default function ViewLogcat() {
|
|||
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-4 bg-bg-raised shrink-0 flex-wrap">
|
||||
{/* Level filter */}
|
||||
<div className="flex items-center gap-1">
|
||||
<span className="text-xs text-text-muted">Level:</span>
|
||||
{['V', 'D', 'I', 'W', 'E', 'F'].map(level => (
|
||||
<span className="text-xs text-text-muted cursor-help" title="Android log severity: V=Verbose, D=Debug, I=Info, W=Warning, E=Error, F=Fatal. Click letters to filter.">Level:</span>
|
||||
{([['V', 'Verbose'], ['D', 'Debug'], ['I', 'Info'], ['W', 'Warning'], ['E', 'Error'], ['F', 'Fatal']] as const).map(([level, name]) => (
|
||||
<button
|
||||
key={level}
|
||||
onClick={() => toggleLevel(level)}
|
||||
title={`${name}${levelFilter.includes(level) ? ' (filtering)' : ''} — click to ${levelFilter.includes(level) ? 'remove' : 'show only'} this level`}
|
||||
className={`w-6 h-6 rounded text-xs font-mono font-bold transition-colors ${
|
||||
levelFilter.includes(level)
|
||||
? 'bg-accent-green/20 text-accent-green'
|
||||
|
|
@ -236,6 +298,14 @@ export default function ViewLogcat() {
|
|||
/>
|
||||
</div>
|
||||
|
||||
{/* Max lines kept in memory */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted">Max lines:</span>
|
||||
<select className="input text-xs w-24" value={maxLines} onChange={e => setMaxLines(Number(e.target.value))}>
|
||||
{MAX_LINE_OPTS.map(n => <option key={n} value={n}>{n.toLocaleString()}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{/* ADB filter string */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xs text-text-muted">ADB filter:</span>
|
||||
|
|
@ -259,11 +329,14 @@ export default function ViewLogcat() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Visual map — kept mounted so it keeps ingesting the stream; hidden in text mode */}
|
||||
<LogcatMap running={running} registerSink={registerMapSink} onInspectEntity={inspectEntity} hidden={viewMode !== 'map'} search={search} />
|
||||
|
||||
{/* Log output */}
|
||||
<div
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-auto bg-bg-base p-2 font-mono text-xs"
|
||||
className={`flex-1 overflow-auto bg-bg-base p-2 font-mono text-xs ${viewMode === 'map' ? 'hidden' : ''}`}
|
||||
>
|
||||
{filteredLines.length === 0 && (
|
||||
<div className="flex items-center justify-center h-32 text-text-muted">
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
SelectFileForInstall, InstallPackage
|
||||
} from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import { ensureDangerUnlocked } from '../../lib/applock'
|
||||
import type { PackageInfo } from '../../lib/types'
|
||||
|
||||
type Filter = 'all' | 'user' | 'system'
|
||||
|
|
@ -57,6 +58,7 @@ export default function ViewPackages() {
|
|||
|
||||
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])
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { useState, useRef } from 'react'
|
||||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Zap, AlertTriangle, FolderOpen, Check, X, RefreshCw, ChevronDown, ChevronRight } from 'lucide-react'
|
||||
import { GetFastbootDevices, Reboot } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import DismissibleBanner from '../DismissibleBanner'
|
||||
import type { Device } from '../../lib/types'
|
||||
|
||||
type StepStatus = 'waiting' | 'running' | 'done' | 'error' | 'skipped'
|
||||
|
|
@ -143,36 +144,56 @@ export default function ViewPixelFlasher() {
|
|||
}
|
||||
}
|
||||
|
||||
const loadZip = async (path: string) => {
|
||||
if (!path) return
|
||||
if (!path.toLowerCase().endsWith('.zip')) {
|
||||
notify.error('Please choose a Pixel factory image .zip')
|
||||
return
|
||||
}
|
||||
setFactoryZip(path)
|
||||
setSteps([])
|
||||
setParsedSteps([])
|
||||
setLog([])
|
||||
setDone(false)
|
||||
// Read flash-all.sh from inside the zip using Go backend
|
||||
try {
|
||||
// @ts-ignore
|
||||
const content: string = await window['go']['main']['App']['ReadFileFromZip'](path, 'flash-all.sh')
|
||||
if (content) {
|
||||
const parsed = parseFlashAllSh(content)
|
||||
setParsedSteps(parsed)
|
||||
setSteps(buildSteps(parsed, opts))
|
||||
addLog(`Parsed flash-all.sh: ${parsed.length} steps found`)
|
||||
} else {
|
||||
addLog('Warning: flash-all.sh not found in zip — is this a valid Pixel factory image?')
|
||||
}
|
||||
} catch {
|
||||
addLog('Could not read flash-all.sh from zip. Make sure this is an extracted factory image folder or valid zip.')
|
||||
}
|
||||
}
|
||||
|
||||
const handleSelectZip = async () => {
|
||||
try {
|
||||
// @ts-ignore
|
||||
const path: string = await window['go']['main']['App']['SelectFileForFlash']()
|
||||
if (!path) return
|
||||
setFactoryZip(path)
|
||||
setSteps([])
|
||||
setParsedSteps([])
|
||||
setLog([])
|
||||
setDone(false)
|
||||
// Read flash-all.sh from inside the zip using Go backend
|
||||
try {
|
||||
// @ts-ignore
|
||||
const content: string = await window['go']['main']['App']['ReadFileFromZip'](path, 'flash-all.sh')
|
||||
if (content) {
|
||||
const parsed = parseFlashAllSh(content)
|
||||
setParsedSteps(parsed)
|
||||
setSteps(buildSteps(parsed, opts))
|
||||
addLog(`Parsed flash-all.sh: ${parsed.length} steps found`)
|
||||
} else {
|
||||
addLog('Warning: flash-all.sh not found in zip — is this a valid Pixel factory image?')
|
||||
}
|
||||
} catch {
|
||||
addLog('Could not read flash-all.sh from zip. Make sure this is an extracted factory image folder or valid zip.')
|
||||
}
|
||||
await loadZip(path)
|
||||
} catch (e: any) {
|
||||
notify.error('Could not open file dialog')
|
||||
}
|
||||
}
|
||||
|
||||
// Drag-and-drop a factory .zip onto the drop target below to auto-load it.
|
||||
useEffect(() => {
|
||||
const rt = (window as any)['runtime']
|
||||
rt?.OnFileDrop?.((_x: number, _y: number, paths: string[]) => {
|
||||
const zip = (paths || []).find(p => p.toLowerCase().endsWith('.zip'))
|
||||
if (zip) loadZip(zip)
|
||||
else if (paths?.length) notify.error('Drop a Pixel factory image .zip')
|
||||
}, true)
|
||||
return () => rt?.OnFileDropOff?.()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [opts])
|
||||
|
||||
const updateOpts = (newOpts: FlashOptions) => {
|
||||
setOpts(newOpts)
|
||||
if (parsedSteps.length > 0) {
|
||||
|
|
@ -342,7 +363,7 @@ export default function ViewPixelFlasher() {
|
|||
<h1 className="text-base font-medium text-text-primary">Pixel Factory Flash</h1>
|
||||
|
||||
{/* Warning */}
|
||||
<div className="flex items-start gap-3 bg-danger/5 border border-danger/20 rounded-lg px-4 py-3 shrink-0">
|
||||
<DismissibleBanner id="warn-pixelflasher" className="bg-danger/5 border border-danger/20 rounded-lg px-4 py-3 shrink-0 text-danger">
|
||||
<AlertTriangle size={16} className="text-danger shrink-0 mt-0.5" />
|
||||
<div className="text-xs text-danger/90 space-y-1">
|
||||
<p className="font-medium">This will completely overwrite your device firmware.</p>
|
||||
|
|
@ -354,7 +375,7 @@ export default function ViewPixelFlasher() {
|
|||
Bootloader must be unlocked.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</DismissibleBanner>
|
||||
|
||||
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
|
||||
{/* Left: config */}
|
||||
|
|
@ -379,17 +400,17 @@ export default function ViewPixelFlasher() {
|
|||
</div>
|
||||
|
||||
{/* Factory image */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="card p-4 space-y-3" style={{ '--wails-drop-target': 'drop' } as React.CSSProperties}>
|
||||
<p className="section-title">Factory Image Zip</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
Extract the outer zip from Google, then select the inner <span className="mono">device-build-factory-*.zip</span>
|
||||
Extract the outer zip from Google, then select the inner <span className="mono">device-build-factory-*.zip</span> — or <span className="text-text-secondary">drag & drop a .zip anywhere on this panel</span>.
|
||||
</p>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
className="input text-xs flex-1 mono"
|
||||
value={factoryZip}
|
||||
readOnly
|
||||
placeholder="Select factory image zip..."
|
||||
placeholder="Select or drop a factory image zip..."
|
||||
/>
|
||||
<button onClick={handleSelectZip} className="btn-ghost text-xs shrink-0">
|
||||
<FolderOpen size={13} /> Browse
|
||||
|
|
|
|||
281
frontend/src/components/views/ViewScreenMirror.tsx
Normal file
281
frontend/src/components/views/ViewScreenMirror.tsx
Normal file
|
|
@ -0,0 +1,281 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { MonitorSmartphone, Play, Square, Check, AlertTriangle, Camera } from 'lucide-react'
|
||||
import { ScrcpyAvailable, ScrcpyRunning, StartScrcpy, StopScrcpy, CaptureScreenshot } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
|
||||
// Wails runtime is injected on window['runtime'] (same access as ViewLogcat).
|
||||
const rt = () => (window as any)['runtime']
|
||||
|
||||
interface Options {
|
||||
maxSize: number
|
||||
bitRateMbps: number
|
||||
maxFps: number
|
||||
stayAwake: boolean
|
||||
turnScreenOff: boolean
|
||||
showTouches: boolean
|
||||
alwaysOnTop: boolean
|
||||
fullscreen: boolean
|
||||
borderless: boolean
|
||||
record: boolean
|
||||
detached: boolean
|
||||
noAudio: boolean
|
||||
viewOnly: boolean
|
||||
videoCodec: string
|
||||
orientation: string
|
||||
}
|
||||
|
||||
const DEFAULTS: Options = {
|
||||
maxSize: 0, bitRateMbps: 8, maxFps: 60,
|
||||
stayAwake: true, turnScreenOff: false, showTouches: false,
|
||||
alwaysOnTop: false, fullscreen: false, borderless: false, record: false, detached: false,
|
||||
noAudio: false, viewOnly: false, videoCodec: '', orientation: '',
|
||||
}
|
||||
|
||||
export default function ViewScreenMirror() {
|
||||
const [available, setAvailable] = useState<string | null>(null)
|
||||
const [missing, setMissing] = useState('')
|
||||
const [running, setRunning] = useState(false)
|
||||
const [starting, setStarting] = useState(false)
|
||||
const [opts, setOpts] = useState<Options>(DEFAULTS)
|
||||
|
||||
useEffect(() => {
|
||||
ScrcpyAvailable().then(setAvailable).catch((e: any) => setMissing(String(e)))
|
||||
ScrcpyRunning().then(setRunning).catch(() => {})
|
||||
const off = rt()?.EventsOn?.('scrcpy:stopped', () => setRunning(false))
|
||||
return () => off?.()
|
||||
}, [])
|
||||
|
||||
const set = <K extends keyof Options>(k: K, v: Options[K]) => setOpts(o => ({ ...o, [k]: v }))
|
||||
|
||||
const start = async () => {
|
||||
setStarting(true)
|
||||
try {
|
||||
await StartScrcpy(opts)
|
||||
setRunning(true)
|
||||
notify.success('Mirror started — the window opens separately and can be moved anywhere')
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
} finally {
|
||||
setStarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const stop = async () => {
|
||||
try {
|
||||
await StopScrcpy()
|
||||
setRunning(false)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const screenshot = async () => {
|
||||
try {
|
||||
const path = await CaptureScreenshot()
|
||||
if (path) notify.success(`Saved ${path}`)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-4 h-full overflow-auto max-w-2xl">
|
||||
<div className="flex items-center gap-2">
|
||||
<MonitorSmartphone size={18} className="text-accent-green" />
|
||||
<h1 className="text-base font-medium text-text-primary">Screen Mirror</h1>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-muted leading-relaxed">
|
||||
Mirror and control your phone on your computer. The mirror opens in its own
|
||||
window you can move, resize, and snap anywhere — drive the phone with your
|
||||
mouse and keyboard. Powered by scrcpy.
|
||||
</p>
|
||||
|
||||
{/* Availability */}
|
||||
{available && (
|
||||
<div className="card p-3 flex items-center gap-2 text-xs">
|
||||
<Check size={14} className="text-accent-green shrink-0" />
|
||||
<span className="text-text-secondary">{available} detected</span>
|
||||
</div>
|
||||
)}
|
||||
{missing && (
|
||||
<div className="card p-3 flex items-start gap-2 text-xs border-warn/30">
|
||||
<AlertTriangle size={14} className="text-warn shrink-0 mt-0.5" />
|
||||
<div>
|
||||
<p className="text-text-secondary">scrcpy isn't installed.</p>
|
||||
<p className="text-text-muted mt-1">Install it with <span className="mono">sudo apt install scrcpy</span>, then reopen this view.</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Options */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<p className="section-title">Options</p>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<Select label="Max resolution" value={opts.maxSize} onChange={v => set('maxSize', v)}
|
||||
options={[[0, 'Original'], [1920, '1920'], [1280, '1280'], [1024, '1024'], [800, '800']]} />
|
||||
<Select label="Bitrate (Mbps)" value={opts.bitRateMbps} onChange={v => set('bitRateMbps', v)}
|
||||
options={[[2, '2'], [4, '4'], [8, '8'], [16, '16'], [32, '32']]} />
|
||||
<Select label="Max FPS" value={opts.maxFps} onChange={v => set('maxFps', v)}
|
||||
options={[[0, 'Unlimited'], [30, '30'], [60, '60'], [120, '120']]} />
|
||||
<label className="block">
|
||||
<span className="text-xs text-text-muted">Video codec</span>
|
||||
<select className="input text-xs w-full mt-1" value={opts.videoCodec} onChange={e => set('videoCodec', e.target.value)}>
|
||||
{[['', 'Auto'], ['h264', 'H.264'], ['h265', 'H.265'], ['av1', 'AV1']].map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="text-xs text-text-muted">Orientation</span>
|
||||
<select className="input text-xs w-full mt-1" value={opts.orientation} onChange={e => set('orientation', e.target.value)}>
|
||||
{[['', 'Auto'], ['0', '0°'], ['90', '90°'], ['180', '180°'], ['270', '270°']].map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 pt-1">
|
||||
<Toggle label="Keep phone awake" on={opts.stayAwake} onChange={v => set('stayAwake', v)} />
|
||||
<Toggle label="Turn phone screen off" on={opts.turnScreenOff} onChange={v => set('turnScreenOff', v)} />
|
||||
<Toggle label="Show touches on phone" on={opts.showTouches} onChange={v => set('showTouches', v)} />
|
||||
<Toggle label="Always on top" on={opts.alwaysOnTop} onChange={v => set('alwaysOnTop', v)} />
|
||||
<Toggle label="Borderless (no title bar)" on={opts.borderless} onChange={v => set('borderless', v)} />
|
||||
<Toggle label="Start fullscreen" on={opts.fullscreen} onChange={v => set('fullscreen', v)} />
|
||||
<Toggle label="Mute audio" on={opts.noAudio} onChange={v => set('noAudio', v)} />
|
||||
<Toggle label="View only (no control)" on={opts.viewOnly} onChange={v => set('viewOnly', v)} />
|
||||
<Toggle label="Record to file" on={opts.record} onChange={v => set('record', v)} />
|
||||
</div>
|
||||
|
||||
<div className="pt-2 mt-1 border-t border-bg-border flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs text-text-secondary">Keep running after ATK closes</p>
|
||||
<p className="text-[11px] text-text-muted mt-0.5">Detaches the mirror — quitting ATK won't close it. It'll show up here again next time you open ATK.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => set('detached', !opts.detached)}
|
||||
role="switch"
|
||||
aria-checked={opts.detached}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors shrink-0 ${opts.detached ? 'bg-accent-green' : 'bg-bg-border'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-bg-surface shadow transition-all ${opts.detached ? 'left-[18px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Capture */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<p className="section-title">Capture</p>
|
||||
<div className="flex items-center gap-3">
|
||||
<button onClick={screenshot} className="btn-ghost text-sm shrink-0">
|
||||
<Camera size={14} /> Screenshot
|
||||
</button>
|
||||
<span className="text-xs text-text-muted">Saves the phone's current screen as a PNG. Works anytime a device is connected — no mirror needed.</span>
|
||||
</div>
|
||||
<p className="text-[11px] text-text-muted leading-relaxed border-t border-bg-border pt-2">
|
||||
<span className="text-text-secondary">Screen recording:</span> enable “Record to file” above, then Start — a save dialog asks <span className="text-text-secondary">where to save the .mp4</span> (pick any folder/name). It records the whole session and finalizes the file when you Stop the mirror (or close its window). Perfect for repro clips.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-2">
|
||||
{running ? (
|
||||
<button onClick={stop} className="btn-danger text-sm">
|
||||
<Square size={14} /> Stop mirror
|
||||
</button>
|
||||
) : (
|
||||
<button onClick={start} disabled={starting || !!missing} className="btn-primary text-sm">
|
||||
<Play size={14} /> {starting ? 'Starting…' : 'Start mirror'}
|
||||
</button>
|
||||
)}
|
||||
{running && <span className="text-xs text-accent-green">● Mirroring — check the separate scrcpy window</span>}
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-text-muted leading-relaxed">
|
||||
Borderless hides the window's title bar for a clean look. Move it with
|
||||
<span className="text-text-secondary"> Super + drag</span>, and close it with
|
||||
<span className="text-text-secondary"> Stop mirror</span> above (the phone's own
|
||||
title bar can't be themed by ATK — it's drawn by your window manager).
|
||||
</p>
|
||||
|
||||
{/* Shortcut cheat-sheet */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<p className="section-title">Controls & shortcuts</p>
|
||||
<p className="text-xs text-text-muted">
|
||||
<span className="text-text-secondary">MOD</span> = <Kbd>Left Alt</Kbd> or <Kbd>Super</Kbd> (⊞ / ⌘ key) — use these when a laptop has no middle-click.
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-x-6 gap-y-1.5">
|
||||
{SHORTCUTS.map(s => (
|
||||
<div key={s.action} className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-text-secondary">{s.action}</span>
|
||||
<span className="flex items-center gap-1 shrink-0">
|
||||
<Kbd>{s.keys}</Kbd>
|
||||
{s.alt && <><span className="text-text-muted text-[10px]">or</span><Kbd>{s.alt}</Kbd></>}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const SHORTCUTS: { action: string; keys: string; alt?: string }[] = [
|
||||
{ action: 'Home', keys: 'MOD+H', alt: 'Middle-click' },
|
||||
{ action: 'Back', keys: 'MOD+B', alt: 'Right-click' },
|
||||
{ action: 'Tap', keys: 'Left-click' },
|
||||
{ action: 'Long-press / select', keys: 'Click + hold' },
|
||||
{ action: 'Recent apps', keys: 'MOD+S' },
|
||||
{ action: 'App menu', keys: 'MOD+M' },
|
||||
{ action: 'Notifications', keys: 'MOD+N' },
|
||||
{ action: 'Power', keys: 'MOD+P' },
|
||||
{ action: 'Volume up', keys: 'MOD+↑' },
|
||||
{ action: 'Volume down', keys: 'MOD+↓' },
|
||||
{ action: 'Rotate screen', keys: 'MOD+← / →' },
|
||||
{ action: 'Fullscreen', keys: 'MOD+F' },
|
||||
{ action: 'Phone screen off', keys: 'MOD+O' },
|
||||
{ action: 'Phone screen on', keys: 'MOD+⇧+O' },
|
||||
{ action: 'Copy to computer', keys: 'MOD+C' },
|
||||
{ action: 'Paste to phone', keys: 'MOD+V' },
|
||||
{ action: 'Swipe / gesture', keys: 'Click + drag' },
|
||||
{ action: 'Pinch to zoom', keys: 'Ctrl + drag' },
|
||||
]
|
||||
|
||||
function Kbd({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<kbd className="px-1.5 py-0.5 rounded bg-bg-raised border border-bg-border mono text-[10px] text-text-secondary whitespace-nowrap">
|
||||
{children}
|
||||
</kbd>
|
||||
)
|
||||
}
|
||||
|
||||
function Select({ label, value, onChange, options }: {
|
||||
label: string; value: number; onChange: (v: number) => void; options: [number, string][]
|
||||
}) {
|
||||
return (
|
||||
<label className="block">
|
||||
<span className="text-xs text-text-muted">{label}</span>
|
||||
<select
|
||||
className="input text-xs w-full mt-1"
|
||||
value={value}
|
||||
onChange={e => onChange(Number(e.target.value))}
|
||||
>
|
||||
{options.map(([v, l]) => <option key={v} value={v}>{l}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
function Toggle({ label, on, onChange }: { label: string; on: boolean; onChange: (v: boolean) => void }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-text-secondary">{label}</span>
|
||||
<button
|
||||
onClick={() => onChange(!on)}
|
||||
role="switch"
|
||||
aria-checked={on}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors shrink-0 ${on ? 'bg-accent-green' : 'bg-bg-border'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-bg-surface shadow transition-all ${on ? 'left-[18px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,13 +1,83 @@
|
|||
import { useState, useEffect } from 'react'
|
||||
import { Shield, RefreshCw, Check, AlertTriangle } from 'lucide-react'
|
||||
import { GetBinaryInfo, SetAdbPath, SetFastbootPath } from '../../lib/wails'
|
||||
import { Shield, RefreshCw, Check, AlertTriangle, Palette, Lock } from 'lucide-react'
|
||||
import { GetBinaryInfo, SetAdbPath, SetFastbootPath, AppLockStatus, SetAppPassword, DisableAppLock, SetRequireForDanger } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import { refreshAppLockStatus } from '../../lib/applock'
|
||||
import { applyTheme, getTheme, THEMES, type Theme } from '../../lib/theme'
|
||||
import { getSidebarPosition, setSidebarPosition, SIDEBAR_POSITIONS, getSidebarLabels, setSidebarLabels, type SidebarPosition } from '../../lib/layout'
|
||||
import { getRootTools, setRootTools, getHiddenViews, setHiddenViews, TOGGLEABLE_VIEWS, getMuteNoDevice, setMuteNoDevice } from '../../lib/featureflags'
|
||||
import { resetDismissed } from '../../lib/dismissible'
|
||||
|
||||
export default function ViewSettings() {
|
||||
const [binaryInfo, setBinaryInfo] = useState<Record<string, string>>({})
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [adbPath, setAdbPath] = useState('')
|
||||
const [fastbootPath, setFastbootPath] = useState('')
|
||||
const [theme, setTheme] = useState<Theme>(getTheme())
|
||||
const [sidebarPos, setSidebarPos] = useState<SidebarPosition>(getSidebarPosition())
|
||||
const [sidebarLabels, setSidebarLabelsState] = useState<boolean>(getSidebarLabels())
|
||||
const [rootTools, setRootToolsState] = useState<boolean>(getRootTools())
|
||||
|
||||
const changeTheme = (t: Theme) => { setTheme(t); applyTheme(t) }
|
||||
const changeSidebarPos = (p: SidebarPosition) => { setSidebarPos(p); setSidebarPosition(p) }
|
||||
const changeSidebarLabels = (on: boolean) => { setSidebarLabelsState(on); setSidebarLabels(on) }
|
||||
const changeRootTools = (on: boolean) => { setRootToolsState(on); setRootTools(on) }
|
||||
|
||||
// App lock
|
||||
const [lock, setLock] = useState({ enabled: false, requireForDanger: false })
|
||||
const [pwCurrent, setPwCurrent] = useState('')
|
||||
const [pwNew, setPwNew] = useState('')
|
||||
const [pwConfirm, setPwConfirm] = useState('')
|
||||
const [lockBusy, setLockBusy] = useState(false)
|
||||
|
||||
useEffect(() => { AppLockStatus().then(setLock).catch(() => {}) }, [])
|
||||
|
||||
const reloadLock = async () => {
|
||||
try { setLock(await AppLockStatus()) } catch {}
|
||||
await refreshAppLockStatus() // keep the live danger-gate cache in sync
|
||||
}
|
||||
|
||||
const savePassword = async () => {
|
||||
if (pwNew.length < 4) { notify.error('Password must be at least 4 characters'); return }
|
||||
if (pwNew !== pwConfirm) { notify.error('Passwords do not match'); return }
|
||||
setLockBusy(true)
|
||||
try {
|
||||
await SetAppPassword(lock.enabled ? pwCurrent : '', pwNew)
|
||||
notify.success(lock.enabled ? 'Password changed' : 'App lock enabled')
|
||||
setPwCurrent(''); setPwNew(''); setPwConfirm('')
|
||||
await reloadLock()
|
||||
} catch (e: any) { notify.error(e) } finally { setLockBusy(false) }
|
||||
}
|
||||
|
||||
const removeLock = async () => {
|
||||
if (!confirm('Remove the app password? ATK will open without prompting.')) return
|
||||
setLockBusy(true)
|
||||
try {
|
||||
await DisableAppLock(pwCurrent)
|
||||
notify.success('App lock removed')
|
||||
setPwCurrent(''); setPwNew(''); setPwConfirm('')
|
||||
await reloadLock()
|
||||
} catch (e: any) { notify.error(e) } finally { setLockBusy(false) }
|
||||
}
|
||||
|
||||
const toggleDanger = async (on: boolean) => {
|
||||
if (!pwCurrent) { notify.error('Enter your current password above to change this'); return }
|
||||
setLockBusy(true)
|
||||
try {
|
||||
await SetRequireForDanger(pwCurrent, on)
|
||||
notify.success(on ? 'Destructive actions now require the password' : 'Re-auth on destructive actions turned off')
|
||||
setPwCurrent('')
|
||||
await reloadLock()
|
||||
} catch (e: any) { notify.error(e) } finally { setLockBusy(false) }
|
||||
}
|
||||
|
||||
const [hidden, setHiddenState] = useState<string[]>(getHiddenViews())
|
||||
const [muteND, setMuteND] = useState<boolean>(getMuteNoDevice())
|
||||
const changeMuteND = (on: boolean) => { setMuteND(on); setMuteNoDevice(on) }
|
||||
const toggleFeature = (view: string) => {
|
||||
const next = hidden.includes(view) ? hidden.filter(v => v !== view) : [...hidden, view]
|
||||
setHiddenState(next); setHiddenViews(next)
|
||||
}
|
||||
|
||||
const loadBinaryInfo = async () => {
|
||||
setLoading(true)
|
||||
|
|
@ -47,6 +117,229 @@ export default function ViewSettings() {
|
|||
<div className="p-4 space-y-4 h-full overflow-auto max-w-2xl">
|
||||
<h1 className="text-base font-medium text-text-primary">Settings</h1>
|
||||
|
||||
{/* Appearance / theme */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette size={14} className="text-accent-green" />
|
||||
<p className="section-title">Appearance</p>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">Choose a colour theme. Applies instantly and is remembered.</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{THEMES.map(t => (
|
||||
<button
|
||||
key={t.id}
|
||||
onClick={() => changeTheme(t.id)}
|
||||
className={`text-left rounded border p-3 transition-colors ${
|
||||
theme === t.id
|
||||
? 'border-accent-green bg-accent-green/10'
|
||||
: 'border-bg-border hover:bg-bg-raised'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-text-primary">{t.label}</span>
|
||||
{theme === t.id && <Check size={12} className="text-accent-green" />}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-1 leading-snug">{t.hint}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-text-muted pt-1">Sidebar position. Applies instantly and is remembered.</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{SIDEBAR_POSITIONS.map(p => (
|
||||
<button
|
||||
key={p.id}
|
||||
onClick={() => changeSidebarPos(p.id)}
|
||||
className={`text-left rounded border p-3 transition-colors ${
|
||||
sidebarPos === p.id
|
||||
? 'border-accent-green bg-accent-green/10'
|
||||
: 'border-bg-border hover:bg-bg-raised'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-xs font-medium text-text-primary">{p.label}</span>
|
||||
{sidebarPos === p.id && <Check size={12} className="text-accent-green" />}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted mt-1 leading-snug">{p.hint}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<div className="pr-3">
|
||||
<p className="text-xs font-medium text-text-primary">Show navigation labels</p>
|
||||
<p className="text-xs text-text-muted">Display the name under each sidebar icon (e.g. Dashboard, Files).</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => changeSidebarLabels(!sidebarLabels)}
|
||||
role="switch"
|
||||
aria-checked={sidebarLabels}
|
||||
title="Toggle navigation labels"
|
||||
className={`relative h-5 w-9 rounded-full transition-colors shrink-0 ${
|
||||
sidebarLabels ? 'bg-accent-green' : 'bg-bg-border'
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 h-4 w-4 rounded-full bg-bg-surface shadow transition-all ${
|
||||
sidebarLabels ? 'left-[18px]' : 'left-0.5'
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<div className="pr-3">
|
||||
<p className="text-xs font-medium text-text-primary">Mute "no device" pop-ups</p>
|
||||
<p className="text-xs text-text-muted">Hide error toasts about a missing / offline / unauthorized device while browsing.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => changeMuteND(!muteND)}
|
||||
role="switch"
|
||||
aria-checked={muteND}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors shrink-0 ${muteND ? 'bg-accent-green' : 'bg-bg-border'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-bg-surface shadow transition-all ${muteND ? 'left-[18px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between pt-1">
|
||||
<p className="text-xs text-text-muted">Restore warnings you've hidden with the ✕ button.</p>
|
||||
<button
|
||||
onClick={() => { resetDismissed(); notify.success('Hidden warnings restored — reopen views to see them') }}
|
||||
className="btn-ghost text-xs shrink-0"
|
||||
>
|
||||
Show hidden warnings
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Sidebar features kill-switch */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Palette size={14} className="text-accent-green" />
|
||||
<p className="section-title">Sidebar Features</p>
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">Turn off the tools you don't use to declutter the sidebar. Settings always stays.</p>
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2">
|
||||
{TOGGLEABLE_VIEWS.map(f => {
|
||||
const on = !hidden.includes(f.view)
|
||||
return (
|
||||
<div key={f.view} className="flex items-center justify-between gap-2">
|
||||
<span className="text-xs text-text-secondary">{f.label}</span>
|
||||
<button
|
||||
onClick={() => toggleFeature(f.view)}
|
||||
role="switch"
|
||||
aria-checked={on}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors shrink-0 ${on ? 'bg-accent-green' : 'bg-bg-border'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-bg-surface shadow transition-all ${on ? 'left-[18px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* App lock / security */}
|
||||
<div className="card p-4 space-y-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Lock size={14} className="text-accent-green" />
|
||||
<p className="section-title">App Lock</p>
|
||||
{lock.enabled && <span className="badge-green text-xs">enabled</span>}
|
||||
</div>
|
||||
<p className="text-xs text-text-muted">
|
||||
Require a password to open ATK. Stored only as a salted scrypt hash — never the password itself.
|
||||
<br />
|
||||
<span className="text-warn">Note:</span> this gates the ATK app so it can't be driven into flashing
|
||||
or uninstalling without the password. It can't stop a compromised computer from running{' '}
|
||||
<span className="mono">adb</span>/<span className="mono">fastboot</span> directly, outside ATK — nothing
|
||||
running as your user can.
|
||||
</p>
|
||||
|
||||
{/* Current password (needed to change/remove or toggle re-auth when a lock exists) */}
|
||||
{lock.enabled && (
|
||||
<input
|
||||
type="password"
|
||||
className="input text-xs w-full"
|
||||
placeholder="Current password"
|
||||
value={pwCurrent}
|
||||
onChange={e => setPwCurrent(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Set / change password */}
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<input
|
||||
type="password"
|
||||
className="input text-xs w-full"
|
||||
placeholder={lock.enabled ? 'New password' : 'Password'}
|
||||
value={pwNew}
|
||||
onChange={e => setPwNew(e.target.value)}
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
className="input text-xs w-full"
|
||||
placeholder="Confirm password"
|
||||
value={pwConfirm}
|
||||
onChange={e => setPwConfirm(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button onClick={savePassword} disabled={lockBusy} className="btn-primary text-xs">
|
||||
{lock.enabled ? 'Change password' : 'Enable app lock'}
|
||||
</button>
|
||||
{lock.enabled && (
|
||||
<button onClick={removeLock} disabled={lockBusy} className="btn-ghost text-xs text-danger">
|
||||
Remove app lock
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Optional: re-auth before destructive actions */}
|
||||
{lock.enabled && (
|
||||
<div className="flex items-center justify-between gap-3 pt-2 border-t border-bg-border/50">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-primary">Require password for destructive actions</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">
|
||||
Re-prompt before flashing, uninstalling/debloating, and Magisk installs. Enter your current
|
||||
password above first. Stays unlocked for a few minutes after each confirmation.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => toggleDanger(!lock.requireForDanger)}
|
||||
role="switch"
|
||||
aria-checked={lock.requireForDanger}
|
||||
disabled={lockBusy}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors shrink-0 ${lock.requireForDanger ? 'bg-accent-green' : 'bg-bg-border'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-bg-surface shadow transition-all ${lock.requireForDanger ? 'left-[18px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Advanced / root tools */}
|
||||
<div className="card p-4 space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertTriangle size={14} className="text-warn" />
|
||||
<p className="section-title">Advanced</p>
|
||||
</div>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-medium text-text-primary">Enable rooting tools (Magisk patching)</p>
|
||||
<p className="text-xs text-text-muted mt-0.5">Adds a Magisk boot-patching panel to the Flasher for rooting. Off by default — these operations can wipe or brick a device if misused.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => changeRootTools(!rootTools)}
|
||||
role="switch"
|
||||
aria-checked={rootTools}
|
||||
className={`relative h-5 w-9 rounded-full transition-colors shrink-0 ${rootTools ? 'bg-accent-green' : 'bg-bg-border'}`}
|
||||
>
|
||||
<span className={`absolute top-0.5 h-4 w-4 rounded-full bg-bg-surface shadow transition-all ${rootTools ? 'left-[18px]' : 'left-0.5'}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Binary trust section */}
|
||||
<div className="card p-4 space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
import { useState, useRef, useEffect } from 'react'
|
||||
import { Terminal, Trash2, ChevronRight } from 'lucide-react'
|
||||
import { RunShellCommand, RunAdbHostCommand } from '../../lib/wails'
|
||||
import { Terminal, Trash2, ChevronRight, ChevronDown, Library, Search, Copy, Save } from 'lucide-react'
|
||||
import { RunShellCommand, RunAdbHostCommand, SaveTextFile } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
import { CATEGORIES, type Command } from './ViewUtilities'
|
||||
|
||||
interface HistoryEntry {
|
||||
cmd: string
|
||||
|
|
@ -11,13 +13,16 @@ interface HistoryEntry {
|
|||
|
||||
export default function ViewShell() {
|
||||
const [history, setHistory] = useState<HistoryEntry[]>([
|
||||
{ cmd: '', output: 'ADBKit Shell — commands run via adb shell (no pipes/redirects — args are split directly, no shell injection)\nSwitch to "adb" mode to run adb host commands (e.g. adb devices, adb logcat)', mode: 'shell' }
|
||||
{ cmd: '', output: 'Commands run via adb shell (no pipes/redirects — args are split directly, no shell injection)\nSwitch to "adb" mode to run adb host commands (e.g. adb devices, adb logcat)\nClick "Commands" to browse the command library and drop one into the prompt.', mode: 'shell' }
|
||||
])
|
||||
const [input, setInput] = useState('')
|
||||
const [mode, setMode] = useState<'shell' | 'adb'>('shell')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [cmdHistory, setCmdHistory] = useState<string[]>([])
|
||||
const [historyIdx, setHistoryIdx] = useState(-1)
|
||||
const [showLib, setShowLib] = useState(false)
|
||||
const [libSearch, setLibSearch] = useState('')
|
||||
const [openCats, setOpenCats] = useState<Set<string>>(new Set())
|
||||
const bottomRef = useRef<HTMLDivElement>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
|
|
@ -68,82 +73,202 @@ export default function ViewShell() {
|
|||
}
|
||||
}
|
||||
|
||||
// Library commands are stored as adb host args (e.g. "shell getprop ..."),
|
||||
// so dropping one into the prompt = adb host mode + the full string. That way
|
||||
// the user never has to pick shell-vs-host; it's set for them.
|
||||
const pickCommand = (cmd: Command) => {
|
||||
setMode('adb')
|
||||
setInput(cmd.cmd)
|
||||
setHistoryIdx(-1)
|
||||
inputRef.current?.focus()
|
||||
}
|
||||
|
||||
const toggleCat = (name: string) => {
|
||||
setOpenCats(prev => {
|
||||
const next = new Set(prev)
|
||||
next.has(name) ? next.delete(name) : next.add(name)
|
||||
return next
|
||||
})
|
||||
}
|
||||
|
||||
// Whole-session transcript: each command and its output, blank-line separated.
|
||||
const transcript = () =>
|
||||
history.map(e => (e.cmd ? `[${e.mode}]$ ${e.cmd}\n` : '') + e.output).join('\n\n').trim()
|
||||
|
||||
const hasSession = history.some(e => e.cmd)
|
||||
|
||||
const copyAll = async () => {
|
||||
await navigator.clipboard?.writeText(transcript())
|
||||
notify.success('Session copied to clipboard')
|
||||
}
|
||||
|
||||
const exportSession = async () => {
|
||||
try {
|
||||
const path = await SaveTextFile('atk-shell-session.txt', transcript())
|
||||
if (path) notify.success(`Saved to ${path}`)
|
||||
} catch (e: any) {
|
||||
notify.error(e)
|
||||
}
|
||||
}
|
||||
|
||||
const q = libSearch.toLowerCase()
|
||||
const filteredCats = CATEGORIES.map(cat => ({
|
||||
...cat,
|
||||
commands: q
|
||||
? cat.commands.filter(c => c.label.toLowerCase().includes(q) || c.cmd.toLowerCase().includes(q))
|
||||
: cat.commands,
|
||||
})).filter(cat => cat.commands.length > 0)
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Toolbar */}
|
||||
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-3 shrink-0">
|
||||
<Terminal size={14} className="text-accent-green" />
|
||||
<span className="text-xs text-text-muted">Mode:</span>
|
||||
<div className="flex gap-1 bg-bg-raised rounded p-0.5">
|
||||
{(['shell', 'adb'] as const).map(m => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`px-3 py-0.5 rounded text-xs font-medium transition-colors ${
|
||||
mode === m ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{m === 'shell' ? 'adb shell' : 'adb host'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => setHistory([{ cmd: '', output: 'Terminal cleared.', mode }])}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
<Trash2 size={12} /> Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Output */}
|
||||
<div
|
||||
className="flex-1 overflow-auto p-4 font-mono text-xs space-y-3 bg-bg-base cursor-text"
|
||||
onClick={() => inputRef.current?.focus()}
|
||||
>
|
||||
{history.map((entry, i) => (
|
||||
<div key={i}>
|
||||
{entry.cmd && (
|
||||
<div className="flex items-center gap-2 text-accent-green mb-1">
|
||||
<span className="text-text-muted">[{entry.mode}]$</span>
|
||||
<span>{entry.cmd}</span>
|
||||
<div className="flex h-full overflow-hidden">
|
||||
{/* Command library panel */}
|
||||
{showLib && (
|
||||
<div className="w-72 shrink-0 border-r border-bg-border flex flex-col overflow-hidden">
|
||||
<div className="px-3 py-2 border-b border-bg-border shrink-0 space-y-2">
|
||||
<p className="section-title">Command Library</p>
|
||||
<p className="text-text-muted text-xs">Click to drop into the prompt (sets adb-host mode). Fill any <span className="badge-yellow text-xs">args</span> tokens, then Enter.</p>
|
||||
<div className="relative">
|
||||
<Search size={12} className="absolute left-2 top-1/2 -translate-y-1/2 text-text-muted" />
|
||||
<input
|
||||
className="input text-xs w-full pl-6"
|
||||
placeholder="Search commands..."
|
||||
value={libSearch}
|
||||
onChange={e => { setLibSearch(e.target.value); if (e.target.value) setOpenCats(new Set(CATEGORIES.map(c => c.name))) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex-1 overflow-auto">
|
||||
{filteredCats.map(cat => (
|
||||
<div key={cat.name} className="border-b border-bg-border/40">
|
||||
<button
|
||||
onClick={() => toggleCat(cat.name)}
|
||||
className="w-full flex items-center justify-between px-3 py-2 hover:bg-bg-raised transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{openCats.has(cat.name)
|
||||
? <ChevronDown size={12} className="text-accent-green shrink-0" />
|
||||
: <ChevronRight size={12} className="text-text-muted shrink-0" />}
|
||||
<span className="text-xs font-medium text-text-primary">{cat.name}</span>
|
||||
</div>
|
||||
<span className="text-xs text-text-muted">{cat.commands.length}</span>
|
||||
</button>
|
||||
{openCats.has(cat.name) && (
|
||||
<div className="pb-1">
|
||||
{cat.commands.map(cmd => (
|
||||
<div
|
||||
key={cmd.label}
|
||||
onClick={() => pickCommand(cmd)}
|
||||
className="flex items-start gap-1 mx-2 rounded px-2 py-1.5 hover:bg-bg-raised transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<p className="text-xs text-text-secondary truncate">{cmd.label}</p>
|
||||
{cmd.needsInput && <span className="badge-yellow shrink-0 text-xs">args</span>}
|
||||
</div>
|
||||
<p className="text-xs mono text-text-muted truncate leading-tight mt-0.5">{cmd.cmd}</p>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<pre
|
||||
className={`whitespace-pre-wrap break-words leading-relaxed ${
|
||||
entry.error ? 'text-danger' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{entry.output}
|
||||
</pre>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<span className="animate-pulse">▌</span>
|
||||
<span>Running...</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-bg-border px-4 py-2 flex items-center gap-2 bg-bg-surface shrink-0">
|
||||
<span className="text-accent-green font-mono text-xs shrink-0">[{mode}]$</span>
|
||||
<ChevronRight size={12} className="text-text-muted shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
className="flex-1 bg-transparent text-text-primary font-mono text-xs focus:outline-none placeholder:text-text-muted"
|
||||
placeholder={mode === 'shell' ? 'ls /sdcard' : 'devices'}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={loading}
|
||||
/>
|
||||
{loading && (
|
||||
<div className="w-3 h-3 border border-accent-green border-t-transparent rounded-full animate-spin shrink-0" />
|
||||
)}
|
||||
{/* Terminal */}
|
||||
<div className="flex flex-col h-full flex-1 overflow-hidden">
|
||||
{/* Toolbar */}
|
||||
<div className="border-b border-bg-border px-4 py-2 flex items-center gap-3 shrink-0">
|
||||
<Terminal size={14} className="text-accent-green" />
|
||||
<span className="text-xs text-text-muted">Mode:</span>
|
||||
<div className="flex gap-1 bg-bg-raised rounded p-0.5">
|
||||
{(['shell', 'adb'] as const).map(m => (
|
||||
<button
|
||||
key={m}
|
||||
onClick={() => setMode(m)}
|
||||
className={`px-3 py-0.5 rounded text-xs font-medium transition-colors ${
|
||||
mode === m ? 'bg-accent-green/20 text-accent-green' : 'text-text-muted hover:text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{m === 'shell' ? 'adb shell' : 'adb host'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<button
|
||||
onClick={() => setShowLib(s => !s)}
|
||||
className={`btn-ghost text-xs ${showLib ? 'text-accent-green' : ''}`}
|
||||
>
|
||||
<Library size={12} /> Commands
|
||||
</button>
|
||||
<button onClick={copyAll} disabled={!hasSession} className="btn-ghost text-xs">
|
||||
<Copy size={12} /> Copy all
|
||||
</button>
|
||||
<button onClick={exportSession} disabled={!hasSession} className="btn-ghost text-xs">
|
||||
<Save size={12} /> Export
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setHistory([{ cmd: '', output: 'Terminal cleared.', mode }])}
|
||||
className="btn-ghost text-xs"
|
||||
>
|
||||
<Trash2 size={12} /> Clear
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Output */}
|
||||
<div
|
||||
className="flex-1 overflow-auto p-4 font-mono text-xs space-y-3 bg-bg-base cursor-text"
|
||||
// Only refocus the prompt on a bare click — if the user has selected
|
||||
// output text, stealing focus would collapse the highlight (and leave
|
||||
// the right-click menu with nothing to copy).
|
||||
onClick={() => { if (!window.getSelection()?.toString()) inputRef.current?.focus() }}
|
||||
>
|
||||
{history.map((entry, i) => (
|
||||
<div key={i}>
|
||||
{entry.cmd && (
|
||||
<div className="flex items-center gap-2 text-accent-green mb-1">
|
||||
<span className="text-text-muted">[{entry.mode}]$</span>
|
||||
<span>{entry.cmd}</span>
|
||||
</div>
|
||||
)}
|
||||
<pre
|
||||
className={`whitespace-pre-wrap break-words leading-relaxed ${
|
||||
entry.error ? 'text-danger' : 'text-text-secondary'
|
||||
}`}
|
||||
>
|
||||
{entry.output}
|
||||
</pre>
|
||||
</div>
|
||||
))}
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-text-muted">
|
||||
<span className="animate-pulse">▌</span>
|
||||
<span>Running...</span>
|
||||
</div>
|
||||
)}
|
||||
<div ref={bottomRef} />
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<div className="border-t border-bg-border px-4 py-2 flex items-center gap-2 bg-bg-surface shrink-0">
|
||||
<span className="text-accent-green font-mono text-xs shrink-0">[{mode}]$</span>
|
||||
<ChevronRight size={12} className="text-text-muted shrink-0" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
autoFocus
|
||||
className="flex-1 bg-transparent text-text-primary font-mono text-xs focus:outline-none placeholder:text-text-muted"
|
||||
placeholder={mode === 'shell' ? 'ls /sdcard' : 'devices'}
|
||||
value={input}
|
||||
onChange={e => setInput(e.target.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={loading}
|
||||
/>
|
||||
{loading && (
|
||||
<div className="w-3 h-3 border border-accent-green border-t-transparent rounded-full animate-spin shrink-0" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -3,18 +3,18 @@ import { FileText, Wrench, ChevronDown, ChevronRight, Play, Copy, Check } from '
|
|||
import { Reboot, RunAdbHostCommand } from '../../lib/wails'
|
||||
import { notify } from '../../lib/notify'
|
||||
|
||||
interface Command {
|
||||
export interface Command {
|
||||
label: string
|
||||
cmd: string
|
||||
needsInput?: { placeholder: string; token: string }[]
|
||||
}
|
||||
|
||||
interface Category {
|
||||
export interface Category {
|
||||
name: string
|
||||
commands: Command[]
|
||||
}
|
||||
|
||||
const CATEGORIES: Category[] = [
|
||||
export const CATEGORIES: Category[] = [
|
||||
// ─────────────────────────────────────────────
|
||||
{
|
||||
name: 'Device Info',
|
||||
|
|
@ -796,13 +796,371 @@ const CATEGORIES: Category[] = [
|
|||
{ label: 'Remount system (root)', cmd: 'remount' },
|
||||
],
|
||||
},
|
||||
|
||||
// ════════════════ EXPANDED CATEGORIES ════════════════
|
||||
{
|
||||
name: 'App Ops & Privacy',
|
||||
commands: [
|
||||
{ label: 'All app-ops for a package', cmd: 'shell appops get <package>',
|
||||
needsInput: [{ placeholder: 'com.example.app', token: '<package>' }] },
|
||||
{ label: 'Dump full appops service', cmd: 'shell dumpsys appops' },
|
||||
{ label: 'Apps allowed a given op', cmd: 'shell appops query-op <op> allow',
|
||||
needsInput: [{ placeholder: 'CAMERA', token: '<op>' }] },
|
||||
{ label: 'Set op → allow', cmd: 'shell appops set <package> <op> allow',
|
||||
needsInput: [{ placeholder: 'com.example.app', token: '<package>' }, { placeholder: 'CAMERA', token: '<op>' }] },
|
||||
{ label: 'Set op → deny', cmd: 'shell appops set <package> <op> deny',
|
||||
needsInput: [{ placeholder: 'com.example.app', token: '<package>' }, { placeholder: 'CAMERA', token: '<op>' }] },
|
||||
{ label: 'Set op → ignore', cmd: 'shell appops set <package> <op> ignore',
|
||||
needsInput: [{ placeholder: 'com.example.app', token: '<package>' }, { placeholder: 'CAMERA', token: '<op>' }] },
|
||||
{ label: 'Reset all ops for a package', cmd: 'shell appops reset <package>',
|
||||
needsInput: [{ placeholder: 'com.example.app', token: '<package>' }] },
|
||||
{ label: 'Background run access', cmd: 'shell appops get <package> RUN_ANY_IN_BACKGROUND',
|
||||
needsInput: [{ placeholder: 'com.example.app', token: '<package>' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Display & Screen',
|
||||
commands: [
|
||||
{ label: 'Current resolution', cmd: 'shell wm size' },
|
||||
{ label: 'Override resolution', cmd: 'shell wm size <WxH>',
|
||||
needsInput: [{ placeholder: '1080x2400', token: '<WxH>' }] },
|
||||
{ label: 'Reset resolution', cmd: 'shell wm size reset' },
|
||||
{ label: 'Current density (DPI)', cmd: 'shell wm density' },
|
||||
{ label: 'Override density', cmd: 'shell wm density <dpi>',
|
||||
needsInput: [{ placeholder: '420', token: '<dpi>' }] },
|
||||
{ label: 'Reset density', cmd: 'shell wm density reset' },
|
||||
{ label: 'Displays (dumpsys display)', cmd: 'shell dumpsys display' },
|
||||
{ label: 'SurfaceFlinger state', cmd: 'shell dumpsys SurfaceFlinger' },
|
||||
{ label: 'Force rotation (0-3)', cmd: 'shell settings put system user_rotation <0-3>',
|
||||
needsInput: [{ placeholder: '0', token: '<0-3>' }] },
|
||||
{ label: 'Disable auto-rotate', cmd: 'shell settings put system accelerometer_rotation 0' },
|
||||
{ label: 'Enable auto-rotate', cmd: 'shell settings put system accelerometer_rotation 1' },
|
||||
{ label: 'Screen-off timeout (ms)', cmd: 'shell settings put system screen_off_timeout <ms>',
|
||||
needsInput: [{ placeholder: '600000', token: '<ms>' }] },
|
||||
{ label: 'Wake screen', cmd: 'shell input keyevent KEYCODE_WAKEUP' },
|
||||
{ label: 'Sleep screen', cmd: 'shell input keyevent KEYCODE_SLEEP' },
|
||||
{ label: 'Stay awake while charging', cmd: 'shell settings put global stay_on_while_plugged_in 3' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Screen Capture & Recording',
|
||||
commands: [
|
||||
{ label: 'Screenshot to /sdcard', cmd: 'shell screencap -p /sdcard/atk_screen.png' },
|
||||
{ label: 'Record screen 10s to /sdcard', cmd: 'shell screenrecord --time-limit 10 /sdcard/atk_rec.mp4' },
|
||||
{ label: 'Record with bit-rate', cmd: 'shell screenrecord --bit-rate 8000000 --time-limit 10 /sdcard/atk_rec.mp4' },
|
||||
{ label: 'Record at size', cmd: 'shell screenrecord --size <WxH> --time-limit 10 /sdcard/atk_rec.mp4',
|
||||
needsInput: [{ placeholder: '720x1280', token: '<WxH>' }] },
|
||||
{ label: 'List captured files', cmd: 'shell ls -l /sdcard/atk_screen.png /sdcard/atk_rec.mp4' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Input & Automation',
|
||||
commands: [
|
||||
{ label: 'Tap at coordinate', cmd: 'shell input tap <x> <y>',
|
||||
needsInput: [{ placeholder: '540', token: '<x>' }, { placeholder: '1200', token: '<y>' }] },
|
||||
{ label: 'Swipe', cmd: 'shell input swipe <x1> <y1> <x2> <y2> 300',
|
||||
needsInput: [{ placeholder: '300', token: '<x1>' }, { placeholder: '1500', token: '<y1>' }, { placeholder: '300', token: '<x2>' }, { placeholder: '500', token: '<y2>' }] },
|
||||
{ label: 'Type text', cmd: 'shell input text <text>',
|
||||
needsInput: [{ placeholder: 'hello', token: '<text>' }] },
|
||||
{ label: 'Key event (code/name)', cmd: 'shell input keyevent <key>',
|
||||
needsInput: [{ placeholder: 'KEYCODE_HOME', token: '<key>' }] },
|
||||
{ label: 'Home', cmd: 'shell input keyevent KEYCODE_HOME' },
|
||||
{ label: 'Back', cmd: 'shell input keyevent KEYCODE_BACK' },
|
||||
{ label: 'App switch (recents)', cmd: 'shell input keyevent KEYCODE_APP_SWITCH' },
|
||||
{ label: 'Power button', cmd: 'shell input keyevent KEYCODE_POWER' },
|
||||
{ label: 'Volume up', cmd: 'shell input keyevent KEYCODE_VOLUME_UP' },
|
||||
{ label: 'Unlock (menu key)', cmd: 'shell input keyevent 82' },
|
||||
{ label: 'Monkey: random events on app', cmd: 'shell monkey -p <package> -v 200',
|
||||
needsInput: [{ placeholder: 'com.example.app', token: '<package>' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'WiFi',
|
||||
commands: [
|
||||
{ label: 'WiFi state dump', cmd: 'shell dumpsys wifi' },
|
||||
{ label: 'Connection status', cmd: 'shell cmd wifi status' },
|
||||
{ label: 'Trigger scan', cmd: 'shell cmd wifi start-scan' },
|
||||
{ label: 'Scan results', cmd: 'shell cmd wifi list-scan-results' },
|
||||
{ label: 'Saved networks', cmd: 'shell cmd wifi list-networks' },
|
||||
{ label: 'Enable WiFi', cmd: 'shell svc wifi enable' },
|
||||
{ label: 'Disable WiFi', cmd: 'shell svc wifi disable' },
|
||||
{ label: 'WiFi MAC (factory)', cmd: 'shell cat /sys/class/net/wlan0/address' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Bluetooth',
|
||||
commands: [
|
||||
{ label: 'Bluetooth manager dump', cmd: 'shell dumpsys bluetooth_manager' },
|
||||
{ label: 'Enable Bluetooth', cmd: 'shell cmd bluetooth_manager enable' },
|
||||
{ label: 'Disable Bluetooth', cmd: 'shell cmd bluetooth_manager disable' },
|
||||
{ label: 'Adapter on/off state', cmd: 'shell settings get global bluetooth_on' },
|
||||
{ label: 'Bluetooth MAC address', cmd: 'shell settings get secure bluetooth_address' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Telephony & SIM',
|
||||
commands: [
|
||||
{ label: 'Telephony registry dump', cmd: 'shell dumpsys telephony.registry' },
|
||||
{ label: 'IMEI / device id (svc call)', cmd: 'shell service call iphonesubinfo 1' },
|
||||
{ label: 'SIM operator', cmd: 'shell getprop gsm.sim.operator.alpha' },
|
||||
{ label: 'Network operator', cmd: 'shell getprop gsm.operator.alpha' },
|
||||
{ label: 'SIM state', cmd: 'shell getprop gsm.sim.state' },
|
||||
{ label: 'Data network type', cmd: 'shell getprop gsm.network.type' },
|
||||
{ label: 'Airplane mode state', cmd: 'shell settings get global airplane_mode_on' },
|
||||
{ label: 'Airplane mode on', cmd: 'shell cmd connectivity airplane-mode enable' },
|
||||
{ label: 'Airplane mode off', cmd: 'shell cmd connectivity airplane-mode disable' },
|
||||
{ label: 'Carrier config dump', cmd: 'shell dumpsys carrier_config' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Location & GPS',
|
||||
commands: [
|
||||
{ label: 'Location service dump', cmd: 'shell dumpsys location' },
|
||||
{ label: 'Location mode', cmd: 'shell settings get secure location_mode' },
|
||||
{ label: 'Enable location', cmd: 'shell settings put secure location_mode 3' },
|
||||
{ label: 'Disable location', cmd: 'shell settings put secure location_mode 0' },
|
||||
{ label: 'Providers allowed', cmd: 'shell settings get secure location_providers_allowed' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'NFC & Sensors',
|
||||
commands: [
|
||||
{ label: 'NFC service dump', cmd: 'shell dumpsys nfc' },
|
||||
{ label: 'NFC enabled state', cmd: 'shell settings get secure nfc_on' },
|
||||
{ label: 'Sensor service dump', cmd: 'shell dumpsys sensorservice' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Biometrics & Lock',
|
||||
commands: [
|
||||
{ label: 'Fingerprint service dump', cmd: 'shell dumpsys fingerprint' },
|
||||
{ label: 'Face service dump', cmd: 'shell dumpsys face' },
|
||||
{ label: 'Biometric manager dump', cmd: 'shell dumpsys biometric' },
|
||||
{ label: 'Lock settings / keyguard', cmd: 'shell dumpsys lock_settings' },
|
||||
{ label: 'Trust agent state', cmd: 'shell dumpsys trust' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Notifications',
|
||||
commands: [
|
||||
{ label: 'Notification service dump', cmd: 'shell dumpsys notification' },
|
||||
{ label: 'Notification listeners', cmd: 'shell settings get secure enabled_notification_listeners' },
|
||||
{ label: 'Do-Not-Disturb state', cmd: 'shell settings get global zen_mode' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Jobs, Alarms & Doze',
|
||||
commands: [
|
||||
{ label: 'JobScheduler dump', cmd: 'shell dumpsys jobscheduler' },
|
||||
{ label: 'Alarm manager dump', cmd: 'shell dumpsys alarm' },
|
||||
{ label: 'Doze / idle state', cmd: 'shell dumpsys deviceidle' },
|
||||
{ label: 'Force into Doze', cmd: 'shell dumpsys deviceidle force-idle' },
|
||||
{ label: 'Exit Doze', cmd: 'shell dumpsys deviceidle unforce' },
|
||||
{ label: 'Doze whitelist', cmd: 'shell dumpsys deviceidle whitelist' },
|
||||
{ label: 'Standby bucket for app', cmd: 'shell am get-standby-bucket <package>',
|
||||
needsInput: [{ placeholder: 'com.example.app', token: '<package>' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Users & Profiles',
|
||||
commands: [
|
||||
{ label: 'List users', cmd: 'shell pm list users' },
|
||||
{ label: 'Current user', cmd: 'shell am get-current-user' },
|
||||
{ label: 'Packages for a user', cmd: 'shell pm list packages --user <userId>',
|
||||
needsInput: [{ placeholder: '0', token: '<userId>' }] },
|
||||
{ label: 'Max supported users', cmd: 'shell pm get-max-users' },
|
||||
{ label: 'Work / managed users dump', cmd: 'shell dumpsys user' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Device Policy & MDM',
|
||||
commands: [
|
||||
{ label: 'Device policy dump', cmd: 'shell dumpsys device_policy' },
|
||||
{ label: 'Active device admins', cmd: 'shell dpm list-owners' },
|
||||
{ label: 'Device owner?', cmd: 'shell dumpsys device_policy | grep -i "Device Owner"' },
|
||||
{ label: 'Profile owner?', cmd: 'shell dumpsys device_policy | grep -i "Profile Owner"' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Storage & Disk',
|
||||
commands: [
|
||||
{ label: 'Volume list', cmd: 'shell sm list-volumes' },
|
||||
{ label: 'Disk list', cmd: 'shell sm list-disks' },
|
||||
{ label: 'Filesystem usage', cmd: 'shell df -h' },
|
||||
{ label: 'Storage stats (diskstats)', cmd: 'shell dumpsys diskstats' },
|
||||
{ label: 'storaged dump', cmd: 'shell dumpsys storaged' },
|
||||
{ label: 'Mounted filesystems', cmd: 'shell mount' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Accessibility & IME',
|
||||
commands: [
|
||||
{ label: 'Accessibility service dump', cmd: 'shell dumpsys accessibility' },
|
||||
{ label: 'Enabled a11y services', cmd: 'shell settings get secure enabled_accessibility_services' },
|
||||
{ label: 'List input methods', cmd: 'shell ime list -a' },
|
||||
{ label: 'Enabled IMEs', cmd: 'shell ime list -s' },
|
||||
{ label: 'Current default IME', cmd: 'shell settings get secure default_input_method' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Content Providers',
|
||||
commands: [
|
||||
{ label: 'Query secure settings', cmd: 'shell content query --uri content://settings/secure' },
|
||||
{ label: 'Query global settings', cmd: 'shell content query --uri content://settings/global' },
|
||||
{ label: 'Query system settings', cmd: 'shell content query --uri content://settings/system' },
|
||||
{ label: 'Query custom URI', cmd: 'shell content query --uri <uri>',
|
||||
needsInput: [{ placeholder: 'content://telephony/carriers', token: '<uri>' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Window Manager',
|
||||
commands: [
|
||||
{ label: 'Window manager dump', cmd: 'shell dumpsys window' },
|
||||
{ label: 'Focused window / app', cmd: 'shell dumpsys window windows | grep -iE "mCurrentFocus|mFocusedApp"' },
|
||||
{ label: 'Foreground activity', cmd: 'shell dumpsys activity activities | grep -i mResumedActivity' },
|
||||
{ label: 'Recent tasks', cmd: 'shell dumpsys activity recents | grep -i intent' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Network — Firewall & Routing',
|
||||
commands: [
|
||||
{ label: 'IP addresses (all ifaces)', cmd: 'shell ip addr' },
|
||||
{ label: 'Routing table', cmd: 'shell ip route' },
|
||||
{ label: 'Routing rules', cmd: 'shell ip rule' },
|
||||
{ label: 'ARP / neighbour table', cmd: 'shell ip neigh' },
|
||||
{ label: 'Open sockets (ss)', cmd: 'shell ss -tunap' },
|
||||
{ label: 'Listening sockets', cmd: 'shell ss -ltnp' },
|
||||
{ label: 'iptables filter (root)', cmd: 'shell iptables -L -n -v' },
|
||||
{ label: 'DNS resolver props', cmd: 'shell getprop | grep -i "net.dns"' },
|
||||
{ label: 'Connectivity dump', cmd: 'shell dumpsys connectivity' },
|
||||
{ label: 'Per-uid net policy', cmd: 'shell dumpsys netpolicy' },
|
||||
{ label: 'TCP connection states', cmd: 'shell cat /proc/net/tcp' },
|
||||
{ label: 'Ping a host', cmd: 'shell ping -c 4 <host>',
|
||||
needsInput: [{ placeholder: '8.8.8.8', token: '<host>' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Audio & Camera',
|
||||
commands: [
|
||||
{ label: 'Audio service dump', cmd: 'shell dumpsys audio' },
|
||||
{ label: 'Audio policy / routing', cmd: 'shell dumpsys media.audio_policy' },
|
||||
{ label: 'Media sessions', cmd: 'shell dumpsys media_session' },
|
||||
{ label: 'Play / pause media', cmd: 'shell input keyevent KEYCODE_MEDIA_PLAY_PAUSE' },
|
||||
{ label: 'Camera service dump', cmd: 'shell dumpsys media.camera' },
|
||||
{ label: 'Camera characteristics', cmd: 'shell dumpsys media.camera | grep -iE "Camera [0-9]|Facing"' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Backup Manager (bmgr)',
|
||||
commands: [
|
||||
{ label: 'Backup enabled?', cmd: 'shell bmgr enabled' },
|
||||
{ label: 'List transports', cmd: 'shell bmgr list transports' },
|
||||
{ label: 'Backed-up sets', cmd: 'shell bmgr list sets' },
|
||||
{ label: 'Run backup for app', cmd: 'shell bmgr backupnow <package>',
|
||||
needsInput: [{ placeholder: 'com.example.app', token: '<package>' }] },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Security & Integrity',
|
||||
commands: [
|
||||
{ label: 'SELinux mode', cmd: 'shell getenforce' },
|
||||
{ label: 'Verified boot state', cmd: 'shell getprop ro.boot.verifiedbootstate' },
|
||||
{ label: 'Bootloader locked?', cmd: 'shell getprop ro.boot.flash.locked' },
|
||||
{ label: 'dm-verity mode', cmd: 'shell getprop ro.boot.veritymode' },
|
||||
{ label: 'Build tags (test-keys?)', cmd: 'shell getprop ro.build.tags' },
|
||||
{ label: 'Debuggable / secure flags', cmd: 'shell getprop | grep -iE "ro.debuggable|ro.secure"' },
|
||||
{ label: 'su present?', cmd: 'shell which su' },
|
||||
{ label: 'Magisk present?', cmd: 'shell ls -l /data/adb/magisk 2>/dev/null' },
|
||||
{ label: 'Frida ports listening?', cmd: 'shell netstat -tlnp 2>/dev/null | grep -E "27042|27043"' },
|
||||
{ label: 'Running uid', cmd: 'shell id' },
|
||||
{ label: 'Writable (rw) mounts', cmd: 'shell mount | grep -iE " rw,| rw "' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Developer & Debug',
|
||||
commands: [
|
||||
{ label: 'List all global settings', cmd: 'shell settings list global' },
|
||||
{ label: 'Show touches overlay on', cmd: 'shell settings put system show_touches 1' },
|
||||
{ label: 'Show touches overlay off', cmd: 'shell settings put system show_touches 0' },
|
||||
{ label: 'Pointer location overlay on', cmd: 'shell settings put system pointer_location 1' },
|
||||
{ label: 'Disable animations', cmd: 'shell settings put global window_animation_scale 0' },
|
||||
{ label: 'Reset animations', cmd: 'shell settings put global window_animation_scale 1' },
|
||||
{ label: 'GPU overdraw debug', cmd: 'shell setprop debug.hwui.overdraw show' },
|
||||
{ label: 'USB debugging state', cmd: 'shell settings get global adb_enabled' },
|
||||
{ label: 'Wireless debugging state', cmd: 'shell settings get global adb_wifi_enabled' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Fastboot — OEM & Advanced',
|
||||
commands: [
|
||||
{ label: 'All fastboot variables', cmd: 'fastboot getvar all' },
|
||||
{ label: 'Bootloader lock state', cmd: 'fastboot getvar unlocked' },
|
||||
{ label: 'Current slot (A/B)', cmd: 'fastboot getvar current-slot' },
|
||||
{ label: 'Product / device', cmd: 'fastboot getvar product' },
|
||||
{ label: 'Set active slot A', cmd: 'fastboot --set-active=a' },
|
||||
{ label: 'Set active slot B', cmd: 'fastboot --set-active=b' },
|
||||
{ label: 'Erase eSIM (Pixel, oem)', cmd: 'fastboot oem esim_erase' },
|
||||
{ label: 'eSIM info (Pixel, oem)', cmd: 'fastboot oem esim_id' },
|
||||
{ label: 'Device info (oem)', cmd: 'fastboot oem device-info' },
|
||||
{ label: 'Carrier / config (oem)', cmd: 'fastboot oem get_config' },
|
||||
{ label: 'Unlock bootloader', cmd: 'fastboot flashing unlock' },
|
||||
{ label: 'Lock bootloader', cmd: 'fastboot flashing lock' },
|
||||
{ label: 'Unlock critical partitions', cmd: 'fastboot flashing unlock_critical' },
|
||||
{ label: 'Reboot to bootloader', cmd: 'fastboot reboot bootloader' },
|
||||
{ label: 'Reboot to fastbootd (userspace)', cmd: 'fastboot reboot fastboot' },
|
||||
{ label: 'Boot a kernel image (no flash)', cmd: 'fastboot boot <image>',
|
||||
needsInput: [{ placeholder: 'boot.img', token: '<image>' }] },
|
||||
{ label: 'Wipe userdata', cmd: 'fastboot -w' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'UWB (Ultra-Wideband)',
|
||||
commands: [
|
||||
{ label: 'UWB service dump', cmd: 'shell dumpsys uwb' },
|
||||
{ label: 'UWB status', cmd: 'shell cmd uwb status' },
|
||||
{ label: 'UWB device state', cmd: 'shell cmd uwb get-device-state' },
|
||||
{ label: 'UWB country code', cmd: 'shell cmd uwb get-country-code' },
|
||||
{ label: 'UWB enabled (setting)', cmd: 'shell settings get global uwb_enabled' },
|
||||
{ label: 'Enable UWB', cmd: 'shell settings put global uwb_enabled 1' },
|
||||
{ label: 'Disable UWB', cmd: 'shell settings put global uwb_enabled 0' },
|
||||
{ label: 'UWB hardware feature', cmd: 'shell pm list features | grep -i uwb' },
|
||||
{ label: 'UWB related props', cmd: 'shell getprop | grep -i uwb' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Satellite',
|
||||
commands: [
|
||||
{ label: 'Satellite service dump', cmd: 'shell dumpsys satellite' },
|
||||
{ label: 'Satellite controller (usage)', cmd: 'shell cmd satellite_controller' },
|
||||
{ label: 'Satellite in telephony registry', cmd: 'shell dumpsys telephony.registry | grep -i satellite' },
|
||||
{ label: 'Carrier satellite config', cmd: 'shell dumpsys carrier_config | grep -i satellite' },
|
||||
{ label: 'Satellite hardware feature', cmd: 'shell pm list features | grep -i satellite' },
|
||||
{ label: 'Satellite related props', cmd: 'shell getprop | grep -i satellite' },
|
||||
{ label: 'NTN / non-terrestrial props', cmd: 'shell getprop | grep -iE "ntn|non.terrestrial"' },
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Verified Boot / AVB / PQC',
|
||||
commands: [
|
||||
{ label: 'Verified boot state', cmd: 'shell getprop ro.boot.verifiedbootstate' },
|
||||
{ label: 'vbmeta hash algorithm', cmd: 'shell getprop ro.boot.vbmeta.hash_alg' },
|
||||
{ label: 'vbmeta digest', cmd: 'shell getprop ro.boot.vbmeta.digest' },
|
||||
{ label: 'vbmeta size', cmd: 'shell getprop ro.boot.vbmeta.size' },
|
||||
{ label: 'All vbmeta / AVB props', cmd: 'shell getprop | grep -iE "vbmeta|avb"' },
|
||||
{ label: 'dm-verity mode', cmd: 'shell getprop ro.boot.veritymode' },
|
||||
// Android 17 introduced PQC signatures on system partitions — surfaces any
|
||||
// related props if the device exposes them (names may vary by build).
|
||||
{ label: 'PQC signature props (Android 17)', cmd: 'shell getprop | grep -iE "pqc|dilithium|ml.?dsa|sphincs|falcon"' },
|
||||
{ label: 'Bootloader lock state', cmd: 'shell getprop ro.boot.flash.locked' },
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
export default function ViewUtilities() {
|
||||
const [output, setOutput] = useState('')
|
||||
const [outputLabel, setOutputLabel] = useState('')
|
||||
const [running, setRunning] = useState(false)
|
||||
const [openCats, setOpenCats] = useState<Set<string>>(new Set(['Device Info']))
|
||||
const [openCats, setOpenCats] = useState<Set<string>>(new Set())
|
||||
const [inputs, setInputs] = useState<Record<string, string>>({})
|
||||
const [copied, setCopied] = useState(false)
|
||||
const [activeCmd, setActiveCmd] = useState<Command | null>(null)
|
||||
|
|
|
|||
68
frontend/src/lib/applock.ts
Normal file
68
frontend/src/lib/applock.ts
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
// App-lock frontend orchestration.
|
||||
//
|
||||
// Two things live here:
|
||||
// 1. A cached copy of the backend lock status (enabled / requireForDanger) so
|
||||
// destructive handlers can decide whether to prompt without an await round-
|
||||
// trip every time.
|
||||
// 2. ensureDangerUnlocked() — call this at the top of any destructive action.
|
||||
// When "require password for destructive actions" is on and the backend
|
||||
// session window has lapsed, it pops a re-auth modal (hosted by <DangerGate/>
|
||||
// in App.tsx) and resolves true only once UnlockDanger succeeds.
|
||||
//
|
||||
// The backend enforces the gate for real (see backend_applock.go); this is the
|
||||
// UX layer that collects the password and keeps the window warm.
|
||||
|
||||
import { AppLockStatus, UnlockDanger } from './wails'
|
||||
|
||||
export type AppLockState = { enabled: boolean; requireForDanger: boolean }
|
||||
|
||||
let cached: AppLockState = { enabled: false, requireForDanger: false }
|
||||
|
||||
export function appLockState(): AppLockState {
|
||||
return cached
|
||||
}
|
||||
|
||||
export async function refreshAppLockStatus(): Promise<AppLockState> {
|
||||
try {
|
||||
cached = await AppLockStatus()
|
||||
} catch {
|
||||
// backend not reachable yet — keep last known (defaults to unlocked)
|
||||
}
|
||||
return cached
|
||||
}
|
||||
|
||||
// ----- danger re-auth modal host wiring -----
|
||||
|
||||
export type DangerRequest = { resolve: (ok: boolean) => void }
|
||||
let host: ((req: DangerRequest | null) => void) | null = null
|
||||
|
||||
// Called once by <DangerGate/> to register itself as the modal host.
|
||||
export function _registerDangerHost(fn: (req: DangerRequest | null) => void): () => void {
|
||||
host = fn
|
||||
return () => { if (host === fn) host = null }
|
||||
}
|
||||
|
||||
// Local mirror of the backend's unlock window. Kept slightly shorter so we
|
||||
// re-prompt a touch before the server window actually lapses.
|
||||
const DANGER_WINDOW_MS = 4.5 * 60 * 1000
|
||||
let unlockedUntil = 0
|
||||
|
||||
// Call the backend with the entered password; on success arm the local window.
|
||||
export async function tryUnlockDanger(password: string): Promise<boolean> {
|
||||
const ok = await UnlockDanger(password)
|
||||
if (ok) unlockedUntil = Date.now() + DANGER_WINDOW_MS
|
||||
return ok
|
||||
}
|
||||
|
||||
// Guard for destructive handlers: `if (!(await ensureDangerUnlocked())) return`.
|
||||
export async function ensureDangerUnlocked(): Promise<boolean> {
|
||||
if (!cached.enabled || !cached.requireForDanger) return true
|
||||
if (Date.now() < unlockedUntil) return true
|
||||
if (!host) return true // modal not mounted (shouldn't happen) — backend still gates
|
||||
return new Promise<boolean>(resolve => host!({ resolve }))
|
||||
}
|
||||
|
||||
// Recognise the backend sentinel so callers can surface a friendlier message.
|
||||
export function isDangerLocked(err: unknown): boolean {
|
||||
return String(err).includes('DANGER_LOCKED')
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
37
frontend/src/lib/dismissible.ts
Normal file
37
frontend/src/lib/dismissible.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
// Remembers which dismissible banners/warnings the user has hidden.
|
||||
// Each banner has a stable string id; dismissals persist in localStorage.
|
||||
|
||||
const STORAGE_KEY = 'atk-dismissed'
|
||||
|
||||
function load(): Record<string, true> {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(STORAGE_KEY) || '{}')
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
}
|
||||
|
||||
function save(map: Record<string, true>): void {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(map))
|
||||
}
|
||||
|
||||
export function isDismissed(id: string): boolean {
|
||||
return load()[id] === true
|
||||
}
|
||||
|
||||
export function dismiss(id: string): void {
|
||||
const map = load()
|
||||
map[id] = true
|
||||
save(map)
|
||||
}
|
||||
|
||||
export function undismiss(id: string): void {
|
||||
const map = load()
|
||||
delete map[id]
|
||||
save(map)
|
||||
}
|
||||
|
||||
/** Clear every remembered dismissal (used by a "show all warnings again" action). */
|
||||
export function resetDismissed(): void {
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
}
|
||||
81
frontend/src/lib/featureflags.ts
Normal file
81
frontend/src/lib/featureflags.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
// Opt-in feature flags persisted in localStorage. Read on view mount (no live
|
||||
// event needed — switching views remounts and re-reads).
|
||||
|
||||
const ROOT_TOOLS_KEY = 'atk-root-tools'
|
||||
|
||||
// Rooting / Magisk patching tools in the Flasher. Off by default — these are
|
||||
// advanced, destructive-adjacent operations.
|
||||
export function getRootTools(): boolean {
|
||||
return localStorage.getItem(ROOT_TOOLS_KEY) === '1'
|
||||
}
|
||||
|
||||
export function setRootTools(on: boolean): void {
|
||||
localStorage.setItem(ROOT_TOOLS_KEY, on ? '1' : '0')
|
||||
}
|
||||
|
||||
// Mute error pop-ups that are just "no device / offline / unauthorized".
|
||||
const MUTE_NODEVICE_KEY = 'atk-mute-nodevice'
|
||||
export function getMuteNoDevice(): boolean {
|
||||
return localStorage.getItem(MUTE_NODEVICE_KEY) === '1'
|
||||
}
|
||||
export function setMuteNoDevice(on: boolean): void {
|
||||
localStorage.setItem(MUTE_NODEVICE_KEY, on ? '1' : '0')
|
||||
}
|
||||
|
||||
// ── Sidebar feature kill-switch ──────────────────────────────────────────────
|
||||
// Users can hide nav entries they don't use. Settings is never hideable.
|
||||
const HIDDEN_KEY = 'atk-hidden-views'
|
||||
const HIDDEN_EVENT = 'atk-hidden-views-change'
|
||||
|
||||
export const TOGGLEABLE_VIEWS: { view: string; label: string }[] = [
|
||||
{ view: 'dashboard', label: 'Dashboard' },
|
||||
{ view: 'files', label: 'Files' },
|
||||
{ view: 'mirror', label: 'Screen Mirror' },
|
||||
{ view: 'packages', label: 'Packages' },
|
||||
{ view: 'debloater', label: 'Debloater' },
|
||||
{ view: 'shell', label: 'Shell' },
|
||||
{ view: 'logcat', label: 'Logcat' },
|
||||
{ view: 'appinspect', label: 'App Inspector' },
|
||||
{ view: 'apkaudit', label: 'APK Audit' },
|
||||
{ view: 'certs', label: 'Certificates' },
|
||||
{ view: 'backup', label: 'Backup' },
|
||||
{ view: 'props', label: 'Prop Editor' },
|
||||
{ view: 'utilities', label: 'Utilities' },
|
||||
{ view: 'flasher', label: 'Flasher' },
|
||||
]
|
||||
|
||||
export function getHiddenViews(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(HIDDEN_KEY)
|
||||
return raw ? JSON.parse(raw) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function setHiddenViews(views: string[]): void {
|
||||
localStorage.setItem(HIDDEN_KEY, JSON.stringify(views))
|
||||
window.dispatchEvent(new CustomEvent(HIDDEN_EVENT, { detail: views }))
|
||||
}
|
||||
|
||||
export function onHiddenViewsChange(cb: (views: string[]) => void): () => void {
|
||||
const handler = (e: Event) => cb((e as CustomEvent).detail as string[])
|
||||
window.addEventListener(HIDDEN_EVENT, handler)
|
||||
return () => window.removeEventListener(HIDDEN_EVENT, handler)
|
||||
}
|
||||
|
||||
// ── Custom sidebar order (drag-to-reorder, dock-style) ───────────────────────
|
||||
const ORDER_KEY = 'atk-nav-order'
|
||||
|
||||
export function getNavOrder(): string[] {
|
||||
try {
|
||||
const raw = localStorage.getItem(ORDER_KEY)
|
||||
return raw ? JSON.parse(raw) : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export function setNavOrder(order: string[]): void {
|
||||
localStorage.setItem(ORDER_KEY, JSON.stringify(order))
|
||||
}
|
||||
51
frontend/src/lib/layout.ts
Normal file
51
frontend/src/lib/layout.ts
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
// Sidebar position preference. Mirrors src/lib/theme.ts: persisted in
|
||||
// localStorage, but here we also broadcast a window event so App.tsx can swap
|
||||
// its layout live (the theme just flips a <html> attribute and needs no React
|
||||
// state — the sidebar position changes the React tree, so it does).
|
||||
|
||||
export type SidebarPosition = 'left' | 'top' | 'bottom'
|
||||
|
||||
export const SIDEBAR_POSITIONS: { id: SidebarPosition; label: string; hint: string }[] = [
|
||||
{ id: 'left', label: 'Left', hint: 'Vertical rail on the side' },
|
||||
{ id: 'top', label: 'Top', hint: 'Horizontal bar across the top' },
|
||||
{ id: 'bottom', label: 'Bottom', hint: 'Horizontal bar across the bottom (default)' },
|
||||
]
|
||||
|
||||
const STORAGE_KEY = 'atk-sidebar-position'
|
||||
const EVENT = 'atk-sidebar-position-change'
|
||||
|
||||
export function getSidebarPosition(): SidebarPosition {
|
||||
const p = localStorage.getItem(STORAGE_KEY)
|
||||
return p === 'top' || p === 'bottom' || p === 'left' ? p : 'bottom'
|
||||
}
|
||||
|
||||
export function setSidebarPosition(p: SidebarPosition): void {
|
||||
localStorage.setItem(STORAGE_KEY, p)
|
||||
window.dispatchEvent(new CustomEvent(EVENT, { detail: p }))
|
||||
}
|
||||
|
||||
export function onSidebarPositionChange(cb: (p: SidebarPosition) => void): () => void {
|
||||
const handler = (e: Event) => cb((e as CustomEvent).detail as SidebarPosition)
|
||||
window.addEventListener(EVENT, handler)
|
||||
return () => window.removeEventListener(EVENT, handler)
|
||||
}
|
||||
|
||||
// Whether to show the text label under each sidebar icon. Same live-broadcast
|
||||
// pattern as the position pref above.
|
||||
const LABELS_KEY = 'atk-sidebar-labels'
|
||||
const LABELS_EVENT = 'atk-sidebar-labels-change'
|
||||
|
||||
export function getSidebarLabels(): boolean {
|
||||
return localStorage.getItem(LABELS_KEY) !== '0' // on by default
|
||||
}
|
||||
|
||||
export function setSidebarLabels(on: boolean): void {
|
||||
localStorage.setItem(LABELS_KEY, on ? '1' : '0')
|
||||
window.dispatchEvent(new CustomEvent(LABELS_EVENT, { detail: on }))
|
||||
}
|
||||
|
||||
export function onSidebarLabelsChange(cb: (on: boolean) => void): () => void {
|
||||
const handler = (e: Event) => cb((e as CustomEvent).detail as boolean)
|
||||
window.addEventListener(LABELS_EVENT, handler)
|
||||
return () => window.removeEventListener(LABELS_EVENT, handler)
|
||||
}
|
||||
788
frontend/src/lib/logcatgraph.ts
Normal file
788
frontend/src/lib/logcatgraph.ts
Normal file
|
|
@ -0,0 +1,788 @@
|
|||
// LogGraph — the stateful engine behind the Logcat visual map.
|
||||
//
|
||||
// It turns the firehose of log lines into a *bounded* graph plus an ephemeral
|
||||
// particle stream (the two-layer model): persistent nodes/edges that decay over
|
||||
// time, and short-lived particles that carry each event along its edge. Layout
|
||||
// (force simulation) lives here too so the renderer stays a thin draw loop.
|
||||
//
|
||||
// Pure TS, no deps, no rendering — testable and reusable.
|
||||
|
||||
import type { LogcatLine, RefKind } from './types'
|
||||
|
||||
// Relationships are mined natively by the Go backend and arrive on each line as
|
||||
// line.refs / line.mentions; this engine just consumes them. Severity weight per
|
||||
// kind (used to colour/size the edge) stays here — it's a trivial lookup, not the
|
||||
// mining logic.
|
||||
const REF_SEVERITY: Record<RefKind, number> = {
|
||||
crash: 5, anr: 5, death: 3, signal: 3, spawn: 2, activity: 1, gfx: 1, mention: 0,
|
||||
}
|
||||
|
||||
export type EdgeKind = 'cooccur' | RefKind
|
||||
export type NodeKind = 'process' | 'tag' | 'package' | 'component'
|
||||
|
||||
export interface GNode {
|
||||
id: string
|
||||
kind: NodeKind
|
||||
label: string
|
||||
x: number; y: number; vx: number; vy: number
|
||||
pinned: boolean
|
||||
heat: number // recent activity, decays
|
||||
count: number // total lines attributed
|
||||
worst: number // recent peak severity 0..5, decays
|
||||
lastTs: number
|
||||
recent: LogcatLine[] // ring buffer (newest last), for the inspector
|
||||
levels: number[] // histogram V..F counts (length 6)
|
||||
glat?: number; glon?: number // (legacy globe) cached sphere position
|
||||
tx?: number; ty?: number; tz?: number; tdepth?: number // 3D hanging-tree position (stable once set)
|
||||
tparent?: string // 3D tree parent node id (the edge we actually draw in 3D)
|
||||
baseline?: boolean // existed when the user set a baseline (so non-baseline = "new since")
|
||||
}
|
||||
|
||||
export interface GEdge {
|
||||
id: string
|
||||
a: string; b: string // directed a -> b (flow direction)
|
||||
kind: EdgeKind
|
||||
weight: number // decays
|
||||
count: number
|
||||
worst: number
|
||||
lastTs: number
|
||||
}
|
||||
|
||||
export interface Particle {
|
||||
a: string; b: string
|
||||
t: number // 0..1 progress along the edge
|
||||
speed: number
|
||||
level: number
|
||||
kind: EdgeKind
|
||||
line?: LogcatLine
|
||||
}
|
||||
|
||||
// A severe event (Error/Fatal or a crash/ANR/kill/signal relationship) — powers
|
||||
// the Alerts panel so the analyst is told WHEN something breaks and WHERE.
|
||||
export interface AlertEvent {
|
||||
ts: number
|
||||
id: string // node id it happened on
|
||||
level: number
|
||||
tag: string
|
||||
msg: string
|
||||
rule?: string // the user keyword rule that matched (undefined = severity alert)
|
||||
}
|
||||
|
||||
// A real event that just flowed along an edge — powers the live "packet feed"
|
||||
// so the user can see WHAT each moving particle is (which log line, src->dst).
|
||||
export interface FlowEvent {
|
||||
ts: number
|
||||
a: string; b: string // node ids (labels resolved live in the UI)
|
||||
kind: EdgeKind
|
||||
level: number
|
||||
tag: string
|
||||
msg: string
|
||||
}
|
||||
|
||||
export interface GraphConfig {
|
||||
grouping: 'process' | 'tag'
|
||||
cooccur: boolean
|
||||
cooccurWindowMs: number
|
||||
parsed: boolean
|
||||
mentions: boolean
|
||||
nodeHalfLifeMs: number
|
||||
edgeHalfLifeMs: number
|
||||
maxNodes: number
|
||||
maxEdges: number
|
||||
maxParticles: number
|
||||
levelFloor: number // ignore lines below this severity (0=V..5=F)
|
||||
particleIntensity: number
|
||||
particleSpeed: number
|
||||
// layout
|
||||
repulsion: number
|
||||
linkDistance: number
|
||||
gravity: number
|
||||
damping: number
|
||||
freeze: boolean
|
||||
clusterByKind: number // extra pull between same-kind nodes
|
||||
timeScale: number // global speed multiplier (slow-mo ↔ fast)
|
||||
// visual
|
||||
glow: number // glow/bloom multiplier
|
||||
showGrid: boolean
|
||||
edgeColorMode: 'kind' | 'source' | 'severity' // colour edges by kind, source-hub hue, or severity
|
||||
nodeColorMode: 'auto' | 'kind' | 'severity' | 'hub' // how node colour is chosen
|
||||
boxLayout: boolean // arrange nodes into 8 hub boxes (2x4 grid) instead of force layout
|
||||
geometry: GeometryShape // arrange nodes onto a geometric structure ('none' = force/box)
|
||||
layoutScale: number // scale the box/geometry arrangement bigger/smaller (around centre)
|
||||
wireframe: boolean // schematic look: hollow ring nodes + crisp lines, no fills/halos
|
||||
}
|
||||
|
||||
export type GeometryShape = 'none' | 'tree' | 'radial' | 'ring' | 'grid' | 'spiral' | 'cube' | 'metatron'
|
||||
|
||||
// A box in the "box layout" mode: a screen-space rectangle holding the nodes
|
||||
// clustered around one of the busiest hubs.
|
||||
export interface BoxRect {
|
||||
x: number; y: number; w: number; h: number
|
||||
label: string; count: number
|
||||
}
|
||||
|
||||
export const DEFAULT_CONFIG: GraphConfig = {
|
||||
grouping: 'process',
|
||||
cooccur: true,
|
||||
cooccurWindowMs: 700,
|
||||
parsed: true,
|
||||
mentions: false,
|
||||
nodeHalfLifeMs: 16000,
|
||||
edgeHalfLifeMs: 12000,
|
||||
maxNodes: 180,
|
||||
maxEdges: 900,
|
||||
maxParticles: 3500,
|
||||
levelFloor: 0,
|
||||
particleIntensity: 0.75,
|
||||
particleSpeed: 0.9,
|
||||
repulsion: 11000,
|
||||
linkDistance: 125,
|
||||
gravity: 0.004,
|
||||
damping: 0.8,
|
||||
freeze: false,
|
||||
clusterByKind: 0,
|
||||
timeScale: 1,
|
||||
glow: 0.45,
|
||||
showGrid: false,
|
||||
edgeColorMode: 'source',
|
||||
nodeColorMode: 'auto',
|
||||
boxLayout: false,
|
||||
geometry: 'none',
|
||||
layoutScale: 1,
|
||||
wireframe: false,
|
||||
}
|
||||
|
||||
// Target points for a geometric arrangement of n nodes (ordered by activity).
|
||||
// All 2D/projected so the existing renderers can draw them; 3D shapes (cube,
|
||||
// metatron) use a fixed isometric projection to read as structure.
|
||||
export function geometryPoints(shape: GeometryShape, n: number, w: number, h: number): { x: number; y: number }[] {
|
||||
const cx = w / 2, cy = h / 2, R = Math.min(w, h) * 0.42, pts: { x: number; y: number }[] = []
|
||||
if (n <= 0) return pts
|
||||
const s = R * 0.85, ax = 0.5, ay = 0.62
|
||||
const proj3 = (x: number, y: number, z: number) => {
|
||||
const x1 = x * Math.cos(ay) + z * Math.sin(ay)
|
||||
const z1 = -x * Math.sin(ay) + z * Math.cos(ay)
|
||||
const y2 = y * Math.cos(ax) - z1 * Math.sin(ax)
|
||||
return { x: cx + x1 * s, y: cy + y2 * s }
|
||||
}
|
||||
if (shape === 'radial') {
|
||||
// biggest / most-active nodes (lowest index — caller sorts by activity desc) on
|
||||
// the OUTER rim, smaller ones filling toward the centre. sqrt falloff → even
|
||||
// disc fill (not a central clump). Elliptical + near-edge to fill wide screens.
|
||||
const rx = w * 0.48, ry = h * 0.46
|
||||
for (let i = 0; i < n; i++) {
|
||||
const t = n > 1 ? i / (n - 1) : 0
|
||||
const rad = 0.1 + 0.9 * Math.sqrt(1 - t)
|
||||
const a = i * 2.399963 - Math.PI / 2
|
||||
pts.push({ x: cx + Math.cos(a) * rx * rad, y: cy + Math.sin(a) * ry * rad })
|
||||
}
|
||||
} else if (shape === 'ring') {
|
||||
for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI * 2 - Math.PI / 2; pts.push({ x: cx + Math.cos(a) * R, y: cy + Math.sin(a) * R }) }
|
||||
} else if (shape === 'grid') {
|
||||
const cols = Math.max(1, Math.ceil(Math.sqrt(n * (w / h)))), rows = Math.ceil(n / cols)
|
||||
const pad = 64, gw = w - pad * 2, gh = h - pad * 2
|
||||
for (let i = 0; i < n; i++) { const c = i % cols, r = Math.floor(i / cols); pts.push({ x: pad + (cols === 1 ? gw / 2 : (c / (cols - 1)) * gw), y: pad + (rows === 1 ? gh / 2 : (r / (rows - 1)) * gh) }) }
|
||||
} else if (shape === 'spiral') {
|
||||
for (let i = 0; i < n; i++) { const a = i * 2.399963, rr = R * Math.sqrt((i + 1) / n); pts.push({ x: cx + Math.cos(a) * rr, y: cy + Math.sin(a) * rr }) }
|
||||
} else if (shape === 'cube') {
|
||||
const v = [[-1, -1, -1], [1, -1, -1], [1, 1, -1], [-1, 1, -1], [-1, -1, 1], [1, -1, 1], [1, 1, 1], [-1, 1, 1]]
|
||||
const ed = [[0, 1], [1, 2], [2, 3], [3, 0], [4, 5], [5, 6], [6, 7], [7, 4], [0, 4], [1, 5], [2, 6], [3, 7]]
|
||||
const per = Math.max(1, Math.ceil(n / ed.length))
|
||||
for (let i = 0; i < n; i++) {
|
||||
const e = ed[i % ed.length], k = Math.floor(i / ed.length), t = (k + 0.5) / per
|
||||
const a = v[e[0]], b = v[e[1]]
|
||||
pts.push(proj3(a[0] + (b[0] - a[0]) * t, a[1] + (b[1] - a[1]) * t, a[2] + (b[2] - a[2]) * t))
|
||||
}
|
||||
} else if (shape === 'metatron') {
|
||||
// 13 centres: 1 centre + inner hex (r) + outer hex (2r), classic Metatron's cube
|
||||
const centres: { x: number; y: number }[] = [{ x: cx, y: cy }]
|
||||
for (let ring = 1; ring <= 2; ring++) for (let k = 0; k < 6; k++) {
|
||||
const a = (k / 6) * Math.PI * 2 - Math.PI / 2
|
||||
centres.push({ x: cx + Math.cos(a) * R * 0.5 * ring, y: cy + Math.sin(a) * R * 0.5 * ring })
|
||||
}
|
||||
for (let i = 0; i < n; i++) {
|
||||
const c = centres[i % centres.length], k = Math.floor(i / centres.length)
|
||||
const a = k * 2.399963, rr = k === 0 ? 0 : R * 0.06 * Math.sqrt(k)
|
||||
pts.push({ x: c.x + Math.cos(a) * rr, y: c.y + Math.sin(a) * rr })
|
||||
}
|
||||
}
|
||||
return pts
|
||||
}
|
||||
|
||||
// Named presets — applied on top of the current config from the settings drawer.
|
||||
export const PRESETS: Record<string, Partial<GraphConfig>> = {
|
||||
Investigate: { cooccurWindowMs: 700, nodeHalfLifeMs: 16000, edgeHalfLifeMs: 12000, maxNodes: 180, glow: 0.45, particleIntensity: 0.75, particleSpeed: 0.9, gravity: 0.004, repulsion: 11000, linkDistance: 125, timeScale: 1 },
|
||||
'See everything': { cooccurWindowMs: 1200, nodeHalfLifeMs: 600000, edgeHalfLifeMs: 600000, maxNodes: 400, maxEdges: 2500, glow: 0.4, particleIntensity: 0.6, gravity: 0.003, repulsion: 11000, linkDistance: 120, timeScale: 1 },
|
||||
'Live pulse': { cooccurWindowMs: 500, nodeHalfLifeMs: 4000, edgeHalfLifeMs: 3000, maxNodes: 120, glow: 0.55, particleIntensity: 0.9, particleSpeed: 1.2, gravity: 0.008, repulsion: 7500, timeScale: 1 },
|
||||
Calm: { glow: 0.4, particleIntensity: 0.45, particleSpeed: 0.6, timeScale: 0.6, nodeHalfLifeMs: 14000, edgeHalfLifeMs: 11000 },
|
||||
Cinematic: { glow: 1, particleIntensity: 0.95, particleSpeed: 1.1, maxParticles: 5000, timeScale: 1 },
|
||||
}
|
||||
|
||||
export const LEVELS = ['V', 'D', 'I', 'W', 'E', 'F']
|
||||
export function levelNum(l: string): number {
|
||||
const i = LEVELS.indexOf(l)
|
||||
return i < 0 ? 2 : i
|
||||
}
|
||||
|
||||
const RECENT_CAP = 80
|
||||
const FLOWLOG_CAP = 160
|
||||
|
||||
function now() { return performance.now() }
|
||||
|
||||
// Visual radius of a node — shared by the renderer (draw size) and the layout
|
||||
// (collision separation) so big nodes can't overlap into a blob.
|
||||
export function nodeRadius(n: GNode): number {
|
||||
return Math.min(26, 4 + Math.sqrt(Math.max(0, n.heat)) * 2.2 + Math.log(1 + n.count) * 1.6)
|
||||
}
|
||||
|
||||
export class LogGraph {
|
||||
nodes = new Map<string, GNode>()
|
||||
edges = new Map<string, GEdge>()
|
||||
particles: Particle[] = []
|
||||
flowLog: FlowEvent[] = [] // live ring buffer of flowing events (newest last)
|
||||
captured: FlowEvent[] = [] // capture/record buffer (large, only while capturing)
|
||||
capturing = false
|
||||
alerts: AlertEvent[] = [] // severe events (E/F + crash/anr/kill/signal), newest last
|
||||
alertRules: string[] = [] // user keyword/tag rules (lowercased) that also raise alerts
|
||||
watchedIds = new Set<string>() // user's watchlist — these nodes are never evicted
|
||||
baselineActive = false // diff mode: highlight nodes that appeared since baseline
|
||||
cfg: GraphConfig = { ...DEFAULT_CONFIG }
|
||||
processNames: Record<string, string> = {}
|
||||
|
||||
private recentActive: { id: string; ts: number }[] = []
|
||||
private lastDecay = now()
|
||||
totalLines = 0
|
||||
droppedParticles = 0
|
||||
|
||||
// 3D hanging-tree placement state (incremental, stable)
|
||||
private treeChildN = new Map<string, number>()
|
||||
private treeRootN = 0
|
||||
|
||||
// box-layout state (recomputed on an interval, eased toward each frame)
|
||||
boxes: BoxRect[] = []
|
||||
private boxTarget = new Map<string, { x: number; y: number }>()
|
||||
private lastBoxCalc = 0
|
||||
|
||||
// timeline: rolling per-second event counts by level [V,D,I,W,E,F], newest last
|
||||
tl: number[][] = []
|
||||
private tlLast = 0
|
||||
|
||||
setConfig(c: Partial<GraphConfig>) { this.cfg = { ...this.cfg, ...c } }
|
||||
setProcessNames(m: Record<string, string>) {
|
||||
this.processNames = m
|
||||
// relabel existing process nodes in place
|
||||
for (const n of this.nodes.values()) {
|
||||
if (n.kind === 'process') {
|
||||
const pid = n.id.slice(2)
|
||||
n.label = m[pid] || pid
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// snapshot the current nodes as the baseline; afterwards any node without the
|
||||
// flag is "new since" and gets highlighted by the renderers
|
||||
setBaseline(on: boolean) {
|
||||
this.baselineActive = on
|
||||
if (on) for (const n of this.nodes.values()) n.baseline = true
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.baselineActive = false
|
||||
this.treeChildN.clear(); this.treeRootN = 0
|
||||
this.nodes.clear(); this.edges.clear(); this.particles = []
|
||||
this.flowLog = []; this.captured = []; this.alerts = []; this.boxes = []; this.boxTarget.clear(); this.tl = []
|
||||
this.recentActive = []; this.totalLines = 0; this.droppedParticles = 0
|
||||
}
|
||||
|
||||
// ---- ingestion ---------------------------------------------------------
|
||||
|
||||
ingest(line: LogcatLine, w: number, h: number) {
|
||||
const lv = levelNum(line.level)
|
||||
if (lv < this.cfg.levelFloor) return
|
||||
this.totalLines++
|
||||
const ts = now()
|
||||
this.bumpTimeline(ts, lv)
|
||||
|
||||
const primary = this.primaryNode(line, w, h)
|
||||
this.touch(primary, line, lv, ts)
|
||||
|
||||
let flowed = false
|
||||
let severe = lv >= 4 // Error / Fatal
|
||||
|
||||
// parsed "real" relationships → directed edges to target nodes
|
||||
if (this.cfg.parsed) {
|
||||
const refs = line.refs || []
|
||||
for (const r of refs) {
|
||||
if (REF_SEVERITY[r.kind] >= 4) severe = true // crash / anr / fatal kind
|
||||
const target = this.refNode(r.kind, r.target, r.targetKind, w, h)
|
||||
if (target && target.id !== primary.id) {
|
||||
this.link(primary.id, target.id, r.kind, REF_SEVERITY[r.kind], lv, ts, line)
|
||||
flowed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
let matchedRule: string | undefined
|
||||
if (this.alertRules.length) {
|
||||
const hay = ((line.tag || '') + ' ' + (line.message || line.raw || '')).toLowerCase()
|
||||
for (const r of this.alertRules) { if (r && hay.includes(r)) { matchedRule = r; severe = true; break } }
|
||||
}
|
||||
if (severe) {
|
||||
this.alerts.push({ ts, id: primary.id, level: lv, tag: line.tag || '', msg: line.message || line.raw || '', rule: matchedRule })
|
||||
if (this.alerts.length > 240) this.alerts.shift()
|
||||
}
|
||||
if (this.cfg.mentions) {
|
||||
for (const r of (line.mentions || [])) {
|
||||
const target = this.refNode(r.kind, r.target, r.targetKind, w, h)
|
||||
if (target && target.id !== primary.id) {
|
||||
this.link(primary.id, target.id, 'mention', 0, lv, ts, line)
|
||||
flowed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ambient co-occurrence: link to the recently-active OTHER nodes within the
|
||||
// window (not just the immediately-previous line). This is what makes a busy
|
||||
// process actually connect to — and visibly flow toward — whatever else is
|
||||
// active at the same time, instead of looking dead when it dominates the log.
|
||||
if (this.cfg.cooccur) {
|
||||
let linked = 0
|
||||
for (const ra of this.recentActive) {
|
||||
if (linked >= 3) break
|
||||
if (ra.id === primary.id || ts - ra.ts > this.cfg.cooccurWindowMs) continue
|
||||
if (!this.nodes.has(ra.id)) continue
|
||||
this.link(ra.id, primary.id, 'cooccur', 0, lv, ts, line)
|
||||
flowed = true
|
||||
linked++
|
||||
}
|
||||
}
|
||||
// update the recency ring (distinct, most-recent first)
|
||||
this.recentActive = this.recentActive.filter(r => r.id !== primary.id)
|
||||
this.recentActive.unshift({ id: primary.id, ts })
|
||||
if (this.recentActive.length > 8) this.recentActive.length = 8
|
||||
|
||||
if (!flowed) primary.heat += 0.4 // truly isolated event: just glow harder
|
||||
}
|
||||
|
||||
// roll the 1s timeline buckets forward to `ts`, then count this event
|
||||
private bumpTimeline(ts: number, lv: number) {
|
||||
const BUCKET = 1000, CAP = 120
|
||||
if (!this.tl.length) { this.tl.push([0, 0, 0, 0, 0, 0]); this.tlLast = ts }
|
||||
while (ts - this.tlLast >= BUCKET) {
|
||||
this.tl.push([0, 0, 0, 0, 0, 0]); this.tlLast += BUCKET
|
||||
if (this.tl.length > CAP) this.tl.shift()
|
||||
}
|
||||
this.tl[this.tl.length - 1][lv]++
|
||||
}
|
||||
|
||||
private primaryNode(line: LogcatLine, w: number, h: number): GNode {
|
||||
if (this.cfg.grouping === 'tag') {
|
||||
const id = 't:' + (line.tag || '?')
|
||||
return this.ensure(id, 'tag', line.tag || '?', w, h)
|
||||
}
|
||||
const pid = line.pid || '?'
|
||||
return this.ensure('p:' + pid, 'process', this.processNames[pid] || pid, w, h)
|
||||
}
|
||||
|
||||
private refNode(_kind: RefKind, target: string, targetKind: 'package' | 'component' | 'pid', w: number, h: number): GNode | null {
|
||||
if (targetKind === 'pid') return this.ensure('p:' + target, 'process', this.processNames[target] || target, w, h)
|
||||
if (targetKind === 'component') return this.ensure('cmp:' + target, 'component', target, w, h)
|
||||
return this.ensure('pkg:' + target, 'package', target, w, h)
|
||||
}
|
||||
|
||||
private ensure(id: string, kind: NodeKind, label: string, w: number, h: number): GNode {
|
||||
let n = this.nodes.get(id)
|
||||
if (!n) {
|
||||
// spawn on a wide golden-angle spiral so a burst of new nodes doesn't pile
|
||||
// up at the centre and explode outward (the "lots of movement on Start")
|
||||
const a = (this.nodes.size * 2.399963) % (Math.PI * 2)
|
||||
const r = 90 + (this.nodes.size % 19) * 26
|
||||
n = {
|
||||
id, kind, label,
|
||||
x: w / 2 + Math.cos(a) * r, y: h / 2 + Math.sin(a) * r,
|
||||
vx: 0, vy: 0, pinned: false,
|
||||
heat: 0, count: 0, worst: 0, lastTs: 0, recent: [], levels: [0, 0, 0, 0, 0, 0],
|
||||
}
|
||||
this.nodes.set(id, n)
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
private touch(n: GNode, line: LogcatLine, lv: number, ts: number) {
|
||||
n.count++
|
||||
n.heat += 1
|
||||
n.worst = Math.max(n.worst, lv)
|
||||
n.lastTs = ts
|
||||
n.levels[lv]++
|
||||
// collapse consecutive identical lines so the inspector shows variety, not
|
||||
// 80 copies of the same chatty message
|
||||
const last = n.recent[n.recent.length - 1]
|
||||
if (!last || last.raw !== line.raw) {
|
||||
n.recent.push(line)
|
||||
if (n.recent.length > RECENT_CAP) n.recent.shift()
|
||||
}
|
||||
}
|
||||
|
||||
private link(a: string, b: string, kind: EdgeKind, sev: number, lv: number, ts: number, line: LogcatLine) {
|
||||
const id = a + '>' + b
|
||||
let e = this.edges.get(id)
|
||||
if (!e) {
|
||||
e = { id, a, b, kind, weight: 0, count: 0, worst: 0, lastTs: ts }
|
||||
this.edges.set(id, e)
|
||||
}
|
||||
e.weight += 1
|
||||
e.count++
|
||||
e.lastTs = ts
|
||||
if (sev >= e.worst) { e.worst = sev; if (sev > 0) e.kind = kind }
|
||||
this.spawnParticle(a, b, lv, e.kind, sev, line)
|
||||
// record the real event for the live packet feed (dedupe immediate repeats)
|
||||
const prev = this.flowLog[this.flowLog.length - 1]
|
||||
const msg = line.message || line.raw || ''
|
||||
if (!prev || prev.a !== a || prev.b !== b || prev.msg !== msg) {
|
||||
const ev: FlowEvent = { ts, a, b, kind: e.kind, level: lv, tag: line.tag || '', msg }
|
||||
this.flowLog.push(ev)
|
||||
if (this.flowLog.length > FLOWLOG_CAP) this.flowLog.shift()
|
||||
if (this.capturing) { this.captured.push(ev); if (this.captured.length > 8000) this.captured.shift() }
|
||||
}
|
||||
}
|
||||
|
||||
private spawnParticle(a: string, b: string, lv: number, kind: EdgeKind, sev: number, line: LogcatLine) {
|
||||
// always emit for severe events; otherwise sample by intensity
|
||||
if (sev < 3 && Math.random() > this.cfg.particleIntensity) return
|
||||
if (this.particles.length >= this.cfg.maxParticles) {
|
||||
this.particles.shift(); this.droppedParticles++
|
||||
}
|
||||
this.particles.push({
|
||||
a, b, t: 0,
|
||||
speed: (0.45 + Math.random() * 0.4) * this.cfg.particleSpeed * (sev >= 3 ? 1.5 : 1),
|
||||
level: lv, kind,
|
||||
line, // the actual event this particle carries
|
||||
})
|
||||
}
|
||||
|
||||
// ---- per-frame updates -------------------------------------------------
|
||||
|
||||
decay(scale = 1) {
|
||||
const t = now()
|
||||
// Clamp elapsed: after a pause / hidden view, lastDecay is stale and an
|
||||
// unclamped dt would decay all heat+edges in one tick and evict the whole
|
||||
// graph, leaving an empty map on return. Cap at 2s of decay per call.
|
||||
const dt = Math.min(t - this.lastDecay, 2000) * scale
|
||||
this.lastDecay = t
|
||||
if (dt <= 0) return
|
||||
const nf = Math.pow(0.5, dt / this.cfg.nodeHalfLifeMs)
|
||||
const ef = Math.pow(0.5, dt / this.cfg.edgeHalfLifeMs)
|
||||
for (const n of this.nodes.values()) { n.heat *= nf; n.worst *= nf }
|
||||
for (const [id, e] of this.edges) { e.weight *= ef; e.worst *= ef; if (e.weight < 0.04) this.edges.delete(id) }
|
||||
this.evict()
|
||||
}
|
||||
|
||||
private evict() {
|
||||
// drop cold, edgeless, unpinned nodes; then cap total by heat
|
||||
const connected = new Set<string>()
|
||||
for (const e of this.edges.values()) { connected.add(e.a); connected.add(e.b) }
|
||||
for (const [id, n] of this.nodes) {
|
||||
if (!n.pinned && !this.watchedIds.has(id) && n.heat < 0.02 && !connected.has(id)) this.nodes.delete(id)
|
||||
}
|
||||
if (this.nodes.size > this.cfg.maxNodes) {
|
||||
const arr = [...this.nodes.values()].filter(n => !n.pinned && !this.watchedIds.has(n.id)).sort((a, b) => a.heat - b.heat)
|
||||
let over = this.nodes.size - this.cfg.maxNodes
|
||||
for (const n of arr) {
|
||||
if (over-- <= 0) break
|
||||
this.nodes.delete(n.id)
|
||||
for (const [eid, e] of this.edges) if (e.a === n.id || e.b === n.id) this.edges.delete(eid)
|
||||
}
|
||||
}
|
||||
if (this.edges.size > this.cfg.maxEdges) {
|
||||
const arr = [...this.edges.values()].sort((a, b) => a.weight - b.weight)
|
||||
let over = this.edges.size - this.cfg.maxEdges
|
||||
for (const e of arr) { if (over-- <= 0) break; this.edges.delete(e.id) }
|
||||
}
|
||||
}
|
||||
|
||||
advanceParticles(dt: number) {
|
||||
const keep: Particle[] = []
|
||||
// Clamp the step: a single long frame (view switch, GC pause, WebKitGTK
|
||||
// render stall) would otherwise push every particle's t past 1 in one tick
|
||||
// and cull the entire stream — the "0 flows" bug. Cap at ~4 frames' worth
|
||||
// so motion stays continuous after a hitch instead of resetting to empty.
|
||||
const step = Math.min(dt, 64) / 1000
|
||||
for (const p of this.particles) {
|
||||
p.t += p.speed * step
|
||||
if (p.t < 1 && this.nodes.has(p.a) && this.nodes.has(p.b)) keep.push(p)
|
||||
}
|
||||
this.particles = keep
|
||||
}
|
||||
|
||||
// Incrementally place every not-yet-placed node into a 3D HANGING TREE: parent =
|
||||
// strongest INCOMING edge, child hangs one level below its parent and fans out in
|
||||
// the XZ plane (golden angle) so siblings spread into a cone. Parents are placed
|
||||
// before children (multi-pass); true roots (no incoming edge) sit at the top.
|
||||
// Stable once set → no re-jumping; the tree grows downward as the graph builds.
|
||||
placeTree3D() {
|
||||
const LEVEL = 115, CR = 230
|
||||
const placeRoot = (n: GNode) => {
|
||||
const ri = this.treeRootN++, a = ri * 2.399963, rr = 50 + ri * 16
|
||||
n.tx = Math.cos(a) * rr; n.tz = Math.sin(a) * rr; n.ty = 0; n.tdepth = 0
|
||||
}
|
||||
const placeChild = (n: GNode, par: GNode) => {
|
||||
const k = this.treeChildN.get(par.id) || 0; this.treeChildN.set(par.id, k + 1)
|
||||
const depth = (par.tdepth || 0) + 1
|
||||
const r = (CR / Math.sqrt(depth + 1)) * (0.55 + 0.45 * ((k % 6) / 5)), a = k * 2.399963
|
||||
n.tx = (par.tx || 0) + Math.cos(a) * r
|
||||
n.tz = (par.tz || 0) + Math.sin(a) * r
|
||||
n.ty = (par.ty || 0) - LEVEL
|
||||
n.tdepth = depth
|
||||
n.tparent = par.id
|
||||
}
|
||||
let changed = true, guard = 0
|
||||
while (changed && guard++ < 60) {
|
||||
changed = false
|
||||
for (const n of this.nodes.values()) {
|
||||
if (n.tx !== undefined) continue
|
||||
let par: GNode | null = null, bestW = -1, hasIn = false
|
||||
for (const e of this.edges.values()) {
|
||||
if (e.b !== n.id) continue
|
||||
hasIn = true
|
||||
const src = this.nodes.get(e.a)
|
||||
if (src && src.tx !== undefined && e.weight > bestW) { bestW = e.weight; par = src }
|
||||
}
|
||||
if (par) { placeChild(n, par); changed = true }
|
||||
else if (!hasIn) { placeRoot(n); changed = true }
|
||||
}
|
||||
}
|
||||
for (const n of this.nodes.values()) if (n.tx === undefined) placeRoot(n) // cycles / unreachable
|
||||
}
|
||||
|
||||
// ---- box layout: 8 hub boxes in a 2-col grid --------------------------
|
||||
// Group nodes around the busiest hubs (each box = one hub + the nodes that
|
||||
// connect to it most), lay the boxes out 2-wide, and ease nodes to their slot.
|
||||
// Recomputed on an interval so the hub set doesn't reshuffle every frame.
|
||||
computeBoxes(w: number, h: number, N = 8) {
|
||||
const nodes = [...this.nodes.values()]
|
||||
this.boxes = []; this.boxTarget.clear()
|
||||
if (!nodes.length) return
|
||||
|
||||
// degree weight = incident edge weight (+ a little heat as tiebreak)
|
||||
const deg = new Map<string, number>()
|
||||
for (const n of nodes) deg.set(n.id, n.heat * 0.5)
|
||||
for (const e of this.edges.values()) {
|
||||
deg.set(e.a, (deg.get(e.a) || 0) + e.weight)
|
||||
deg.set(e.b, (deg.get(e.b) || 0) + e.weight)
|
||||
}
|
||||
const k = Math.min(N, nodes.length)
|
||||
const anchors = [...nodes].sort((a, b) => (deg.get(b.id) || 0) - (deg.get(a.id) || 0)).slice(0, k)
|
||||
const anchorBox = new Map<string, number>()
|
||||
anchors.forEach((n, i) => anchorBox.set(n.id, i))
|
||||
|
||||
// assign every node to a box: anchors own theirs; others go to the box of
|
||||
// their strongest-connected anchor; unconnected fall back to a stable hash.
|
||||
const members: string[][] = Array.from({ length: k }, () => [])
|
||||
for (const n of nodes) {
|
||||
let box = anchorBox.get(n.id)
|
||||
if (box === undefined) {
|
||||
let bestW = -1
|
||||
for (const e of this.edges.values()) {
|
||||
const other = e.a === n.id ? e.b : e.b === n.id ? e.a : null
|
||||
if (other !== null && anchorBox.has(other) && e.weight > bestW) { bestW = e.weight; box = anchorBox.get(other) }
|
||||
}
|
||||
if (box === undefined) {
|
||||
let hsh = 0; for (let i = 0; i < n.id.length; i++) hsh = (hsh * 31 + n.id.charCodeAt(i)) >>> 0
|
||||
box = hsh % k
|
||||
}
|
||||
}
|
||||
members[box].push(n.id)
|
||||
}
|
||||
|
||||
// grid: 2 columns (like the drawing), rows as needed
|
||||
const cols = Math.min(2, k), rows = Math.ceil(k / cols)
|
||||
const pad = 36, gap = 46
|
||||
const bw = (w - pad * 2 - gap * (cols - 1)) / cols
|
||||
const bh = (h - pad * 2 - gap * (rows - 1)) / rows
|
||||
for (let i = 0; i < k; i++) {
|
||||
const c = i % cols, r = Math.floor(i / cols)
|
||||
const x = pad + c * (bw + gap), y = pad + r * (bh + gap)
|
||||
this.boxes.push({ x, y, w: bw, h: bh, label: anchors[i].label, count: members[i].length })
|
||||
// hierarchy: busiest node (the hub) sits prominently at the box's top-
|
||||
// centre, the rest sorted by activity flow into a grid beneath it.
|
||||
const mem = members[i].sort((p, q) => (deg.get(q) || 0) - (deg.get(p) || 0))
|
||||
const ipad = 26, iw = bw - ipad * 2, ih = bh - ipad * 2
|
||||
this.boxTarget.set(mem[0], { x: x + bw / 2, y: y + ipad + 6 })
|
||||
const rest = mem.slice(1)
|
||||
const top = y + ipad + 36, gh = Math.max(1, ih - 36)
|
||||
const gc = Math.max(1, Math.round(Math.sqrt(rest.length * (iw / Math.max(1, gh)))))
|
||||
const gr = Math.max(1, Math.ceil(rest.length / gc))
|
||||
rest.forEach((id, j) => {
|
||||
const cc = j % gc, rr = Math.floor(j / gc)
|
||||
const tx = x + ipad + (gc === 1 ? iw / 2 : (cc / (gc - 1)) * iw)
|
||||
const ty = top + (gr === 1 ? gh / 2 : (rr / (gr - 1)) * gh)
|
||||
this.boxTarget.set(id, { x: tx, y: ty })
|
||||
})
|
||||
}
|
||||
// scale the whole arrangement (boxes + node targets) around the centre
|
||||
const sc = this.cfg.layoutScale || 1
|
||||
if (sc !== 1) {
|
||||
const cx = w / 2, cy = h / 2
|
||||
for (const b of this.boxes) { b.x = cx + (b.x - cx) * sc; b.y = cy + (b.y - cy) * sc; b.w *= sc; b.h *= sc }
|
||||
for (const [id, t] of this.boxTarget) this.boxTarget.set(id, { x: cx + (t.x - cx) * sc, y: cy + (t.y - cy) * sc })
|
||||
}
|
||||
}
|
||||
|
||||
private easeToTargets() {
|
||||
for (const n of this.nodes.values()) {
|
||||
if (n.pinned) continue
|
||||
const tg = this.boxTarget.get(n.id); if (!tg) continue
|
||||
n.vx = 0; n.vy = 0
|
||||
n.x += (tg.x - n.x) * 0.16; n.y += (tg.y - n.y) * 0.16
|
||||
}
|
||||
}
|
||||
private stepBoxLayout(w: number, h: number) {
|
||||
const t = now()
|
||||
if (t - this.lastBoxCalc > 1200 || !this.boxes.length) { this.computeBoxes(w, h); this.lastBoxCalc = t }
|
||||
this.easeToTargets()
|
||||
}
|
||||
// hierarchical tidy tree: parent = strongest INCOMING edge; root(s) at top,
|
||||
// children cascade down, leaves spread across the width, internal nodes centred
|
||||
// over their children (Reingold–Tilford-ish). Fills the viewport.
|
||||
computeTreeTargets(w: number, h: number) {
|
||||
const nodes = [...this.nodes.values()]
|
||||
this.boxes = []; this.boxTarget.clear()
|
||||
if (!nodes.length) return
|
||||
const parent = new Map<string, string>(), inW = new Map<string, number>()
|
||||
for (const e of this.edges.values()) {
|
||||
if ((inW.get(e.b) ?? -1) < e.weight) { inW.set(e.b, e.weight); parent.set(e.b, e.a) }
|
||||
}
|
||||
const children = new Map<string, string[]>(), roots: string[] = []
|
||||
for (const n of nodes) {
|
||||
const p = parent.get(n.id)
|
||||
// root if no parent, parent missing, or a 2-cycle where this node is the stronger
|
||||
if (p && p !== n.id && this.nodes.has(p) && !(parent.get(p) === n.id && (inW.get(n.id) ?? 0) >= (inW.get(p) ?? 0))) {
|
||||
const arr = children.get(p); if (arr) arr.push(n.id); else children.set(p, [n.id])
|
||||
} else roots.push(n.id)
|
||||
}
|
||||
const visited = new Set<string>(), xpos = new Map<string, number>(), depth = new Map<string, number>()
|
||||
let cursor = 0, maxDepth = 0
|
||||
const dfs = (id: string, d: number) => {
|
||||
if (visited.has(id)) return
|
||||
visited.add(id); depth.set(id, d); if (d > maxDepth) maxDepth = d
|
||||
const ch = (children.get(id) || []).filter(c => !visited.has(c))
|
||||
if (!ch.length) { xpos.set(id, cursor++); return }
|
||||
let sum = 0; for (const c of ch) { dfs(c, d + 1); sum += xpos.get(c) ?? 0 }
|
||||
xpos.set(id, sum / ch.length)
|
||||
}
|
||||
for (const r of roots) dfs(r, 0)
|
||||
for (const n of nodes) if (!visited.has(n.id)) { depth.set(n.id, 0); xpos.set(n.id, cursor++) } // stragglers/cycles
|
||||
const maxX = Math.max(1, cursor - 1), pad = 60, sc = this.cfg.layoutScale || 1
|
||||
const cx = w / 2, cy = h / 2, levelGap = maxDepth > 0 ? (h - pad * 2) / maxDepth : 0
|
||||
for (const n of nodes) {
|
||||
const x0 = pad + ((xpos.get(n.id) ?? 0) / maxX) * (w - pad * 2)
|
||||
const y0 = pad + (depth.get(n.id) ?? 0) * levelGap
|
||||
this.boxTarget.set(n.id, { x: cx + (x0 - cx) * sc, y: cy + (y0 - cy) * sc })
|
||||
}
|
||||
}
|
||||
|
||||
// arrange nodes (ordered by activity) onto the chosen geometric structure
|
||||
computeGeometry(w: number, h: number) {
|
||||
if (this.cfg.geometry === 'tree') { this.computeTreeTargets(w, h); return }
|
||||
const nodes = [...this.nodes.values()]
|
||||
this.boxes = []; this.boxTarget.clear()
|
||||
if (!nodes.length || this.cfg.geometry === 'none') return
|
||||
const deg = new Map<string, number>()
|
||||
for (const n of nodes) deg.set(n.id, n.heat)
|
||||
for (const e of this.edges.values()) { deg.set(e.a, (deg.get(e.a) || 0) + e.weight); deg.set(e.b, (deg.get(e.b) || 0) + e.weight) }
|
||||
const ordered = nodes.sort((a, b) => (deg.get(b.id) || 0) - (deg.get(a.id) || 0))
|
||||
const pts = geometryPoints(this.cfg.geometry, ordered.length, w, h)
|
||||
if (!pts.length) return
|
||||
const cx = w / 2, cy = h / 2, sc = this.cfg.layoutScale || 1
|
||||
ordered.forEach((n, i) => {
|
||||
const p = pts[i % pts.length]
|
||||
this.boxTarget.set(n.id, { x: cx + (p.x - cx) * sc, y: cy + (p.y - cy) * sc })
|
||||
})
|
||||
}
|
||||
private stepGeometry(w: number, h: number) {
|
||||
const t = now()
|
||||
if (t - this.lastBoxCalc > 1200 || !this.boxTarget.size) { this.computeGeometry(w, h); this.lastBoxCalc = t }
|
||||
this.easeToTargets()
|
||||
}
|
||||
|
||||
// simple O(n²) force layout — fine for the bounded node count
|
||||
stepForces(w: number, h: number, dtMs: number) {
|
||||
if (this.cfg.freeze) return // freeze = stop ALL node movement, in every layout mode
|
||||
if (this.cfg.boxLayout) { this.stepBoxLayout(w, h); return }
|
||||
if (this.cfg.geometry !== 'none') { this.stepGeometry(w, h); return }
|
||||
this.boxes = []
|
||||
const dt = Math.min(dtMs, 40) / 16.67
|
||||
const ns = [...this.nodes.values()]
|
||||
const cx = w / 2, cy = h / 2
|
||||
// layoutScale spreads the force layout too: more repulsion + longer springs +
|
||||
// weaker gravity → the whole graph grows/shrinks with the slider.
|
||||
const sc = this.cfg.layoutScale || 1
|
||||
const rep = this.cfg.repulsion * sc
|
||||
const grav = this.cfg.gravity / sc
|
||||
|
||||
for (let i = 0; i < ns.length; i++) {
|
||||
const a = ns[i]
|
||||
for (let j = i + 1; j < ns.length; j++) {
|
||||
const b = ns[j]
|
||||
let dx = a.x - b.x, dy = a.y - b.y
|
||||
let d2 = dx * dx + dy * dy
|
||||
if (d2 < 0.01) { dx = (i - j) || 1; dy = 1; d2 = 2 }
|
||||
const inv = 1 / d2
|
||||
let f = rep * inv
|
||||
if (this.cfg.clusterByKind && a.kind === b.kind) f *= (1 - this.cfg.clusterByKind * 0.6)
|
||||
const d = Math.sqrt(d2)
|
||||
// size-aware collision: if the discs overlap, add a strong extra push so
|
||||
// big (hot) nodes separate instead of stacking into a central blob.
|
||||
const minSep = nodeRadius(a) + nodeRadius(b) + 10
|
||||
if (d < minSep) f += (minSep - d) * 1.4
|
||||
const fx = (dx / d) * f, fy = (dy / d) * f
|
||||
a.vx += fx; a.vy += fy
|
||||
b.vx -= fx; b.vy -= fy
|
||||
}
|
||||
// gravity toward centre
|
||||
a.vx += (cx - a.x) * grav
|
||||
a.vy += (cy - a.y) * grav
|
||||
}
|
||||
|
||||
// springs
|
||||
const L = this.cfg.linkDistance * sc
|
||||
for (const e of this.edges.values()) {
|
||||
const a = this.nodes.get(e.a), b = this.nodes.get(e.b)
|
||||
if (!a || !b) continue
|
||||
const dx = b.x - a.x, dy = b.y - a.y
|
||||
const d = Math.hypot(dx, dy) || 1
|
||||
const rest = L / (1 + Math.min(e.weight, 6) * 0.12)
|
||||
const f = (d - rest) * 0.02
|
||||
const fx = (dx / d) * f, fy = (dy / d) * f
|
||||
a.vx += fx; a.vy += fy
|
||||
b.vx -= fx; b.vy -= fy
|
||||
}
|
||||
|
||||
for (const n of ns) {
|
||||
if (n.pinned) { n.vx = 0; n.vy = 0; continue }
|
||||
n.vx *= this.cfg.damping; n.vy *= this.cfg.damping
|
||||
// clamp velocity for stability — lower cap calms the initial settle
|
||||
const v = Math.hypot(n.vx, n.vy)
|
||||
if (v > 14) { n.vx = (n.vx / v) * 14; n.vy = (n.vy / v) * 14 }
|
||||
n.x += n.vx * dt; n.y += n.vy * dt
|
||||
}
|
||||
}
|
||||
|
||||
nodeAt(x: number, y: number, radiusFn: (n: GNode) => number): GNode | null {
|
||||
let best: GNode | null = null, bestD = Infinity
|
||||
for (const n of this.nodes.values()) {
|
||||
const r = radiusFn(n) + 4
|
||||
const d = Math.hypot(n.x - x, n.y - y)
|
||||
if (d <= r && d < bestD) { best = n; bestD = d }
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
neighbors(id: string): { node: GNode; edge: GEdge; dir: 'out' | 'in' }[] {
|
||||
const out: { node: GNode; edge: GEdge; dir: 'out' | 'in' }[] = []
|
||||
for (const e of this.edges.values()) {
|
||||
if (e.a === id) { const n = this.nodes.get(e.b); if (n) out.push({ node: n, edge: e, dir: 'out' }) }
|
||||
else if (e.b === id) { const n = this.nodes.get(e.a); if (n) out.push({ node: n, edge: e, dir: 'in' }) }
|
||||
}
|
||||
return out.sort((x, y) => y.edge.weight - x.edge.weight)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,9 +1,22 @@
|
|||
// Simple toast state management - used with sonner
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// When the user enables "mute no-device pop-ups" (Settings), swallow error
|
||||
// toasts that are just about a missing/offline/unauthorized device.
|
||||
function mutedNoDevice(msg: string): boolean {
|
||||
if (localStorage.getItem('atk-mute-nodevice') !== '1') return false
|
||||
const s = msg.toLowerCase()
|
||||
return s.includes('no device') || s.includes('no devices/emulators') ||
|
||||
s.includes('offline') || s.includes('unauthorized') || s.includes('device not found')
|
||||
}
|
||||
|
||||
export const notify = {
|
||||
success: (msg: string) => toast.success(msg, { duration: 3000 }),
|
||||
error: (msg: string) => toast.error(msg, { duration: 5000 }),
|
||||
error: (msg: string) => {
|
||||
const s = String(msg)
|
||||
if (mutedNoDevice(s)) return
|
||||
return toast.error(s, { duration: 5000 })
|
||||
},
|
||||
info: (msg: string) => toast(msg, { duration: 3000 }),
|
||||
loading: (msg: string) => toast.loading(msg),
|
||||
dismiss: (id?: string | number) => toast.dismiss(id),
|
||||
|
|
|
|||
22
frontend/src/lib/theme.ts
Normal file
22
frontend/src/lib/theme.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
// Theme management. Palettes are defined in src/styles/global.css and selected
|
||||
// by the data-theme attribute on <html>. Choice is persisted in localStorage.
|
||||
|
||||
export type Theme = 'dark' | 'frappe' | 'latte'
|
||||
|
||||
export const THEMES: { id: Theme; label: string; hint: string }[] = [
|
||||
{ id: 'dark', label: 'Dark', hint: 'Terminal green on black' },
|
||||
{ id: 'frappe', label: 'Frappé', hint: 'Catppuccin — soft pastels, dark' },
|
||||
{ id: 'latte', label: 'Latte', hint: 'Catppuccin — soft pastels, light (default)' },
|
||||
]
|
||||
|
||||
const STORAGE_KEY = 'atk-theme'
|
||||
|
||||
export function getTheme(): Theme {
|
||||
const t = localStorage.getItem(STORAGE_KEY)
|
||||
return t === 'frappe' || t === 'latte' || t === 'dark' ? t : 'latte'
|
||||
}
|
||||
|
||||
export function applyTheme(theme: Theme): void {
|
||||
document.documentElement.setAttribute('data-theme', theme)
|
||||
localStorage.setItem(STORAGE_KEY, theme)
|
||||
}
|
||||
|
|
@ -39,13 +39,127 @@ export interface PackageInfo {
|
|||
isEnabled: boolean
|
||||
}
|
||||
|
||||
// Relationship kinds mined (in Go) from a log line for the visual map.
|
||||
export type RefKind = 'activity' | 'spawn' | 'death' | 'crash' | 'anr' | 'signal' | 'gfx' | 'mention'
|
||||
export interface LogRef {
|
||||
kind: RefKind
|
||||
target: string
|
||||
targetKind: 'package' | 'component' | 'pid'
|
||||
}
|
||||
|
||||
export interface LogcatLine {
|
||||
raw: string
|
||||
level: string
|
||||
tag: string
|
||||
message: string
|
||||
pid: string
|
||||
tid?: string
|
||||
time: string
|
||||
refs?: LogRef[] // relationships mined natively by the Go backend
|
||||
mentions?: LogRef[] // generic package mentions (optional/noisy)
|
||||
}
|
||||
|
||||
export interface APKAuditPermission {
|
||||
name: string
|
||||
dangerous: boolean
|
||||
}
|
||||
|
||||
export interface APKAuditComponent {
|
||||
type: string
|
||||
name: string
|
||||
exported: boolean
|
||||
exportedImplicit: boolean
|
||||
permission: string
|
||||
intentFilters: string[]
|
||||
}
|
||||
|
||||
export interface APKAuditCert {
|
||||
verified: boolean
|
||||
subject: string
|
||||
issuer: string
|
||||
sigAlgo: string
|
||||
serial: string
|
||||
sha256: string
|
||||
sha1: string
|
||||
validFrom: string
|
||||
validTo: string
|
||||
v1: boolean
|
||||
v2: boolean
|
||||
v3: boolean
|
||||
isDebug: boolean
|
||||
expired: boolean
|
||||
weakAlgo: boolean
|
||||
error: string
|
||||
}
|
||||
|
||||
export interface APKAuditFindingMatch {
|
||||
file: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface APKAuditFinding {
|
||||
id: string
|
||||
title: string
|
||||
severity: 'critical' | 'high' | 'medium' | 'low' | 'info'
|
||||
category: string
|
||||
description: string
|
||||
cwe: string
|
||||
masvs: string
|
||||
confidence: number
|
||||
matches: APKAuditFindingMatch[]
|
||||
}
|
||||
|
||||
export interface APKAuditTracker {
|
||||
name: string
|
||||
category: string
|
||||
matches: number
|
||||
}
|
||||
|
||||
export interface APKAuditFile {
|
||||
path: string
|
||||
size: number
|
||||
compressed: number
|
||||
}
|
||||
|
||||
export interface APKEntryContent {
|
||||
name: string
|
||||
size: number
|
||||
kind: 'text' | 'image' | 'binary'
|
||||
mime: string
|
||||
text: string
|
||||
base64: string
|
||||
hex: string
|
||||
truncated: boolean
|
||||
}
|
||||
|
||||
export interface APKAudit {
|
||||
source: string
|
||||
path: string
|
||||
localPath: string
|
||||
fileName: string
|
||||
fileSize: number
|
||||
sha256: string
|
||||
packageName: string
|
||||
appLabel: string
|
||||
versionName: string
|
||||
versionCode: string
|
||||
minSdk: string
|
||||
targetSdk: string
|
||||
compileSdk: string
|
||||
debuggable: boolean
|
||||
allowBackup: boolean
|
||||
usesCleartext: boolean
|
||||
hasNetworkSecurityConfig: boolean
|
||||
permissions: APKAuditPermission[]
|
||||
components: APKAuditComponent[]
|
||||
cert: APKAuditCert
|
||||
findings: APKAuditFinding[]
|
||||
trackers: APKAuditTracker[]
|
||||
files: APKAuditFile[]
|
||||
manifestXml: string
|
||||
score: number
|
||||
grade: string
|
||||
counts: Record<string, number>
|
||||
}
|
||||
|
||||
export interface AppInspection {
|
||||
|
|
@ -103,11 +217,13 @@ export interface BackupOptions {
|
|||
export type View =
|
||||
| 'dashboard'
|
||||
| 'files'
|
||||
| 'mirror'
|
||||
| 'packages'
|
||||
| 'debloater'
|
||||
| 'shell'
|
||||
| 'logcat'
|
||||
| 'appinspect'
|
||||
| 'apkaudit'
|
||||
| 'certs'
|
||||
| 'backup'
|
||||
| 'props'
|
||||
|
|
|
|||
|
|
@ -7,6 +7,8 @@ export const GetDevices = () => window['go']['main']['App']['GetDevices']()
|
|||
// @ts-ignore
|
||||
export const GetDeviceInfo = () => window['go']['main']['App']['GetDeviceInfo']()
|
||||
// @ts-ignore
|
||||
export const GetSecurityOverview = () => window['go']['main']['App']['GetSecurityOverview']()
|
||||
// @ts-ignore
|
||||
export const GetDeviceMode = () => window['go']['main']['App']['GetDeviceMode']()
|
||||
// @ts-ignore
|
||||
export const Reboot = (mode: string) => window['go']['main']['App']['Reboot'](mode)
|
||||
|
|
@ -42,6 +44,30 @@ export const CopyFile = (src: string, dst: string) => window['go']['main']['App'
|
|||
export const PullMultipleFiles = (paths: string[]) => window['go']['main']['App']['PullMultipleFiles'](paths)
|
||||
// @ts-ignore
|
||||
export const SelectFileForPush = () => window['go']['main']['App']['SelectFileForPush']()
|
||||
// @ts-ignore
|
||||
export const PushWithProgress = (local: string, remoteDir: string) => window['go']['main']['App']['PushWithProgress'](local, remoteDir)
|
||||
// @ts-ignore
|
||||
export const PullPathsWithProgress = (paths: string[]) => window['go']['main']['App']['PullPathsWithProgress'](paths)
|
||||
// @ts-ignore
|
||||
export const SaveTextFile = (defaultName: string, content: string) => window['go']['main']['App']['SaveTextFile'](defaultName, content)
|
||||
// @ts-ignore
|
||||
export const HomeDir = () => window['go']['main']['App']['HomeDir']()
|
||||
// @ts-ignore
|
||||
export const ListLocalFiles = (path: string) => window['go']['main']['App']['ListLocalFiles'](path)
|
||||
// @ts-ignore
|
||||
export const PushPathsWithProgress = (localPaths: string[], remoteDir: string) => window['go']['main']['App']['PushPathsWithProgress'](localPaths, remoteDir)
|
||||
|
||||
// Screen mirror (scrcpy)
|
||||
// @ts-ignore
|
||||
export const ScrcpyAvailable = () => window['go']['main']['App']['ScrcpyAvailable']()
|
||||
// @ts-ignore
|
||||
export const ScrcpyRunning = () => window['go']['main']['App']['ScrcpyRunning']()
|
||||
// @ts-ignore
|
||||
export const StartScrcpy = (opts: any) => window['go']['main']['App']['StartScrcpy'](opts)
|
||||
// @ts-ignore
|
||||
export const StopScrcpy = () => window['go']['main']['App']['StopScrcpy']()
|
||||
// @ts-ignore
|
||||
export const CaptureScreenshot = () => window['go']['main']['App']['CaptureScreenshot']()
|
||||
|
||||
// Package ops
|
||||
// @ts-ignore
|
||||
|
|
@ -67,8 +93,12 @@ export const UninstallMultiplePackages = (pkgs: string[]) => window['go']['main'
|
|||
// @ts-ignore
|
||||
export const DisableMultiplePackages = (pkgs: string[]) => window['go']['main']['App']['DisableMultiplePackages'](pkgs)
|
||||
// @ts-ignore
|
||||
export const UninstallAndDisableMultiplePackages = (pkgs: string[]) => window['go']['main']['App']['UninstallAndDisableMultiplePackages'](pkgs)
|
||||
// @ts-ignore
|
||||
export const EnableMultiplePackages = (pkgs: string[]) => window['go']['main']['App']['EnableMultiplePackages'](pkgs)
|
||||
// @ts-ignore
|
||||
export const RestoreMultiplePackages = (pkgs: string[]) => window['go']['main']['App']['RestoreMultiplePackages'](pkgs)
|
||||
// @ts-ignore
|
||||
export const SelectFileForInstall = () => window['go']['main']['App']['SelectFileForInstall']()
|
||||
// @ts-ignore
|
||||
export const SideloadPackage = (path: string) => window['go']['main']['App']['SideloadPackage'](path)
|
||||
|
|
@ -91,11 +121,55 @@ export const DisconnectWirelessAdb = (ip: string, port: string) => window['go'][
|
|||
// @ts-ignore
|
||||
export const GetFastbootDevices = () => window['go']['main']['App']['GetFastbootDevices']()
|
||||
// @ts-ignore
|
||||
export const FlashPartition = (partition: string, file: string) => window['go']['main']['App']['FlashPartition'](partition, file)
|
||||
export const FlashPartition = (partition: string, file: string, force: boolean) => window['go']['main']['App']['FlashPartition'](partition, file, force)
|
||||
// @ts-ignore
|
||||
export const FastbootGetVar = (variable: string) => window['go']['main']['App']['FastbootGetVar'](variable)
|
||||
// @ts-ignore
|
||||
export const SelectFileForFlash = () => window['go']['main']['App']['SelectFileForFlash']()
|
||||
// @ts-ignore
|
||||
export const FastbootBoot = (file: string) => window['go']['main']['App']['FastbootBoot'](file)
|
||||
// @ts-ignore
|
||||
export const FlashBootImage = (partition: string, file: string, slot: string, force: boolean) => window['go']['main']['App']['FlashBootImage'](partition, file, slot, force)
|
||||
// @ts-ignore
|
||||
export const FastbootFlashing = (action: string) => window['go']['main']['App']['FastbootFlashing'](action)
|
||||
// @ts-ignore
|
||||
export const FastbootReboot = (target: string) => window['go']['main']['App']['FastbootReboot'](target)
|
||||
// @ts-ignore
|
||||
export const FlasherDeviceInfo = () => window['go']['main']['App']['FlasherDeviceInfo']()
|
||||
// Magisk root tools
|
||||
// @ts-ignore
|
||||
export const MagiskInstalled = () => window['go']['main']['App']['MagiskInstalled']()
|
||||
// @ts-ignore
|
||||
export const InstallMagisk = () => window['go']['main']['App']['InstallMagisk']()
|
||||
// @ts-ignore
|
||||
export const ExtractBootImages = (zipPath: string) => window['go']['main']['App']['ExtractBootImages'](zipPath)
|
||||
// @ts-ignore
|
||||
export const PushImageToDevice = (localPath: string) => window['go']['main']['App']['PushImageToDevice'](localPath)
|
||||
// @ts-ignore
|
||||
export const OpenMagisk = () => window['go']['main']['App']['OpenMagisk']()
|
||||
// @ts-ignore
|
||||
export const PullPatchedBoot = () => window['go']['main']['App']['PullPatchedBoot']()
|
||||
// @ts-ignore
|
||||
export const ListMagiskModules = () => window['go']['main']['App']['ListMagiskModules']()
|
||||
// @ts-ignore
|
||||
export const ToggleMagiskModule = (id: string, enable: boolean) => window['go']['main']['App']['ToggleMagiskModule'](id, enable)
|
||||
// @ts-ignore
|
||||
export const RemoveMagiskModule = (id: string) => window['go']['main']['App']['RemoveMagiskModule'](id)
|
||||
// Firmware download
|
||||
// @ts-ignore
|
||||
export const ListFirmware = (codename: string, kind: string) => window['go']['main']['App']['ListFirmware'](codename, kind)
|
||||
// @ts-ignore
|
||||
export const DownloadFirmware = (url: string, sha256: string) => window['go']['main']['App']['DownloadFirmware'](url, sha256)
|
||||
// @ts-ignore
|
||||
export const ListPayloadPartitions = (zipPath: string) => window['go']['main']['App']['ListPayloadPartitions'](zipPath)
|
||||
// @ts-ignore
|
||||
export const ExtractPayloadPartition = (zipPath: string, name: string) => window['go']['main']['App']['ExtractPayloadPartition'](zipPath, name)
|
||||
// @ts-ignore
|
||||
export const AnalyzeBootImage = (path: string) => window['go']['main']['App']['AnalyzeBootImage'](path)
|
||||
// @ts-ignore
|
||||
export const HashFile = (path: string) => window['go']['main']['App']['HashFile'](path)
|
||||
// @ts-ignore
|
||||
export const SelectAnyFile = () => window['go']['main']['App']['SelectAnyFile']()
|
||||
|
||||
// Logcat
|
||||
// @ts-ignore
|
||||
|
|
@ -104,6 +178,8 @@ export const StartLogcat = (filter: string, buffer: string) => window['go']['mai
|
|||
export const StopLogcat = () => window['go']['main']['App']['StopLogcat']()
|
||||
// @ts-ignore
|
||||
export const ClearLogcat = () => window['go']['main']['App']['ClearLogcat']()
|
||||
// @ts-ignore
|
||||
export const LogcatProcessNames = (): Promise<Record<string, string>> => window['go']['main']['App']['LogcatProcessNames']()
|
||||
|
||||
// App inspection
|
||||
// @ts-ignore
|
||||
|
|
@ -140,3 +216,29 @@ export const GetAllProps = () => window['go']['main']['App']['GetAllProps']()
|
|||
export const SetProp = (key: string, value: string) => window['go']['main']['App']['SetProp'](key, value)
|
||||
// @ts-ignore
|
||||
export const GetProp = (key: string) => window['go']['main']['App']['GetProp'](key)
|
||||
|
||||
// APK Auditor
|
||||
// @ts-ignore
|
||||
export const SelectAPKForAudit = () => window['go']['main']['App']['SelectAPKForAudit']()
|
||||
// @ts-ignore
|
||||
export const AuditAPK = (path: string) => window['go']['main']['App']['AuditAPK'](path)
|
||||
// @ts-ignore
|
||||
export const AuditInstalledApp = (pkg: string) => window['go']['main']['App']['AuditInstalledApp'](pkg)
|
||||
// @ts-ignore
|
||||
export const ReadAPKEntry = (apkPath: string, entry: string) => window['go']['main']['App']['ReadAPKEntry'](apkPath, entry)
|
||||
// @ts-ignore
|
||||
export const ExportAudit = (audit: any, format: string) => window['go']['main']['App']['ExportAudit'](audit, format)
|
||||
|
||||
// App lock
|
||||
// @ts-ignore
|
||||
export const AppLockStatus = (): Promise<{ enabled: boolean; requireForDanger: boolean }> => window['go']['main']['App']['AppLockStatus']()
|
||||
// @ts-ignore
|
||||
export const VerifyAppPassword = (password: string): Promise<boolean> => window['go']['main']['App']['VerifyAppPassword'](password)
|
||||
// @ts-ignore
|
||||
export const SetAppPassword = (current: string, next: string): Promise<void> => window['go']['main']['App']['SetAppPassword'](current, next)
|
||||
// @ts-ignore
|
||||
export const DisableAppLock = (current: string): Promise<void> => window['go']['main']['App']['DisableAppLock'](current)
|
||||
// @ts-ignore
|
||||
export const SetRequireForDanger = (current: string, require: boolean): Promise<void> => window['go']['main']['App']['SetRequireForDanger'](current, require)
|
||||
// @ts-ignore
|
||||
export const UnlockDanger = (password: string): Promise<boolean> => window['go']['main']['App']['UnlockDanger'](password)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,17 @@
|
|||
import React from 'react'
|
||||
import ReactDOM from 'react-dom/client'
|
||||
import App from './App'
|
||||
// Local self-hosted fonts (bundled into the app — no network/CDN at runtime)
|
||||
import '@fontsource/ibm-plex-sans/400.css'
|
||||
import '@fontsource/ibm-plex-sans/500.css'
|
||||
import '@fontsource/ibm-plex-sans/600.css'
|
||||
import '@fontsource/jetbrains-mono/400.css'
|
||||
import '@fontsource/jetbrains-mono/500.css'
|
||||
import './styles/global.css'
|
||||
import { applyTheme, getTheme } from './lib/theme'
|
||||
|
||||
// Apply the saved theme before first paint to avoid a flash of the default.
|
||||
applyTheme(getTheme())
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
<React.StrictMode>
|
||||
|
|
|
|||
|
|
@ -2,6 +2,66 @@
|
|||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ============================================================
|
||||
Theme palettes — switch via data-theme on <html>.
|
||||
Values are RGB channels so Tailwind opacity modifiers work
|
||||
(e.g. bg-accent-green/5 -> rgb(var(--accent-green) / 0.05)).
|
||||
============================================================ */
|
||||
:root,
|
||||
:root[data-theme="dark"] {
|
||||
--bg-base: 10 10 15;
|
||||
--bg-surface: 17 17 24;
|
||||
--bg-raised: 24 24 31;
|
||||
--bg-border: 37 37 48;
|
||||
--accent-green: 0 255 136;
|
||||
--accent-dim: 0 204 106;
|
||||
--accent-muted: 0 51 34;
|
||||
--text-primary: 232 232 240;
|
||||
--text-secondary: 136 136 170;
|
||||
--text-muted: 68 68 90;
|
||||
--danger: 255 68 68;
|
||||
--warn: 255 170 0;
|
||||
--scrollbar-hover: 51 51 68;
|
||||
}
|
||||
|
||||
/* Catppuccin Frappé — soft pastels on a dark blue-grey base */
|
||||
:root[data-theme="frappe"] {
|
||||
--bg-base: 48 52 70; /* base #303446 */
|
||||
--bg-surface: 41 44 60; /* mantle #292c3c */
|
||||
--bg-raised: 65 69 89; /* surface0 #414559 */
|
||||
--bg-border: 81 87 109; /* surface1 #51576d */
|
||||
--accent-green: 166 209 137; /* green #a6d189 */
|
||||
--accent-dim: 140 180 115; /* darker green for hovers */
|
||||
--accent-muted: 65 69 89; /* surface0 */
|
||||
--text-primary: 198 208 245; /* text #c6d0f5 */
|
||||
--text-secondary: 165 173 206; /* subtext0 #a5adce */
|
||||
--text-muted: 115 121 148; /* overlay0 #737994 */
|
||||
--danger: 231 130 132; /* red #e78284 */
|
||||
--warn: 229 200 144; /* yellow #e5c890 */
|
||||
--scrollbar-hover: 98 104 128; /* surface2 #626880 */
|
||||
}
|
||||
|
||||
/* Catppuccin Latte — soft pastels, true light mode */
|
||||
:root[data-theme="latte"] {
|
||||
--bg-base: 239 241 245; /* base #eff1f5 */
|
||||
--bg-surface: 230 233 239; /* mantle #e6e9ef */
|
||||
--bg-raised: 220 224 232; /* crust #dce0e8 */
|
||||
--bg-border: 204 208 218; /* surface0 #ccd0da */
|
||||
--accent-green: 64 160 43; /* green #40a02b */
|
||||
--accent-dim: 50 130 35; /* darker green for hovers */
|
||||
--accent-muted: 204 227 192; /* light green tint */
|
||||
--text-primary: 76 79 105; /* text #4c4f69 */
|
||||
--text-secondary: 108 111 133; /* subtext0 #6c6f85 */
|
||||
--text-muted: 140 143 161; /* overlay1 #8c8fa1 */
|
||||
--danger: 210 15 57; /* red #d20f39 */
|
||||
--warn: 223 142 29; /* yellow #df8e1d */
|
||||
--scrollbar-hover: 188 192 204;/* surface1 #bcc0cc */
|
||||
}
|
||||
|
||||
/* Latte is the only light theme — theme its native form controls light. Set on
|
||||
body (not :root) so it never tints the transparent document canvas. */
|
||||
:root[data-theme="latte"] body { color-scheme: light; }
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
|
|
@ -13,29 +73,66 @@
|
|||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
/* Keep the WebKit canvas transparent so the rounded app-root corners show
|
||||
through to the desktop. Without this the browser paints an OPAQUE canvas
|
||||
backdrop dictated by `color-scheme`, which fills the four corners with a
|
||||
square — the theme-switch "square corners" bug. color-scheme is therefore
|
||||
set on <body>/controls below, NOT on :root, so it can't tint the canvas. */
|
||||
background-color: transparent;
|
||||
}
|
||||
|
||||
/* The rounded window surface lives HERE — on #root, which is always present —
|
||||
not on a per-screen container. Previously only the main app root was
|
||||
rounded, so the loading screen and the lock gate showed a SQUARE window
|
||||
until the main view mounted (the "square at login, rounds a few seconds
|
||||
after the password" bug). Rounding #root makes every screen rounded from the
|
||||
first paint. overflow:hidden clips children to the radius; bg-base fills it;
|
||||
outside the radius stays transparent so the corners show the desktop. */
|
||||
#root {
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
background: rgb(var(--bg-base));
|
||||
}
|
||||
|
||||
body {
|
||||
background: #0a0a0f;
|
||||
color: #e8e8f0;
|
||||
/* The rounded app root (App.tsx) carries the real bg so the window corners
|
||||
clip to transparency. Needs the translucent window surface set in main.go.
|
||||
color-scheme lives here (not :root) to theme native controls without
|
||||
forcing an opaque document canvas. */
|
||||
background: transparent;
|
||||
color-scheme: dark;
|
||||
color: rgb(var(--text-primary));
|
||||
font-family: 'IBM Plex Sans', sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
user-select: none;
|
||||
/* Content is selectable/copyable; interactive chrome opts out below. */
|
||||
user-select: text;
|
||||
-webkit-user-select: text;
|
||||
}
|
||||
|
||||
/* Force dark theme on all form elements — overrides system/browser defaults */
|
||||
/* Buttons, navigation and the title bar shouldn't be text-selectable —
|
||||
keeps the native app feel and avoids accidental drag-selection of UI. */
|
||||
button, [role="button"], nav, aside, .titlebar {
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgb(var(--accent-green) / 0.25);
|
||||
color: rgb(var(--text-primary));
|
||||
}
|
||||
|
||||
/* Themed form elements — color-scheme is set per theme on :root */
|
||||
input, textarea, select {
|
||||
background-color: #18181f;
|
||||
color: #e8e8f0;
|
||||
border-color: #252530;
|
||||
color-scheme: dark;
|
||||
background-color: rgb(var(--bg-raised));
|
||||
color: rgb(var(--text-primary));
|
||||
border-color: rgb(var(--bg-border));
|
||||
}
|
||||
|
||||
select {
|
||||
background-color: #18181f;
|
||||
color: #e8e8f0;
|
||||
background-color: rgb(var(--bg-raised));
|
||||
color: rgb(var(--text-primary));
|
||||
appearance: none;
|
||||
-webkit-appearance: none;
|
||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%2388889a' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'%3E%3C/polyline%3E%3C/svg%3E");
|
||||
|
|
@ -45,17 +142,17 @@
|
|||
}
|
||||
|
||||
select option {
|
||||
background-color: #18181f;
|
||||
color: #e8e8f0;
|
||||
background-color: rgb(var(--bg-raised));
|
||||
color: rgb(var(--text-primary));
|
||||
}
|
||||
|
||||
input[type="checkbox"] {
|
||||
accent-color: #00ff88;
|
||||
background-color: #18181f;
|
||||
accent-color: rgb(var(--accent-green));
|
||||
background-color: rgb(var(--bg-raised));
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: #44445a;
|
||||
color: rgb(var(--text-muted));
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
|
|
@ -66,11 +163,11 @@
|
|||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #252530;
|
||||
background: rgb(var(--bg-border));
|
||||
border-radius: 3px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #333344;
|
||||
background: rgb(var(--scrollbar-hover));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -135,6 +232,13 @@
|
|||
@apply text-xs font-medium uppercase tracking-widest text-text-muted;
|
||||
}
|
||||
|
||||
/* compact icon button for the Logcat visual map overlays */
|
||||
.map-btn {
|
||||
@apply inline-flex items-center justify-center h-7 w-7 rounded bg-black/40
|
||||
text-text-secondary border border-bg-border hover:bg-bg-raised
|
||||
hover:text-text-primary transition-colors cursor-pointer;
|
||||
}
|
||||
|
||||
.mono {
|
||||
@apply font-mono text-sm;
|
||||
}
|
||||
|
|
@ -142,7 +246,7 @@
|
|||
|
||||
/* Glow effect on accent elements */
|
||||
.glow {
|
||||
box-shadow: 0 0 12px rgba(0, 255, 136, 0.15);
|
||||
box-shadow: 0 0 12px rgb(var(--accent-green) / 0.15);
|
||||
}
|
||||
|
||||
@keyframes pulse-dot {
|
||||
|
|
@ -158,6 +262,6 @@
|
|||
animation: pulse-dot 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
.status-dot-green { background: #00ff88; box-shadow: 0 0 6px #00ff8888; }
|
||||
.status-dot-red { background: #ff4444; box-shadow: 0 0 6px #ff444488; }
|
||||
.status-dot-gray { background: #44445a; animation: none; }
|
||||
.status-dot-green { background: rgb(var(--accent-green)); box-shadow: 0 0 6px rgb(var(--accent-green) / 0.53); }
|
||||
.status-dot-red { background: rgb(var(--danger)); box-shadow: 0 0 6px rgb(var(--danger) / 0.53); }
|
||||
.status-dot-gray { background: rgb(var(--text-muted)); animation: none; }
|
||||
|
|
|
|||
|
|
@ -1,4 +1,9 @@
|
|||
/** @type {import('tailwindcss').Config} */
|
||||
// Colors are driven by CSS variables (RGB channels) defined per theme in
|
||||
// src/styles/global.css, so opacity modifiers like `bg-accent-green/5` keep
|
||||
// working. Switch themes by setting data-theme="dark|frappe|latte" on <html>.
|
||||
const rgbVar = (name) => `rgb(var(${name}) / <alpha-value>)`
|
||||
|
||||
export default {
|
||||
content: [
|
||||
"./index.html",
|
||||
|
|
@ -8,23 +13,23 @@ export default {
|
|||
extend: {
|
||||
colors: {
|
||||
bg: {
|
||||
base: '#0a0a0f',
|
||||
surface: '#111118',
|
||||
raised: '#18181f',
|
||||
border: '#252530',
|
||||
base: rgbVar('--bg-base'),
|
||||
surface: rgbVar('--bg-surface'),
|
||||
raised: rgbVar('--bg-raised'),
|
||||
border: rgbVar('--bg-border'),
|
||||
},
|
||||
accent: {
|
||||
green: '#00ff88',
|
||||
dim: '#00cc6a',
|
||||
muted: '#003322',
|
||||
green: rgbVar('--accent-green'),
|
||||
dim: rgbVar('--accent-dim'),
|
||||
muted: rgbVar('--accent-muted'),
|
||||
},
|
||||
text: {
|
||||
primary: '#e8e8f0',
|
||||
secondary: '#8888aa',
|
||||
muted: '#44445a',
|
||||
primary: rgbVar('--text-primary'),
|
||||
secondary: rgbVar('--text-secondary'),
|
||||
muted: rgbVar('--text-muted'),
|
||||
},
|
||||
danger: '#ff4444',
|
||||
warn: '#ffaa00',
|
||||
danger: rgbVar('--danger'),
|
||||
warn: rgbVar('--warn'),
|
||||
},
|
||||
fontFamily: {
|
||||
mono: ['"JetBrains Mono"', '"Fira Code"', 'monospace'],
|
||||
|
|
|
|||
|
|
@ -1,8 +1,48 @@
|
|||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import obfuscator from 'vite-plugin-javascript-obfuscator'
|
||||
|
||||
// The Logcat visual map is PROPRIETARY. Wails embeds the compiled frontend JS into
|
||||
// the release binary (//go:embed all:frontend/dist), so without this the map's logic
|
||||
// would ship in readable form. We obfuscate ONLY the map files (engine + renderers),
|
||||
// and only in production builds (`apply: 'build'`, so `wails dev` stays debuggable).
|
||||
// Settings are deliberately moderate: NO control-flow-flattening / self-defending
|
||||
// (would wreck the 60fps render loop) and NO transformObjectKeys (the GraphConfig
|
||||
// object is accessed across files — renaming its keys would break the app). What we
|
||||
// DO get: local identifiers renamed + every string literal encoded into a base64
|
||||
// string-array, so the algorithms aren't human-readable in the shipped bundle.
|
||||
const MAP_FILES = [
|
||||
'src/lib/logcatgraph.ts',
|
||||
'src/components/views/LogcatMap.tsx',
|
||||
]
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
plugins: [
|
||||
react(),
|
||||
obfuscator({
|
||||
apply: 'build',
|
||||
include: MAP_FILES,
|
||||
exclude: [/node_modules/],
|
||||
options: {
|
||||
compact: true,
|
||||
controlFlowFlattening: false,
|
||||
deadCodeInjection: false,
|
||||
debugProtection: false,
|
||||
selfDefending: false,
|
||||
renameGlobals: false,
|
||||
transformObjectKeys: false,
|
||||
identifierNamesGenerator: 'hexadecimal',
|
||||
numbersToExpressions: true,
|
||||
simplify: true,
|
||||
stringArray: true,
|
||||
stringArrayEncoding: ['base64'],
|
||||
stringArrayThreshold: 1, // encode EVERY string (deterministic; no stray plaintext labels)
|
||||
splitStrings: true,
|
||||
splitStringsChunkLength: 8,
|
||||
unicodeEscapeSequence: false,
|
||||
},
|
||||
}),
|
||||
],
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue