Compare commits

..

7 Commits

Author SHA1 Message Date
349445219b Android: reliable build script; GUI: fix list not drawn until first tap.
scripts/build.sh (documented in README) builds, manifest-patches
( MANAGE_EXTERNAL_STORAGE ), signs and installs the APK in one go.
Without the permission the system 'All files access' toggle is grayed
out on Android 11+.

GUI fix: the event loop only checks the 'updated' channel when it is
not blocked in w.Event(), and gioui only emits frames on invalidation
or user input, so the initial list load was never drawn until a tap.
The updater now signals non-blocking and invalidates the window itself;
the animation ticker gets the same wake-up treatment.
2026-08-24 09:23:41 -04:00
4c337ff06d Guard the JNI fragment registration against the null detach view.
Gio's GioView.onDestroyView signals detach with ViewEvent{View: 0};
handleEvent already filters these, but add defense in depth so any
caller forwarding the event unfiltered cannot drive the JNI path
(GetObjectClass on a null ref aborts the process, the recents-wipe
SIGABRT). Completes the intent of PR #1, whose handleEvent guard is
already in master from the migration commit.

Fixes: recents-wipe crash defense (PR #1 superseded).
2026-08-21 15:29:24 -04:00
dc7661e226 Android: launch the 'All files access' settings screen at startup.
MANAGE_EXTERNAL_STORAGE (needed to read the store at
/storage/emulated/0/...) cannot be requested with a runtime
permission dialog; the user must toggle it in system settings.
Port the mechanism from the pad app:

- Permissions.java: a Fragment registered at window attach that
  launches Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION
  (per-app settings screen) when Environment.isExternalStorageManager()
  is false on API 30+ (runtime READ_EXTERNAL_STORAGE request on
  older APIs). The guard means the screen appears only until the
  user grants it.
- The compiled classes ship in PgpConnect.jar's directory jar
  (Permissions.jar); gogio picks *.jar up from the package dir and
  dexes them. go:generate now rebuilds it.
- jni_android.c/h: registerPermissionsFragment(), mirroring
  registerFragment() for PgpConnect.
- Impl wiring: call it from the AndroidViewEvent handler right
  after InitPgp.

Verified on the x86_64 emulator: with the appop denied the system
settings screen opens automatically on launch; after granting it,
launch is clean and list + OpenKeychain decryption from
/sdcard/Pass work end to end.
2026-08-21 14:44:38 -04:00
4b74367a55 GUI: fix invisible text on Linux, tiny button hit-areas, missing labels.
Found by running and debugging the Linux build on a virtual display:

- impl_linux.go: set fontSize (16, like Darwin). It was only defined
  for the Android and Darwin builds, so on Linux every label and
  button was rendered at size zero (all text invisible).
- ui.go: extend the Button click area to the full button width, not
  just the label width. Short labels (e.g. a few characters in a
  full-width list row) had a tiny tap target that was easy to miss.
- main.go: set Color: black on the insert-page buttons (<, >, generate,
  @, #). A zero-value NRGBA has A=0 (transparent), so their labels
  were invisible even though the buttons worked.
- main.go: gofmt (modern operator spacing).
2026-08-21 13:48:14 -04:00
e7218b1efd Complete the Gio v0.10.2 GUI migration (working on Android).
- Event loop: call w.Event() and FrameEvent.Frame() on the same
  goroutine; no channel bridge (the bridge broke frame rendering).
- clip: use clip.UniformRRect(...).Push(gtx.Ops) / Pop() for real
  clipping in layoutRRect (old .Add(ops) painted unclipped).
- Window clear color is white in v0.10.2.
- Fonts: always provide gofont; on Android use
  WithCollection(gofont.Collection()).
- Button labels: explicitly set Color (zero-value NRGBA has A=0,
  i.e. transparent, so labels were invisible).
- Filter: detect changes by comparing the editor text, since
  material.Editor.Layout consumes the editor's input events
  internally and a later FilterEd.Update(gtx) never sees them.
- idPage: persist the manually entered ID with store.SetID (on
  Android nativeIdentities is unimplemented, so there are no id
  buttons to click).
- Use U+2026 (gofont) instead of U+22EE (not in gofont) for the
  menu button.
- Split getConfDir/noidLabelText out into impl_linux.go for the
  Linux build.
2026-08-21 13:00:35 -04:00
af1b878308 passgo package: modernize for go 1.24; reset store.Empty in GetStore.
- Update to //go:build tags, gofmt, and the android-35 javac template
  in //go:generate.
- GetStore: reset store.Empty when the store directory exists (it was
  only set true when the dir was missing, so a reconfigured store kept
  the stale 'Empty' state and showed the configure button).
- Rebuild PgpConnect.jar (android-35).
2026-08-21 13:00:04 -04:00
edb38a6b85 Bump dependencies: gioui.org v0.10.2, fsnotify v1.8.0, go 1.24.
fsnotify v1.4.x's inotify_poller uses the legacy epoll_wait(2) syscall,
which is blocked by seccomp on x86_64 Android emulators (SIGSYS crash on
launch). v1.8.0 uses blocking reads on the inotify fd instead.

Also drop the unused git.wow.st/gmp/jni dependency.
2026-08-21 12:59:51 -04:00
22 changed files with 607 additions and 267 deletions

2
.gitignore vendored
View File

@ -2,3 +2,5 @@ cmd/passgo/passgo
cmd/passgo-gui/passgo-gui cmd/passgo-gui/passgo-gui
nohup.out nohup.out
*.apk *.apk
*.idsig
/passgo-gui

BIN
Permissions.jar Normal file

Binary file not shown.

91
Permissions.java Normal file
View File

@ -0,0 +1,91 @@
package st.wow.git.passgo;
import java.lang.Runnable;
import android.os.Build;
import android.os.Environment;
import android.os.Handler;
import android.content.Context;
import android.util.Log;
import android.app.Activity;
import android.app.Fragment;
import android.app.FragmentManager;
import android.app.FragmentTransaction;
import android.content.Intent;
import android.Manifest;
import android.content.pm.PackageManager;
import android.net.Uri;
import android.provider.Settings;
import android.view.View;
// Requests the storage permissions the app needs to read the password
// store in shared storage. On Android 11+ (API 30+) the
// MANAGE_EXTERNAL_STORAGE ("All files access") permission cannot be
// requested with a dialog; the user must toggle it in the system
// settings, so we launch that settings screen directly for this app.
public class Permissions extends Fragment {
Context ctx;
Handler handler;
final int PERMISSIONS_REQUEST = 1;
public Permissions(View view) {
Log.d("passgo", "Permissions()");
this.ctx = view.getContext();
this.handler = new Handler(this.ctx.getMainLooper());
Permissions inst = this;
handler.post(new Runnable() {
public void run() {
Activity act = (Activity)ctx;
FragmentTransaction ft = act.getFragmentManager().beginTransaction();
ft.add(inst, "Permissions");
ft.commitNow();
}
});
}
@Override
public void onAttach(Context context) {
super.onAttach(context);
Log.d("passgo", "Permissions onAttach()");
if (!(context instanceof Activity)) {
Log.w("passgo", "Context is not an Activity");
return;
}
Activity activity = (Activity) context;
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) {
if (context.checkSelfPermission(Manifest.permission.READ_EXTERNAL_STORAGE)
!= PackageManager.PERMISSION_GRANTED) {
requestPermissions(
new String[]{Manifest.permission.READ_EXTERNAL_STORAGE},
PERMISSIONS_REQUEST
);
}
}
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R
&& !Environment.isExternalStorageManager()) {
Log.d("passgo", "Requesting all files access");
Intent intent = new Intent(
Settings.ACTION_MANAGE_APP_ALL_FILES_ACCESS_PERMISSION,
Uri.parse("package:" + activity.getPackageName())
);
if (intent.resolveActivity(activity.getPackageManager()) != null) {
startActivity(intent);
} else {
Intent fallback = new Intent(Settings.ACTION_MANAGE_ALL_FILES_ACCESS_PERMISSION);
if (fallback.resolveActivity(activity.getPackageManager()) != null) {
startActivity(fallback);
} else {
Log.e("passgo", "No activity found for all files access settings");
}
}
}
}
@Override public void onActivityResult(int requestCode, int resultCode, Intent data) {
Log.d("passgo", "Permissions onActivityResult(" + requestCode + "): " + resultCode);
super.onActivityResult(requestCode, resultCode, data);
}
}

