From e4fe08d6589a140171e05f29e235f5d0a02deb10 Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:37:58 +0800 Subject: [PATCH 01/29] Add reusable native UI inset layout --- GUI/ui_layout.go | 47 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 GUI/ui_layout.go diff --git a/GUI/ui_layout.go b/GUI/ui_layout.go new file mode 100644 index 0000000..0f1d239 --- /dev/null +++ b/GUI/ui_layout.go @@ -0,0 +1,47 @@ +package main + +import ( + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" +) + +// insetLayout adds predictable page and shell spacing without changing the +// sizing behavior of the wrapped Fyne widget or container. +type insetLayout struct { + horizontal float32 + vertical float32 +} + +func (l insetLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { + if len(objects) == 0 || objects[0] == nil { + return + } + + width := size.Width - 2*l.horizontal + height := size.Height - 2*l.vertical + if width < 0 { + width = 0 + } + if height < 0 { + height = 0 + } + + objects[0].Move(fyne.NewPos(l.horizontal, l.vertical)) + objects[0].Resize(fyne.NewSize(width, height)) +} + +func (l insetLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { + if len(objects) == 0 || objects[0] == nil { + return fyne.NewSize(2*l.horizontal, 2*l.vertical) + } + + minimum := objects[0].MinSize() + return fyne.NewSize( + minimum.Width+2*l.horizontal, + minimum.Height+2*l.vertical, + ) +} + +func withPadding(object fyne.CanvasObject, horizontal, vertical float32) fyne.CanvasObject { + return container.New(insetLayout{horizontal: horizontal, vertical: vertical}, object) +} From 8c03bc70aa8add2aec8fcd4bf6052a059328c811 Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:38:55 +0800 Subject: [PATCH 02/29] Redesign native workspace navigation and improve core flows --- GUI/main.go | 484 +++++++++++++++++++++++++++++++++++++--------------- 1 file changed, 347 insertions(+), 137 deletions(-) diff --git a/GUI/main.go b/GUI/main.go index eb4ca87..2c8ed5b 100644 --- a/GUI/main.go +++ b/GUI/main.go @@ -33,6 +33,7 @@ type desktopUI struct { activity *widget.ProgressBarInfinite contentTitle *widget.Label content *fyne.Container + navButtons map[string]*widget.Button monitorCancel context.CancelFunc logCancel context.CancelFunc @@ -100,30 +101,48 @@ func (ui *desktopUI) buildShell() fyne.CanvasObject { }) 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"), ) + deviceTarget := container.NewBorder(nil, nil, widget.NewLabel("DEVICE"), nil, ui.deviceSelect) + actions := container.NewHBox( + widget.NewButtonWithIcon("Add Wireless", theme.ContentAddIcon(), ui.showWirelessDialog), + widget.NewButtonWithIcon("Refresh", theme.ViewRefreshIcon(), func() { go ui.refreshDevices() }), + ) + top := withPadding(container.NewBorder(nil, nil, brand, container.NewHBox(deviceTarget, actions), nil), 20, 12) + 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), + ) + + center := container.NewBorder( + withPadding(ui.contentTitle, 22, 12), + nil, nil, nil, + ui.content, ) - 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) + sidebar := withPadding(nav, 14, 18) + bottom := withPadding(container.NewBorder(nil, nil, ui.status, nil, ui.activity), 20, 8) + shell := container.NewBorder(top, bottom, sidebar, nil, center) + ui.showDevices() return shell } @@ -172,7 +191,20 @@ 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} + + 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() + } + + ui.content.Objects = []fyne.CanvasObject{withPadding(obj, 22, 18)} ui.content.Refresh() }) } @@ -308,45 +340,66 @@ 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 := container.NewHBox( + 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 := dev.Label() + if alias := ui.core.State().DeviceAliases[dev.Serial]; alias != "" { + name = alias } - 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" } - 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()) + actions := container.NewHBox( + widget.NewButton(selectText, func() { + ui.selectedSerial = dev.Serial + _ = ui.core.SaveState(func(state *AppState) { state.SelectedSerial = dev.Serial }) + ui.deviceSelect.SetSelected(dev.Label()) + 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 + } + body.Add(widget.NewCard(name, subtitle, 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 +408,15 @@ func (ui *desktopUI) showDevices() { if connected { state = "Connected" } - rows.Add(container.NewBorder(nil, nil, widget.NewLabel(addr+" — "+state), container.NewHBox( + actions := container.NewHBox( 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", container.NewVScroll(body)) } func (ui *desktopUI) renameDevice(serial string) { @@ -487,6 +541,7 @@ func (ui *desktopUI) showScreen() { if ok { deviceText = d.Label() } + always := widget.NewCheck("Keep mirror window on top", nil) fullscreen := widget.NewCheck("Start fullscreen", nil) quality := widget.NewSelect([]string{"Native", "1080p", "720p"}, nil) @@ -521,9 +576,7 @@ func (ui *desktopUI) showScreen() { } if recordPath != "" { ui.setStatus("Mirror and recording active", false) - fyne.Do(func() { - mirrorState.SetText("Mirror and recording are active. Stop Mirror finishes the recording file.") - }) + fyne.Do(func() { mirrorState.SetText("Mirror and recording are active. Stop Mirror finishes the recording file.") }) } else { ui.setStatus("Mirror active in companion window", false) fyne.Do(func() { mirrorState.SetText("Mirror active in scrcpy companion window.") }) @@ -551,9 +604,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 +622,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, + )) + controls := widget.NewCard("Session controls", "Start, stop, record, or capture the current device screen.", container.NewVBox( + mirrorState, container.NewHBox(start, record, stop, screenshot), - ) - ui.replaceContent("Screen", body) + )) + ui.replaceContent("Screen", container.NewVBox(intro, target, options, controls)) } func (ui *desktopUI) saveScreenshot() { @@ -720,57 +775,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 := container.NewHBox( 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) + }), + ) + listBox.Add(widget.NewCard(pkg, kind, container.NewVBox( + 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 := container.NewHBox(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.NewBorder(header, nil, nil, nil, container.NewVScroll(listBox))) load(false) } -type packageActionFunc func(context.Context, string, string) (string, error) - func (ui *desktopUI) confirmPackageAction(name, pkg, warning string, fn packageActionFunc) { dialog.NewConfirm(name+"?", warning+"\n\nPackage: "+pkg, func(ok bool) { if ok { @@ -815,29 +904,92 @@ 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() { + + start := widget.NewButtonWithIcon("Start", theme.MediaPlayIcon(), func() { ui.startLogs() }) + stop := widget.NewButtonWithIcon("Stop", theme.MediaStopIcon(), func() { + ui.stopLogs() + ui.setStatus("Log stream stopped", false) + }) + pause := widget.NewButton("Pause", func() { ui.logPaused = !ui.logPaused - ui.setStatus(map[bool]string{true: "Logs paused", false: "Logs resumed"}[ui.logPaused], false) + if ui.logPaused { + pause.SetText("Resume") + ui.setStatus("Logs paused", false) + } else { + pause.SetText("Pause") + ui.setStatus("Logs resumed", false) + } + }) + 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) + }() }) - clear := widget.NewButton("Clear Device Log", func() { - if d, ok := ui.requireDevice(); ok { - go ui.core.adb.ClearLogcat(context.Background(), d.Serial) + 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 } - ui.logMu.Lock() - ui.logEntries = nil - ui.logMu.Unlock() - ui.logList.Refresh() + 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 entrie(s)", 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 := container.NewHBox(start, stop, pause, 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 { @@ -926,22 +1078,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 +1121,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, + )) + runCard := widget.NewCard("Deployment progress", "The flow stops when a step fails; it does not report partial success as complete.", container.NewVBox( + progress, container.NewHBox(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 +1219,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() }) @@ -1057,18 +1262,23 @@ func (ui *desktopUI) showSettings() { }) 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) + )) + 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, 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.")) + 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() { From 40b097924acc6b1226ab90b01dbd79dbc3794a85 Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:39:43 +0800 Subject: [PATCH 03/29] Fix log pause control callback initialization --- GUI/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/GUI/main.go b/GUI/main.go index 2c8ed5b..f293dd3 100644 --- a/GUI/main.go +++ b/GUI/main.go @@ -920,7 +920,8 @@ func (ui *desktopUI) showLogs() { ui.stopLogs() ui.setStatus("Log stream stopped", false) }) - pause := widget.NewButton("Pause", func() { + var pause *widget.Button + pause = widget.NewButton("Pause", func() { ui.logPaused = !ui.logPaused if ui.logPaused { pause.SetText("Resume") From d39b01715ddd2243d6ee97d0f75f7ab2aa5dafc3 Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:39:58 +0800 Subject: [PATCH 04/29] Document workspace UX and workflow improvements --- README.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 269ddc6..19c8a8d 100644 --- a/README.md +++ b/README.md @@ -18,14 +18,15 @@ 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, 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. +- Native workspace shell with consistent page spacing, clearer card-based sections, and active navigation state. - Command palette via `Ctrl+K`; refresh devices via `Ctrl+R`. 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. From 002b42fb85c96c747cf157d9400664d96032718f Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:40:52 +0800 Subject: [PATCH 05/29] Polish device identity and log export controls --- GUI/main.go | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/GUI/main.go b/GUI/main.go index f293dd3..73a8289 100644 --- a/GUI/main.go +++ b/GUI/main.go @@ -389,7 +389,10 @@ func (ui *desktopUI) showDevices() { if dev.Serial == ui.selectedSerial { subtitle = "ACTIVE DEVICE · " + subtitle } - body.Add(widget.NewCard(name, subtitle, actions)) + body.Add(widget.NewCard(name, subtitle, container.NewVBox( + widget.NewLabelWithStyle("Serial: "+serial, fyne.TextAlignLeading, fyne.TextStyle{Monospace: true}), + actions, + ))) } } @@ -931,6 +934,9 @@ func (ui *desktopUI) showLogs() { ui.setStatus("Logs resumed", false) } }) + if ui.logPaused { + pause.SetText("Resume") + } clear := widget.NewButton("Clear device log", func() { d, ok := ui.requireDevice() if !ok { @@ -976,7 +982,7 @@ func (ui *desktopUI) showLogs() { ui.showError(err) return } - ui.setStatus(fmt.Sprintf("Exported %d log entrie(s)", len(entries)), false) + 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"})) From 523be9319a14aa6bbe1ef45a19b05e9d353d11e9 Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 08:53:44 +0800 Subject: [PATCH 06/29] Fix missing package action type and Go formatting --- GUI/main.go | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/GUI/main.go b/GUI/main.go index 73a8289..fe91342 100644 --- a/GUI/main.go +++ b/GUI/main.go @@ -579,7 +579,9 @@ func (ui *desktopUI) showScreen() { } if recordPath != "" { ui.setStatus("Mirror and recording active", false) - fyne.Do(func() { mirrorState.SetText("Mirror and recording are active. Stop Mirror finishes the recording file.") }) + fyne.Do(func() { + mirrorState.SetText("Mirror and recording are active. Stop Mirror finishes the recording file.") + }) } else { ui.setStatus("Mirror active in companion window", false) fyne.Do(func() { mirrorState.SetText("Mirror active in scrcpy companion window.") }) @@ -863,6 +865,8 @@ func (ui *desktopUI) showApps() { load(false) } +type packageActionFunc func(context.Context, string, string) (string, error) + func (ui *desktopUI) confirmPackageAction(name, pkg, warning string, fn packageActionFunc) { dialog.NewConfirm(name+"?", warning+"\n\nPackage: "+pkg, func(ok bool) { if ok { From b9fd8e22ea63e935b84f96fdafd2022cbd8634ad Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:46:58 +0800 Subject: [PATCH 07/29] Add fixed-width layout for scrollable sidebar --- GUI/ui_layout.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/GUI/ui_layout.go b/GUI/ui_layout.go index 0f1d239..49aeff8 100644 --- a/GUI/ui_layout.go +++ b/GUI/ui_layout.go @@ -45,3 +45,29 @@ func (l insetLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { func withPadding(object fyne.CanvasObject, horizontal, vertical float32) fyne.CanvasObject { return container.New(insetLayout{horizontal: horizontal, vertical: vertical}, object) } + + +// fixedWidthLayout reserves a stable width for a child while allowing it to +// fill the available height. This keeps a scrollable sidebar from collapsing +// to the scroll widget's small default minimum width. +type fixedWidthLayout struct { + width float32 +} + +func (l fixedWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { + if len(objects) == 0 || objects[0] == nil { + return + } + + objects[0].Move(fyne.NewPos(0, 0)) + objects[0].Resize(fyne.NewSize(l.width, size.Height)) +} + +func (l fixedWidthLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { + if len(objects) == 0 || objects[0] == nil { + return fyne.NewSize(l.width, 0) + } + + minimum := objects[0].MinSize() + return fyne.NewSize(l.width, minimum.Height) +} From 749c2d1ab21af03f5ffc29651549a189af4947e5 Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:47:02 +0800 Subject: [PATCH 08/29] Rework desktop shell scrolling sidebar and window modes --- GUI/main.go | 137 +++++++++++++++++++++++++++++++++++++++++++++------- 1 file changed, 120 insertions(+), 17 deletions(-) diff --git a/GUI/main.go b/GUI/main.go index fe91342..32c5d54 100644 --- a/GUI/main.go +++ b/GUI/main.go @@ -32,8 +32,15 @@ type desktopUI struct { status *widget.Label activity *widget.ProgressBarInfinite contentTitle *widget.Label - content *fyne.Container + contentScroll *container.Scroll navButtons map[string]*widget.Button + sidebarPane fyne.CanvasObject + sidebarSlot *fyne.Container + sidebarToggle *widget.Button + fullscreenButton *widget.Button + windowSizeSelect *widget.Select + shell *fyne.Container + windowedSize fyne.Size monitorCancel context.CancelFunc logCancel context.CancelFunc @@ -51,9 +58,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(1280, 820)) - ui := &desktopUI{fyneApp: fyneApp, win: win, core: core} + ui := &desktopUI{fyneApp: fyneApp, win: win, core: core, windowedSize: fyne.NewSize(1280, 820)} win.SetContent(ui.buildShell()) ui.setupShortcuts() win.SetOnClosed(func() { @@ -87,7 +94,9 @@ func (ui *desktopUI) buildShell() fyne.CanvasObject { ui.activity = widget.NewProgressBarInfinite() ui.activity.Hide() ui.contentTitle = widget.NewLabelWithStyle("Devices", fyne.TextAlignLeading, fyne.TextStyle{Bold: true}) - ui.content = container.NewMax() + ui.contentScroll = container.NewVScroll(widget.NewLabel("Select a workspace")) + ui.contentScroll.Direction = container.ScrollVerticalOnly + ui.deviceSelect = widget.NewSelect([]string{"No devices"}, func(label string) { for _, d := range ui.devices { if label == d.Label() { @@ -110,7 +119,24 @@ func (ui *desktopUI) buildShell() fyne.CanvasObject { widget.NewButtonWithIcon("Add Wireless", theme.ContentAddIcon(), ui.showWirelessDialog), widget.NewButtonWithIcon("Refresh", theme.ViewRefreshIcon(), func() { go ui.refreshDevices() }), ) - top := withPadding(container.NewBorder(nil, nil, brand, container.NewHBox(deviceTarget, actions), nil), 20, 12) + + ui.sidebarToggle = widget.NewButtonWithIcon("Hide sidebar", theme.MenuIcon(), ui.toggleSidebar) + ui.windowSizeSelect = widget.NewSelect([]string{ + "Compact · 1100 × 720", + "Default · 1280 × 820", + "Wide · 1480 × 920", + }, ui.setWindowSize) + ui.windowSizeSelect.SetSelected("Default · 1280 × 820") + ui.fullscreenButton = widget.NewButtonWithIcon("Full screen", theme.ViewFullScreenIcon(), ui.toggleFullscreen) + + windowControls := container.NewHBox( + ui.sidebarToggle, + deviceTarget, + actions, + ui.windowSizeSelect, + ui.fullscreenButton, + ) + top := withPadding(container.NewBorder(nil, nil, brand, windowControls, nil), 18, 10) ui.navButtons = make(map[string]*widget.Button) makeNavButton := func(name string, icon fyne.Resource, action func()) *widget.Button { @@ -133,18 +159,21 @@ func (ui *desktopUI) buildShell() fyne.CanvasObject { makeNavButton("Diagnostics", theme.WarningIcon(), ui.showDiagnostics), makeNavButton("Help", theme.HelpIcon(), ui.showHelp), ) + sidebarScroll := container.NewVScroll(withPadding(nav, 12, 16)) + sidebarScroll.Direction = container.ScrollVerticalOnly + ui.sidebarPane = container.New(fixedWidthLayout{width: 244}, sidebarScroll) + ui.sidebarSlot = container.NewMax(ui.sidebarPane) center := container.NewBorder( withPadding(ui.contentTitle, 22, 12), nil, nil, nil, - ui.content, + ui.contentScroll, ) - sidebar := withPadding(nav, 14, 18) bottom := withPadding(container.NewBorder(nil, nil, ui.status, nil, ui.activity), 20, 8) - shell := container.NewBorder(top, bottom, sidebar, nil, center) + ui.shell = container.NewBorder(top, bottom, ui.sidebarSlot, nil, center) ui.showDevices() - return shell + return ui.shell } func (ui *desktopUI) setupShortcuts() { @@ -160,6 +189,20 @@ 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) { @@ -204,11 +247,72 @@ func (ui *desktopUI) replaceContent(title string, obj fyne.CanvasObject) { button.Refresh() } - ui.content.Objects = []fyne.CanvasObject{withPadding(obj, 22, 18)} - ui.content.Refresh() + // A single page-level scroll host keeps tall workspaces (including + // Screen, Deploy, Diagnostics, and Help) reachable on short windows. + ui.contentScroll.Content = withPadding(obj, 22, 18) + ui.contentScroll.ScrollToTop() + ui.contentScroll.Refresh() }) } +func (ui *desktopUI) toggleSidebar() { + if ui.sidebarSlot == nil || ui.sidebarPane == nil { + return + } + if len(ui.sidebarSlot.Objects) > 0 { + ui.sidebarSlot.Objects = nil + ui.sidebarToggle.SetText("Show sidebar") + } else { + ui.sidebarSlot.Objects = []fyne.CanvasObject{ui.sidebarPane} + ui.sidebarToggle.SetText("Hide sidebar") + } + ui.sidebarSlot.Refresh() + if ui.shell != nil { + ui.shell.Refresh() + } +} + +func (ui *desktopUI) setWindowSize(value string) { + var size fyne.Size + switch value { + case "Compact · 1100 × 720": + size = fyne.NewSize(1100, 720) + case "Wide · 1480 × 920": + size = fyne.NewSize(1480, 920) + default: + value = "Default · 1280 × 820" + size = fyne.NewSize(1280, 820) + } + + 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) selectedDevice() (Device, bool) { for _, d := range ui.devices { if d.Serial == ui.selectedSerial { @@ -359,8 +463,7 @@ func (ui *desktopUI) showDevices() { name = alias } status := fmt.Sprintf("%s · %s · Android %s · Battery %s · Storage %s", - dev.Transport, - dev.Status, + dev.Transport, dev.Status, fallback(dev.AndroidVersion, "unknown"), fallback(dev.BatteryPercent, "unknown"), fallback(dev.StorageSummary, "unknown"), @@ -419,7 +522,7 @@ func (ui *desktopUI) showDevices() { } } - ui.replaceContent("Devices", container.NewVScroll(body)) + ui.replaceContent("Devices", body) } func (ui *desktopUI) renameDevice(serial string) { @@ -772,7 +875,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() { @@ -861,7 +964,7 @@ func (ui *desktopUI) showApps() { filter, toolbar, ) - ui.replaceContent("Apps", container.NewBorder(header, nil, nil, nil, container.NewVScroll(listBox))) + ui.replaceContent("Apps", container.NewVBox(header, listBox)) load(false) } @@ -1287,7 +1390,7 @@ func (ui *desktopUI) showSettings() { func (ui *desktopUI) showHelp() { 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.")) + 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.")) 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)) } From 9e0daa33ddc066e3b71fcb7806593da8860c878c Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:47:29 +0800 Subject: [PATCH 09/29] Test page padding and sidebar layout helpers --- GUI/ui_layout_test.go | 47 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 47 insertions(+) create mode 100644 GUI/ui_layout_test.go diff --git a/GUI/ui_layout_test.go b/GUI/ui_layout_test.go new file mode 100644 index 0000000..624c6b5 --- /dev/null +++ b/GUI/ui_layout_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "image/color" + "testing" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/canvas" +) + +func TestInsetLayoutAppliesPagePadding(t *testing.T) { + object := canvas.NewRectangle(color.Black) + layout := insetLayout{horizontal: 20, vertical: 12} + + layout.Layout([]fyne.CanvasObject{object}, 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 TestInsetLayoutMinSizeIncludesPadding(t *testing.T) { + object := canvas.NewRectangle(color.Black) + layout := insetLayout{horizontal: 18, vertical: 10} + + got := layout.MinSize([]fyne.CanvasObject{object}) + if got.Width < 36 || got.Height < 20 { + t.Fatalf("minimum size %v does not include the configured padding", got) + } +} + +func TestFixedWidthLayoutKeepsSidebarWidth(t *testing.T) { + object := canvas.NewRectangle(color.Black) + layout := fixedWidthLayout{width: 244} + + layout.Layout([]fyne.CanvasObject{object}, fyne.NewSize(900, 640)) + + if got, want := object.Size(), fyne.NewSize(244, 640); got != want { + t.Fatalf("size = %v, want %v", got, want) + } + if got := layout.MinSize([]fyne.CanvasObject{object}); got.Width != 244 { + t.Fatalf("minimum width = %v, want 244", got.Width) + } +} From 7802c7285b6cfbd184a6c19f467eee76849d8e2c Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:47:44 +0800 Subject: [PATCH 10/29] Document desktop navigation and window controls --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 19c8a8d..e284aac 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,8 @@ This repository has started the migration from the earlier APK installer/mirror - 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. - Native workspace shell with consistent page spacing, clearer card-based sections, and active navigation state. -- Command palette via `Ctrl+K`; refresh devices via `Ctrl+R`. +- Collapsible, independently scrollable sidebar; a shared page-level vertical scroll host; 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. From effcca766e63d380606a09a0dfba4c988e14ff44 Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:48:10 +0800 Subject: [PATCH 11/29] Format desktop shell state fields --- GUI/main.go | 28 ++++++++++++++-------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/GUI/main.go b/GUI/main.go index 32c5d54..141e4eb 100644 --- a/GUI/main.go +++ b/GUI/main.go @@ -25,22 +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 - contentScroll *container.Scroll - navButtons map[string]*widget.Button - sidebarPane fyne.CanvasObject - sidebarSlot *fyne.Container - sidebarToggle *widget.Button + deviceRefreshMu sync.Mutex + devices []Device + selectedSerial string + deviceSelect *widget.Select + status *widget.Label + activity *widget.ProgressBarInfinite + contentTitle *widget.Label + contentScroll *container.Scroll + navButtons map[string]*widget.Button + sidebarPane fyne.CanvasObject + sidebarSlot *fyne.Container + sidebarToggle *widget.Button fullscreenButton *widget.Button windowSizeSelect *widget.Select - shell *fyne.Container - windowedSize fyne.Size + shell *fyne.Container + windowedSize fyne.Size monitorCancel context.CancelFunc logCancel context.CancelFunc From 1a045fe0d0058e6f580573be90c169242ba0315d Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 11:48:12 +0800 Subject: [PATCH 12/29] Format sidebar layout helper --- GUI/ui_layout.go | 1 - 1 file changed, 1 deletion(-) diff --git a/GUI/ui_layout.go b/GUI/ui_layout.go index 49aeff8..ac7134d 100644 --- a/GUI/ui_layout.go +++ b/GUI/ui_layout.go @@ -46,7 +46,6 @@ func withPadding(object fyne.CanvasObject, horizontal, vertical float32) fyne.Ca return container.New(insetLayout{horizontal: horizontal, vertical: vertical}, object) } - // fixedWidthLayout reserves a stable width for a child while allowing it to // fill the available height. This keeps a scrollable sidebar from collapsing // to the scroll widget's small default minimum width. From 7ab9e62064f5518a2dae2da9d42e859fe7047a4c Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:19:57 +0800 Subject: [PATCH 13/29] Make desktop layout fluid and constrain window presets --- GUI/main.go | 245 +++++++++++++++++++++++++++++++--------------------- 1 file changed, 148 insertions(+), 97 deletions(-) diff --git a/GUI/main.go b/GUI/main.go index 141e4eb..acc4b5b 100644 --- a/GUI/main.go +++ b/GUI/main.go @@ -25,22 +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 - contentScroll *container.Scroll - navButtons map[string]*widget.Button - sidebarPane fyne.CanvasObject - sidebarSlot *fyne.Container - sidebarToggle *widget.Button - fullscreenButton *widget.Button - windowSizeSelect *widget.Select - shell *fyne.Container - windowedSize fyne.Size + 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 @@ -58,9 +58,9 @@ func main() { core := NewApp() applyTheme(fyneApp, core.State().Theme) win := fyneApp.NewWindow("AdbPureFlow " + version) - win.Resize(fyne.NewSize(1280, 820)) + win.Resize(fyne.NewSize(1180, 700)) - ui := &desktopUI{fyneApp: fyneApp, win: win, core: core, windowedSize: fyne.NewSize(1280, 820)} + ui := &desktopUI{fyneApp: fyneApp, win: win, core: core, windowedSize: fyne.NewSize(1180, 700)} win.SetContent(ui.buildShell()) ui.setupShortcuts() win.SetOnClosed(func() { @@ -91,21 +91,30 @@ 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.contentScroll = container.NewVScroll(widget.NewLabel("Select a workspace")) ui.contentScroll.Direction = container.ScrollVerticalOnly - 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.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 "+compactDeviceLabel(device), false) + ui.showDevices() + return } }) ui.deviceSelect.PlaceHolder = "Select device" @@ -114,29 +123,24 @@ func (ui *desktopUI) buildShell() fyne.CanvasObject { widget.NewLabelWithStyle(appName, fyne.TextAlignLeading, fyne.TextStyle{Bold: true}), widget.NewLabel("Android device workstation"), ) - deviceTarget := container.NewBorder(nil, nil, widget.NewLabel("DEVICE"), nil, ui.deviceSelect) - actions := container.NewHBox( + 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.sidebarToggle = widget.NewButtonWithIcon("Hide sidebar", theme.MenuIcon(), ui.toggleSidebar) ui.windowSizeSelect = widget.NewSelect([]string{ "Compact · 1100 × 720", - "Default · 1280 × 820", - "Wide · 1480 × 920", + "Default · 1180 × 700", + "Wide · 1260 × 700", }, ui.setWindowSize) - ui.windowSizeSelect.SetSelected("Default · 1280 × 820") - ui.fullscreenButton = widget.NewButtonWithIcon("Full screen", theme.ViewFullScreenIcon(), ui.toggleFullscreen) - - windowControls := container.NewHBox( - ui.sidebarToggle, - deviceTarget, - actions, - ui.windowSizeSelect, - ui.fullscreenButton, - ) - top := withPadding(container.NewBorder(nil, nil, brand, windowControls, nil), 18, 10) + ui.windowSizeSelect.Selected = "Default · 1180 × 700" ui.navButtons = make(map[string]*widget.Button) makeNavButton := func(name string, icon fyne.Resource, action func()) *widget.Button { @@ -159,21 +163,22 @@ func (ui *desktopUI) buildShell() fyne.CanvasObject { makeNavButton("Diagnostics", theme.WarningIcon(), ui.showDiagnostics), makeNavButton("Help", theme.HelpIcon(), ui.showHelp), ) - sidebarScroll := container.NewVScroll(withPadding(nav, 12, 16)) - sidebarScroll.Direction = container.ScrollVerticalOnly - ui.sidebarPane = container.New(fixedWidthLayout{width: 244}, sidebarScroll) - ui.sidebarSlot = container.NewMax(ui.sidebarPane) + ui.sidebarScroll = container.NewVScroll(withPadding(nav, 12, 16)) + ui.sidebarScroll.Direction = container.ScrollVerticalOnly center := container.NewBorder( - withPadding(ui.contentTitle, 22, 12), + withPadding(ui.contentTitle, 18, 8), nil, nil, nil, ui.contentScroll, ) - bottom := withPadding(container.NewBorder(nil, nil, ui.status, nil, ui.activity), 20, 8) - ui.shell = container.NewBorder(top, bottom, ui.sidebarSlot, 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 ui.shell + return shell } func (ui *desktopUI) setupShortcuts() { @@ -205,9 +210,9 @@ func (ui *desktopUI) setupShortcuts() { }) } -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(shortenText(message, 52)) if busy { ui.activity.Show() } else { @@ -247,29 +252,26 @@ func (ui *desktopUI) replaceContent(title string, obj fyne.CanvasObject) { button.Refresh() } - // A single page-level scroll host keeps tall workspaces (including - // Screen, Deploy, Diagnostics, and Help) reachable on short windows. - ui.contentScroll.Content = withPadding(obj, 22, 18) + // One vertical scroll viewport handles long form-like pages. Collection + // widgets such as List retain their own internal scrolling behavior. + ui.contentScroll.Content = withPadding(obj, 18, 14) ui.contentScroll.ScrollToTop() ui.contentScroll.Refresh() }) } func (ui *desktopUI) toggleSidebar() { - if ui.sidebarSlot == nil || ui.sidebarPane == nil { + if ui.sidebarScroll == nil || ui.mainSplit == nil { return } - if len(ui.sidebarSlot.Objects) > 0 { - ui.sidebarSlot.Objects = nil + if ui.sidebarScroll.Visible() { + ui.sidebarScroll.Hide() ui.sidebarToggle.SetText("Show sidebar") } else { - ui.sidebarSlot.Objects = []fyne.CanvasObject{ui.sidebarPane} + ui.sidebarScroll.Show() ui.sidebarToggle.SetText("Hide sidebar") } - ui.sidebarSlot.Refresh() - if ui.shell != nil { - ui.shell.Refresh() - } + ui.mainSplit.Refresh() } func (ui *desktopUI) setWindowSize(value string) { @@ -277,11 +279,10 @@ func (ui *desktopUI) setWindowSize(value string) { switch value { case "Compact · 1100 × 720": size = fyne.NewSize(1100, 720) - case "Wide · 1480 × 920": - size = fyne.NewSize(1480, 920) + case "Wide · 1260 × 700": + size = fyne.NewSize(1260, 700) default: - value = "Default · 1280 × 820" - size = fyne.NewSize(1280, 820) + size = fyne.NewSize(1180, 700) } if ui.win.FullScreen() { @@ -313,6 +314,70 @@ func (ui *desktopUI) toggleFullscreen() { 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 := compactDeviceLabel(device) + 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 compactDeviceLabel(device Device) string { + 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 (ui *desktopUI) selectedDevice() (Device, bool) { for _, d := range ui.devices { if d.Serial == ui.selectedSerial { @@ -334,22 +399,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) }) } @@ -422,21 +472,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) } @@ -1375,22 +1419,29 @@ 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 + appearance := widget.NewCard("Appearance", "Choose how AdbPureFlow follows your desktop.", container.NewVBox( widget.NewForm(widget.NewFormItem("Theme", themeSelect)), )) + 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)), + widget.NewLabel("Compact · 1100 × 720"), + widget.NewLabel("Default · 1180 × 700"), + widget.NewLabel("Wide · 1260 × 700"), + )) + 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, storage, help)) + ui.replaceContent("Settings", container.NewVBox(appearance, window, storage, help)) } func (ui *desktopUI) showHelp() { 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.")) + 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)) } From 0e841abee87135a16f150e9dcabba7024c4cd8f3 Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:20:09 +0800 Subject: [PATCH 14/29] Replace custom sizing layouts with Fyne padded layout --- GUI/ui_layout.go | 70 +++++------------------------------------------- 1 file changed, 7 insertions(+), 63 deletions(-) diff --git a/GUI/ui_layout.go b/GUI/ui_layout.go index ac7134d..d2f95b7 100644 --- a/GUI/ui_layout.go +++ b/GUI/ui_layout.go @@ -3,70 +3,14 @@ package main import ( "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/layout" ) -// insetLayout adds predictable page and shell spacing without changing the -// sizing behavior of the wrapped Fyne widget or container. -type insetLayout struct { - horizontal float32 - vertical float32 -} - -func (l insetLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { - if len(objects) == 0 || objects[0] == nil { - return - } - - width := size.Width - 2*l.horizontal - height := size.Height - 2*l.vertical - if width < 0 { - width = 0 - } - if height < 0 { - height = 0 - } - - objects[0].Move(fyne.NewPos(l.horizontal, l.vertical)) - objects[0].Resize(fyne.NewSize(width, height)) -} - -func (l insetLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { - if len(objects) == 0 || objects[0] == nil { - return fyne.NewSize(2*l.horizontal, 2*l.vertical) - } - - minimum := objects[0].MinSize() - return fyne.NewSize( - minimum.Width+2*l.horizontal, - minimum.Height+2*l.vertical, - ) -} - +// 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(insetLayout{horizontal: horizontal, vertical: vertical}, object) -} - -// fixedWidthLayout reserves a stable width for a child while allowing it to -// fill the available height. This keeps a scrollable sidebar from collapsing -// to the scroll widget's small default minimum width. -type fixedWidthLayout struct { - width float32 -} - -func (l fixedWidthLayout) Layout(objects []fyne.CanvasObject, size fyne.Size) { - if len(objects) == 0 || objects[0] == nil { - return - } - - objects[0].Move(fyne.NewPos(0, 0)) - objects[0].Resize(fyne.NewSize(l.width, size.Height)) -} - -func (l fixedWidthLayout) MinSize(objects []fyne.CanvasObject) fyne.Size { - if len(objects) == 0 || objects[0] == nil { - return fyne.NewSize(l.width, 0) - } - - minimum := objects[0].MinSize() - return fyne.NewSize(l.width, minimum.Height) + return container.New( + layout.NewCustomPaddedLayout(vertical, vertical, horizontal, horizontal), + object, + ) } From 60a8c13c954e31f861c1b7c3aaa9db4d95b2a747 Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:20:12 +0800 Subject: [PATCH 15/29] Test Fyne padding and bounded desktop labels --- GUI/ui_layout_test.go | 40 ++++++++++++++++++++++++++-------------- 1 file changed, 26 insertions(+), 14 deletions(-) diff --git a/GUI/ui_layout_test.go b/GUI/ui_layout_test.go index 624c6b5..458b08e 100644 --- a/GUI/ui_layout_test.go +++ b/GUI/ui_layout_test.go @@ -2,17 +2,18 @@ package main import ( "image/color" + "strings" "testing" "fyne.io/fyne/v2" "fyne.io/fyne/v2/canvas" ) -func TestInsetLayoutAppliesPagePadding(t *testing.T) { +func TestWithPaddingUsesFyneLayoutSizing(t *testing.T) { object := canvas.NewRectangle(color.Black) - layout := insetLayout{horizontal: 20, vertical: 12} + padded := withPadding(object, 20, 12) - layout.Layout([]fyne.CanvasObject{object}, fyne.NewSize(400, 300)) + 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) @@ -22,26 +23,37 @@ func TestInsetLayoutAppliesPagePadding(t *testing.T) { } } -func TestInsetLayoutMinSizeIncludesPadding(t *testing.T) { +func TestWithPaddingMinSizeIncludesInsets(t *testing.T) { object := canvas.NewRectangle(color.Black) - layout := insetLayout{horizontal: 18, vertical: 10} + padded := withPadding(object, 18, 10) - got := layout.MinSize([]fyne.CanvasObject{object}) + got := padded.MinSize() if got.Width < 36 || got.Height < 20 { t.Fatalf("minimum size %v does not include the configured padding", got) } } -func TestFixedWidthLayoutKeepsSidebarWidth(t *testing.T) { - object := canvas.NewRectangle(color.Black) - layout := fixedWidthLayout{width: 244} +func TestCompactDeviceLabelStaysBounded(t *testing.T) { + device := Device{ + DisplayName: strings.Repeat("A very long Android model name ", 4), + Serial: strings.Repeat("SERIAL", 8), + } - layout.Layout([]fyne.CanvasObject{object}, fyne.NewSize(900, 640)) + 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) + } +} - if got, want := object.Size(), fyne.NewSize(244, 640); got != want { - t.Fatalf("size = %v, want %v", got, want) +func TestShortenTextBoundsStatusMessages(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 got := layout.MinSize([]fyne.CanvasObject{object}); got.Width != 244 { - t.Fatalf("minimum width = %v, want 244", got.Width) + if !strings.HasSuffix(got, "…") { + t.Fatalf("shortened status %q should end with an ellipsis", got) } } From 49a553d766eef6207bd23894aa8602b0fc2da1c4 Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:20:14 +0800 Subject: [PATCH 16/29] Document fluid split layout and safe window sizing --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index e284aac..67f7ea8 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ This repository has started the migration from the earlier APK installer/mirror - 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. - Native workspace shell with consistent page spacing, clearer card-based sections, and active navigation state. -- Collapsible, independently scrollable sidebar; a shared page-level vertical scroll host; window-size presets; and full-screen/windowed controls. +- 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. From 68ebc2e4bb6d81db7386a18f40a6f731b8c5bf4c Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:21:05 +0800 Subject: [PATCH 17/29] Format responsive desktop UI fields --- GUI/main.go | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/GUI/main.go b/GUI/main.go index acc4b5b..e722092 100644 --- a/GUI/main.go +++ b/GUI/main.go @@ -25,22 +25,22 @@ type desktopUI struct { win fyne.Window core *App - deviceRefreshMu sync.Mutex - devices []Device - selectedSerial string - deviceSelect *widget.Select + 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 + 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 From f9f8bf6ce0cbfcf95de55eea59f1e0e1e7980795 Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:22:05 +0800 Subject: [PATCH 18/29] Keep Logs on its native list scrolling and simplify window settings --- GUI/main.go | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/GUI/main.go b/GUI/main.go index e722092..f86ceea 100644 --- a/GUI/main.go +++ b/GUI/main.go @@ -141,6 +141,7 @@ func (ui *desktopUI) buildShell() fyne.CanvasObject { "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 { @@ -254,6 +255,13 @@ func (ui *desktopUI) replaceContent(title string, obj fyne.CanvasObject) { // 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() @@ -1425,9 +1433,6 @@ func (ui *desktopUI) showSettings() { )) 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)), - widget.NewLabel("Compact · 1100 × 720"), - widget.NewLabel("Default · 1180 × 700"), - widget.NewLabel("Wide · 1260 × 700"), )) statePath := ui.core.stateStore.path storage := widget.NewCard("Local preferences", "Only non-sensitive application preferences are stored here.", container.NewVBox( From aefde89b4eab77474c5e9c338def5d2b09022976 Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:23:54 +0800 Subject: [PATCH 19/29] Wrap action groups to fit narrower desktop layouts --- GUI/main.go | 29 ++++++++++++++++++----------- 1 file changed, 18 insertions(+), 11 deletions(-) diff --git a/GUI/main.go b/GUI/main.go index f86ceea..23d8bc2 100644 --- a/GUI/main.go +++ b/GUI/main.go @@ -386,6 +386,10 @@ func shortenText(value string, limit int) string { 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 { @@ -502,7 +506,7 @@ func (ui *desktopUI) showDevices() { if len(ui.devices) == 0 { 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 := container.NewHBox( + actions := wrapActions( widget.NewButtonWithIcon("Add Wireless Device", theme.ContentAddIcon(), ui.showWirelessDialog), widget.NewButtonWithIcon("Refresh Devices", theme.ViewRefreshIcon(), func() { go ui.refreshDevices() }), ) @@ -510,9 +514,9 @@ func (ui *desktopUI) showDevices() { } else { for _, device := range ui.devices { dev := device - name := dev.Label() + name := compactDeviceLabel(dev) if alias := ui.core.State().DeviceAliases[dev.Serial]; alias != "" { - name = alias + name = shortenText(alias, 32) } status := fmt.Sprintf("%s · %s · Android %s · Battery %s · Storage %s", dev.Transport, dev.Status, @@ -525,7 +529,7 @@ func (ui *desktopUI) showDevices() { if dev.Serial == ui.selectedSerial { selectText = "Currently selected" } - actions := container.NewHBox( + actions := wrapActions( widget.NewButton(selectText, func() { ui.selectedSerial = dev.Serial _ = ui.core.SaveState(func(state *AppState) { state.SelectedSerial = dev.Serial }) @@ -566,7 +570,7 @@ func (ui *desktopUI) showDevices() { if connected { state = "Connected" } - actions := container.NewHBox( + actions := wrapActions( widget.NewButton("Reconnect", func() { go ui.reconnectWireless(addr) }), widget.NewButton("Forget", func() { ui.forgetWireless(addr) }), ) @@ -796,7 +800,7 @@ func (ui *desktopUI) showScreen() { )) controls := widget.NewCard("Session controls", "Start, stop, record, or capture the current device screen.", container.NewVBox( mirrorState, - container.NewHBox(start, record, stop, screenshot), + wrapActions(start, record, stop, screenshot), )) ui.replaceContent("Screen", container.NewVBox(intro, target, options, controls)) } @@ -956,7 +960,7 @@ func (ui *desktopUI) showApps() { if !app.UserApp { kind = "System app" } - actions := container.NewHBox( + actions := wrapActions( widget.NewButton("Launch", func() { go ui.packageAction("Launch", pkg, ui.core.adb.Launch) }), widget.NewButton("Force stop", func() { go ui.packageAction("Force stop", pkg, ui.core.adb.ForceStop) }), widget.NewButton("Clear data", func() { @@ -970,7 +974,10 @@ func (ui *desktopUI) showApps() { ui.setStatus("Copied package name", false) }), ) - listBox.Add(widget.NewCard(pkg, kind, container.NewVBox( + 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, ))) @@ -1010,7 +1017,7 @@ func (ui *desktopUI) showApps() { install := widget.NewButtonWithIcon("Install APK…", theme.ContentAddIcon(), func() { ui.chooseAPK(ui.inspectAPK) }) refresh := widget.NewButtonWithIcon("Refresh", theme.ViewRefreshIcon(), func() { load(showSystem.Checked) }) - toolbar := container.NewHBox(showSystem, install, refresh, countLabel) + 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, @@ -1149,7 +1156,7 @@ func (ui *desktopUI) showLogs() { }) filter.OnChanged = func(string) { ui.logList.Refresh() } - controls := container.NewHBox(start, stop, pause, clear, export) + controls := wrapActions(start, stop, pause, 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, @@ -1343,7 +1350,7 @@ func (ui *desktopUI) showDeploy() { )) runCard := widget.NewCard("Deployment progress", "The flow stops when a step fails; it does not report partial success as complete.", container.NewVBox( progress, - container.NewHBox(run, cancelButton), + wrapActions(run, cancelButton), )) recentCard := widget.NewCard("Recent APKs", "Quickly reuse a previously deployed artifact.", recentContent) From 5b98f4eb9611ecab835eb5514ce2e094a1e1dada Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:24:16 +0800 Subject: [PATCH 20/29] Keep device cards and compact selector in sync --- GUI/main.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/GUI/main.go b/GUI/main.go index 23d8bc2..bf0e5f1 100644 --- a/GUI/main.go +++ b/GUI/main.go @@ -355,6 +355,15 @@ func (ui *desktopUI) syncDeviceSelect(devices []Device, preferredSerial string) 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 { name := strings.TrimSpace(device.DisplayName) if name == "" { @@ -533,7 +542,9 @@ func (ui *desktopUI) showDevices() { widget.NewButton(selectText, func() { ui.selectedSerial = dev.Serial _ = ui.core.SaveState(func(state *AppState) { state.SelectedSerial = dev.Serial }) - ui.deviceSelect.SetSelected(dev.Label()) + if option := ui.deviceOptionForSerial(dev.Serial); option != "" { + ui.deviceSelect.SetSelected(option) + } ui.setStatus("Selected "+dev.Label(), false) ui.showDevices() }), From 5f2f95667992c18d7ea5b8ff6bd3f03e0b91414d Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:24:28 +0800 Subject: [PATCH 21/29] Keep screen device label within responsive width --- GUI/main.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/GUI/main.go b/GUI/main.go index bf0e5f1..79cb549 100644 --- a/GUI/main.go +++ b/GUI/main.go @@ -712,7 +712,7 @@ func (ui *desktopUI) showScreen() { d, ok := ui.selectedDevice() deviceText := "No device selected" if ok { - deviceText = d.Label() + deviceText = compactDeviceLabel(d) } always := widget.NewCheck("Keep mirror window on top", nil) From 0d327fecce6277be60953b505e46470835188142 Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 13:24:47 +0800 Subject: [PATCH 22/29] Avoid duplicate refresh when selecting a device card --- GUI/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/GUI/main.go b/GUI/main.go index 79cb549..4e696d2 100644 --- a/GUI/main.go +++ b/GUI/main.go @@ -543,7 +543,8 @@ func (ui *desktopUI) showDevices() { ui.selectedSerial = dev.Serial _ = ui.core.SaveState(func(state *AppState) { state.SelectedSerial = dev.Serial }) if option := ui.deviceOptionForSerial(dev.Serial); option != "" { - ui.deviceSelect.SetSelected(option) + ui.deviceSelect.Selected = option + ui.deviceSelect.Refresh() } ui.setStatus("Selected "+dev.Label(), false) ui.showDevices() From c1abb75923b393f36180ef39fabaf7e8d1e6c282 Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:09:11 +0800 Subject: [PATCH 23/29] Polish log follow behavior and device labels --- GUI/main.go | 63 ++++++++++++++++++++++++++++++++++++++++++----------- 1 file changed, 50 insertions(+), 13 deletions(-) diff --git a/GUI/main.go b/GUI/main.go index 4e696d2..b415100 100644 --- a/GUI/main.go +++ b/GUI/main.go @@ -50,6 +50,7 @@ type desktopUI struct { logFilter *widget.Entry logLevel *widget.Select logPaused bool + logAutoScroll bool lastLogRefresh time.Time } @@ -60,7 +61,7 @@ func main() { win := fyneApp.NewWindow("AdbPureFlow " + version) win.Resize(fyne.NewSize(1180, 700)) - ui := &desktopUI{fyneApp: fyneApp, win: win, core: core, windowedSize: fyne.NewSize(1180, 700)} + ui := &desktopUI{fyneApp: fyneApp, win: win, core: core, windowedSize: fyne.NewSize(1180, 700), logAutoScroll: true} win.SetContent(ui.buildShell()) ui.setupShortcuts() win.SetOnClosed(func() { @@ -112,7 +113,7 @@ func (ui *desktopUI) buildShell() fyne.CanvasObject { } ui.selectedSerial = device.Serial _ = ui.core.SaveState(func(state *AppState) { state.SelectedSerial = device.Serial }) - ui.setStatus("Selected "+compactDeviceLabel(device), false) + ui.setStatus("Selected "+compactDeviceLabelWithAlias(device, ui.core.State().DeviceAliases[device.Serial]), false) ui.showDevices() return } @@ -213,7 +214,7 @@ func (ui *desktopUI) setupShortcuts() { func (ui *desktopUI) setStatus(message string, busy bool) { fyne.Do(func() { - ui.status.SetText(shortenText(message, 52)) + ui.status.SetText(strings.TrimSpace(message)) if busy { ui.activity.Show() } else { @@ -326,7 +327,7 @@ func (ui *desktopUI) syncDeviceSelect(devices []Device, preferredSerial string) options := make([]string, 0, len(devices)) ui.deviceOptionSerial = make(map[string]string, len(devices)) for _, device := range devices { - base := compactDeviceLabel(device) + base := compactDeviceLabelWithAlias(device, ui.core.State().DeviceAliases[device.Serial]) option := base for suffix := 2; ; suffix++ { serial, exists := ui.deviceOptionSerial[option] @@ -365,7 +366,14 @@ func (ui *desktopUI) deviceOptionForSerial(serial string) string { } func compactDeviceLabel(device Device) string { - name := strings.TrimSpace(device.DisplayName) + 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) } @@ -713,7 +721,7 @@ func (ui *desktopUI) showScreen() { d, ok := ui.selectedDevice() deviceText := "No device selected" if ok { - deviceText = compactDeviceLabel(d) + deviceText = compactDeviceLabelWithAlias(d, ui.core.State().DeviceAliases[d.Serial]) } always := widget.NewCheck("Keep mirror window on top", nil) @@ -1103,8 +1111,11 @@ func (ui *desktopUI) showLogs() { }) var pause *widget.Button pause = widget.NewButton("Pause", func() { + ui.logMu.Lock() ui.logPaused = !ui.logPaused - if ui.logPaused { + paused := ui.logPaused + ui.logMu.Unlock() + if paused { pause.SetText("Resume") ui.setStatus("Logs paused", false) } else { @@ -1112,9 +1123,31 @@ func (ui *desktopUI) showLogs() { ui.setStatus("Logs resumed", false) } }) - if ui.logPaused { + ui.logMu.Lock() + paused := ui.logPaused + followEnabled := ui.logAutoScroll + ui.logMu.Unlock() + if paused { pause.SetText("Resume") } + followLive := widget.NewButton("Follow live: On", func() { + ui.logMu.Lock() + ui.logAutoScroll = !ui.logAutoScroll + enabled := ui.logAutoScroll + ui.logMu.Unlock() + 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 { @@ -1168,7 +1201,7 @@ func (ui *desktopUI) showLogs() { }) filter.OnChanged = func(string) { ui.logList.Refresh() } - controls := wrapActions(start, stop, pause, clear, export) + 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, @@ -1219,10 +1252,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:] @@ -1231,14 +1265,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)) + } } } }) From c92f4b8b0e34f82f59a6377379ac2d408eb6e914 Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:09:24 +0800 Subject: [PATCH 24/29] Test local device aliases in compact labels --- GUI/ui_layout_test.go | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/GUI/ui_layout_test.go b/GUI/ui_layout_test.go index 458b08e..9b560d6 100644 --- a/GUI/ui_layout_test.go +++ b/GUI/ui_layout_test.go @@ -48,7 +48,22 @@ func TestCompactDeviceLabelStaysBounded(t *testing.T) { } } -func TestShortenTextBoundsStatusMessages(t *testing.T) { +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) From b5fad62fa95c6c2403e36f21bad611713de77a9f Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:09:33 +0800 Subject: [PATCH 25/29] Document v5.7.0 workspace release --- CHANGELOG.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) 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. From d0c5b8a33099c98b24535f05af9c8003d27de442 Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:09:42 +0800 Subject: [PATCH 26/29] Document live-follow control in logcat workspace --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 67f7ea8..b076cb5 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ 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, export of the currently filtered entries, and raw technical detail preserved. +- 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. From ee39d6406184e2781a3ef41e150e1d4f99ffa009 Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:10:48 +0800 Subject: [PATCH 27/29] Fix log follow button callback declaration --- GUI/main.go | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/GUI/main.go b/GUI/main.go index b415100..58acf36 100644 --- a/GUI/main.go +++ b/GUI/main.go @@ -1130,7 +1130,8 @@ func (ui *desktopUI) showLogs() { if paused { pause.SetText("Resume") } - followLive := widget.NewButton("Follow live: On", func() { + var followLive *widget.Button + followLive = widget.NewButton("Follow live: On", func() { ui.logMu.Lock() ui.logAutoScroll = !ui.logAutoScroll enabled := ui.logAutoScroll From e34234d2cd15290495b8718e74f7ab6b3d000a4f Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:12:13 +0800 Subject: [PATCH 28/29] Support tagged releases from main merge commits --- .github/workflows/ci.yml | 57 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d029b8..bb2bc33 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 ')) 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 From a9e9ed734c6bc2465e5a5a13a2ecb1e5af3963fb Mon Sep 17 00:00:00 2001 From: Thio Saputra <132740827+flessan@users.noreply.github.com> Date: Sat, 31 Oct 2026 20:12:31 +0800 Subject: [PATCH 29/29] Narrow main-branch release marker condition --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bb2bc33..ae4eaaf 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -373,7 +373,7 @@ jobs: startsWith(github.ref, 'refs/tags/v') || (github.event_name == 'push' && github.ref == 'refs/heads/main' && - contains(github.event.head_commit.message, '[release ')) + contains(github.event.head_commit.message, '[release v')) runs-on: ubuntu-latest