diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d029b8..ae4eaaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -288,6 +288,7 @@ jobs: GOARCH: ${{ matrix.goarch }} CGO_ENABLED: 1 + COMMIT_MESSAGE: ${{ github.event.head_commit.message }} @@ -296,7 +297,17 @@ jobs: cd GUI - FLAGS="-s -w" + VERSION="dev" + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + VERSION="$GITHUB_REF_NAME" + else + RELEASE_VERSION="$(printf '%s' "$COMMIT_MESSAGE" | sed -nE 's/.*\[release (v[0-9]+\.[0-9]+\.[0-9]+)\].*/\1/p' | head -n1)" + if [ -n "$RELEASE_VERSION" ]; then + VERSION="$RELEASE_VERSION" + fi + fi + + FLAGS="-s -w -X main.version=$VERSION" if [ "${{ matrix.goos }}" = "windows" ]; then @@ -358,7 +369,11 @@ jobs: needs: multi-platform-build - if: startsWith(github.ref,'refs/tags/v') + if: >- + startsWith(github.ref, 'refs/tags/v') || + (github.event_name == 'push' && + github.ref == 'refs/heads/main' && + contains(github.event.head_commit.message, '[release v')) runs-on: ubuntu-latest @@ -389,12 +404,50 @@ jobs: -exec cp {} release-assets/ \; + - name: Resolve release tag + + id: release-tag + + shell: bash + + env: + + COMMIT_MESSAGE: ${{ github.event.head_commit.message }} + + run: | + + if [[ "$GITHUB_REF" == refs/tags/* ]]; then + + RELEASE_TAG="$GITHUB_REF_NAME" + + else + + RELEASE_TAG="$(printf '%s' "$COMMIT_MESSAGE" | sed -nE 's/.*\[release (v[0-9]+\.[0-9]+\.[0-9]+)\].*/\1/p' | head -n1)" + + fi + + if [[ -z "$RELEASE_TAG" ]]; then + + echo "Could not resolve a release tag from this commit." >&2 + + exit 1 + + fi + + echo "tag=$RELEASE_TAG" >> "$GITHUB_OUTPUT" + + + - name: Create Release uses: softprops/action-gh-release@v2 with: + tag_name: ${{ steps.release-tag.outputs.tag }} + + target_commitish: ${{ github.sha }} + files: release-assets/* generate_release_notes: true diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e548d4..7a9868d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,6 @@ # Changelog -## Unreleased +## [v5.7.0] - 2026-10-31 ### Changed @@ -8,6 +8,8 @@ - Reworked the GUI from a single APK installer window into a device-centric shell with Devices, Screen, Apps, Logs, Deploy, Workflows, Diagnostics, and Help workspaces. - Introduced typed domain models and a process-runner abstraction for ADB/scrcpy operations. - Rewrote documentation to match the desktop product direction. +- Rebuilt the workspace shell with a draggable Fyne split view, independently scrollable sidebar, consistent page insets, active navigation, and safer window presets. +- Reworked crowded action rows to wrap naturally at narrower window widths and kept long device labels and status messages from forcing the layout wider. ### Added @@ -17,6 +19,9 @@ - APK inspection with zip validation, SHA-256, size, and optional `aapt` badging metadata. - Apps workspace with searchable user-app list and actions for launch, force-stop, clear data, and uninstall. - Logs workspace with live `logcat` streaming, filtering, pause/resume, and clear behavior. +- Export of filtered logcat entries and a live-follow toggle so older log entries can be inspected while streaming continues. +- Keyboard shortcuts for command palette, device refresh, sidebar visibility, and fullscreen/windowed mode. +- Clearer Apps and Deploy workspaces with package counts, an APK install shortcut, step progress, and recently used APKs. - Deploy workflow backed by an extensible Flow engine. - Diagnostics/Doctor checks for ADB, ADB server, devices/authorization, and scrcpy. - First-launch onboarding and Help & Tutorials content. diff --git a/GUI/main.go b/GUI/main.go index eb4ca87..58acf36 100644 --- a/GUI/main.go +++ b/GUI/main.go @@ -25,14 +25,22 @@ type desktopUI struct { win fyne.Window core *App - deviceRefreshMu sync.Mutex - devices []Device - selectedSerial string - deviceSelect *widget.Select - status *widget.Label - activity *widget.ProgressBarInfinite - contentTitle *widget.Label - content *fyne.Container + deviceRefreshMu sync.Mutex + devices []Device + selectedSerial string + deviceSelect *widget.Select + deviceOptionSerial map[string]string + status *widget.Label + activity *widget.ProgressBarInfinite + contentTitle *widget.Label + contentScroll *container.Scroll + navButtons map[string]*widget.Button + sidebarScroll *container.Scroll + mainSplit *container.Split + sidebarToggle *widget.Button + fullscreenButton *widget.Button + windowSizeSelect *widget.Select + windowedSize fyne.Size monitorCancel context.CancelFunc logCancel context.CancelFunc @@ -42,6 +50,7 @@ type desktopUI struct { logFilter *widget.Entry logLevel *widget.Select logPaused bool + logAutoScroll bool lastLogRefresh time.Time } @@ -50,9 +59,9 @@ func main() { core := NewApp() applyTheme(fyneApp, core.State().Theme) win := fyneApp.NewWindow("AdbPureFlow " + version) - win.Resize(fyne.NewSize(1120, 760)) + win.Resize(fyne.NewSize(1180, 700)) - ui := &desktopUI{fyneApp: fyneApp, win: win, core: core} + ui := &desktopUI{fyneApp: fyneApp, win: win, core: core, windowedSize: fyne.NewSize(1180, 700), logAutoScroll: true} win.SetContent(ui.buildShell()) ui.setupShortcuts() win.SetOnClosed(func() { @@ -83,47 +92,93 @@ func main() { func (ui *desktopUI) buildShell() fyne.CanvasObject { ui.status = widget.NewLabel("Ready") + ui.status.Truncation = fyne.TextTruncateEllipsis + ui.status.Wrapping = fyne.TextWrapOff ui.activity = widget.NewProgressBarInfinite() ui.activity.Hide() ui.contentTitle = widget.NewLabelWithStyle("Devices", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) - ui.content = container.NewMax() - ui.deviceSelect = widget.NewSelect([]string{"No devices"}, func(label string) { - for _, d := range ui.devices { - if label == d.Label() { - ui.selectedSerial = d.Serial - _ = ui.core.SaveState(func(state *AppState) { state.SelectedSerial = d.Serial }) - ui.setStatus("Selected "+d.Label(), false) - ui.showDevices() - return + + ui.contentScroll = container.NewVScroll(widget.NewLabel("Select a workspace")) + ui.contentScroll.Direction = container.ScrollVerticalOnly + + ui.deviceOptionSerial = make(map[string]string) + ui.deviceSelect = widget.NewSelect([]string{"No devices"}, func(option string) { + serial, ok := ui.deviceOptionSerial[option] + if !ok || serial == "" { + return + } + for _, device := range ui.devices { + if device.Serial != serial { + continue } + ui.selectedSerial = device.Serial + _ = ui.core.SaveState(func(state *AppState) { state.SelectedSerial = device.Serial }) + ui.setStatus("Selected "+compactDeviceLabelWithAlias(device, ui.core.State().DeviceAliases[device.Serial]), false) + ui.showDevices() + return } }) ui.deviceSelect.PlaceHolder = "Select device" - top := container.NewBorder(nil, nil, + brand := container.NewVBox( widget.NewLabelWithStyle(appName, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), - container.NewHBox( - widget.NewButtonWithIcon("Add Wireless", theme.ContentAddIcon(), ui.showWirelessDialog), - widget.NewButtonWithIcon("Refresh", theme.ViewRefreshIcon(), func() { go ui.refreshDevices() }), - ), - container.NewBorder(nil, nil, widget.NewLabel("Device:"), nil, ui.deviceSelect), + widget.NewLabel("Android device workstation"), ) + ui.sidebarToggle = widget.NewButtonWithIcon("Hide sidebar", theme.MenuIcon(), ui.toggleSidebar) + ui.fullscreenButton = widget.NewButtonWithIcon("Full screen", theme.ViewFullScreenIcon(), ui.toggleFullscreen) + headerActions := container.NewHBox(ui.sidebarToggle, ui.fullscreenButton) + headerTop := container.NewBorder(nil, nil, brand, headerActions) + deviceActions := container.NewHBox( + widget.NewButtonWithIcon("Add Wireless", theme.ContentAddIcon(), ui.showWirelessDialog), + widget.NewButtonWithIcon("Refresh", theme.ViewRefreshIcon(), func() { go ui.refreshDevices() }), + ) + deviceRow := container.NewBorder(nil, nil, widget.NewLabel("DEVICE"), deviceActions, ui.deviceSelect) + top := withPadding(container.NewVBox(headerTop, deviceRow), 16, 8) + + ui.windowSizeSelect = widget.NewSelect([]string{ + "Compact · 1100 × 720", + "Default · 1180 × 700", + "Wide · 1260 × 700", + }, ui.setWindowSize) + ui.windowSizeSelect.Selected = "Default · 1180 × 700" + ui.windowSizeSelect.Refresh() + + ui.navButtons = make(map[string]*widget.Button) + makeNavButton := func(name string, icon fyne.Resource, action func()) *widget.Button { + button := widget.NewButtonWithIcon(name, icon, action) + button.Importance = widget.MediumImportance + ui.navButtons[name] = button + return button + } nav := container.NewVBox( - widget.NewButtonWithIcon("Devices", theme.ComputerIcon(), ui.showDevices), - widget.NewButtonWithIcon("Screen", theme.MediaPlayIcon(), ui.showScreen), - widget.NewButtonWithIcon("Apps", theme.FileIcon(), ui.showApps), - widget.NewButtonWithIcon("Logs", theme.FileIcon(), ui.showLogs), - widget.NewButtonWithIcon("Deploy", theme.FileIcon(), ui.showDeploy), - widget.NewButtonWithIcon("Workflows", theme.SettingsIcon(), ui.showWorkflows), + widget.NewLabelWithStyle("WORKSPACE", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + makeNavButton("Devices", theme.ComputerIcon(), ui.showDevices), + makeNavButton("Screen", theme.MediaPlayIcon(), ui.showScreen), + makeNavButton("Apps", theme.FileIcon(), ui.showApps), + makeNavButton("Logs", theme.DocumentIcon(), ui.showLogs), + makeNavButton("Deploy", theme.UploadIcon(), ui.showDeploy), + makeNavButton("Workflows", theme.ListIcon(), ui.showWorkflows), widget.NewSeparator(), - widget.NewButtonWithIcon("Settings", theme.SettingsIcon(), ui.showSettings), - widget.NewButtonWithIcon("Diagnostics", theme.WarningIcon(), ui.showDiagnostics), - widget.NewButtonWithIcon("Help", theme.HelpIcon(), ui.showHelp), + widget.NewLabelWithStyle("TOOLS", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), + makeNavButton("Settings", theme.SettingsIcon(), ui.showSettings), + makeNavButton("Diagnostics", theme.WarningIcon(), ui.showDiagnostics), + makeNavButton("Help", theme.HelpIcon(), ui.showHelp), + ) + ui.sidebarScroll = container.NewVScroll(withPadding(nav, 12, 16)) + ui.sidebarScroll.Direction = container.ScrollVerticalOnly + + center := container.NewBorder( + withPadding(ui.contentTitle, 18, 8), + nil, nil, nil, + ui.contentScroll, ) - center := container.NewBorder(container.NewPadded(ui.contentTitle), nil, nil, nil, ui.content) - bottom := container.NewBorder(nil, nil, ui.status, nil, ui.activity) - shell := container.NewBorder(top, bottom, nav, nil, center) + ui.mainSplit = container.NewHSplit(ui.sidebarScroll, center) + ui.mainSplit.SetOffset(0.23) + + bottom := withPadding(container.NewBorder(nil, nil, ui.status, nil, ui.activity), 16, 6) + shell := container.NewBorder(top, bottom, nil, nil, ui.mainSplit) + ui.showDevices() return shell } @@ -141,11 +196,25 @@ func (ui *desktopUI) setupShortcuts() { } go ui.refreshDevices() }) + ui.win.Canvas().AddShortcut(&desktop.CustomShortcut{KeyName: fyne.KeyB, Modifier: fyne.KeyModifierControl}, func(fyne.Shortcut) { + if _, editing := ui.win.Canvas().Focused().(*widget.Entry); editing { + return + } + ui.toggleSidebar() + }) + ui.win.Canvas().AddShortcut(&desktop.CustomShortcut{KeyName: fyne.KeyF11}, func(fyne.Shortcut) { + ui.toggleFullscreen() + }) + ui.win.Canvas().AddShortcut(&desktop.CustomShortcut{KeyName: fyne.KeyEscape}, func(fyne.Shortcut) { + if ui.win.FullScreen() { + ui.toggleFullscreen() + } + }) } -func (ui *desktopUI) setStatus(text string, busy bool) { +func (ui *desktopUI) setStatus(message string, busy bool) { fyne.Do(func() { - ui.status.SetText(text) + ui.status.SetText(strings.TrimSpace(message)) if busy { ui.activity.Show() } else { @@ -172,11 +241,172 @@ func (ui *desktopUI) notify(title, message string) { func (ui *desktopUI) replaceContent(title string, obj fyne.CanvasObject) { fyne.Do(func() { ui.contentTitle.SetText(title) - ui.content.Objects = []fyne.CanvasObject{obj} - ui.content.Refresh() + + active := title + if active == "Help & Tutorials" { + active = "Help" + } + for name, button := range ui.navButtons { + button.Importance = widget.MediumImportance + if name == active { + button.Importance = widget.HighImportance + } + button.Refresh() + } + + // One vertical scroll viewport handles long form-like pages. Collection + // widgets such as List retain their own internal scrolling behavior. + if title == "Logs" { + // widget.List already owns its scroll behavior; do not nest a page + // scroll container around a virtualized collection. + ui.contentScroll.Direction = container.ScrollNone + } else { + ui.contentScroll.Direction = container.ScrollVerticalOnly + } + ui.contentScroll.Content = withPadding(obj, 18, 14) + ui.contentScroll.ScrollToTop() + ui.contentScroll.Refresh() }) } +func (ui *desktopUI) toggleSidebar() { + if ui.sidebarScroll == nil || ui.mainSplit == nil { + return + } + if ui.sidebarScroll.Visible() { + ui.sidebarScroll.Hide() + ui.sidebarToggle.SetText("Show sidebar") + } else { + ui.sidebarScroll.Show() + ui.sidebarToggle.SetText("Hide sidebar") + } + ui.mainSplit.Refresh() +} + +func (ui *desktopUI) setWindowSize(value string) { + var size fyne.Size + switch value { + case "Compact · 1100 × 720": + size = fyne.NewSize(1100, 720) + case "Wide · 1260 × 700": + size = fyne.NewSize(1260, 700) + default: + size = fyne.NewSize(1180, 700) + } + + if ui.win.FullScreen() { + ui.win.SetFullScreen(false) + ui.fullscreenButton.SetText("Full screen") + ui.windowSizeSelect.Enable() + } + ui.windowedSize = size + ui.win.Resize(size) + ui.win.CenterOnScreen() +} + +func (ui *desktopUI) toggleFullscreen() { + if ui.win.FullScreen() { + ui.win.SetFullScreen(false) + ui.win.Resize(ui.windowedSize) + ui.win.CenterOnScreen() + ui.fullscreenButton.SetText("Full screen") + ui.windowSizeSelect.Enable() + return + } + + current := ui.win.Canvas().Size() + if current.Width >= 640 && current.Height >= 480 { + ui.windowedSize = current + } + ui.win.SetFullScreen(true) + ui.fullscreenButton.SetText("Windowed") + ui.windowSizeSelect.Disable() +} + +func (ui *desktopUI) syncDeviceSelect(devices []Device, preferredSerial string) string { + options := make([]string, 0, len(devices)) + ui.deviceOptionSerial = make(map[string]string, len(devices)) + for _, device := range devices { + base := compactDeviceLabelWithAlias(device, ui.core.State().DeviceAliases[device.Serial]) + option := base + for suffix := 2; ; suffix++ { + serial, exists := ui.deviceOptionSerial[option] + if !exists || serial == device.Serial { + break + } + option = fmt.Sprintf("%s (%d)", base, suffix) + } + options = append(options, option) + ui.deviceOptionSerial[option] = device.Serial + } + if len(options) == 0 { + options = []string{"No devices"} + } + + selected := ReconcileSelectedDevice(preferredSerial, ui.core.State().SelectedSerial, devices) + ui.deviceSelect.Options = options + ui.deviceSelect.Selected = "" + for _, option := range options { + if ui.deviceOptionSerial[option] == selected && selected != "" { + ui.deviceSelect.Selected = option + break + } + } + ui.deviceSelect.Refresh() + return selected +} + +func (ui *desktopUI) deviceOptionForSerial(serial string) string { + for option, mappedSerial := range ui.deviceOptionSerial { + if mappedSerial == serial { + return option + } + } + return "" +} + +func compactDeviceLabel(device Device) string { + return compactDeviceLabelWithAlias(device, "") +} + +func compactDeviceLabelWithAlias(device Device, alias string) string { + name := strings.TrimSpace(alias) + if name == "" { + name = strings.TrimSpace(device.DisplayName) + } + if name == "" { + name = strings.TrimSpace(device.Manufacturer + " " + device.Model) + } + if name == "" { + name = "Android device" + } + name = shortenText(name, 18) + + serial := []rune(device.Serial) + if len(serial) > 6 { + serial = serial[len(serial)-6:] + } + if len(serial) == 0 { + return name + } + return fmt.Sprintf("%s · …%s", name, string(serial)) +} + +func shortenText(value string, limit int) string { + runes := []rune(strings.TrimSpace(value)) + if limit <= 0 || len(runes) <= limit { + return string(runes) + } + if limit == 1 { + return "…" + } + return string(runes[:limit-1]) + "…" +} + +func wrapActions(objects ...fyne.CanvasObject) fyne.CanvasObject { + return container.NewGridWrap(fyne.NewSize(208, 40), objects...) +} + func (ui *desktopUI) selectedDevice() (Device, bool) { for _, d := range ui.devices { if d.Serial == ui.selectedSerial { @@ -198,22 +428,7 @@ func (ui *desktopUI) requireDevice() (Device, bool) { func (ui *desktopUI) applyDevices(devices []Device) { fyne.Do(func() { ui.devices = devices - labels := []string{} - for _, d := range devices { - labels = append(labels, d.Label()) - } - if len(labels) == 0 { - labels = []string{"No devices"} - } - ui.deviceSelect.Options = labels - ui.deviceSelect.Refresh() - ui.selectedSerial = ReconcileSelectedDevice(ui.selectedSerial, ui.core.State().SelectedSerial, devices) - for _, d := range devices { - if d.Serial == ui.selectedSerial { - ui.deviceSelect.SetSelected(d.Label()) - break - } - } + ui.selectedSerial = ui.syncDeviceSelect(devices, ui.selectedSerial) }) } @@ -286,21 +501,15 @@ func (ui *desktopUI) refreshDevicesPassive(ctx context.Context) { } fyne.Do(func() { previous := ui.selectedSerial - ui.devices = devices - labels := []string{} selectedStillOnline := false - for _, d := range devices { - labels = append(labels, d.Label()) - if d.Serial == previous && d.Status == DeviceOnline { + for _, device := range devices { + if device.Serial == previous && device.Status == DeviceOnline { selectedStillOnline = true + break } } - if len(labels) == 0 { - labels = []string{"No devices"} - } - ui.deviceSelect.Options = labels - ui.deviceSelect.Refresh() - ui.selectedSerial = ReconcileSelectedDevice(previous, ui.core.State().SelectedSerial, devices) + ui.devices = devices + ui.selectedSerial = ui.syncDeviceSelect(devices, previous) if previous != "" && !selectedStillOnline { ui.setStatus("Selected device disconnected or unavailable", false) } @@ -308,45 +517,71 @@ func (ui *desktopUI) refreshDevicesPassive(ctx context.Context) { } func (ui *desktopUI) showDevices() { - rows := container.NewVBox() + body := container.NewVBox( + widget.NewRichTextFromMarkdown("### Your Android devices\nConnect over USB or Wireless debugging. Select one device to use it across Screen, Apps, Logs, and Deploy."), + ) + if len(ui.devices) == 0 { - if ui.selectedSerial != "" { - rows.Add(widget.NewLabel("Previously selected device is unavailable: " + ui.selectedSerial)) - } - rows.Add(widget.NewLabel("No devices yet. Connect USB with debugging enabled, or use Add Wireless for Android 11+ Wireless debugging.")) + detail := widget.NewLabel("Connect your phone with a data-capable USB cable and accept the debugging prompt, or pair a device over Android 11+ Wireless debugging.") + actions := wrapActions( + widget.NewButtonWithIcon("Add Wireless Device", theme.ContentAddIcon(), ui.showWirelessDialog), + widget.NewButtonWithIcon("Refresh Devices", theme.ViewRefreshIcon(), func() { go ui.refreshDevices() }), + ) + body.Add(widget.NewCard("No Android device connected", "Your workspace is ready when a device appears here.", container.NewVBox(detail, actions))) } else { - for _, d := range ui.devices { - dev := d - status := fmt.Sprintf("%s • %s • Android %s • Battery %s • Storage %s", d.Transport, d.Status, fallback(d.AndroidVersion, "unknown"), fallback(d.BatteryPercent, "unknown"), fallback(d.StorageSummary, "unknown")) - name := d.Label() - if alias := ui.core.State().DeviceAliases[d.Serial]; alias != "" { - name = alias + " — " + d.Serial + for _, device := range ui.devices { + dev := device + name := compactDeviceLabel(dev) + if alias := ui.core.State().DeviceAliases[dev.Serial]; alias != "" { + name = shortenText(alias, 32) } - selectedMark := "" - if d.Serial == ui.selectedSerial { - selectedMark = "Selected • " + status := fmt.Sprintf("%s · %s · Android %s · Battery %s · Storage %s", + dev.Transport, dev.Status, + fallback(dev.AndroidVersion, "unknown"), + fallback(dev.BatteryPercent, "unknown"), + fallback(dev.StorageSummary, "unknown"), + ) + serial := dev.Serial + selectText := "Select device" + if dev.Serial == ui.selectedSerial { + selectText = "Currently selected" + } + actions := wrapActions( + widget.NewButton(selectText, func() { + ui.selectedSerial = dev.Serial + _ = ui.core.SaveState(func(state *AppState) { state.SelectedSerial = dev.Serial }) + if option := ui.deviceOptionForSerial(dev.Serial); option != "" { + ui.deviceSelect.Selected = option + ui.deviceSelect.Refresh() + } + ui.setStatus("Selected "+dev.Label(), false) + ui.showDevices() + }), + widget.NewButton("Rename", func() { ui.renameDevice(dev.Serial) }), + widget.NewButton("Disconnect", func() { go ui.disconnectDevice(dev.Serial) }), + widget.NewButton("Copy serial", func() { + ui.win.Clipboard().SetContent(serial) + ui.setStatus("Copied device serial", false) + }), + ) + subtitle := status + if dev.Serial == ui.selectedSerial { + subtitle = "ACTIVE DEVICE · " + subtitle } - rows.Add(container.NewBorder(nil, nil, nil, - container.NewHBox( - widget.NewButton("Select", func() { - ui.selectedSerial = dev.Serial - _ = ui.core.SaveState(func(state *AppState) { state.SelectedSerial = dev.Serial }) - ui.deviceSelect.SetSelected(dev.Label()) - }), - widget.NewButton("Rename", func() { ui.renameDevice(dev.Serial) }), - widget.NewButton("Disconnect", func() { go ui.disconnectDevice(dev.Serial) }), - ), - container.NewVBox(widget.NewLabelWithStyle(name, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabel(selectedMark+status)))) - rows.Add(widget.NewSeparator()) + body.Add(widget.NewCard(name, subtitle, container.NewVBox( + widget.NewLabelWithStyle("Serial: "+serial, fyne.TextAlignLeading, fyne.TextStyle{Monospace: true}), + actions, + ))) } } + if endpoints := ui.core.State().WirelessEndpoints; len(endpoints) > 0 { - rows.Add(widget.NewRichTextFromMarkdown("### Known wireless endpoints")) + body.Add(widget.NewRichTextFromMarkdown("### Saved wireless devices\nReconnect a previously paired endpoint or remove it from this list.")) for _, endpoint := range endpoints { addr := endpoint connected := false - for _, d := range ui.devices { - if d.Serial == addr { + for _, device := range ui.devices { + if device.Serial == addr { connected = true break } @@ -355,14 +590,15 @@ func (ui *desktopUI) showDevices() { if connected { state = "Connected" } - rows.Add(container.NewBorder(nil, nil, widget.NewLabel(addr+" — "+state), container.NewHBox( + actions := wrapActions( widget.NewButton("Reconnect", func() { go ui.reconnectWireless(addr) }), widget.NewButton("Forget", func() { ui.forgetWireless(addr) }), - ), nil)) + ) + body.Add(widget.NewCard(addr, state, actions)) } } - intro := widget.NewRichTextFromMarkdown("### Device workstation\nSelect a device once; Screen, Apps, Logs, Deploy, and Workflows use that same target. Unauthorized devices need you to accept Android's debugging prompt on the phone.") - ui.replaceContent("Devices", container.NewVScroll(container.NewVBox(intro, rows))) + + ui.replaceContent("Devices", body) } func (ui *desktopUI) renameDevice(serial string) { @@ -485,8 +721,9 @@ func (ui *desktopUI) showScreen() { d, ok := ui.selectedDevice() deviceText := "No device selected" if ok { - deviceText = d.Label() + deviceText = compactDeviceLabelWithAlias(d, ui.core.State().DeviceAliases[d.Serial]) } + always := widget.NewCheck("Keep mirror window on top", nil) fullscreen := widget.NewCheck("Start fullscreen", nil) quality := widget.NewSelect([]string{"Native", "1080p", "720p"}, nil) @@ -551,9 +788,7 @@ func (ui *desktopUI) showScreen() { }() } - start := widget.NewButtonWithIcon("Start Mirror", theme.MediaPlayIcon(), func() { - startWithOptions("") - }) + start := widget.NewButtonWithIcon("Start Mirror", theme.MediaPlayIcon(), func() { startWithOptions("") }) record := widget.NewButton("Start Mirror + Record…", func() { save := dialog.NewFileSave(func(w fyne.URIWriteCloser, err error) { if err != nil || w == nil { @@ -571,19 +806,23 @@ func (ui *desktopUI) showScreen() { if d, ok := ui.selectedDevice(); ok { ui.core.scrcpy.StopMirror(d.Serial) ui.setStatus("Mirror stopped", false) + mirrorState.SetText("Mirror stop requested.") } }) screenshot := widget.NewButtonWithIcon("Capture Screenshot…", theme.DocumentSaveIcon(), ui.saveScreenshot) - body := container.NewVBox( - widget.NewRichTextFromMarkdown("### Screen\nAdbPureFlow manages scrcpy as an intentional Windows companion window, not an embedded web view. That keeps mouse, keyboard, fullscreen, rotation, and recording behavior aligned with scrcpy itself."), - widget.NewLabel("Target: "+deviceText), - mirrorState, - widget.NewForm(widget.NewFormItem("Size", quality), widget.NewFormItem("Bit rate", bitrate)), + + intro := widget.NewCard("Screen mirroring", "Powered by scrcpy in a native companion window.", widget.NewLabel("Mouse, keyboard, fullscreen, rotation, and recording stay in scrcpy itself. AdbPureFlow manages the session and its lifecycle.")) + target := widget.NewCard("Selected device", deviceText, widget.NewLabel("The selected device is shared with the other workspaces.")) + options := widget.NewCard("Mirror options", "Tune the session before starting.", container.NewVBox( + widget.NewForm(widget.NewFormItem("Resolution", quality), widget.NewFormItem("Video bit rate", bitrate)), always, fullscreen, - container.NewHBox(start, record, stop, screenshot), - ) - ui.replaceContent("Screen", body) + )) + controls := widget.NewCard("Session controls", "Start, stop, record, or capture the current device screen.", container.NewVBox( + mirrorState, + wrapActions(start, record, stop, screenshot), + )) + ui.replaceContent("Screen", container.NewVBox(intro, target, options, controls)) } func (ui *desktopUI) saveScreenshot() { @@ -712,7 +951,7 @@ func (ui *desktopUI) showAPKInstall(meta APKMetadata) { ui.showInfo("Install complete", fallback(msg, "APK installed successfully.")) }() }) - ui.replaceContent("Install APK", container.NewVScroll(container.NewVBox(widget.NewRichTextFromMarkdown("### APK workflow\n"+strings.Join(lines, "\n\n")), clearData, launch, install))) + ui.replaceContent("Install APK", container.NewVBox(widget.NewRichTextFromMarkdown("### APK workflow\n"+strings.Join(lines, "\n\n")), clearData, launch, install)) } func (ui *desktopUI) showApps() { @@ -720,52 +959,91 @@ func (ui *desktopUI) showApps() { if !ok { return } + filter := widget.NewEntry() - filter.SetPlaceHolder("Search packages…") + filter.SetPlaceHolder("Search by package name…") showSystem := widget.NewCheck("Include system apps", nil) + countLabel := widget.NewLabel("Loading apps…") listBox := container.NewVBox(widget.NewLabel("Loading apps…")) + var loaded []PackageInfo + render := func(pkgs []PackageInfo) { - q := strings.ToLower(filter.Text) + query := strings.ToLower(strings.TrimSpace(filter.Text)) listBox.Objects = nil - for _, p := range pkgs { - if q != "" && !strings.Contains(strings.ToLower(p.PackageName), q) { + visible := 0 + for _, app := range pkgs { + if query != "" && !strings.Contains(strings.ToLower(app.PackageName), query) { continue } - pkg := p.PackageName + pkg := app.PackageName kind := "User app" - if !p.UserApp { + if !app.UserApp { kind = "System app" } - listBox.Add(container.NewBorder(nil, nil, nil, container.NewHBox( + actions := wrapActions( widget.NewButton("Launch", func() { go ui.packageAction("Launch", pkg, ui.core.adb.Launch) }), - widget.NewButton("Stop", func() { go ui.packageAction("Force stop", pkg, ui.core.adb.ForceStop) }), - widget.NewButton("Clear", func() { + widget.NewButton("Force stop", func() { go ui.packageAction("Force stop", pkg, ui.core.adb.ForceStop) }), + widget.NewButton("Clear data", func() { ui.confirmPackageAction("Clear data", pkg, "This removes local app data on the selected Android device.", ui.core.adb.ClearData) }), widget.NewButton("Uninstall", func() { ui.confirmPackageAction("Uninstall", pkg, "This removes the app from the selected Android device.", ui.core.adb.Uninstall) }), - widget.NewButton("Copy", func() { ui.win.Clipboard().SetContent(pkg); ui.setStatus("Copied package name", false) }), - ), container.NewVBox(widget.NewLabelWithStyle(pkg, fyne.TextAlignLeading, fyne.TextStyle{Monospace: true}), widget.NewLabel(kind+" • "+fallback(p.APKPath, "installed package"))))) - listBox.Add(widget.NewSeparator()) + widget.NewButton("Copy package", func() { + ui.win.Clipboard().SetContent(pkg) + ui.setStatus("Copied package name", false) + }), + ) + packageLabel := widget.NewLabel(pkg) + packageLabel.Wrapping = fyne.TextWrapBreak + listBox.Add(widget.NewCard(shortenText(pkg, 36), kind, container.NewVBox( + packageLabel, + widget.NewLabel(fallback(app.APKPath, "Installed package")), + actions, + ))) + visible++ + } + + countLabel.SetText(fmt.Sprintf("%d app(s)", visible)) + if visible == 0 { + message := "No apps are installed on this device." + if len(pkgs) > 0 { + message = "No packages match your search. Try another package name or clear the filter." + } + listBox.Add(widget.NewCard("No matching apps", message, widget.NewLabel("Change the search or refresh the app list."))) } listBox.Refresh() } - filter.OnChanged = func(string) {} + + filter.OnChanged = func(string) { render(loaded) } load := func(includeSystem bool) { - listBox.Objects = []fyne.CanvasObject{widget.NewLabel("Loading apps…")} + loaded = nil + countLabel.SetText("Loading apps…") + listBox.Objects = []fyne.CanvasObject{widget.NewLabel("Loading installed apps…")} listBox.Refresh() go func() { - pkgs, err := ui.core.adb.Packages(context.Background(), dev.Serial, !includeSystem) + packages, err := ui.core.adb.Packages(context.Background(), dev.Serial, !includeSystem) if err != nil { ui.showError(err) return } - fyne.Do(func() { render(pkgs); filter.OnChanged = func(string) { render(pkgs) } }) + fyne.Do(func() { + loaded = packages + render(loaded) + }) }() } showSystem.OnChanged = func(include bool) { load(include) } - ui.replaceContent("Apps", container.NewBorder(container.NewVBox(filter, showSystem), nil, nil, nil, container.NewVScroll(listBox))) + + install := widget.NewButtonWithIcon("Install APK…", theme.ContentAddIcon(), func() { ui.chooseAPK(ui.inspectAPK) }) + refresh := widget.NewButtonWithIcon("Refresh", theme.ViewRefreshIcon(), func() { load(showSystem.Checked) }) + toolbar := wrapActions(showSystem, install, refresh, countLabel) + header := container.NewVBox( + widget.NewRichTextFromMarkdown("### Installed applications\nSearch packages and run actions on the selected Android device. Destructive actions ask for confirmation."), + filter, + toolbar, + ) + ui.replaceContent("Apps", container.NewVBox(header, listBox)) load(false) } @@ -815,29 +1093,122 @@ func (ui *desktopUI) showLogs() { level.SetSelected("All levels") ui.logFilter = filter ui.logLevel = level - ui.logList = widget.NewList(func() int { return len(ui.filteredLogs()) }, func() fyne.CanvasObject { return widget.NewLabel("template") }, func(id widget.ListItemID, obj fyne.CanvasObject) { + ui.logList = widget.NewList(func() int { return len(ui.filteredLogs()) }, func() fyne.CanvasObject { + label := widget.NewLabel("Log entry") + label.Wrapping = fyne.TextWrapWord + return label + }, func(id widget.ListItemID, obj fyne.CanvasObject) { entries := ui.filteredLogs() if id < len(entries) { obj.(*widget.Label).SetText(formatLog(entries[id])) } }) - start := widget.NewButton("Start", func() { ui.startLogs() }) - stop := widget.NewButton("Stop", func() { ui.stopLogs(); ui.setStatus("Log stream stopped", false) }) - pause := widget.NewButton("Pause/Resume", func() { - ui.logPaused = !ui.logPaused - ui.setStatus(map[bool]string{true: "Logs paused", false: "Logs resumed"}[ui.logPaused], false) + + start := widget.NewButtonWithIcon("Start", theme.MediaPlayIcon(), func() { ui.startLogs() }) + stop := widget.NewButtonWithIcon("Stop", theme.MediaStopIcon(), func() { + ui.stopLogs() + ui.setStatus("Log stream stopped", false) }) - clear := widget.NewButton("Clear Device Log", func() { - if d, ok := ui.requireDevice(); ok { - go ui.core.adb.ClearLogcat(context.Background(), d.Serial) + var pause *widget.Button + pause = widget.NewButton("Pause", func() { + ui.logMu.Lock() + ui.logPaused = !ui.logPaused + paused := ui.logPaused + ui.logMu.Unlock() + if paused { + pause.SetText("Resume") + ui.setStatus("Logs paused", false) + } else { + pause.SetText("Pause") + ui.setStatus("Logs resumed", false) } + }) + ui.logMu.Lock() + paused := ui.logPaused + followEnabled := ui.logAutoScroll + ui.logMu.Unlock() + if paused { + pause.SetText("Resume") + } + var followLive *widget.Button + followLive = widget.NewButton("Follow live: On", func() { ui.logMu.Lock() - ui.logEntries = nil + ui.logAutoScroll = !ui.logAutoScroll + enabled := ui.logAutoScroll ui.logMu.Unlock() - ui.logList.Refresh() + if enabled { + followLive.SetText("Follow live: On") + entries := ui.filteredLogs() + if len(entries) > 0 { + ui.logList.ScrollTo(widget.ListItemID(len(entries) - 1)) + } + } else { + followLive.SetText("Follow live: Off") + } + }) + if !followEnabled { + followLive.SetText("Follow live: Off") + } + clear := widget.NewButton("Clear device log", func() { + d, ok := ui.requireDevice() + if !ok { + return + } + go func() { + if err := ui.core.adb.ClearLogcat(context.Background(), d.Serial); err != nil { + ui.showError(err) + return + } + ui.logMu.Lock() + ui.logEntries = nil + ui.logMu.Unlock() + fyne.Do(func() { ui.logList.Refresh() }) + ui.setStatus("Device log cleared", false) + }() + }) + export := widget.NewButtonWithIcon("Export visible logs…", theme.DocumentSaveIcon(), func() { + entries := ui.filteredLogs() + if len(entries) == 0 { + ui.showInfo("Nothing to export", "There are no visible log entries. Start logcat or adjust the filters first.") + return + } + var output strings.Builder + for _, entry := range entries { + output.WriteString(formatLog(entry)) + output.WriteByte('\n') + } + save := dialog.NewFileSave(func(file fyne.URIWriteCloser, err error) { + if err != nil { + ui.showError(err) + return + } + if file == nil { + return + } + if _, err := file.Write([]byte(output.String())); err != nil { + _ = file.Close() + ui.showError(err) + return + } + if err := file.Close(); err != nil { + ui.showError(err) + return + } + ui.setStatus(fmt.Sprintf("Exported %d log entries", len(entries)), false) + }, ui.win) + save.SetFileName("adbpureflow-logcat.txt") + save.SetFilter(storage.NewExtensionFileFilter([]string{".txt", ".log"})) + save.Show() }) + filter.OnChanged = func(string) { ui.logList.Refresh() } - ui.replaceContent("Logs", container.NewBorder(container.NewVBox(widget.NewRichTextFromMarkdown("Live logcat uses readable rows while preserving the raw technical message. The visible buffer keeps the latest 2,000 entries to stay responsive."), filter, level, container.NewHBox(start, stop, pause, clear)), nil, nil, nil, ui.logList)) + controls := wrapActions(start, stop, pause, followLive, clear, export) + header := container.NewVBox( + widget.NewRichTextFromMarkdown("### Live device logs\nStream Android logcat, narrow the visible entries, then export only the entries that match your filters."), + filter, + container.NewHBox(level, controls), + ) + ui.replaceContent("Logs", container.NewBorder(header, nil, nil, nil, ui.logList)) } func (ui *desktopUI) filteredLogs() []LogEntry { @@ -882,10 +1253,11 @@ func (ui *desktopUI) startLogs() { ui.setStatus("Streaming logcat…", true) go func() { _, err := ui.core.adb.StartLogcat(ctx, dev.Serial, func(e LogEntry) { + ui.logMu.Lock() if ui.logPaused { + ui.logMu.Unlock() return } - ui.logMu.Lock() ui.logEntries = append(ui.logEntries, e) if len(ui.logEntries) > 2000 { ui.logEntries = ui.logEntries[len(ui.logEntries)-2000:] @@ -894,14 +1266,17 @@ func (ui *desktopUI) startLogs() { if shouldRefresh { ui.lastLogRefresh = time.Now() } + follow := ui.logAutoScroll ui.logMu.Unlock() if shouldRefresh { fyne.Do(func() { if ui.logList != nil { ui.logList.Refresh() - entries := ui.filteredLogs() - if len(entries) > 0 { - ui.logList.ScrollTo(widget.ListItemID(len(entries) - 1)) + if follow { + entries := ui.filteredLogs() + if len(entries) > 0 { + ui.logList.ScrollTo(widget.ListItemID(len(entries) - 1)) + } } } }) @@ -926,22 +1301,26 @@ func (ui *desktopUI) showDeploy() { apkPath := widget.NewEntry() apkPath.SetPlaceHolder("Choose an APK artifact…") pkg := widget.NewEntry() - pkg.SetPlaceHolder("Package to launch/filter, e.g. com.example.app") - clear := widget.NewCheck("Clear data before launch", nil) - mirror := widget.NewCheck("Start screen mirror", nil) - logs := widget.NewCheck("Open filtered logs after deploy", nil) + pkg.SetPlaceHolder("Package to launch, e.g. com.example.app") + clear := widget.NewCheck("Clear app data before launch", nil) + mirror := widget.NewCheck("Start screen mirror after install", nil) + logs := widget.NewCheck("Open device logs after deploy", nil) + progress := widget.NewLabel("Ready. Choose an APK and review the options.") + + setAPK := func(path string) { + apkPath.SetText(path) + go func() { + meta, inspectErr := InspectAPK(context.Background(), path) + if inspectErr == nil && meta.PackageID != "" { + fyne.Do(func() { pkg.SetText(meta.PackageID) }) + } + }() + } choose := widget.NewButton("Browse…", func() { - ui.chooseAPK(func(path string) { - apkPath.SetText(path) - go func() { - meta, inspectErr := InspectAPK(context.Background(), path) - if inspectErr == nil && meta.PackageID != "" { - fyne.Do(func() { pkg.SetText(meta.PackageID) }) - } - }() - }) + ui.chooseAPK(setAPK) }) - run := widget.NewButtonWithIcon("Deploy", theme.FileIcon(), func() { + + run := widget.NewButtonWithIcon("Run deploy", theme.MediaPlayIcon(), func() { dev, ok := ui.requireDevice() if !ok { return @@ -965,51 +1344,93 @@ func (ui *desktopUI) showDeploy() { if mirror.Checked { profile.Actions = append(profile.Actions, FlowAction{Type: FlowStartMirror}) } + artifact := apkPath.Text + openLogs := logs.Checked go func() { ui.setStatus("Deploying…", true) - err := ui.core.RunFlow(ctx, dev.Serial, profile, func(e FlowEvent) { - ui.setStatus(string(e.Action.Type)+" "+e.State, true) + fyne.Do(func() { progress.SetText("Deployment started…") }) + err := ui.core.RunFlow(ctx, dev.Serial, profile, func(event FlowEvent) { + ui.setStatus(string(event.Action.Type)+" "+event.State, true) + fyne.Do(func() { progress.SetText(string(event.Action.Type) + " · " + event.State) }) }) deployCancel = nil if err != nil { ui.setStatus("Deploy failed", false) + fyne.Do(func() { progress.SetText("Deployment failed: " + err.Error()) }) ui.showError(err) return } ui.setStatus("Deploy complete", false) - ui.notify("Deploy complete", filepath.Base(apkPath.Text)) + fyne.Do(func() { progress.SetText("Deployment completed successfully.") }) + ui.notify("Deploy complete", filepath.Base(artifact)) _ = ui.core.SaveState(func(state *AppState) { - state.RecentAPKs = addRecentPath(state.RecentAPKs, apkPath.Text, 12) + state.RecentAPKs = addRecentPath(state.RecentAPKs, artifact, 12) }) - if logs.Checked { + if openLogs { fyne.Do(ui.showLogs) ui.startLogs() } }() }) - cancelButton := widget.NewButton("Cancel Deploy", func() { + cancelButton := widget.NewButton("Cancel deploy", func() { if deployCancel != nil { deployCancel() ui.setStatus("Deploy cancellation requested", false) + progress.SetText("Cancellation requested…") } }) - ui.replaceContent("Deploy", container.NewVBox( - widget.NewRichTextFromMarkdown("### Repeatable deploy\nChoose an APK artifact, choose behavior, then run a deterministic install → optional clear → launch → mirror/log flow. Failures stop the sequence instead of being reported as success."), - container.NewBorder(nil, nil, nil, choose, apkPath), + + recentContent := container.NewVBox() + recentPaths := ui.core.State().RecentAPKs + if len(recentPaths) == 0 { + recentContent.Add(widget.NewLabel("APKs appear here after a successful deployment.")) + } else { + for _, recentPath := range recentPaths { + path := recentPath + recentContent.Add(widget.NewCard(filepath.Base(path), path, widget.NewButton("Use this APK", func() { setAPK(path) }))) + } + } + + artifactCard := widget.NewCard("APK artifact", "Choose a local APK or reuse a recent artifact.", container.NewBorder(nil, nil, nil, choose, apkPath)) + optionsCard := widget.NewCard("Deployment options", "Choose what should happen after installation.", container.NewVBox( pkg, clear, mirror, logs, - container.NewHBox(run, cancelButton), + )) + runCard := widget.NewCard("Deployment progress", "The flow stops when a step fails; it does not report partial success as complete.", container.NewVBox( + progress, + wrapActions(run, cancelButton), + )) + recentCard := widget.NewCard("Recent APKs", "Quickly reuse a previously deployed artifact.", recentContent) + + ui.replaceContent("Deploy", container.NewVBox( + widget.NewRichTextFromMarkdown("### Repeatable deployment\nInstall an APK and optionally clear data, launch the app, start screen mirroring, or open logcat."), + artifactCard, + optionsCard, + runCard, + recentCard, )) } func (ui *desktopUI) showWorkflows() { - ui.replaceContent("Workflows", widget.NewRichTextFromMarkdown("### Flow automation\nThe workflow engine is implemented underneath Deploy and currently executes Install APK, Clear Data, Launch App, Start Mirror, and Wait with explicit running/complete/failed states. Live logs remain a UI-owned streaming feature, so Deploy opens Logs after a successful flow instead of pretending Start Logs is a completed core action. A persistent visual editor is not exposed yet because the available actions should remain honest and reliable rather than decorative.")) + description := widget.NewCard( + "Repeatable device actions", + "Run a predictable sequence instead of repeating the same manual steps.", + widget.NewLabel("The workflow engine currently supports Install APK, Clear Data, Launch App, Start Mirror, and Wait. Deploy uses this same engine and reports each step as it runs."), + ) + steps := container.NewVBox( + widget.NewCard("1 · Install APK", "Install an APK file on the selected device.", widget.NewLabel("The artifact is validated and installed through ADB.")), + widget.NewCard("2 · Optional cleanup", "Clear application data before launching.", widget.NewLabel("This step is opt-in because it removes local app data.")), + widget.NewCard("3 · Launch and inspect", "Launch the package, then optionally open mirror and logs.", widget.NewLabel("The flow stops on a failed step and preserves the error for troubleshooting.")), + ) + actions := widget.NewCard("Create a deployment run", "Choose the APK and configure the available actions.", widget.NewButtonWithIcon("Open Deploy", theme.UploadIcon(), ui.showDeploy)) + ui.replaceContent("Workflows", container.NewVBox(description, steps, actions)) } func (ui *desktopUI) showDiagnostics() { - box := container.NewVBox(widget.NewLabel("Running diagnostics…"), widget.NewButton("Restart ADB Server", func() { + box := container.NewVBox(widget.NewLabel("Running diagnostics…")) + restart := widget.NewButtonWithIcon("Restart ADB server", theme.ViewRefreshIcon(), func() { go func() { ui.setStatus("Restarting ADB server…", true) err := ui.core.adb.RestartServer(context.Background()) @@ -1021,18 +1442,25 @@ func (ui *desktopUI) showDiagnostics() { ui.setStatus("ADB server restarted", false) ui.showDiagnostics() }() - })) - ui.replaceContent("Diagnostics", container.NewVScroll(box)) + }) + ui.replaceContent("Diagnostics", container.NewVBox( + widget.NewRichTextFromMarkdown("### Device and tool health\nChecks ADB availability, device authorization, and scrcpy capability. Expand technical details only when you need to investigate a failure."), + restart, + box, + )) go func() { checks := ui.core.Doctor(context.Background()) fyne.Do(func() { box.Objects = nil - for _, c := range checks { - box.Add(widget.NewRichTextFromMarkdown(fmt.Sprintf("**%s:** %s\n\n%s\n\n%s", c.Name, c.Status, c.Message, c.Recovery))) - if c.Technical != "" { - box.Add(widget.NewAccordion(widget.NewAccordionItem("Technical details", widget.NewLabel(c.Technical)))) + for _, check := range checks { + content := container.NewVBox( + widget.NewLabel(check.Message), + widget.NewLabel("Suggested action: "+check.Recovery), + ) + if check.Technical != "" { + content.Add(widget.NewAccordion(widget.NewAccordionItem("Technical details", widget.NewLabel(check.Technical)))) } - box.Add(widget.NewSeparator()) + box.Add(widget.NewCard(check.Name, check.Status, content)) } box.Refresh() }) @@ -1056,19 +1484,28 @@ func (ui *desktopUI) showSettings() { _ = ui.core.SaveState(func(state *AppState) { state.Theme = value }) }) themeSelect.SetSelected(fallback(ui.core.State().Theme, "system")) - statePath := ui.core.stateStore.path - body := container.NewVBox( - widget.NewRichTextFromMarkdown("### Settings\nAdbPureFlow stores non-sensitive preferences in the current user's application data. External tools are kept in the current user's cache/tools location so the app does not need administrator rights."), + + appearance := widget.NewCard("Appearance", "Choose how AdbPureFlow follows your desktop.", container.NewVBox( widget.NewForm(widget.NewFormItem("Theme", themeSelect)), - widget.NewLabel("Preferences: "+statePath), - widget.NewButton("Open Help & Tutorials", ui.showHelp), - ) - ui.replaceContent("Settings", body) + )) + window := widget.NewCard("Window layout", "Choose a safe preset or resize the window manually. The sidebar divider can also be dragged to change the workspace width.", container.NewVBox( + widget.NewForm(widget.NewFormItem("Window size", ui.windowSizeSelect)), + )) + statePath := ui.core.stateStore.path + storage := widget.NewCard("Local preferences", "Only non-sensitive application preferences are stored here.", container.NewVBox( + widget.NewLabel("Preferences file:"), + widget.NewLabelWithStyle(statePath, fyne.TextAlignLeading, fyne.TextStyle{Monospace: true}), + widget.NewLabel("Downloaded tools are stored in the current user's cache/config directory so a normal installation does not need administrator rights."), + )) + help := widget.NewCard("Need a hand?", "Setup steps and troubleshooting are always available.", widget.NewButtonWithIcon("Open Help & Tutorials", theme.HelpIcon(), ui.showHelp)) + ui.replaceContent("Settings", container.NewVBox(appearance, window, storage, help)) } func (ui *desktopUI) showHelp() { - text := "### Help & Tutorials\n\n**New here? Baru di sini?** Start with Devices: connect by USB, or Add Wireless for Android 11+ pairing-code setup. Then try Screen, Install APK, Apps, Logs, and Deploy.\n\n**Keyboard:** Ctrl+K opens the command palette. Ctrl+R refreshes devices.\n\n**Troubleshooting:** Diagnostics checks ADB, connected devices, authorization, and scrcpy. Raw details are kept behind expandable technical sections. Help can be reopened from Settings → Open Help & Tutorials.\n\n**Windows-first choices:** APK selection, screenshots, and build artifacts use native file dialogs. File management intentionally stays in Windows Explorer where possible." - ui.replaceContent("Help & Tutorials", widget.NewRichTextFromMarkdown(text)) + guide := widget.NewCard("Start here", "A short route through the main workspaces.", widget.NewRichTextFromMarkdown("1. **Devices:** connect by USB or Add Wireless, then select your phone.\n2. **Screen:** start scrcpy mirroring, capture a screenshot, or record a session.\n3. **Apps:** search installed packages, launch, stop, clear data, or uninstall.\n4. **Logs:** stream logcat, filter entries, and export the visible results.\n5. **Deploy:** install an APK and optionally clear, launch, mirror, or open logs.")) + shortcuts := widget.NewCard("Keyboard shortcuts", "Use the app without leaving the keyboard.", widget.NewRichTextFromMarkdown("- **Ctrl+K** opens the command palette.\n- **Ctrl+R** refreshes connected devices.\n- **Ctrl+B** shows or hides the sidebar.\n- **F11** toggles full screen; **Esc** returns to windowed mode.\n- Drag the divider beside the sidebar to resize the workspace.")) + recovery := widget.NewCard("When something does not work", "Start with Diagnostics before changing device settings.", widget.NewLabel("Check that USB debugging is enabled, accept the RSA prompt on the phone, and verify ADB/scrcpy in Diagnostics. Wireless pairing uses Android's pairing-code flow; QR pairing is not simulated.")) + ui.replaceContent("Help & Tutorials", container.NewVBox(guide, shortcuts, recovery)) } func (ui *desktopUI) copySelectedSerial() { diff --git a/GUI/ui_layout.go b/GUI/ui_layout.go new file mode 100644 index 0000000..d2f95b7 --- /dev/null +++ b/GUI/ui_layout.go @@ -0,0 +1,16 @@ +package main + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" +) + +// withPadding keeps spacing inside Fyne's standard layout system so minimum +// sizes and resize calculations remain consistent with the surrounding UI. +func withPadding(object fyne.CanvasObject, horizontal, vertical float32) fyne.CanvasObject { + return container.New( + layout.NewCustomPaddedLayout(vertical, vertical, horizontal, horizontal), + object, + ) +} diff --git a/GUI/ui_layout_test.go b/GUI/ui_layout_test.go new file mode 100644 index 0000000..9b560d6 --- /dev/null +++ b/GUI/ui_layout_test.go @@ -0,0 +1,74 @@ +package main + +import ( + "image/color" + "strings" + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" +) + +func TestWithPaddingUsesFyneLayoutSizing(t *testing.T) { + object := canvas.NewRectangle(color.Black) + padded := withPadding(object, 20, 12) + + padded.Resize(fyne.NewSize(400, 300)) + + if got, want := object.Position(), fyne.NewPos(20, 12); got != want { + t.Fatalf("position = %v, want %v", got, want) + } + if got, want := object.Size(), fyne.NewSize(360, 276); got != want { + t.Fatalf("size = %v, want %v", got, want) + } +} + +func TestWithPaddingMinSizeIncludesInsets(t *testing.T) { + object := canvas.NewRectangle(color.Black) + padded := withPadding(object, 18, 10) + + got := padded.MinSize() + if got.Width < 36 || got.Height < 20 { + t.Fatalf("minimum size %v does not include the configured padding", got) + } +} + +func TestCompactDeviceLabelStaysBounded(t *testing.T) { + device := Device{ + DisplayName: strings.Repeat("A very long Android model name ", 4), + Serial: strings.Repeat("SERIAL", 8), + } + + got := compactDeviceLabel(device) + if length := len([]rune(got)); length > 28 { + t.Fatalf("compact device label has %d runes (%q), want at most 28", length, got) + } + if !strings.Contains(got, "…") { + t.Fatalf("compact device label %q should indicate truncated values", got) + } +} + +func TestCompactDeviceLabelUsesLocalAlias(t *testing.T) { + device := Device{ + DisplayName: "Pixel 8", + Serial: "emulator-5555123456", + } + + got := compactDeviceLabelWithAlias(device, "My test phone") + if !strings.HasPrefix(got, "My test phone · …") { + t.Fatalf("device label = %q, want alias and serial suffix", got) + } + if !strings.HasSuffix(got, "123456") { + t.Fatalf("device label = %q, want last six serial characters", got) + } +} + +func TestShortenTextBoundsLongLabels(t *testing.T) { + got := shortenText(strings.Repeat("status ", 20), 52) + if length := len([]rune(got)); length > 52 { + t.Fatalf("shortened status has %d runes, want at most 52", length) + } + if !strings.HasSuffix(got, "…") { + t.Fatalf("shortened status %q should end with an ellipsis", got) + } +} diff --git a/README.md b/README.md index 269ddc6..b076cb5 100644 --- a/README.md +++ b/README.md @@ -18,15 +18,17 @@ This repository has started the migration from the earlier APK installer/mirror - Recording start flow through native Save File dialog; output is verified when the scrcpy companion process exits. - APK drag/drop or file selection, APK zip validation, size/SHA-256, and `aapt`-powered package/version/SDK/permission metadata when Android build-tools are available. - Install/reinstall, launch, force stop, clear app data, uninstall, and searchable app list with user/system distinction when requested. -- Live logcat streaming with readable rows, pause/resume/stop, text and level filtering, clear/reset, bounded visible buffer, and raw technical detail preserved. -- Deploy workflow: install APK → optional clear data → launch → optional mirror/logs. +- Live logcat streaming with readable rows, pause/resume/stop, text and level filtering, clear/reset, bounded visible buffer, export of the currently filtered entries, optional live-follow scrolling, and raw technical detail preserved. +- Deploy workflow: install APK → optional clear data → launch → optional mirror/logs, with a saved recent-APK list for quick reuse. - Diagnostics/Doctor checks for ADB executable capability, device visibility/authorization, and scrcpy capability, plus an ADB server restart action. - Passive device monitoring with explicit selection when multiple devices are present. - Known wireless endpoint reconnect/forget actions and local device aliases. - First-launch onboarding with “Baru di sini? / New here?” and reopenable Help & Tutorials. - Settings for theme selection and persisted non-sensitive user state in per-user application data. - Clipboard shortcuts/actions for device serials and package names, plus desktop notifications for completed long-running actions where supported by the OS. -- Command palette via `Ctrl+K`; refresh devices via `Ctrl+R`. +- Native workspace shell with consistent page spacing, clearer card-based sections, and active navigation state. +- Resizable Fyne split-view sidebar with its own scroll area, shared page-level scrolling, bounded device-picker labels, safe window-size presets, and full-screen/windowed controls. +- Command palette via `Ctrl+K`; refresh devices via `Ctrl+R`; toggle the sidebar with `Ctrl+B`; toggle full screen with `F11` and return to windowed mode with `Esc`. See [`docs/AUDIT.md`](docs/AUDIT.md) for the repository audit, [`docs/HARDENING.md`](docs/HARDENING.md) for reliability findings, [`docs/WINDOWS_READINESS.md`](docs/WINDOWS_READINESS.md) for Windows verification notes, [`docs/WINDOWS_SMOKE_TEST.md`](docs/WINDOWS_SMOKE_TEST.md) for manual runtime validation, [`docs/DEVICE_TEST_MATRIX.md`](docs/DEVICE_TEST_MATRIX.md) for real-device coverage, [`docs/TROUBLESHOOTING.md`](docs/TROUBLESHOOTING.md) for failure recovery, [`docs/RELEASE_CHECKLIST.md`](docs/RELEASE_CHECKLIST.md) for release gates, and [`docs/WIRELESS_DEBUGGING.md`](docs/WIRELESS_DEBUGGING.md) for the Wireless Debugging support boundary.