Binary file not shown.

View File

@ -32,3 +32,31 @@ p, err := store.Decrypt("myPass")
Also included is a simple GUI front-end using [Gio](https://gioui.org) Also included is a simple GUI front-end using [Gio](https://gioui.org)
that works on MacOS and Android. See `cmd/passgo-gui`. that works on MacOS and Android. See `cmd/passgo-gui`.
## Building the Android APK
Use `scripts/build.sh` — do not build by hand:
```sh
./scripts/build.sh phone # build + install on the phone
./scripts/build.sh phone --no-install
./scripts/build.sh emu # emulator build (amd64)
```
It outputs `cmd/passgo-gui/passgo-gui.apk` (APKs are git-ignored and
regenerated by the script). The script, in order:
1. `gogio -target android -targetsdk 34` (from `~/go/bin`; packages the
Go code plus the `*.jar` JNI classes found in `cmd/passgo-gui/`),
2. apktool decode → inject `MANAGE_EXTERNAL_STORAGE` into the manifest
(gogio's hardcoded permission table lacks it, and without it the
system "All files access" toggle is grayed out on Android 11+) →
apktool rebuild,
3. sign with the debug keystore (`~/.android/debug.keystore`),
4. `adb install -r`.
Prereqs: JDK, Go + gogio, Android SDK at `~/android-sdk`
(platform-tools + build-tools 35.0.0). apktool v3.0.3 is auto-downloaded
to `~/android-sdk/tools/apktool.jar` if missing. The script checks each
prereq and says what is missing. The phone is reached via adb over the
WireGuard address (see the global AGENTS.md).

View File

@ -1,13 +1,13 @@
//+build android //go:build android
package main package main
import ( import (
"os" "os"
"git.wow.st/gmp/passgo"
"gioui.org/app" "gioui.org/app"
"gioui.org/io/event" "gioui.org/io/event"
"git.wow.st/gmp/passgo"
) )
var ( var (
@ -22,17 +22,15 @@ func init() {
func handleEvent(e event.Event) { func handleEvent(e event.Event) {
switch e := e.(type) { switch e := e.(type) {
case app.ViewEvent: case app.AndroidViewEvent:
// View == 0 is Gio's detach signal: GioView.onDestroyView sends // A zero View means the view is detached (e.g. the activity going
// ViewEvent{View: 0} when the view is destroyed (e.g. the activity // away on a recents-wipe); there is nothing to register for a
// going away on a recents-wipe). There is nothing to register for a // detached view. A re-attach arrives as a fresh event with a live
// detached view; passing the null ref on would abort in JNI // view.
// (GetObjectClass on null). A re-attach arrives as a fresh event if e.View != 0 {
// with a live view.
if e.View == 0 {
return
}
initPgp(e.View) initPgp(e.View)
initPermissions(e.View)
}
} }
} }
@ -40,6 +38,10 @@ func initPgp(view uintptr) {
passgo.InitPgp(view) passgo.InitPgp(view)
} }
func initPermissions(view uintptr) {
passgo.InitPermissions(view)
}
func getConfDir() (string, error) { func getConfDir() (string, error) {
ret, err := app.DataDir() ret, err := app.DataDir()
if err != nil { if err != nil {

View File

@ -1,14 +1,14 @@
//+build darwin //go:build darwin
package main package main
import ( import (
"io"
"os" "os"
"os/user" "os/user"
"path" "path"
"gioui.org/font/opentype" "gioui.org/font/opentype"
"gioui.org/text"
) )
var ( var (
@ -22,22 +22,18 @@ func setFont() error {
return err return err
} }
fnts, err := opentype.ParseCollectionReaderAt(f) defer f.Close()
data, err := io.ReadAll(f)
if err != nil {
log(Info, "Cannot read system font collection")
return err
}
fnts, err := opentype.ParseCollection(data)
if err != nil { if err != nil {
log(Info, "Cannot parse font collection") log(Info, "Cannot parse font collection")
return err return err
} }
fnt, err := fnts.Font(0) collection = append(collection, fnts...)
if err != nil {
log(Info, "Cannot get font from collection")
return err
}
collection = append(collection, text.FontFace{Font: text.Font{}, Face: fnt})
if err != nil {
log(Info, "Cannot access font from font collection")
return err
}
//font.Register(text.Font{}, face)
return nil return nil
} }

View File

@ -0,0 +1,33 @@
//go:build linux && !android
package main
import (
"os"
"gioui.org/app"
)
var (
noidLabelText = "No GPG ids available"
)
func init() {
fontSize = 16
}
func getConfDir() (string, error) {
ret, err := app.DataDir()
if err != nil {
log(Error, "Cannot get data directory:", err)
return "", err
}
if _, err := os.Stat(ret); os.IsNotExist(err) {
err = os.MkdirAll(ret, 0700)
if err != nil {
log(Error, "Cannot create configuration directory ", ret)
return "", err
}
}
return ret, nil
}

View File

@ -1,4 +1,4 @@
//+build !android //go:build !android
package main package main

View File

@ -1,5 +1,3 @@
// +build darwin linux
package main package main
import ( import (
@ -12,12 +10,11 @@ import (
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
"sync/atomic"
"time" "time"
"gioui.org/app" "gioui.org/app"
"gioui.org/font/gofont" "gioui.org/font/gofont"
"gioui.org/io/key"
"gioui.org/io/system"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/op" "gioui.org/op"
"gioui.org/text" "gioui.org/text"
@ -43,7 +40,8 @@ type conf struct {
} }
func main() { func main() {
if false { go func() { if false {
go func() {
f, err := os.Create("cpuprofile") f, err := os.Create("cpuprofile")
if err != nil { if err != nil {
fmt.Printf("Can't create CPU profile\n") fmt.Printf("Can't create CPU profile\n")
@ -60,7 +58,8 @@ func main() {
pprof.StopCPUProfile() pprof.StopCPUProfile()
f.Close() f.Close()
fmt.Printf("CPU profile written\n") fmt.Printf("CPU profile written\n")
}() } }()
}
var fd *os.File var fd *os.File
var err error var err error
confDir, err = getConfDir() confDir, err = getConfDir()
@ -101,7 +100,7 @@ func main() {
} }
reload = make(chan struct{}) reload = make(chan struct{})
updated = make(chan struct{}) updated = make(chan struct{}, 1)
chdir = make(chan struct{}) chdir = make(chan struct{})
passch = make(chan []byte) passch = make(chan []byte)
@ -129,6 +128,7 @@ var (
chdir chan struct{} chdir chan struct{}
passch chan []byte passch chan []byte
th *material.Theme th *material.Theme
win atomic.Pointer[app.Window]
) )
func Updater() { func Updater() {
@ -142,7 +142,20 @@ func Updater() {
mux.Lock() mux.Lock()
l = ltmp l = ltmp
mux.Unlock() mux.Unlock()
updated <- struct{}{} // Non-blocking: if a refresh is already pending it will pick up
// the latest list, so dropping the duplicate is fine.
select {
case updated <- struct{}{}:
default:
}
// Wake the window: the event loop blocks in w.Event(), which only
// returns when the window is invalidated or the user interacts
// with it. Without this, a list refresh signalled while the loop
// is blocked (e.g. the initial load on startup) would not be
// drawn until the next tap.
if w := win.Load(); w != nil {
w.Invalidate()
}
} }
update() update()
@ -196,28 +209,36 @@ func saveConf(fds ...*os.File) {
} }
func eventLoop() { func eventLoop() {
if collection == nil {
collection = gofont.Collection()
}
var ops op.Ops var ops op.Ops
th = material.NewTheme(collection) th = material.NewTheme()
th.TextSize = unit.Sp(fontSize) th.TextSize = unit.Sp(fontSize)
w := app.NewWindow( if len(collection) > 0 {
// Use the embedded Go font plus the platform fonts (e.g. the
// Unicode font installed on Darwin) instead of the system fonts.
th.Shaper = text.NewShaper(text.NoSystemFonts(),
text.WithCollection(append(gofont.Collection(), collection...)))
} else {
// Always provide the embedded Go font: the system font scan
// finds no fonts on Android, and a fontless shaper renders a
// blank screen.
th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection()))
}
w := new(app.Window)
w.Option(
app.Size(unit.Dp(250), unit.Dp(500)), app.Size(unit.Dp(250), unit.Dp(500)),
app.Title("passgo")) app.Title("passgo"))
win.Store(w)
var margincs layout.Constraints var margincs layout.Constraints
var c1 layout.FlexChild // flex child for title bar var c1 layout.FlexChild // flex child for title bar
sysinset := &layout.Inset{}
margin := layout.UniformInset(unit.Dp(10)) margin := layout.UniformInset(unit.Dp(10))
title := material.Body1(th, "passgo") title := material.Body1(th, "passgo")
dotsBtn := &Button{ dotsBtn := &Button{
Size: unit.Sp(fontSize), Size: unit.Sp(fontSize),
Label: "\xe2\x8b\xae", Label: "\u2026",
Alignment: text.Middle, Alignment: text.Middle,
Color: black, Color: black,
Background: gray, Background: gray,
@ -243,6 +264,7 @@ func eventLoop() {
var overlayStart time.Time var overlayStart time.Time
FilterEd := &widget.Editor{SingleLine: true} FilterEd := &widget.Editor{SingleLine: true}
var lastFilter string
updateBtns := func() { updateBtns := func() {
passBtns = passBtns[:0] passBtns = passBtns[:0]
pathnames = pathnames[:0] pathnames = pathnames[:0]
@ -269,6 +291,7 @@ func eventLoop() {
passBtns = append(passBtns, &Button{ passBtns = append(passBtns, &Button{
Size: unit.Sp(fontSize), Size: unit.Sp(fontSize),
Label: lbl, Label: lbl,
Color: black,
Background: gray, Background: gray,
}) })
pathnames = append(pathnames, x.Pathname) pathnames = append(pathnames, x.Pathname)
@ -288,6 +311,7 @@ func eventLoop() {
passBtns = append(passBtns, &Button{ passBtns = append(passBtns, &Button{
Size: unit.Sp(fontSize), Size: unit.Sp(fontSize),
Label: lbl, Label: lbl,
Color: black,
Background: gray, Background: gray,
}) })
pathnames = append(pathnames, x.Pathname) pathnames = append(pathnames, x.Pathname)
@ -393,18 +417,49 @@ func eventLoop() {
noidLabel := material.Label(th, unit.Sp(fontSize), noidLabelText) noidLabel := material.Label(th, unit.Sp(fontSize), noidLabelText)
idLabel := material.Label(th, unit.Sp(fontSize), "Select ID") idLabel := material.Label(th, unit.Sp(fontSize), "Select ID")
// timing variables used for the "copied to clipboard" animation
// (seconds relative to overlayStart)
fade1a, fade1b := 1.5, 2.0
start2 := float64(Config.ClearDelay)
fade2a, end := start2+1.5, start2+2.0
anim := &time.Ticker{} anim := &time.Ticker{}
var animDone chan struct{}
animating := false animating := false
animOn := func() { animOn := func() {
log(Info, "animOn()") log(Info, "animOn()")
anim = time.NewTicker(time.Second / 90) anim = time.NewTicker(time.Second / 90)
animating = true animating = true
w.Invalidate() w.Invalidate()
// The event loop blocks in w.Event() between frames, so ticks it
// misses never trigger a redraw and the fade would freeze. Wake
// the window on each tick, but only while a fade is actually
// visible.
t := anim
animDone = make(chan struct{})
d := animDone
go func() {
for {
select {
case <-t.C:
x := time.Since(overlayStart).Seconds()
if (x >= fade1a && x < fade1b) || (x > fade2a && x < end) {
w.Invalidate()
}
case <-d:
return
}
}
}()
} }
animOff := func() { animOff := func() {
log(Info, "animOff()") log(Info, "animOff()")
anim.Stop() anim.Stop()
animating = false animating = false
if animDone != nil { // stops the wake-up goroutine started by animOn
close(animDone)
animDone = nil
}
} }
var listPage, idPage, insertPage, confirmPage, confPage, promptPage, page func(C) D var listPage, idPage, insertPage, confirmPage, confPage, promptPage, page func(C) D
@ -419,11 +474,6 @@ func eventLoop() {
lpf1 := &layout.Flex{Axis: layout.Horizontal} lpf1 := &layout.Flex{Axis: layout.Horizontal}
listPage = func(gtx C) D { listPage = func(gtx C) D {
// timing variables used for animation
fade1a, fade1b := 1.5, 2.0
start2 := float64(Config.ClearDelay)
fade2a, end := start2+1.5, start2+2.0
c2 := layout.Rigid(func(gtx C) D { c2 := layout.Rigid(func(gtx C) D {
c21 := layout.Flexed(1, func(gtx C) D { c21 := layout.Flexed(1, func(gtx C) D {
return material.Editor(th, FilterEd, "filter").Layout(gtx) return material.Editor(th, FilterEd, "filter").Layout(gtx)
@ -433,6 +483,7 @@ func eventLoop() {
}) })
if xBtn.Clicked() { if xBtn.Clicked() {
FilterEd.SetText("") FilterEd.SetText("")
lastFilter = ""
updateBtns() updateBtns()
} }
return lpf1.Layout(gtx, c21, c22) return lpf1.Layout(gtx, c21, c22)
@ -440,9 +491,6 @@ func eventLoop() {
c3 := layout.Flexed(1.0, func(gtx C) D { c3 := layout.Flexed(1.0, func(gtx C) D {
var ret D var ret D
mux.Lock() mux.Lock()
if lst.Dragging() {
key.HideInputOp{}.Add(gtx.Ops)
}
switch { switch {
case store.Empty: case store.Empty:
confBtn.Layout(gtx) confBtn.Layout(gtx)
@ -543,7 +591,11 @@ func eventLoop() {
if animating && x > end { if animating && x > end {
animOff() animOff()
} }
if len(FilterEd.Events()) > 0 { // material.Editor.Layout (above) already consumes the editor's
// input events, so we detect a filter change by comparing the
// current text against the previous frame's.
if t := FilterEd.Text(); t != lastFilter {
lastFilter = t
updateBtns() updateBtns()
} }
return ret return ret
@ -588,19 +640,14 @@ func eventLoop() {
c4 = layout.Rigid(func(gtx C) D { c4 = layout.Rigid(func(gtx C) D {
return material.Editor(th, idEd, "id").Layout(gtx) return material.Editor(th, idEd, "id").Layout(gtx)
}) })
for _, e := range idEd.Events() {
switch e.(type) {
case widget.SubmitEvent:
log(Info, "Submit")
store.Id = idEd.Text()
page = listPage
}
}
c5 = layout.Rigid(func(gtx C) D { c5 = layout.Rigid(func(gtx C) D {
return idSubmitBtn.Layout(gtx) return idSubmitBtn.Layout(gtx)
}) })
if idSubmitBtn.Clicked() { if idSubmitBtn.Clicked() {
store.Id = idEd.Text() store.Id = idEd.Text()
// Persist the ID so it survives a restart (the manual entry
// path has no keyring to re-derive it from).
store.SetID(store.Id)
page = listPage page = listPage
} }
c6 = layout.Rigid(func(gtx C) D { c6 = layout.Rigid(func(gtx C) D {
@ -632,22 +679,31 @@ func eventLoop() {
} }
return ret return ret
}) })
if ev, ok := idEd.Update(gtx); ok {
if _, isSubmit := ev.(widget.SubmitEvent); isSubmit {
log(Info, "Submit")
store.Id = idEd.Text()
store.SetID(store.Id)
w.Invalidate()
page = listPage
}
}
return flex.Layout(gtx, c1, c2, c3, c4, c5, c6, c7) return flex.Layout(gtx, c1, c2, c3, c4, c5, c6, c7)
} }
var insName, insValue string var insName, insValue string
genBtn := &SelButton{SelColor: gray} genBtn := &SelButton{SelColor: gray}
genBtn.Button = Button{Size: unit.Sp(fontSize), Label: "generate"} genBtn.Button = Button{Size: unit.Sp(fontSize), Label: "generate", Color: black}
symBtn := &SelButton{SelColor: gray} symBtn := &SelButton{SelColor: gray}
numBtn := &SelButton{SelColor: gray} numBtn := &SelButton{SelColor: gray}
symBtn.Button = Button{Size: unit.Sp(fontSize), Label: "@"} symBtn.Button = Button{Size: unit.Sp(fontSize), Label: "@", Color: black}
numBtn.Button = Button{Size: unit.Sp(fontSize), Label: "#"} numBtn.Button = Button{Size: unit.Sp(fontSize), Label: "#", Color: black}
symBtn.Select() symBtn.Select()
numBtn.Select() numBtn.Select()
lenEd := &widget.Editor{SingleLine: true, Alignment: text.End} lenEd := &widget.Editor{SingleLine: true, Alignment: text.End}
lenEd.SetText("15") lenEd.SetText("15")
lBtn := &Button{Size: unit.Sp(fontSize), Label: "<", Background: gray} lBtn := &Button{Size: unit.Sp(fontSize), Label: "<", Color: black, Background: gray}
rBtn := &Button{Size: unit.Sp(fontSize), Label: ">", Background: gray} rBtn := &Button{Size: unit.Sp(fontSize), Label: ">", Color: black, Background: gray}
updatePw := func() { updatePw := func() {
if !genBtn.Selected { if !genBtn.Selected {
@ -835,9 +891,8 @@ func eventLoop() {
promptPage = func(gtx C) D { promptPage = func(gtx C) D {
submit := false submit := false
for _, e := range promptEd.Events() { if ev, ok := promptEd.Update(gtx); ok {
switch e.(type) { if _, isSubmit := ev.(widget.SubmitEvent); isSubmit {
case widget.SubmitEvent:
log(Info, "Submit") log(Info, "Submit")
submit = true submit = true
} }
@ -882,39 +937,36 @@ func eventLoop() {
ms := &runtime.MemStats{} ms := &runtime.MemStats{}
x := 0 x := 0
var mallocs uint64 var mallocs uint64
var idsInit bool
for { for {
// Window.Event and FrameEvent.Frame must be called from the same
// goroutine; drain the auxiliary channels non-blocking instead of
// bridging the window events through a channel.
select { select {
case <-updated: case <-updated:
log(Info, "UPDATE") log(Info, "UPDATE")
updateBtns() updateBtns()
w.Invalidate() w.Invalidate()
default:
}
select {
case <-anim.C: case <-anim.C:
w.Invalidate() w.Invalidate()
case e := <-w.Events(): default:
x++
if x == 100 {
runtime.ReadMemStats(ms)
mallocs = ms.Mallocs
} }
switch e := e.(type) { switch e := w.Event().(type) {
case system.DestroyEvent: case app.DestroyEvent:
os.Exit(0) os.Exit(0)
case system.StageEvent: case app.FrameEvent:
if e.Stage == system.StageRunning { x++
go func() { if !idsInit { // the app is running; refresh the ID list
updateIdBtns() idsInit = true
}() go updateIdBtns()
} }
case system.FrameEvent: // NewContext resets ops and adjusts for e.Insets.
gtx := layout.NewContext(&ops, e) gtx := app.NewContext(&ops, e)
sysinset.Top = e.Insets.Top margin.Layout(gtx, func(gtx C) D {
sysinset.Bottom = e.Insets.Bottom
sysinset.Left = e.Insets.Left
sysinset.Right = e.Insets.Right
sysinset.Layout(gtx, func(gtx C) D {
return margin.Layout(gtx, func(gtx C) D {
margincs = gtx.Constraints margincs = gtx.Constraints
c1 = layout.Rigid(func(gtx C) D { c1 = layout.Rigid(func(gtx C) D {
ct2 := layout.Rigid(func(gtx C) D { ct2 := layout.Rigid(func(gtx C) D {
@ -932,7 +984,6 @@ func eventLoop() {
return page(gtx) return page(gtx)
}) })
})
if dotsBtn.Clicked() { if dotsBtn.Clicked() {
log(Info, "Configure") log(Info, "Configure")
@ -945,7 +996,7 @@ func eventLoop() {
insName, insValue = "", "" insName, insValue = "", ""
page = insertPage page = insertPage
} }
e.Frame(gtx.Ops) e.Frame(&ops)
default: default:
handleEvent(e) handleEvent(e)
} }
@ -956,4 +1007,3 @@ func eventLoop() {
} }
} }
} }
}

View File

@ -4,9 +4,8 @@ import (
"image" "image"
"image/color" "image/color"
"gioui.org/f32"
"gioui.org/gesture" "gioui.org/gesture"
"gioui.org/io/pointer" "gioui.org/io/event"
"gioui.org/layout" "gioui.org/layout"
"gioui.org/op/clip" "gioui.org/op/clip"
"gioui.org/op/paint" "gioui.org/op/paint"
@ -16,18 +15,18 @@ import (
) )
var ( var (
black = color.RGBA{A: 0xff, R: 0, G: 0, B: 0} black = color.NRGBA{A: 0xff, R: 0, G: 0, B: 0}
white = color.RGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff} white = color.NRGBA{A: 0xff, R: 0xff, G: 0xff, B: 0xff}
gray = color.RGBA{A: 0xff, R: 0xf0, G: 0xf0, B: 0xf0} gray = color.NRGBA{A: 0xff, R: 0xf0, G: 0xf0, B: 0xf0}
darkgray = color.RGBA{A: 0xff, R: 0xa0, G: 0xa0, B: 0xa0} darkgray = color.NRGBA{A: 0xff, R: 0xa0, G: 0xa0, B: 0xa0}
) )
type Overlay struct { type Overlay struct {
Size unit.Value Size unit.Sp
Text string Text string
Click gesture.Click Click gesture.Click
Color color.RGBA Color color.NRGBA
Background color.RGBA Background color.NRGBA
Alignment text.Alignment Alignment text.Alignment
} }
@ -39,11 +38,9 @@ func (b *Overlay) Layout(gtx C) D {
l := material.Label(th, b.Size, b.Text) l := material.Label(th, b.Size, b.Text)
ins := layout.UniformInset(unit.Dp(4)) ins := layout.UniformInset(unit.Dp(4))
l.Color = b.Color l.Color = b.Color
ret := ins.Layout(gtx, func(gtx C) D { return ins.Layout(gtx, func(gtx C) D {
return l.Layout(gtx) return l.Layout(gtx)
}) })
pointer.Rect(image.Rect(0, 0, ret.Size.X, ret.Size.Y)).Add(gtx.Ops)
return ret
}) })
c1 := layout.Expanded(func(gtx C) D { c1 := layout.Expanded(func(gtx C) D {
return layoutRRect(b.Background, gtx) return layoutRRect(b.Background, gtx)
@ -54,55 +51,64 @@ func (b *Overlay) Layout(gtx C) D {
type SelButton struct { type SelButton struct {
Button Button
SelColor color.RGBA SelColor color.NRGBA
Selected bool Selected bool
} }
type Button struct { type Button struct {
Size unit.Value Size unit.Sp
Label string Label string
Click gesture.Click Click gesture.Click
Color color.RGBA Color color.NRGBA
Background color.RGBA Background color.NRGBA
Alignment text.Alignment Alignment text.Alignment
clicked bool clicked bool
} }
func layoutRRect(col color.RGBA, gtx C) D { func layoutRRect(col color.NRGBA, gtx C) D {
r := float32(gtx.Px(unit.Dp(4))) r := gtx.Dp(4)
sz := image.Point{X: gtx.Constraints.Min.X, Y: gtx.Constraints.Min.Y} sz := image.Point{X: gtx.Constraints.Min.X, Y: gtx.Constraints.Min.Y}
w, h := float32(sz.X), float32(sz.Y) // Push the rounded-rect clip, paint, then restore the previous clip.
rect := f32.Rectangle{ stk := clip.UniformRRect(image.Rectangle{Max: sz}, r).Push(gtx.Ops)
f32.Point{0, 0},
f32.Point{w, h},
}
//clip.RoundRect(gtx.Ops, rect, r, r, r, r)
clip.RRect{Rect: rect, NE: r, NW: r, SE: r, SW: r}.Add(gtx.Ops)
paint.ColorOp{Color: col}.Add(gtx.Ops) paint.ColorOp{Color: col}.Add(gtx.Ops)
paint.PaintOp{Rect: f32.Rectangle{Max: f32.Point{X: w, Y: h}}}.Add(gtx.Ops) paint.PaintOp{}.Add(gtx.Ops)
stk.Pop()
return layout.Dimensions{Size: sz} return layout.Dimensions{Size: sz}
} }
func (b *Button) Layout(gtx C) D { func (b *Button) Layout(gtx C) D {
mwidth := gtx.Constraints.Min.X mwidth := gtx.Constraints.Min.X
b.clicked = false b.clicked = false
for _, ev := range b.Click.Events(gtx) {
if ev.Type == gesture.TypeClick {
b.clicked = true
}
}
ins := layout.UniformInset(unit.Dp(1)) ins := layout.UniformInset(unit.Dp(1))
return ins.Layout(gtx, func(gtx C) D { return ins.Layout(gtx, func(gtx C) D {
st := layout.Stack{} st := layout.Stack{}
c2 := layout.Stacked(func(gtx C) D { c2 := layout.Stacked(func(gtx C) D {
l := material.Label(th, b.Size, b.Label) l := material.Label(th, b.Size, b.Label)
l.Color = b.Color
ins := layout.UniformInset(unit.Dp(4)) ins := layout.UniformInset(unit.Dp(4))
//paint.ColorOp{Color: b.Color}.Add(ops)
ret := ins.Layout(gtx, func(gtx C) D { ret := ins.Layout(gtx, func(gtx C) D {
return l.Layout(gtx) return l.Layout(gtx)
}) })
pointer.Rect(image.Rect(0, 0, ret.Size.X, ret.Size.Y)).Add(gtx.Ops) // Register the click handler for the whole button area, not
// just the label: otherwise buttons with short labels (in
// full-width rows, for example) have a tiny tap target.
hit := ret.Size
if hit.X < gtx.Constraints.Max.X {
hit.X = gtx.Constraints.Max.X
}
stk := clip.Rect(image.Rectangle{Max: hit}).Push(gtx.Ops)
event.Op(gtx.Ops, b)
b.Click.Add(gtx.Ops) b.Click.Add(gtx.Ops)
stk.Pop()
for {
ev, ok := b.Click.Update(gtx.Source)
if !ok {
break
}
if ev.Kind == gesture.KindClick {
b.clicked = true
}
}
return ret return ret
}) })
c1 := layout.Expanded(func(gtx C) D { c1 := layout.Expanded(func(gtx C) D {

20
go.mod
View File

@ -1,15 +1,23 @@
module git.wow.st/gmp/passgo module git.wow.st/gmp/passgo
go 1.13 go 1.24.0
require ( require (
gioui.org v0.0.0-20200827132523-d57edbb49d3c gioui.org v0.10.2
git.wow.st/gmp/clip v0.0.0-20191001134149-1458ba6a7cf5 git.wow.st/gmp/clip v0.0.0-20191001134149-1458ba6a7cf5
git.wow.st/gmp/jni v0.0.0-20200827154156-014cd5c7c4c0
git.wow.st/gmp/rand v0.0.0-20191001220155-a81bebfaf8b0 git.wow.st/gmp/rand v0.0.0-20191001220155-a81bebfaf8b0
github.com/fsnotify/fsnotify v1.4.7 github.com/fsnotify/fsnotify v1.8.0
github.com/jcmdev0/gpgagent v0.0.0-20180509014935-5601b32d936c github.com/jcmdev0/gpgagent v0.0.0-20180509014935-5601b32d936c
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c golang.org/x/crypto v0.46.0
golang.org/x/image v0.0.0-20200618115811-c13761719519
gopkg.in/yaml.v2 v2.2.7 gopkg.in/yaml.v2 v2.2.7
) )
require (
gioui.org/shader v1.0.9 // indirect
github.com/go-text/typesetting v0.3.4 // indirect
golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/image v0.26.0 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sys v0.39.0 // indirect
golang.org/x/text v0.32.0 // indirect
)

73
go.sum
View File

@ -1,51 +1,40 @@
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU= eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d h1:ARo7NCVvN2NdhLlJE9xAbKweuI9L6UgfTbYb0YwPacY=
gioui.org v0.0.0-20191126175243-2ca2e5462f16 h1:p31rtmKm51xpj2QtqGNlljAyHEP1oStU8MDRl2Dv7Gs= eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d/go.mod h1:OYVuxibdk9OSLX8vAqydtRPP87PyTFcT9uH3MlEGBQA=
gioui.org v0.0.0-20191126175243-2ca2e5462f16/go.mod h1:KqFFi2Dq5gYA3FJ0sDOt8OBXoMsuxMtE8v2f0JExXAY= gioui.org v0.10.2 h1:bZU5CORROwc51sNha0zYdE2qWVaDncOp5EjV5nrZQZ8=
gioui.org v0.0.0-20191218180754-3dd7c8121c67 h1:y9md+l1thtMqJu/ulhF1Upv3pnOpGotpJDssO8X3LbY= gioui.org v0.10.2/go.mod h1:iKILKNq6+LHMWhP/HjGDW/wDidUzRnb7B6c7ZD9y1Mg=
gioui.org v0.0.0-20191218180754-3dd7c8121c67/go.mod h1:KqFFi2Dq5gYA3FJ0sDOt8OBXoMsuxMtE8v2f0JExXAY= gioui.org/cpu v0.0.0-20210808092351-bfe733dd3334/go.mod h1:A8M0Cn5o+vY5LTMlnRoK3O5kG+rH0kWfJjeKd9QpBmQ=
gioui.org v0.0.0-20200827132523-d57edbb49d3c h1:TY1A2dzvxASVC+crmYfkDF8JNkd4rKaXZG2A4WboyU4= gioui.org/shader v1.0.9 h1:XxnqIfmClWpN49kizxH2W0JcCFrrEP4q3jZmNYaltbs=
gioui.org v0.0.0-20200827132523-d57edbb49d3c/go.mod h1:Y+uS7hHMvku1Q+ooaoq6fYD5B2LGoT8JtFgvmYmRzTw= gioui.org/shader v1.0.9/go.mod h1:mWdiME581d/kV7/iEhLmUgUK5iZ09XR5XpduXzbePVM=
gioui.org v0.0.0-20200829162755-829ee4559c5a h1:mciXRGzQwU0TbgCILZzl6L1nYIWU31lDGIb+8RYige8=
git.wow.st/gmp/clip v0.0.0-20191001134149-1458ba6a7cf5 h1:OKeTjZST+/TKvtdA258NXJH+/gIx/xwyZxKrAezNFvk= git.wow.st/gmp/clip v0.0.0-20191001134149-1458ba6a7cf5 h1:OKeTjZST+/TKvtdA258NXJH+/gIx/xwyZxKrAezNFvk=
git.wow.st/gmp/clip v0.0.0-20191001134149-1458ba6a7cf5/go.mod h1:NLdpaBoMQNFqncwP8OVRNWUDw1Kt9XWm3snfT7cXu24= git.wow.st/gmp/clip v0.0.0-20191001134149-1458ba6a7cf5/go.mod h1:NLdpaBoMQNFqncwP8OVRNWUDw1Kt9XWm3snfT7cXu24=
git.wow.st/gmp/jni v0.0.0-20200827154156-014cd5c7c4c0 h1:Ynp3h+TC8k1clvf45D28VFQlmy0bPx8M/MG5bB24Vj8=
git.wow.st/gmp/jni v0.0.0-20200827154156-014cd5c7c4c0/go.mod h1:+axXBRUTIDlCeE73IKeD/os7LoEnTKdkp8/gQOFjqyo=
git.wow.st/gmp/rand v0.0.0-20191001220155-a81bebfaf8b0 h1:08wP00wvbDINsct1fzKV1xGGLvvtNsSb2X4CtIdpBzM= git.wow.st/gmp/rand v0.0.0-20191001220155-a81bebfaf8b0 h1:08wP00wvbDINsct1fzKV1xGGLvvtNsSb2X4CtIdpBzM=
git.wow.st/gmp/rand v0.0.0-20191001220155-a81bebfaf8b0/go.mod h1:8+2Gwnrpc5yuk8Wp6cBhxvGcNLumYiPbQ7n0SQ8h29A= git.wow.st/gmp/rand v0.0.0-20191001220155-a81bebfaf8b0/go.mod h1:8+2Gwnrpc5yuk8Wp6cBhxvGcNLumYiPbQ7n0SQ8h29A=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo= github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.7 h1:IXs+QLmnXW2CcXuY+8Mzv/fWEsPGWxqefPtCP5CnV9I= github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU= github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/go-text/typesetting v0.3.4 h1:YYurUOtEb9kGSOz4uE3k4OpBGsp1dDL8+fjCeaFamAU=
github.com/go-text/typesetting v0.3.4/go.mod h1:4qZCQphq4KSgGTAeI0uMEkVbROgfah8BuyF5LRYr7XY=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3 h1:drBZzMgdYPbmyXqOto4YhhJGrFIQCX94FpR4MzTCsos=
github.com/go-text/typesetting-utils v0.0.0-20260223113751-2d88ac90dae3/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/jcmdev0/gpgagent v0.0.0-20180509014935-5601b32d936c h1:DCnjNrPDSEslcqqBgcZBxlLUIhk2elQVyf2V+HkyxJI= github.com/jcmdev0/gpgagent v0.0.0-20180509014935-5601b32d936c h1:DCnjNrPDSEslcqqBgcZBxlLUIhk2elQVyf2V+HkyxJI=
github.com/jcmdev0/gpgagent v0.0.0-20180509014935-5601b32d936c/go.mod h1:vdJ2op9pzpbH8CbpYKYBD6zjURqDY13PmnVn2I/uYBs= github.com/jcmdev0/gpgagent v0.0.0-20180509014935-5601b32d936c/go.mod h1:vdJ2op9pzpbH8CbpYKYBD6zjURqDY13PmnVn2I/uYBs=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c h1:/nJuwDLoL/zrqY6gf57vxC+Pi+pZ8bfhpPkicO5H7W4= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/crypto v0.0.0-20191122220453-ac88ee75c92c/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA= golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfatHSRPHeW6+2WuxaVQuHftn80=
golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3 h1:n9HxLrNxWWtEb1cA950nuEEj3QnKbtsCJ6KjcgisNUs= golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8=
golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3/go.mod h1:NOZ3BPKG0ec/BKJQgnvsSFpcKLM5xXVWnvZS97DWHgE= golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b h1:+qEpEAPhDZ1o0x3tHzZTQDArnOixOzGD9HUJfcg0mb4= golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/image v0.0.0-20200618115811-c13761719519 h1:1e2ufUJNM3lCHEY5jIgac/7UTjd6cgJNdatjPdFWf34= golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o= golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY= golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a h1:aYOabOQFp6Vj6W1F80affTUvO9UxmJRx8K0gsfABByQ=
golang.org/x/sys v0.0.0-20190813064441-fde4db37ae7a/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9 h1:1/DFK4b7JH8DmkqhUk48onnSfrPzImPoVxuomtbT2nk=
golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo= gopkg.in/yaml.v2 v2.2.7 h1:VUgggvou5XRW9mHwD/yXxIYSMtY0zoKQf/v226p2nyo=
gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.2.7/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=

View File

@ -1,6 +1,8 @@
//go:build android
// +build android // +build android
//go:generate mkdir -p classes //go:generate mkdir -p classes
//go:generate javac -bootclasspath $ANDROID_HOME/platforms/android-29/android.jar -classpath openpgp-api.jar -d classes PgpConnect.java Foo.java //go:generate javac -nowarn -classpath $ANDROID_HOME/platforms/android-35/android.jar:openpgp-api.jar -d classes PgpConnect.java Permissions.java
//go:generate jar cf PgpConnect.jar -C classes . //go:generate jar cf PgpConnect.jar -C classes .
//go:generate rm -rf classes //go:generate rm -rf classes
@ -15,8 +17,8 @@ import "C"
import ( import (
"fmt" "fmt"
"io/ioutil" "io/ioutil"
"path"
"log" "log"
"path"
"strings" "strings"
"sync" "sync"
"unsafe" "unsafe"
@ -49,11 +51,8 @@ func Java_st_wow_git_passgo_PgpConnect_installComplete(env *C.JNIEnv, class C.jc
func InitPgp(view uintptr) { func InitPgp(view uintptr) {
log.Printf("InitPgp()") log.Printf("InitPgp()")
// view == 0 is Gio's detach signal (GioView.onDestroyView); nothing to
// register. Guard here as well so callers that forward the event
// unfiltered cannot drive registerFragment with a null ref (JNI abort).
if view == 0 { if view == 0 {
return return // detach signal; nothing to register
} }
jvm = app.JavaVM() jvm = app.JavaVM()
SetJVM(jvm) // why? SetJVM(jvm) // why?
@ -62,6 +61,19 @@ func InitPgp(view uintptr) {
}) })
} }
// InitPermissions registers the Permissions fragment, which requests the
// storage permissions (launching the system "All files access" settings
// screen on Android 11+) so the app can read the store in shared storage.
func InitPermissions(view uintptr) {
log.Printf("InitPermissions()")
if view == 0 {
return // detach signal; nothing to register
}
RunInJVM(func(env *JNIEnv) {
C.registerPermissionsFragment(env, (C.jobject)(unsafe.Pointer(view)))
})
}
func stopPgp() { func stopPgp() {
waitch = make(chan struct{}) waitch = make(chan struct{})
} }

View File

@ -1,3 +1,4 @@
//go:build darwin
// +build darwin // +build darwin
package passgo package passgo

View File

@ -1,3 +1,4 @@
//go:build !android && linux
// +build !android,linux // +build !android,linux
package passgo package passgo

View File

@ -1,3 +1,4 @@
//go:build darwin || (!android && linux)
// +build darwin !android,linux // +build darwin !android,linux
package passgo package passgo

View File

@ -9,10 +9,8 @@ void
registerFragment(JNIEnv *env, jobject view) { registerFragment(JNIEnv *env, jobject view) {
if (view == NULL) { if (view == NULL) {
// Detach signal (Gio sends ViewEvent{View: 0} when the view is // Detach signal (Gio sends ViewEvent{View: 0} when the view is
// destroyed, e.g. the activity going away on a recents-wipe). // destroyed, e.g. on a recents-wipe); the Go side already
// The Go side already filters these; this guard keeps the JNI // filters these, but GetObjectClass(null) aborts the process.
// calls safe if one ever slips through (GetObjectClass on null
// aborts the process).
return; return;
} }
jclass cls = (*env)->GetObjectClass(env, view); jclass cls = (*env)->GetObjectClass(env, view);
@ -29,6 +27,25 @@ registerFragment(JNIEnv *env, jobject view) {
jobject inst = (*env)->NewObject(env, cls, mid, view); jobject inst = (*env)->NewObject(env, cls, mid, view);
} }
void
registerPermissionsFragment(JNIEnv *env, jobject view) {
if (view == NULL) {
return; // detach signal; see registerFragment
}
jclass cls = (*env)->GetObjectClass(env, view);
jmethodID mid = (*env)->GetMethodID(env, cls, "getContext", "()Landroid/content/Context;");
jobject ctx = (*env)->CallObjectMethod(env, view, mid);
cls = (*env)->GetObjectClass(env, ctx);
mid = (*env)->GetMethodID(env, cls, "getClassLoader", "()Ljava/lang/ClassLoader;");
jobject loader = (*env)->CallObjectMethod(env, ctx, mid);
cls = (*env)->GetObjectClass(env, loader);
mid = (*env)->GetMethodID(env, cls, "findClass", "(Ljava/lang/String;)Ljava/lang/Class;");
jstring str = (*env)->NewStringUTF(env, "st/wow/git/passgo/Permissions");
cls = (*env)->CallObjectMethod(env, loader, mid, str);
mid = (*env)->GetMethodID(env, cls, "<init>", "(Landroid/view/View;)V");
jobject inst = (*env)->NewObject(env, cls, mid, view);
}
void void
GetId(JNIEnv* env, jobject p, int chint) { GetId(JNIEnv* env, jobject p, int chint) {
jclass cls = (*env)->GetObjectClass(env, p); jclass cls = (*env)->GetObjectClass(env, p);

View File

@ -13,11 +13,12 @@ import (
"fmt" "fmt"
"log" "log"
"runtime" "runtime"
"unsafe"
"sync" "sync"
"unsafe"
) )
var theJVM *C.JavaVM var theJVM *C.JavaVM
type JNIEnv = C.JNIEnv type JNIEnv = C.JNIEnv
func SetJVM(jvm uintptr) { func SetJVM(jvm uintptr) {

View File

@ -1,6 +1,7 @@
#include <jni.h> #include <jni.h>
void registerFragment(JNIEnv *env, jobject view); void registerFragment(JNIEnv *env, jobject view);
void registerPermissionsFragment(JNIEnv *env, jobject view);
void GetId(JNIEnv* env, jobject p, int chint); void GetId(JNIEnv* env, jobject p, int chint);
void Decrypt(JNIEnv* env, jobject p, char* cdata, int datalen, int chint); void Decrypt(JNIEnv* env, jobject p, char* cdata, int datalen, int chint);
void Encrypt(JNIEnv* env, jobject p, char* cid, int idlen, char* cdata, int datalen, int chint); void Encrypt(JNIEnv* env, jobject p, char* cid, int idlen, char* cdata, int datalen, int chint);

View File

@ -49,6 +49,8 @@ func GetStore(store *Store) error {
} }
if _, err := os.Stat(store.Dir); os.IsNotExist(err) { if _, err := os.Stat(store.Dir); os.IsNotExist(err) {
store.Empty = true store.Empty = true
} else {
store.Empty = false
} }
id, err := ioutil.ReadFile(path.Join(store.Dir, ".gpg-id")) id, err := ioutil.ReadFile(path.Join(store.Dir, ".gpg-id"))
if err != nil { if err != nil {

99
scripts/build.sh Executable file
View File

@ -0,0 +1,99 @@
#!/usr/bin/env bash
# Build (and optionally install) the passgo-gui Android APK.
#
# Usage:
# ./scripts/build.sh [phone|emu] # build + install (default: phone)
# ./scripts/build.sh phone --no-install # build only
#
# Output: cmd/passgo-gui/passgo-gui.apk
#
# Prereqs on this workstation:
# - JDK (java on PATH)
# - Go + gogio (~/go/bin)
# - Android SDK at ~/android-sdk (platform-tools, build-tools 35.0.0)
# - debug keystore at ~/.android/debug.keystore
# apktool is not a prerequisite: if it is missing, the script downloads
# v3.0.3 to the stable location ~/android-sdk/tools/apktool.jar.
#
# Why the apktool detour: gogio's manifest permissions come from a hardcoded
# table (only the legacy READ/WRITE_EXTERNAL_STORAGE). On Android 11+ the
# "All files access" settings toggle is grayed out unless the app declares
# MANAGE_EXTERNAL_STORAGE, which Permissions.java opens at startup. So we
# decode the gogio APK, inject the permission, rebuild, and re-sign with
# the debug key.
set -euo pipefail
REPO=$(cd "$(dirname "$0")/.." && pwd)
export ANDROID_HOME=${ANDROID_HOME:-$HOME/android-sdk}
export PATH=$PATH:$ANDROID_HOME/platform-tools:$ANDROID_HOME/build-tools/35.0.0:$HOME/go/bin
MODE=${1:-phone}
INSTALL=1
[ "${2:-}" = "--no-install" ] && INSTALL=0
case "$MODE" in
phone) ARCH="arm64,arm";;
emu) ARCH="amd64";;
*) echo "usage: $0 [phone|emu] [--no-install]" >&2; exit 1;;
esac
die() { echo "ERROR: $*" >&2; exit 1; }
command -v java >/dev/null 2>&1 || die "java not found"
command -v gogio >/dev/null 2>&1 || die "gogio not found (need ~/go/bin)"
command -v apksigner >/dev/null 2>&1 || die "apksigner not found (need $ANDROID_HOME/build-tools/35.0.0)"
command -v adb >/dev/null 2>&1 || die "adb not found (need $ANDROID_HOME/platform-tools)"
[ -f "$HOME/.android/debug.keystore" ] || die "debug keystore missing: $HOME/.android/debug.keystore"
# apktool: auto-download to a stable location if missing.
APKTOOL="$ANDROID_HOME/tools/apktool.jar"
if [ ! -f "$APKTOOL" ]; then
echo "=== apktool missing; downloading v3.0.3 to $APKTOOL ==="
mkdir -p "$ANDROID_HOME/tools"
curl -fsSL -o "$APKTOOL" \
https://github.com/iBotPeaches/Apktool/releases/download/v3.0.3/apktool_3.0.3.jar \
|| die "apktool download failed"
fi
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT
echo "=== gogio (Go -> APK) ==="
# targetSdk 34: targeting 35 makes Android 15 force edge-to-edge, which the
# app does not handle.
(cd "$REPO/cmd/passgo-gui" && gogio -target android -targetsdk 34 -arch "$ARCH" -o "$WORK/passgo-raw.apk" .)
[ -f "$WORK/passgo-raw.apk" ] || die "gogio did not produce an APK"
echo "=== apktool decode + inject MANAGE_EXTERNAL_STORAGE ==="
java -jar "$APKTOOL" d "$WORK/passgo-raw.apk" -o "$WORK/decoded" -f >/dev/null
python3 - "$WORK/decoded/AndroidManifest.xml" << 'PYEOF'
import sys, pathlib
p = pathlib.Path(sys.argv[1])
t = p.read_text()
perm = '<uses-permission android:name="android.permission.MANAGE_EXTERNAL_STORAGE"/>'
if perm not in t:
t = t.replace(" <application", " " + perm + "\n <application", 1)
if perm not in t:
sys.exit("ERROR: failed to inject MANAGE_EXTERNAL_STORAGE")
p.write_text(t)
print("manifest: MANAGE_EXTERNAL_STORAGE present")
PYEOF
echo "=== apktool rebuild ==="
java -jar "$APKTOOL" b "$WORK/decoded" -o "$WORK/passgo-unsigned.apk" >/dev/null
echo "=== sign ==="
apksigner sign \
--ks "$HOME/.android/debug.keystore" --ks-pass pass:android \
--key-pass pass:android --ks-key-alias androiddebugkey \
--out "$REPO/cmd/passgo-gui/passgo-gui.apk" "$WORK/passgo-unsigned.apk"
# ADB_SERIAL selects the device when several are attached (adb devices).
ADB="adb${ADB_SERIAL:+ -s $ADB_SERIAL}"
if [ "$INSTALL" = "1" ]; then
echo "=== install ==="
$ADB get-state >/dev/null 2>&1 || die "no adb device (connect the phone; set ADB_SERIAL if several are attached)"
$ADB install -r "$REPO/cmd/passgo-gui/passgo-gui.apk"
fi
echo "=== DONE: $REPO/cmd/passgo-gui/passgo-gui.apk ==="