diff --git a/app/GioActivity.java b/app/GioActivity.java index 86ac9bcf..722c17ca 100644 --- a/app/GioActivity.java +++ b/app/GioActivity.java @@ -4,6 +4,7 @@ package org.gioui; import android.app.Activity; import android.os.Bundle; +import android.content.Intent; import android.content.res.Configuration; import android.view.ViewGroup; import android.view.View; @@ -29,6 +30,7 @@ public final class GioActivity extends Activity { layer.addView(view); setContentView(layer); + onNewIntent(this.getIntent()); } @Override public void onDestroy() { @@ -60,4 +62,9 @@ public final class GioActivity extends Activity { if (!view.backPressed()) super.onBackPressed(); } + + @Override protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + view.onIntentEvent(intent); + } } diff --git a/app/GioView.java b/app/GioView.java index 25f9e866..412c06f8 100644 --- a/app/GioView.java +++ b/app/GioView.java @@ -12,6 +12,7 @@ import android.app.Fragment; import android.app.FragmentManager; import android.app.FragmentTransaction; import android.content.Context; +import android.content.Intent; import android.graphics.Canvas; import android.graphics.Color; import android.graphics.Matrix; @@ -315,6 +316,15 @@ public final class GioView extends SurfaceView implements Choreographer.FrameCal window.setAttributes(layoutParams); } + protected void onIntentEvent(Intent intent) { + if (intent == null) { + return; + } + if (intent.getData() != null) { + this.onOpenURI(nhandle, intent.getData().toString()); + } + } + @Override protected boolean dispatchHoverEvent(MotionEvent event) { if (!accessManager.isTouchExplorationEnabled()) { return super.dispatchHoverEvent(event); @@ -553,6 +563,7 @@ public final class GioView extends SurfaceView implements Choreographer.FrameCal static private native void onExitTouchExploration(long handle); static private native void onA11yFocus(long handle, int viewId); static private native void onClearA11yFocus(long handle, int viewId); + static private native void onOpenURI(long handle, String uri); static private native void imeSetSnippet(long handle, int start, int end); static private native String imeSnippet(long handle); static private native int imeSnippetStart(long handle); diff --git a/app/app.go b/app/app.go index a9bb0614..ec7ed918 100644 --- a/app/app.go +++ b/app/app.go @@ -3,7 +3,10 @@ package app import ( + "gioui.org/io/event" + "golang.org/x/net/idna" "image" + "net/url" "os" "path/filepath" "strings" @@ -56,6 +59,15 @@ type FrameEvent struct { Source input.Source } +// URLEvent is generated for external requests to open a URL. Unlike window specific events, +// it is delivered through the [Events] iterator. +// +// In order to receive URLEvents the program must register one or more URL schemes. A scheme can +// be registered using gogio, with the `-schemes` flag. +type URLEvent struct { + URL *url.URL +} + // ViewEvent provides handles to the underlying window objects for the // current display protocol. type ViewEvent interface { @@ -136,7 +148,29 @@ func Main() { osMain() } +// Events is an iterator that yields events that are not specific to any window, +// such as [URLEvent]. It never returns. +// +// Events must be called by the main goroutine, and replaces the +// call to [Main]. +func Events(yield func(event.Event) bool) { + yieldGlobalEvent = yield + osMain() +} + +var yieldGlobalEvent func(evt event.Event) bool + +func processGlobalEvent(evt event.Event) { + if yieldGlobalEvent == nil { + return + } + if !yieldGlobalEvent(evt) { + yieldGlobalEvent = nil + } +} + func (FrameEvent) ImplementsEvent() {} +func (URLEvent) ImplementsEvent() {} func init() { if extraArgs != "" { @@ -147,3 +181,20 @@ func init() { ID = filepath.Base(os.Args[0]) } } + +// newURLEvent creates a URLEvent from a raw URL string, handling Punycode decoding. +func newURLEvent(rawurl string) (URLEvent, error) { + u, err := url.Parse(rawurl) + if err != nil { + return URLEvent{}, err + } + u.Host, err = idna.Punycode.ToUnicode(u.Hostname()) + if err != nil { + return URLEvent{}, err + } + u, err = url.Parse(u.String()) + if err != nil { + return URLEvent{}, err + } + return URLEvent{URL: u}, nil +} diff --git a/app/doc.go b/app/doc.go index 23ae874e..e37fe055 100644 --- a/app/doc.go +++ b/app/doc.go @@ -56,6 +56,11 @@ For example, to display a blank but otherwise functional window: app.Main() } +# Events + +The [Events] iterator yields app-specific events such as [URLEvent]. [Window.Event] +yields events that target a particular window. + # Permissions The packages under gioui.org/app/permission should be imported diff --git a/app/os_android.go b/app/os_android.go index 0762074a..ae6db952 100644 --- a/app/os_android.go +++ b/app/os_android.go @@ -121,6 +121,7 @@ import "C" import ( "errors" "fmt" + "gioui.org/io/transfer" "image" "image/color" "io" @@ -146,7 +147,6 @@ import ( "gioui.org/io/pointer" "gioui.org/io/semantic" "gioui.org/io/system" - "gioui.org/io/transfer" "gioui.org/unit" ) @@ -664,6 +664,15 @@ func Java_org_gioui_GioView_onClearA11yFocus(env *C.JNIEnv, class C.jclass, view } } +//export Java_org_gioui_GioView_onOpenURI +func Java_org_gioui_GioView_onOpenURI(env *C.JNIEnv, class C.jclass, view C.jlong, uri C.jstring) { + evt, err := newURLEvent(goString(env, uri)) + if err != nil { + return + } + processGlobalEvent(evt) +} + func (w *window) ProcessEvent(e event.Event) { w.processEvent(e) } diff --git a/app/os_ios.go b/app/os_ios.go index db20e5e4..50d21a44 100644 --- a/app/os_ios.go +++ b/app/os_ios.go @@ -427,6 +427,15 @@ func osMain() { select {} } +//export gio_onOpenURI +func gio_onOpenURI(uri C.CFTypeRef) { + evt, err := newURLEvent(nsstringToString(uri)) + if err != nil { + return + } + processGlobalEvent(evt) +} + //export gio_runMain func gio_runMain() { if !isMainThread() { diff --git a/app/os_ios.m b/app/os_ios.m index bea50690..26ae7301 100644 --- a/app/os_ios.m +++ b/app/os_ios.m @@ -293,6 +293,10 @@ void gio_viewSetHandle(CFTypeRef viewRef, uintptr_t handle) { [self.window makeKeyAndVisible]; return YES; } +- (BOOL)application:(UIApplication *)app openURL:(NSURL *)url options:(NSDictionary *)options { + gio_onOpenURI((__bridge CFTypeRef)url.absoluteString); + return YES; +} @end int gio_applicationMain(int argc, char *argv[]) { diff --git a/app/os_macos.go b/app/os_macos.go index c4ea3114..839a0ae4 100644 --- a/app/os_macos.go +++ b/app/os_macos.go @@ -997,6 +997,16 @@ func gio_onFinishLaunching() { close(launched) } +//export gio_onOpenURI +func gio_onOpenURI(uri C.CFTypeRef) { + evt, err := newURLEvent(nsstringToString(uri)) + if err != nil { + return + } + + processGlobalEvent(evt) +} + func newWindow(win *callbacks, options []Option) { <-launched res := make(chan struct{}) diff --git a/app/os_macos.m b/app/os_macos.m index 1b94b8f9..7d323722 100644 --- a/app/os_macos.m +++ b/app/os_macos.m @@ -421,6 +421,11 @@ void gio_viewSetHandle(CFTypeRef viewRef, uintptr_t handle) { [NSApp setActivationPolicy:NSApplicationActivationPolicyRegular]; [NSApp activateIgnoringOtherApps:YES]; } +- (void)application:(NSApplication *)application openURLs:(NSArray *)urls { + for (NSURL *url in urls) { + gio_onOpenURI((__bridge CFTypeRef)url.absoluteString); + } +} @end void gio_main() { diff --git a/app/os_windows.go b/app/os_windows.go index 40369bba..45bfa319 100644 --- a/app/os_windows.go +++ b/app/os_windows.go @@ -5,8 +5,12 @@ package app import ( "errors" "fmt" + "gioui.org/io/transfer" + syscall "golang.org/x/sys/windows" + "golang.org/x/sys/windows/registry" "image" "io" + "os" "runtime" "sort" "strings" @@ -16,8 +20,6 @@ import ( "unicode/utf8" "unsafe" - syscall "golang.org/x/sys/windows" - "gioui.org/app/internal/windows" "gioui.org/op" "gioui.org/unit" @@ -28,7 +30,6 @@ import ( "gioui.org/io/key" "gioui.org/io/pointer" "gioui.org/io/system" - "gioui.org/io/transfer" ) type Win32ViewEvent struct { @@ -56,6 +57,8 @@ type window struct { const _WM_WAKEUP = windows.WM_USER + iota +const copyDataURLType = 0xffffff00 + type gpuAPI struct { priority int initializer func(w *window) (context, error) @@ -81,6 +84,7 @@ var resources struct { } func osMain() { + processURLEvent(startupURI()) select {} } @@ -452,6 +456,20 @@ func windowProc(hwnd syscall.Handle, msg uint32, wParam, lParam uintptr) uintptr case windows.WM_IME_ENDCOMPOSITION: w.w.SetComposingRegion(key.Range{Start: -1, End: -1}) return windows.TRUE + case windows.WM_COPYDATA: + data := (*windows.CopyDataStruct)(unsafe.Pointer(lParam)) + switch data.DwData { + case copyDataURLType: + if schemesURI == "" { + return windows.TRUE + } + + uri := syscall.UTF16PtrToString((*uint16)(unsafe.Pointer(data.LpData))) + if processURLEvent(uri) { + w.Perform(system.ActionRaise) + } + return windows.TRUE + } } return windows.DefWindowProc(hwnd, msg, wParam, lParam) @@ -1058,3 +1076,215 @@ func getPointerButtons(pi windows.PointerInfo) pointer.Buttons { return btns } + +// schemesURI is a list of schemes, comma separated, that must be +// defined using -X compiler ldflag, that used in gogio. +var schemesURI string + +func init() { + if schemesURI == "" { + return + } + + currentSchemes := strings.Split(schemesURI, ",") + oldSchemes := registeredSchemes(ID) + + for _, s := range currentSchemes { + for i, o := range oldSchemes { + if s == o { + oldSchemes = append(oldSchemes[:i], oldSchemes[i+1:]...) + break + } + } + } + + if len(oldSchemes) > 0 { + go unregisterSchemes(ID, oldSchemes) + } + + if len(currentSchemes) == 0 { + return + } + + // On Windows, launching the app using a URI will start a new instance of the app, + // a new window. That behavior, by default, doesn't align with iOS/Android/macOS, where + // the deeplink sends the event to the running app (if any). We are emulating it. + if hwnd, _ := windows.FindWindow(ID); hwnd != 0 { + if u := startupURI(); u != "" { + broadcastURI(hwnd, u) + } + os.Exit(0) + return + } + + go registerSchemes(ID, currentSchemes) +} + +func startupURI() string { + if len(os.Args) == 3 && os.Args[1] == "-gio_launch_url" { + return os.Args[2] + } + return "" +} + +func processURLEvent(rawurl string) bool { + if rawurl == "" { + return false + } + + evt, err := newURLEvent(rawurl) + if err != nil { + return false + } + + for _, scheme := range strings.Split(schemesURI, ",") { + if strings.EqualFold(scheme, evt.URL.Scheme) { + processGlobalEvent(evt) + return true + } + } + + return false +} + +func broadcastURI(hwnd syscall.Handle, uri string) { + data, err := syscall.UTF16FromString(uri) + if err != nil { + return // Only happens if uri contains NULL character. + } + + pinner := new(runtime.Pinner) + defer pinner.Unpin() + pinner.Pin(unsafe.Pointer(&data[0])) + + msg := &windows.CopyDataStruct{ + DwData: copyDataURLType, + CbData: uint32(len(data) * int(unsafe.Sizeof(data[0]))), + LpData: uintptr(unsafe.Pointer(unsafe.SliceData(data))), + } + pinner.Pin(unsafe.Pointer(msg)) + + // SendMessage blocks until the message is processed. + windows.SendMessage(hwnd, windows.WM_COPYDATA, 0, uintptr(unsafe.Pointer(msg))) +} + +func registeredSchemes(appid string) []string { + meta, err := registry.OpenKey(registry.CURRENT_USER, `Software\\`+appid, registry.ALL_ACCESS) + if err != nil { + return nil + } + defer meta.Close() + + schemes, _, _ := meta.GetStringsValue("URISchemes") + return schemes +} + +func registerSchemes(appid string, schemes []string) error { + reg := func(scheme string) error { + key, existent, err := registry.CreateKey(registry.CURRENT_USER, `Software\\Classes\\`+scheme, registry.ALL_ACCESS) + if err != nil { + return err + } + defer key.Close() + + if existent { + // Check if the existent key belongs to the current application + id, _, err := key.GetStringValue("appid") + if err != nil || id != appid { + return fmt.Errorf("scheme %s already registered by another application", scheme) + } + } + + path, err := os.Executable() + if err != nil { + return err + } + + if err = key.SetStringValue("", "URL:"+scheme+" Protocol"); err != nil { + return err + } + if err = key.SetStringValue("URL Protocol", ""); err != nil { + return err + } + if err = key.SetStringValue("appid", appid); err != nil { + return err + } + + icon, _, err := registry.CreateKey(key, `DefaultIcon`, registry.ALL_ACCESS) + if err != nil { + return err + } + defer icon.Close() + + if err = icon.SetStringValue("", `"`+path+`",1`); err != nil { + return err + } + + cmd, _, err := registry.CreateKey(key, `shell\\open\\command`, registry.ALL_ACCESS) + if err != nil { + return err + } + defer cmd.Close() + + if err = cmd.SetStringValue("", `"`+path+`" -gio_launch_url "%1"`); err != nil { + return err + } + + return nil + } + + for _, scheme := range schemes { + if scheme == "" { + continue // just in case + } + if err := reg(scheme); err != nil { + return err + } + } + + meta, _, err := registry.CreateKey(registry.CURRENT_USER, `Software\\`+appid, registry.ALL_ACCESS) + if err != nil { + return err + } + defer meta.Close() + + if err = meta.SetStringsValue("URISchemes", schemes); err != nil { + return err + } + + return nil +} + +func unregisterSchemes(appid string, schemes []string) { + classes, err := registry.OpenKey(registry.CURRENT_USER, `Software\\Classes`, registry.ALL_ACCESS) + if err != nil { + return + } + defer classes.Close() + + for _, scheme := range schemes { + if scheme == "" { + continue // just in case + } + + key, err := registry.OpenKey(classes, scheme, registry.ALL_ACCESS) + if err != nil { + continue + } + + id, _, err := key.GetStringValue("appid") + if err == nil && id != appid { + continue + } + + for _, k := range []string{`DefaultIcon`, `shell\\open\\command`, `shell\\open`, `shell`} { + registry.DeleteKey(key, k) + } + + if err := key.Close(); err != nil { + continue + } + + registry.DeleteKey(classes, scheme) + } +} diff --git a/go.mod b/go.mod index c5a88792..42f0a6a9 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module gioui.org -go 1.23.8 +go 1.24.0 require ( eliasnaur.com/font v0.0.0-20230308162249-dd43949cb42d @@ -9,6 +9,8 @@ require ( golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 golang.org/x/image v0.26.0 - golang.org/x/sys v0.33.0 - golang.org/x/text v0.24.0 + golang.org/x/sys v0.39.0 + golang.org/x/text v0.32.0 ) + +require golang.org/x/net v0.48.0 diff --git a/go.sum b/go.sum index a6097ef4..abdadc70 100644 --- a/go.sum +++ b/go.sum @@ -13,7 +13,9 @@ golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0 h1:tMSqXTK+AQdW3LpCbfa golang.org/x/exp/shiny v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:ygj7T6vSGhhm/9yTpOQQNvuAUFziTH7RUiH74EoE2C8= golang.org/x/image v0.26.0 h1:4XjIFEZWQmCZi6Wv8BoxsDhRU3RVnLX04dToTDAEPlY= golang.org/x/image v0.26.0/go.mod h1:lcxbMFAovzpnJxzXS3nyL83K27tmqtKzIJpctK8YO5c= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0= -golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU= +golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU= +golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY= +golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= +golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= +golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= +golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY=