Guides
Device and services
The camera, code scanning, photos and files, previewing, printing and PDFs, the mail and message composers, location, notifications and push, audio and recording, network, the media library, work in the background, sign-in and biometrics, the screen and the launch screen, maps, the stores' services, sensors, image editing, speech, contacts and calendars, home screen widgets and shortcuts, and crash reports, on each platform.
Asuka's packages reach what the device has beyond views: the camera, codes, what's in an image, photos and files, previewing, printing and PDFs, the mail and message composers, location, notifications and push, sound and recording, the network's state, the shared library, work in the background, shares from other apps, JavaScript, sign-in and biometrics, the screen, maps, the stores' services, and crash reports. This page covers each package: what it does, its API, the permissions it brings and how it behaves on each platform.
A package has one API everywhere. iOS and Android have nearly all of
it. On macOS, Windows and Linux the host answers each package with the
desktop's own services, and in a browser with the web's APIs. Where a
platform has nothing to offer, the package answers as on a phone
without it: ErrNotAvailable, an error that names what's missing, or a
call that does nothing. Each package's section says what it does and
what each platform does it with. Each desktop and the browser also has a
section of its own (On a Mac, On Windows,
On Linux, In a browser): what an app needs
set up there, and a table of where it differs from iOS and Android.
Permissions and purpose strings
An app that imports a package is given what the package needs: Android permissions in the manifest, iOS purpose strings in Info.plist, and the platform code itself. An app that doesn't import it goes without, so a photo app doesn't ask for the microphone. A Mac app is given the same purpose strings and the entitlements they need; Windows, Linux and the web need nothing declared (see each one's section below).
| Package | Android | iOS |
|---|---|---|
asuka/camera |
CAMERA |
NSCameraUsageDescription |
asuka/camera/record |
RECORD_AUDIO |
NSMicrophoneUsageDescription |
asuka/location |
ACCESS_COARSE_LOCATION, ACCESS_FINE_LOCATION |
NSLocationWhenInUseUsageDescription |
asuka/notify |
POST_NOTIFICATIONS |
|
asuka/network |
ACCESS_NETWORK_STATE |
|
asuka/library |
WRITE_EXTERNAL_STORAGE (Android 9 and earlier) |
NSPhotoLibraryAddUsageDescription; the app's Documents shown in Files |
asuka/background |
FOREGROUND_SERVICE, FOREGROUND_SERVICE_DATA_SYNC, POST_NOTIFICATIONS, WAKE_LOCK; a foreground service |
|
asuka/scan |
ML Kit's barcode model (about 2 MB) | |
asuka/vision |
ML Kit's text, face and barcode models (about 30 MB) | |
asuka/photos |
READ_MEDIA_IMAGES, READ_MEDIA_VISUAL_USER_SELECTED; READ_EXTERNAL_STORAGE (Android 12 and earlier) |
NSPhotoLibraryUsageDescription |
asuka/push |
Firebase Cloud Messaging, POST_NOTIFICATIONS |
the aps-environment entitlement; remote-notification background mode |
asuka/appleauth |
the Sign in with Apple entitlement | |
asuka/biometric |
USE_BIOMETRIC |
NSFaceIDUsageDescription |
asuka/audio/record |
RECORD_AUDIO, FOREGROUND_SERVICE_MICROPHONE; a foreground service |
NSMicrophoneUsageDescription |
asuka/transcribe |
RECORD_AUDIO |
NSSpeechRecognitionUsageDescription |
asuka/screen |
DETECT_SCREEN_CAPTURE |
|
asuka/tracking |
NSUserTrackingUsageDescription |
|
asuka/review |
Play's in-app review library | |
asuka/integrity |
Play Integrity | the App Attest entitlement |
asuka/agerange |
Play Age Signals | the Declared Age Range entitlement |
asuka/maps |
Google's Maps SDK | |
asuka/periodic |
WorkManager | fetch and processing background modes; the task identifiers |
asuka/sensors |
ACTIVITY_RECOGNITION (the pedometer) |
NSMotionUsageDescription (the pedometer) |
asuka/speech |
the text-to-speech engine's visibility (<queries>) |
|
asuka/contacts |
READ_CONTACTS (for List) |
NSContactsUsageDescription |
asuka/calendar |
READ_CALENDAR (for Calendars, Events) |
NSCalendarsFullAccessUsageDescription, NSCalendarsUsageDescription |
asuka/compose |
the visibility of the apps for mailto: and smsto: (<queries>) |
mailto in LSApplicationQueriesSchemes |
asuka/device, asuka/audio, asuka/splash, asuka/imageedit,
asuka/widget, asuka/js, asuka/secure, asuka/preview and
asuka/print need none.
An entitlement needs its capability on the app's App ID
(developer.apple.com, Identifiers) for a release build: the provisioning
profile has it then. asuka build --platform ios stops with what's missing if the
profile lacks one; simulator builds have them all.
The purpose strings Asuka writes are generic ("To take a photo."). iOS
and macOS show them when they ask the user, so write your own in
asuka.json's ios.usage:
{
"ios": {
"usage": {
"NSCameraUsageDescription": "To photograph your receipts.",
"NSLocationWhenInUseUsageDescription": "To find stores near you."
}
}
}Each package asks the user the first time it needs to, not at launch.
Calls that wait (for the user, a location fix, the platform) are marked
"not on the main thread" below: call them from a goroutine and
ui.Post the result back (see the main thread).
The others take a callback, called on the main thread.
On a Mac
A Mac app (asuka dev and asuka build) has the same purpose strings in
its Info.plist (location's as NSLocationUsageDescription too). Its
signature has the hardened runtime's entitlements for what they ask
(camera, microphone, location, contacts, calendars, photos), without
which a Developer ID build is refused them. Push's aps-environment,
Sign in with Apple and App Attest are entitlements only a provisioning
profile grants: asuka build embeds one for an app that uses them
(asuka.json's mac.profile, else ASUKA_MAC_PROFILE, else the best
match installed). Without one, the app builds, the CLI says what won't
work, and on the Mac registering for pushes and signing in with Apple
fail, and App Attest says it isn't supported. asuka dev signs ad hoc,
without a profile, so the same holds there. A Mac App Store build
(--appstore) is sandboxed, with the network and the files the user
picks. See Shipping.
The packages work on macOS as on iOS, through the same frameworks (AVFoundation, PhotoKit, Core Location, MapKit, Vision, Speech, UserNotifications, AuthenticationServices), except where a Mac differs:
| Package | On a Mac |
|---|---|
asuka/device |
PickPhotos and PickFiles: an open panel, a sheet on the window (no permission), copies in the app's caches. Browse opens the page in the user's browser. GetInfo: Model "Mac", System "macOS" |
asuka/camera |
Take: a sheet over the window with a live preview, Take Photo and Cancel (AppKit has no camera screen). View's "back" is the built-in camera (a Mac has no back one), or the one plugged in; scanning codes in it needs macOS 13 |
asuka/audio/record |
No audio session: other apps' sound plays on; Background changes nothing, a Mac app records out of sight anyway |
asuka/contacts, asuka/calendar |
Add asks in an alert over the window (macOS has no new contact or event screen), then saves, asking for access first |
asuka/photos |
PhotoKit, as on iOS; a Mac has no limited library: ManageLimited does nothing |
asuka/library |
Photos and videos go to Photos; Downloads and Music to the user's ~/Downloads and ~/Music, in the folder asked |
asuka/biometric |
Touch ID's prompt, or the user's password (not with NoPasscode) |
asuka/push |
APNs, as on iOS; no Rewrite (it needs a notification service extension). Needs a provisioning profile (above) |
asuka/appleauth |
Apple's button and sheet, as on iOS. Needs a provisioning profile (above) |
asuka/integrity |
App Attest, as on iOS. Needs a provisioning profile (above): without one Supported is false |
asuka/background |
The task keeps App Nap from slowing the app; there's no notification, so Update changes nothing |
asuka/periodic |
NSBackgroundActivityScheduler: the tasks run while the app runs (it isn't launched for them); one that wants power waits while the Mac is on its battery |
asuka/screen |
Lock and SetBars do nothing; PreventCapture leaves the app's windows out of screenshots and recordings; OnScreenshot hears of the screenshots Spotlight finds |
asuka/transcribe |
On the device only; the app's bundle needs its purpose string, which asuka build writes |
asuka/sensors |
A Mac has no motion sensors: Available is false, watching gets nothing, StepsBetween fails with ErrNotAvailable |
ui.Haptic |
The trackpad's feedback (a Force Touch trackpad): an alignment for HapticSelection, a level change for the others |
asuka/widget, asuka/agerange |
Not on a Mac: widget.Set does nothing, agerange.Request fails with ErrNotAvailable |
asuka/splash |
A Mac app has no launch screen: Hold and Hide do nothing |
ui.OnShare, app.ShareSheet, shortcuts |
iOS and Android only: a Mac app isn't offered in other apps' share menus, and asuka.json's shortcuts aren't added |
On Windows
A Windows app declares nothing: asuka build --platform windows writes no
capabilities, and Windows asks a desktop app for no permission. The
privacy switches in Settings (Privacy & security) still hold for the
camera, the microphone and location: with one off, the camera and
recording fail with "not allowed", and location with
location.ErrDenied.
The packages work on Windows through its own services (Media Foundation, WIC, WinRT, Windows Hello, SAPI, the Credential Manager), except where Windows differs; where it has nothing, the package answers as on a device without it:
| Package | On Windows |
|---|---|
asuka/device |
PickPhotos and PickFiles: the open dialog over the window, copies in the app's caches. Browse opens the page in the user's browser. The clipboard's images are PNGs. KeepAwake keeps the display on. GetInfo: Model "PC", System "Windows", SystemVersion "10.0.<build>" |
asuka/secure |
The user's generic credentials in the Credential Manager, named <app>/<key> (a value up to 2560 bytes) |
asuka/camera |
Media Foundation's capture engine. Take: a dialog over the window with a live preview, Take Photo and Cancel (Enter and Escape). View picks the camera Windows names front or rear, else the first; with Scan, it reads codes in four frames a second with Asuka's own reader (as asuka/scan's File). Settings' camera switch off: "not allowed" |
asuka/camera/record |
H.264 and AAC in MPEG-4 (.mp4), the default microphone unless muted |
asuka/audio |
MCI (DirectShow's playback); a URL is downloaded to the app's caches first |
asuka/audio/record |
The default microphone, AAC at 44.1 kHz (voice mono, music stereo); Background changes nothing |
asuka/speech |
SAPI's voices, those installed (Settings, Time & language, Speech) |
asuka/network |
The Network List Manager: Wi-Fi, cellular or a cable; Metered for a connection with a cost, Constrained near or over its data limit |
asuka/location |
WinRT's Geolocator. PlaceName asks OpenStreetMap's Nominatim (Windows's own geocoder needs a Bing Maps key): one request a second at most, answers kept in the app's caches |
asuka/biometric |
Windows Hello's prompt; Check's kind is the sensor's; Hello always offers its PIN, and names its own cancel button |
asuka/webauth |
A window over the app's with WebView2 in it (the app's web profile; Ephemeral: a throwaway one) |
asuka/js |
A WebView2 that's never shown |
asuka/contacts, asuka/calendar |
Windows gives the contacts and calendars to packaged apps only: Request says denied; Add opens a vCard or an event file in the user's contacts or calendar app (saved is false: it doesn't say) |
asuka/photos |
The Pictures folder (no permission to ask); screenshots are its Screenshots folder's |
asuka/library |
The user's Downloads, Videos, Music and Pictures folders, in the folder asked |
asuka/vision |
Windows's OCR, in the languages whose OCR is installed (Settings, Time & language), and its face detector; codes as asuka/scan reads them |
asuka/scan |
Windows has no reader of codes for apps: Asuka's own, in Go, reads QR codes, Data Matrix, EAN-13 and EAN-8, UPC-A and UPC-E, Code 128 and Code 39 (not Aztec, PDF417, Code 93, Codabar nor ITF) |
asuka/imageedit |
Windows's codecs read (HEIC and WebP where their extensions are installed); a video's frame through Media Foundation |
asuka/maps |
Windows has no map control for desktop apps (its own needs a Bing Maps key): the host draws OpenStreetMap's tiles, downloaded two at a time and kept a week in the app's caches, with "© OpenStreetMap contributors" on the map; drag, the wheel or a pinch, double-click, + and -. Satellite and Hybrid show the streets (no free imagery), and ShowsUser shows nothing, as on Linux |
asuka/background |
The task keeps the PC from sleeping; there's no notification, so Update changes nothing |
asuka/periodic |
Timers while the app runs (it isn't launched for them); one that wants power waits while the PC is on its battery |
asuka/screen |
Lock and SetBars do nothing; PreventCapture leaves the app's windows out of screenshots and recordings; OnScreenshot hears of the screenshots saved in the Screenshots folder (Print Screen with Windows, the Snipping Tool) |
asuka/tracking |
No prompt: allowed while the advertising ID is on in Settings, else denied |
asuka/push, asuka/widget, asuka/review, asuka/integrity |
Packaged (Store) apps' or Apple's and Google's: push.Register fails, widget.Set does nothing, review.Request shows nothing, integrity.Supported is false |
asuka/appleauth, asuka/agerange, asuka/transcribe, asuka/sensors |
Not on Windows: unavailable, as on a device without them |
ui.Haptic |
A PC has no haptics: it does nothing |
asuka/splash |
A Windows app has no launch screen: Hold and Hide do nothing |
ui.OnShare, app.ShareSheet, shortcuts |
iOS and Android only |
On Linux
A Linux app declares nothing either. Where the host goes through an XDG
desktop portal (files, the camera, location, notifications, mail,
printing, opening files and links, keeping the system awake), the
portal asks the user as it does, and it works in a Flatpak sandbox as
out of one. The Flatpak manifest asuka build --platform linux writes
lets the app reach only the display, the GPU, the network and the
portals. Services the host reaches without a portal (Speech Dispatcher,
Evolution Data Server for contacts and calendars, NetworkManager for the
connection's type, iio-sensor-proxy, a Video4Linux camera) need the
finish-args that open them, added to the manifest; without them, the
package answers as on a system without the service. A notification due
later needs systemd outside the sandbox: in a Flatpak it shows only
while the app runs (see On each platform).
The Linux host reaches the desktop's services through the portals where there's one, GLib and GTK, and the libraries a desktop has, each loaded the first time it's used: a system without it answers an error that names what's missing.
| Package | On Linux |
|---|---|
asuka/device |
PickPhotos and PickFiles: GTK's file dialog (the portal's in a sandbox), copies in the app's caches. Clipboard images and changes through GDK's clipboard. Browse opens the page in the user's browser. KeepAwake keeps the session from idling. GetInfo: Model "PC", System "Linux", SystemVersion the distribution's (/etc/os-release) |
asuka/secure |
The keyring (libsecret: GNOME Keyring, KWallet, KeePassXC), an item per key labeled with the app's name; without libsecret or a keyring, the calls fail saying so |
asuka/network |
GNetworkMonitor (the portal's in a sandbox); Wi-Fi, ethernet or cellular from NetworkManager; never constrained |
asuka/location |
The location portal (GeoClue's, which asks the user), else GeoClue itself; PlaceName with geocode-glib, when it's installed |
asuka/speech |
Speech Dispatcher (started when it isn't running): its voices, rate, pitch, volume |
asuka/audio, asuka/audio/record |
GStreamer: an AAC encoder (FDK, libav's...) records, the default source the microphone |
asuka/library |
The user's folders (XDG's Pictures, Videos, Music, Downloads), in the folder asked |
ui.ShareFiles |
No share sheet: the files are copied to the clipboard, with a toast |
asuka/camera, asuka/camera/record |
The camera portal (it asks the user once; PipeWire's cameras, gstreamer1.0-pipewire), else the first Video4Linux camera, through GStreamer. Take: a dialog over the window with a live preview, Take Photo and Cancel. View: the preview, mirrored, filling its box; front and back are the same camera (a computer's face the user). Videos are MPEG-4 (H.264 from x264 or OpenH264, AAC), else WebM; scanning codes needs GStreamer's zbar element. No camera: ErrNoCamera; refused: "not allowed" |
asuka/scan, asuka/vision |
ZBar (libzbar0) reads codes: no Aztec or Data Matrix. Tesseract (libtesseract5 and a language's data) reads text; nothing finds faces: Faces is empty. Without ZBar, scan.File fails "not available"; vision.Analyze leaves out what isn't installed |
asuka/imageedit |
GdkPixbuf's loaders decode (HEIF and WebP where their loaders are installed), upright; the actions are done in Go; video frames from GStreamer |
asuka/webauth |
A dialog over the window with a WebKitGTK web view (Ephemeral: a session of its own); closing it cancels |
asuka/js |
A WebKitGTK web view that's never shown |
asuka/maps |
libshumate's map of OpenStreetMap's tiles (libshumate-1.0-1); Satellite and Hybrid show the streets; no user location layer. Without libshumate, the view says so and OnError is called |
asuka/background |
The task keeps the system from suspending; no notification, Update changes nothing |
asuka/periodic |
The tasks run on a timer while the app runs (it isn't launched for them); one that wants power waits while on the battery, one that wants the network while it's offline |
asuka/screen |
Lock, SetBars and PreventCapture do nothing; OnScreenshot hears of new images in Pictures/Screenshots (GNOME's, KDE's) |
asuka/sensors |
iio-sensor-proxy (convertibles, tablets): the light sensor, in lux, and the accelerometer's orientation as 1 g along the axis that's up, as it turns. No others: Available is false, StepsBetween fails with ErrNotAvailable |
asuka/contacts, asuka/calendar |
Evolution Data Server's address books and calendars (GNOME Contacts', GNOME Calendar's, the online accounts'): access is Full when it has one; Pick is a dialog of the contacts with a search field; Add asks, then saves in the default address book or the event's calendar. Without it: contacts' access is Denied, calendars' WriteOnly; Add opens a vCard or an iCalendar file in the user's contacts or calendar app (saved is false); contacts.Pick fails |
asuka/photos' library, asuka/appleauth, asuka/transcribe, asuka/widget |
None on Linux: the library's access is Denied; appleauth.Available is false and its button shows nothing; transcribe fails with ErrNotAvailable; widget.Set does nothing. asuka/splash has nothing to hold |
asuka/biometric, asuka/integrity, asuka/agerange, asuka/push |
None on Linux: not available, not supported. asuka/review does nothing; asuka/tracking answers allowed |
asuka/crash |
Go's crash output, which has a crash in native code too (a signal in GTK's code, a GLib abort), reported as Native; the system's own record (systemd-coredump, apport) isn't read |
ui.Haptic |
A desktop has no haptics: it does nothing |
ui.OnShare, app.ShareSheet, shortcuts |
iOS and Android only |
In a browser
A page declares nothing: the browser asks the user the first time a
package needs the camera, the microphone, location or notifications.
Most of these APIs need a secure page, served over https or from
localhost (asuka dev --platform web's, but not --lan's addresses).
The packages call the web's APIs; the web has the details,
and its service worker for notifications and Web Push.
| Package | In a browser |
|---|---|
asuka/device |
PickPhotos and PickFiles: the browser's file picker, copies in the caches. Browse: a new tab. The clipboard: the app's own copies, and other apps' where the user lets the page read it. KeepAwake: the Screen Wake Lock. GetInfo from the user agent (Chromium's client hints give the system's version and an Android phone's model) |
asuka/secure |
The values encrypted (AES-GCM) in IndexedDB, with a key the browser won't let out; not a keychain (see the web) |
asuka/camera, asuka/camera/record |
Take opens a phone's camera, or a dialog with the live camera and a shutter; View is the camera in a <video>, with Capture, recording, and scanning codes where the browser has BarcodeDetector |
asuka/scan, asuka/vision |
BarcodeDetector reads codes; faces and text only where the browser has them |
asuka/imageedit |
A canvas: JPEG or PNG; a remote image needs CORS |
asuka/photos |
A page can't read the library: access is Denied (device.PickPhotos picks photos) |
asuka/library |
Save downloads the file |
asuka/location |
The Geolocation API; PlaceName isn't on the web (a browser has no geocoder) |
asuka/network |
Online from navigator.onLine; Wi-Fi, cellular or Ethernet where the browser says (Chrome on Android), metered for cellular, constrained for Data Saver |
asuka/notify, asuka/push |
The Notification API and Web Push, through the app's service worker (push.Register's Service is "webpush"); a notification due later can't show while no page of the app is open |
asuka/audio, asuka/audio/record |
An audio element per player (the browser's autoplay rules apply: play from a tap); MediaRecorder, AAC in MPEG-4 where the browser records it, else Opus in WebM |
asuka/speech |
speechSynthesis, the browser's voices |
asuka/sensors |
The Generic Sensor API (Chrome) or device motion and orientation events (Safari asks on a tap), in Android's axes and units; no barometer or pedometer |
asuka/biometric |
The platform's authenticator through WebAuthn (Touch ID, Face ID, Windows Hello, Android's screen lock): the first Authenticate makes a passkey for the site. The prompt is the browser's: Reason and Cancel don't show |
asuka/webauth |
A popup, done when it comes back to a page of the app's own origin (a custom scheme can't open a page) |
asuka/js |
A hidden, sandboxed frame |
asuka/contacts, asuka/calendar |
contacts.Pick where the browser has the Contact Picker (Chrome on Android); Add downloads a vCard or an .ics file; reading them isn't on the web |
asuka/screen |
Lock only full screen or in an installed app; hiding the bars is full screen; no PreventCapture or OnScreenshot |
asuka/background |
The page runs while it's open; a task adds nothing, and has no notification |
asuka/tracking |
No prompt: Denied when the browser sends Global Privacy Control or Do Not Track, else Allowed |
ui.Haptic |
navigator.vibrate, where the browser has it (Chrome on Android) |
asuka/review, asuka/integrity, asuka/agerange, asuka/appleauth, asuka/periodic, asuka/widget, asuka/transcribe |
Not on the web: an error that says so, or the package's ErrNotAvailable |
asuka/maps |
OpenStreetMap's tiles, drawn by the host, as on Windows; ShowsUser once the page has the location permission |
asuka/crash |
Go's crashes (the host keeps the runtime's output) and uncaught JavaScript errors, as Exception reports that aren't fatal |
asuka/splash |
Hold and Hide do nothing |
ui.OnShare, app.ShareSheet, shortcuts |
iOS and Android only |
The camera: asuka/camera
camera.Take shows the system's camera (iOS's camera screen, Android's
camera app) and calls done with the photo's file, a JPEG, or "" if the
user cancelled. On a device without a camera (the iOS simulator) the error
is camera.ErrNoCamera. A desktop has no camera screen: there Take
shows a dialog of Asuka's over the window, with a live preview, Take
Photo and Cancel; a browser opens a phone's camera, or a dialog like it.
ui.Button(ui.ButtonProps{Title: ui.Val("Take a photo"), OnPress: func() {
camera.Take(func(path string, err error) {
switch {
case errors.Is(err, camera.ErrNoCamera):
status.Set("This device has no camera")
case err != nil:
status.Set(err.Error())
case path != "":
photo.Set(path) // ui.Image shows it
}
})
}})For a capture screen of the app's own, camera.View shows what the camera
sees, live, filling its box (AVFoundation's preview on iOS and the Mac,
CameraX's PreviewView on Android, Media Foundation's on Windows,
GStreamer's on Linux, a <video> in a browser), and camera.Capture
takes a photo from it:
camera.View(camera.ViewProps{
Mode: camera.Video,
Facing: camera.Back,
Style: ui.Style{Grow: 1},
OnError: func(message string) { status.Set(message) },
}),
ui.Button(ui.ButtonProps{Title: ui.Val("Photo"), OnPress: func() {
camera.Capture(func(path string, err error) {
if err != nil {
status.Set(err.Error())
return
}
status.Set("Saved " + path)
})
}}),ViewProps:
Facing:camera.Back(the default) orcamera.Front.Mode:camera.Photo(the default), orcamera.Videofor recording withasuka/camera/record, which takes photos too.Style: its box.OnError: called when the camera can't show:"not allowed"if the user refused it,"no camera"if there's none.Scan: reads codes in what it sees; seeasuka/scan.
The view asks for the camera the first time it shows, and the camera is
on while the view is on screen. Show one at a time: Capture and
recording use the one on screen, and fail with "no camera view" if
there's none.
The files from Take and Capture are in the app's caches, which the
system may clear: move one into store.Dir() to keep it, or save it to
the photo library with asuka/library.
Android: as asuka/camera declares the camera permission (the view
needs it), Android requires it of the camera app too: Take asks for it
first, and fails with "not allowed" if the user refuses.
Recording video: asuka/camera/record
asuka/camera/record records video from the camera.View on screen, whose
Mode is camera.Video. record.Start records until record.Stop, or
until MaxDuration if set, then calls done with the video's file (in
the app's caches: .mov on iOS and the Mac, .mp4 on Android and
Windows, .mp4 or else .webm on Linux, as its encoders allow).
ui.Button(ui.ButtonProps{
Title: signal.Func(func() string {
if recording.Get() {
return "Stop"
}
return "Record"
}),
OnPress: func() {
if recording.Peek() {
record.Stop()
return
}
recording.Set(true)
record.Start(record.Options{MaxDuration: time.Minute}, func(path string, err error) {
recording.Set(false)
if err != nil {
status.Set(err.Error())
return
}
status.Set("Recorded " + path) // ui.Video plays it
})
},
})It records sound too, and asks for the microphone the first time; if the
user refuses, the video has no sound. record.Options{Mute: true}
records without sound and without asking. One recording at a time.
The microphone's permission and purpose string come with this package,
not with asuka/camera: an app that only takes photos goes without them.
Barcodes and QR codes: asuka/scan
asuka/scan reads barcodes and QR codes, live in what a camera.View
sees, or in an image file. iOS and the Mac read them with AVFoundation
and Vision; Android with ML Kit, which only the apps that import
asuka/scan are built with; Windows with a reader of Asuka's own, in Go;
Linux with ZBar; a browser with BarcodeDetector. Not every reader has
every kind: see On Windows and On Linux.
scan.Codes makes the view's Scan. It reads the kinds given (every
kind, if none), and calls its function on the main thread as a code comes
into view: once while it stays, and again after it's been out of view for
a second.
camera.View(camera.ViewProps{
Style: ui.Style{Grow: 1},
Scan: scan.Codes(func(c scan.Code) {
found.Set(fmt.Sprintf("%s: %s", c.Kind, c.Value))
ui.Haptic(ui.HapticSuccess)
}, scan.QR, scan.EAN13),
})scan.File reads the codes in an image, a photo taken or picked, and
calls done with them (none, if there are none):
scan.File(path, func(codes []scan.Code, err error) {
switch {
case err != nil:
found.Set(err.Error())
case len(codes) == 0:
found.Set("No code in it")
default:
found.Set(codes[0].Value)
}
}, scan.QR)A scan.Code is its Kind and its Value, the text it holds. The
kinds: QR, Aztec, DataMatrix, PDF417, EAN13, EAN8, UPCA,
UPCE, Code128, Code39, Code93, Codabar, ITF.
iOS reads a UPC-A as an EAN-13 with a leading 0; asked for UPCA,
Asuka gives it as a UPC-A again.
What's in an image: asuka/vision
vision.Analyze(path, options) finds text, faces and codes in an image,
and where each is. It runs on the device: Apple's Vision on iOS and the
Mac, ML Kit's bundled models on Android, which only the apps that import
asuka/vision are built with, Windows's OCR and face detector, Tesseract
on Linux (no faces), and in a browser what its Shape Detection API has.
Not on the main thread.
go func() {
r, err := vision.Analyze(photo, vision.Options{Languages: []string{"zh-Hans", "en-US"}})
if err != nil {
return
}
ui.Post(func() {
for _, l := range r.Lines {
fmt.Println(l.Text, l.Box)
for _, w := range l.Words {
fmt.Println(" ", w.Text, w.Box, w.Start, w.End)
}
}
fmt.Println(len(r.Faces), "faces,", len(r.Codes), "codes")
})
}()- A
Boxis in fractions of the image from its top left, with the image upright: it's the same at any scale, for a view over the image orasuka/imageedit's pixels. - A
Linehas itsText,BoxandWords. AWordis what the platform's word breaking finds (dictionary-based for Chinese and Japanese), or a mark of punctuation.StartandEndare its place in the line's text, in runes. - A
Codehas itsKind(asasuka/scannames them),ValueandBox. Optionsturn off what you don't need (NoText,NoFaces,NoCodes).Languagesare the text's, most likely first, iOS only: Vision reads only the script of the first's family well, so put Chinese first where it may appear. Its Chinese model reads Latin letters too. Android reads Chinese and Latin scripts always.
iOS boxes what it read as one group: words of a Chinese line that share a box divide it by their width, so a word's box there is an estimate. A barcode's box from Vision can be the line it read along; Asuka grows it to the bars. On Android the words' boxes come from each symbol's.
OCR scales what it's given to a size of its own, and loses small text in a very
long screenshot: slice it with imageedit.Crop and analyze each slice.
The photo library: asuka/photos
asuka/photos reads the user's library, with their permission: PhotoKit on iOS
and the Mac, MediaStore on Android, the Pictures folder on Windows. Linux
and the web have no library an app can read: access is Denied. It's for an app whose screen is the library. To let the
user pick a few photos, device.PickPhotos asks for no permission: prefer it
where it'll do. Google Play asks an app that reads the library why it does, in
the Play Console. Every call waits: not on the main thread.
go func() {
if a, _ := photos.Request(); a == photos.Denied {
return
}
assets, _ := photos.List(photos.Query{Screenshots: true, Limit: 60})
thumb, _ := photos.Thumbnail(assets[0].ID, 300) // a file, for ui.Image
full, _ := photos.Export(assets[0].ID) // the original, as a file
fmt.Println(thumb, full.Path, full.Name)
}()photos.Current()says what's allowed without asking:NotAsked,Denied,Limited(only the photos the user chose: iOS 14, Android 14) orAllowed.photos.Request()asks the first time.photos.ManageLimited()lets aLimiteduser change which photos.photos.List(query)is newest first, fromOffset, at mostLimit. AnAssethas itsID,Width,Height,Created()andScreenshot.Screenshotskeeps only those: iOS knows; on Android it's what's in a Screenshots folder.photos.Thumbnail(id, size)is a JPEG at leastsizepixels on its short side, kept in the app's caches for the next time.photos.Export(id)copies the full image as it is in the library (HEIC, JPEG, PNG), with its name. iCloud photos download first.
Photos, the browser, the clipboard: asuka/device
asuka/device is what the platform offers beyond views.
ui.Button(ui.ButtonProps{Title: ui.Val("Pick photos"), OnPress: func() {
device.PickPhotos(3, func(paths []string, err error) {
if err == nil && len(paths) > 0 {
photos.Set(paths)
}
})
}}),
ui.Button(ui.ButtonProps{Title: ui.Val("Help"), OnPress: func() {
device.Browse("https://example.com/help")
}}),device.PickPhotos(limit, done)shows the system's photo picker (PHPicker on iOS, the Photo Picker on Android; the open dialog on the desktops, the file picker in a browser) for up tolimitimages (1 to 255). It asks for no permission: the user picks, and the app sees only what was picked.donegets the images' files, whichui.Imageshows, or none if the user cancelled. The files are copies in the app's temporary files or caches: move one to keep it.device.Browse(url)shows a web page in the app (SFSafariViewController, a Custom Tab), over the app's screens. The URL must be http or https.ui.OpenURLleaves the app for the browser instead. A desktop opens the page in the user's browser, and a browser in a new tab.device.Clipboard()reads the clipboard's text;okis false if it has none. Not on the main thread. iOS asks the user the first time the app reads what another app put there.ui.Copywrites it.device.KeepAwake(on)keeps the screen on whileonis true: a video, a recipe, a map. Turn it off when the screen that wants it goes.device.PickFiles(options, done)shows the system's document picker (iOS's Files browser, Android's storage picker, the open dialog on the desktops, the file picker in a browser; no permission).Typesare MIME types ("application/pdf","image/*"; none: any file) andMultiplelets the user pick several.donegets adevice.Filefor each, itsPatha copy in the app's caches, with itsName,TypeandSize, or none if the user cancelled.device.ClipboardHas()says whether the clipboard holdsText, anImageor aURL, without reading it (iOS doesn't ask the user for this): show a Paste button only when there's something to paste.device.ClipboardImage()reads its image into a file, in the format it was copied in;device.CopyImage(path, done)puts an image file on it.device.OnClipboardChange(f)callsfwhen it changes, untilstop.device.GetInfo()is the device and the app:Model("iPhone", "Pixel 9", "Mac", "PC"),System("iOS", "Android", "macOS", "Windows", "Linux"),SystemVersion, andAppVersionandBuild,asuka.json'sversionandbuild. Not on the main thread.
func Player() ui.Node {
info := signal.New("")
go func() {
if i, err := device.GetInfo(); err == nil {
ui.Post(func() { info.Set(i.Model + ", " + i.System + " " + i.SystemVersion) })
}
}()
device.KeepAwake(true)
signal.OnCleanup(func() { device.KeepAwake(false) })
return ui.Text(info)
}Signing in on the web: asuka/webauth
webauth.Start shows a sign-in page in the system's own sign-in sheet:
ASWebAuthenticationSession on iOS (SwiftUI's
webAuthenticationSession), a Custom Tab on Android. It's the
browser's, so the user's sessions, passkeys and password autofill work
in it, and providers that refuse an app's own WebView (Google's
sign-in) don't refuse it. Use it for OAuth rather than a ui.WebView.
The Mac has ASWebAuthenticationSession too. Windows and Linux have no
system sheet: the host shows the page in a web view of its own over the
window (WebView2, WebKitGTK), which has the app's web profile but not the
user's browser's sessions, and which a provider that refuses embedded
web views refuses there. A browser opens the page in a popup, done when
it comes back to a page of the app's own origin (a custom scheme can't
open a page).
s := webauth.Start(webauth.Options{URL: authorizeURL, CallbackScheme: "myapp"},
func(callback string, err error) {
if errors.Is(err, webauth.ErrCanceled) {
return // the user closed it
}
// callback is myapp://…?code=…: exchange the code.
})It ends in one of two ways:
- A callback scheme. When the page goes to a URL of
CallbackScheme(the app's own), the sheet closes anddonegets the URL. On Android the scheme must be the app's, asuka.json'sscheme; it goes todonerather thanui.OnOpenURL. - The app ends it. Without a scheme, the page redirects somewhere
the app hears from: an OAuth server that allows a redirect to the
loopback interface (
http://localhost:port/callback, RFC 8252) sends the code to a server the app runs on that port, which callss.Cancel()to close the sheet. On Android, cancelling brings the app back over the tab.
done gets webauth.ErrCanceled when the user closes the sheet, or
Cancel does. Ephemeral asks for a private session, without the
browser's cookies (iOS's prefersEphemeralWebBrowserSession; Android's
Custom Tabs keep them). examples/usage signs in
with Claude this way, with a loopback server.
Copy, share and open: ui.Copy, ui.Share, ui.OpenURL
Three calls in ui hand text to the system. All run on the main thread.
ui.Button(ui.ButtonProps{Title: ui.Val("Copy link"), OnPress: func() { ui.Copy("https://example.com/s/42") }}),
ui.Button(ui.ButtonProps{Title: ui.Val("Share"), OnPress: func() { ui.Share("https://example.com/s/42") }}),
ui.Button(ui.ButtonProps{Title: ui.Val("Open in browser"), OnPress: func() { ui.OpenURL("https://example.com") }}),
ui.Button(ui.ButtonProps{Title: ui.Val("Call"), OnPress: func() { ui.OpenURL("tel:+15555550100") }}),ui.Copy(text)puts text on the clipboard.ui.Share(text)shows the system's share sheet, to send it to an app, a contact, AirDrop or Nearby Share. A URL is shared as a link. The Mac shows its sharing services picker, Windows its share UI, a browser the Web Share sheet where it has one; Linux has none, so the text is copied to the clipboard, with a toast (as a browser without one copies it).ui.OpenURL(url)asks the system to open a URL: a web address in the browser, another scheme (tel:,mailto:, another app's) in the app that handles it.
ui.ShareFiles(paths, options, done) shows the share sheet for files: a
video to send, a PDF to save to Files, photos for AirDrop. done runs on
the main thread once the sheet is gone, whether the user shared or not;
its error says when the sheet couldn't show (a file that isn't there).
var share ui.Node
share = ui.Button(ui.ButtonProps{Title: ui.Val("Share"), OnPress: func() {
ui.ShareFiles([]string{report}, ui.ShareOptions{MIME: "application/pdf", Title: "Send the report", From: share}, func(err error) {
if err != nil {
status.Set(err.Error())
}
})
}})ShareOptions are expo-sharing's:
| Option | |
|---|---|
MIME |
The files' type, which the apps on the sheet go by. Android sets the intent's type to it. On iOS it's the type when UTI is empty. Default: from the first file's extension. |
UTI |
iOS: the files' Uniform Type Identifier (com.adobe.pdf), ahead of MIME. |
Title |
The sheet's title: Android's chooser, and a mail's subject on iOS. |
From |
The view the sheet points at where it's a popover (iPad, the Mac), usually the button that shares. Default: the screen's middle. |
On iOS a file whose extension doesn't match its type is shared under a
name that does, as expo-sharing does: image.pdf shared as a JPEG goes
as image.pdf.jpeg, a link to the file, not a copy. On Android the files
go through the app's FileProvider; one outside the app's own folders is
copied to its caches first. On the Mac it's the sharing services
picker. On Windows it's the share UI (done is called once it shows:
Windows doesn't say when it's gone).
Linux has none: the files go on the clipboard, as a shared text does. A
browser shares the files with the Web Share sheet where it can, else
downloads each.
To be opened with URLs yourself, see asuka.json's scheme and links in
Shipping, and ui.OnOpenURL.
Previewing files: asuka/preview
preview.Show(sources, options, done) shows files with the system's
viewer, as a mail app shows an attachment: PDFs, images, video, audio,
Office and iWork documents, text. A source is a file's path, an asset's
(as ui.Image takes it), or an http(s) URL, which the host downloads to
the app's caches first, under its name (or with the extension of the type
the server gives). Main thread.
var open ui.Node
open = ui.Button(ui.ButtonProps{Title: ui.Val("Open the invoice"), OnPress: func() {
preview.Show([]string{invoice, "docs/terms.pdf"}, preview.Options{From: open}, func(err error) {
if errors.Is(err, preview.ErrNoViewer) {
status.Set("Nothing on this device opens it")
}
})
}})| Option | |
|---|---|
Start |
The index of the file shown first. |
From |
The view the viewer grows from (iOS), usually what the user tapped. |
Edit |
Lets the user mark up PDFs and images with Markup, which saves into the files (iOS). |
done runs on the main thread once the viewer is gone, with
preview.ErrNoViewer if nothing can show the files, or another error (no
such file, a download that failed).
| Viewer | done |
|
|---|---|---|
| iOS | Quick Look (QLPreviewController) over the screen: swipe between the files; share, Markup, search |
when it's dismissed |
| Android | ACTION_VIEW of the Start file in the app the user picks for its type (Drive's PDF viewer, Photos), through the app's FileProvider with the right to read it alone |
when the user is back |
| macOS | Quick Look's panel (QLPreviewPanel), its arrows between the files |
when the panel closes |
| Windows | the file's default app (ShellExecuteEx), or the "How do you want to open this?" dialog |
once it's handed over |
| Linux | the file's default app (GtkFileLauncher, the portal's in a sandbox) |
once it's handed over |
| Web | a new tab, for what a browser shows (images, video, audio, text, PDF, HTML); another type is ErrNoViewer |
once it's opened |
Android has no system viewer, nor one that pages through several files: it opens one, as Android's mail apps do with each attachment.
Printing and PDFs: asuka/print
asuka/print shows the system's print dialog, with its printers and
options (copies, pages, paper, orientation; Save as PDF on Android and the
Mac), for a file, HTML markup or a WebView's page; and lays HTML out on
pages as a PDF. Each call but PDF runs on the main thread, and done
gets a print.Result once the dialog is closed:
var printButton ui.Node
printButton = ui.Button(ui.ButtonProps{Title: ui.Val("Print"), OnPress: func() {
print.HTML(receipt, print.Options{Job: "Receipt", From: printButton}, func(r print.Result, err error) {
if err == nil && r == print.Printed {
status.Set("Sent to the printer")
}
})
}})print.File(source, options, done)prints a PDF or an image (a path, an asset, an http(s) URL, aspreview.Showtakes them). Another type fails withprint.ErrUnsupported, but on Windows and Linux (where the printer takes what CUPS can print).print.HTML(markup, options, done)prints markup, paginated as a browser prints a page: CSS's@media printandbreak-beforeapply. Images and styles come from absolute URLs (https:,data:).print.WebView(web, options, done)prints what aui.WebViewshows.
Options has Job, the job's name in the queue (default: the file's
name, the page's title, else the app's name), Orientation, which the
dialog starts with (print.Portrait, print.Landscape), and From, the
view the dialog points at where it's a popover (the iPad).
A Result is print.Printed (the job is in the printer's queue, or saved
as a PDF from the dialog), print.Cancelled, or print.Unknown where
the platform doesn't say. A job that fails is an error.
print.ErrUnavailable says the device can't print.
| Dialog | File | HTML, WebView | Result | |
|---|---|---|---|---|
| iOS | UIPrintInteractionController, a popover on the iPad |
PDFs and images (printingItem) |
WebKit's print formatter | printed or cancelled |
| Android | PrintManager's print screen |
a PDF written through; an image fit to the page, upright (as PrintHelper) |
the WebView's print adapter | printed, cancelled or failed (the job's state) |
| macOS | NSPrintOperation's panel, a sheet on the window, with a preview |
PDFKit (a PDF; an image on a page) | WebKit's print operation | printed or cancelled |
| Windows | WebView2's: the system's print dialog for HTML, the browser's preview for a WebView | the file's app prints it (the shell's print verb), whatever its type |
WebView2 | unknown |
| Linux | GTK's print dialog (the print portal's for a file) | the printer takes it (CUPS: PDFs, images) | WebKitGTK's print operation | printed or cancelled |
| Web | the browser's print dialog | PDFs in the browser's viewer, images | the page's own web views | unknown |
An image wider than tall starts in landscape on Android and iOS, as their photo printing does.
PDFs from HTML
print.PDF(markup, options) lays markup out on pages, as print.HTML
prints it but without a dialog, and writes a PDF in the app's caches,
named after the page's <title> ("Receipt.pdf"; "Document.pdf" without
one). It returns its path, to share, mail or move. Its text stays text:
searchable, selectable. Not on the main thread.
go func() {
path, err := print.PDF(receipt, print.PDFOptions{Page: print.A4, Margins: print.Uniform(48)})
ui.Post(func() {
if err == nil {
ui.ShareFiles([]string{path}, ui.ShareOptions{Title: "Receipt"}, nil)
}
})
}()| Option | |
|---|---|
Page |
The paper, in points: print.A3, A4, A5, Letter, Legal, or a size of your own. Default: the user's region's, Letter in the Americas that use it (CLDR's paper sizes), A4 elsewhere. |
Landscape |
Turns the page: its width the longer side. |
Margins |
The space around each page's content, in points. Default: half an inch (36 points); print.Uniform(0) for none. |
iOS lays it out with WebKit (UIPrintPageRenderer with the web view's
print formatter), Android with its WebView's print adapter, the Mac with
WebKit's print operation, Windows with WebView2 (Chromium's
Page.printToPDF), Linux with WebKitGTK (printed to a file). A browser
makes PDFs only through its print dialog: there PDF fails, and
print.HTML lets the user Save as PDF.
Mail and messages: asuka/compose
asuka/compose opens the system's mail and message composers, filled in
for the user to send. Main thread; done gets a compose.Result:
compose.Sent, Saved (a draft), Cancelled, or Unknown where
another app has the mail and doesn't say.
compose.Mail(compose.MailMessage{
To: []string{"support@example.com"},
Subject: "Order 1042",
Body: "<p>Hello,</p><p>The receipt is attached.</p>",
HTML: true,
Attachments: []compose.Attachment{{Source: receiptPDF, Name: "Receipt 1042.pdf"}},
}, func(r compose.Result, err error) {
if errors.Is(err, compose.ErrUnavailable) {
status.Set("Set up a mail account to send it")
}
})
compose.Message(compose.TextMessage{To: []string{"+15555550100"}, Body: "On my way"}, nil)A MailMessage has To, CC and BCC addresses, a Subject, a Body
(markup if HTML) and Attachments; a TextMessage has To (phone
numbers, or iMessage addresses), a Subject (where Messages has subjects
on; elsewhere it goes before the body), a Body and Attachments. An
Attachment is a Source (a path, an asset, an http(s) URL), its Type
(default: its extension's) and the Name the recipient sees (default:
the file's). Where a mail can't carry markup (a mailto: link, Simple
MAPI), its body goes as text, as compose.PlainText makes it.
compose.Check() says what the device can do (not on the main thread):
Mail, a mail account or app; MailComposer, iOS's own composer, which
says what the user did and takes attachments; Text, text messages; and
TextAttachments, messages with attachments. CanSendMail,
CanSendText and CanSendAttachments read one each. A call the device
can't do fails with compose.ErrUnavailable.
| Messages | Result | ||
|---|---|---|---|
| iOS | Mail's composer (MFMailComposeViewController) over the app; without a Mail account, a mailto: link for the mail app the user set (without attachments) |
Messages' composer (MFMessageComposeViewController) |
sent, saved, cancelled; unknown for a link |
| Android | the mail apps only: ACTION_SENDTO mailto:, or with attachments ACTION_SEND with mailto: as its selector, the files through the app's FileProvider; markup as spans (EXTRA_TEXT) and as EXTRA_HTML_TEXT |
the default SMS app: smsto: with sms_body, or with attachments ACTION_SEND |
unknown, once the user is back |
| macOS | a mailto: link for the user's mail app; with attachments, the mail share service (NSSharingService), which takes no CC or BCC (they fail with ErrUnavailable) |
Messages (NSSharingService) |
unknown, or cancelled |
| Windows | a mailto: link; with attachments, Simple MAPI (MAPISendMailW: Outlook, Thunderbird; the new Outlook and Windows's Mail app don't take it: ErrUnavailable) |
ErrUnavailable |
unknown, or cancelled (MAPI) |
| Linux | the email portal (the mail app's composer, with the attachments), else xdg-email, else a mailto: link |
ErrUnavailable |
unknown, or cancelled (the portal) |
| Web | a mailto: link; with attachments, the Web Share sheet |
an sms: link; with attachments, the Web Share sheet |
unknown |
The iOS simulator has no Mail account and no Messages: try the composers on a device.
Location: asuka/location
asuka/location is where the device is, now and as it moves, with the
user's permission, which it asks for the first time. It's Core Location
on iOS and the Mac, and Android's location manager, which needs no Google
Play services; WinRT's Geolocator on Windows, the location portal (or
GeoClue) on Linux, and the Geolocation API in a browser. It asks for the location while the app is in use, not in the
background.
location.Current() returns where the device is. Not on the main thread:
it waits for the user to answer, then for a fix. If the user hasn't
allowed it, the error is location.ErrDenied.
ui.Button(ui.ButtonProps{Title: ui.Val("Where am I?"), OnPress: func() {
go func() {
p, err := location.Current()
ui.Post(func() {
switch {
case errors.Is(err, location.ErrDenied):
where.Set("Allow location in Settings")
case err != nil:
where.Set(err.Error())
default:
where.Set(fmt.Sprintf("%.4f, %.4f (within %.0f m)", p.Latitude, p.Longitude, p.Accuracy))
}
})
}()
}})location.Watch(f) calls f on the main thread with each position as the
device moves, the first being where it is now, until stop is called. It
asks as Current does; if the user refuses, f isn't called. Stop it
with the component:
func Track() ui.Node {
here := signal.New(location.Position{})
stop := location.Watch(func(p location.Position) { here.Set(p) })
signal.OnCleanup(stop)
return ui.Text(signal.Func(func() string {
p := here.Get()
return fmt.Sprintf("%.5f, %.5f", p.Latitude, p.Longitude)
}))
}location.PlaceName(lat, lon) is what a place is called ("Mission District, San
Francisco"), from the platform's geocoder, which asks its servers: ask only when
the user wants the name. Not on the main thread. Windows asks
OpenStreetMap's Nominatim, Linux geocode-glib where it's installed; a
browser has no geocoder, and it fails there.
A location.Position has Latitude, Longitude, Accuracy (meters:
the radius the device is within), Altitude (meters above sea level; 0 if
not known) and Time.
Android: Current gives a fix from the last minute if there is one;
otherwise it waits up to 10 seconds for a new one, then gives the last
known, or fails with "no location".
Notifications: asuka/notify
asuka/notify shows local notifications, now or at a time the app sets,
whether or not the app is running then. Tapping one opens the app with
its URL. It also has what a messaging app needs, for asuka/push's
notifications too: actions and replies typed in the notification,
threads, Android's channels, messages in conversations, the badge, the
notifications showing, and which ones show while the app is open.
go func() {
ok, err := notify.Request()
if err != nil || !ok {
return
}
err = notify.Schedule(notify.Notification{
ID: "water",
Title: "Water the plants",
Body: "It's been a day.",
URL: "myapp://plants",
After: 24 * time.Hour,
})
if err != nil {
fmt.Println(err)
}
}()notify.Request()asks the user to allow notifications the first time, and reports whether they're allowed: their answer then, or what they've set in Settings since. Not on the main thread.notify.Schedule(n)setsnto shown.Afterfrom now (at least a second);notify.Show(n)shows it now. One with theIDof one waiting or showing replaces it. Not on the main thread.notify.Cancel(id)takes the notification away, waiting or shown. On the main thread.
A Notification's URL should be one of the app's scheme (asuka.json's
scheme): ui.OnOpenURL gets it when the user taps the notification.
Its other fields:
| Field | What it does |
|---|---|
Subtitle |
A line under the title (iOS); Android's sub text beside the app's name |
Data |
The app's own keys, given back in Showing, OnAction and Present |
Category |
A category's ID: the actions it shows (below) |
Thread |
Groups it with the thread's others: iOS's thread, an Android group with a summary notification Asuka keeps |
Summary |
The line under an Android group's summary ("3 new messages"); iOS writes its own |
Channel |
The Android channel it's in (below); iOS has none |
Badge |
The app icon's number when it shows (iOS, macOS); Android's notification number |
Silent |
No sound |
Image |
A picture, as ui.Image's sources (an asset, a file, an http(s) URL): iOS's attachment, Android's big picture |
iOS shows a notification while the app is open too, as a banner,
unless Present says otherwise. Android posts a scheduled one from an
inexact alarm (no exact-alarm permission needed), so it may come a
little after its time.
Actions and replies
A category is a kind of notification and its buttons. Set the app's
categories as it starts, in its root component, before notifications
that name them come (a push's too: aps.category on iOS, the data's
"category" on Android). They hold across launches.
notify.SetCategories(notify.Category{ID: "message", Actions: []notify.Action{
{ID: "reply", Title: "Reply", Icon: "arrowshape.turn.up.left",
Reply: ¬ify.Reply{Button: "Send", Placeholder: "Message"}},
{ID: "read", Title: "Mark as Read"},
{ID: "delete", Title: "Delete", Destructive: true, Unlock: true},
{ID: "open", Title: "Open", Open: true},
}})
notify.OnAction(func(r notify.Response) {
switch r.Action {
case "": // a tap on the notification
openChat(r.Notification.Thread)
case "reply":
_, done := r.Defer() // sending takes a moment
go func() {
defer done()
api.Send(r.Notification.Thread, r.Reply)
}()
case "read":
notify.RemoveThread(r.Notification.Thread)
}
})- An
Actionhas itsIDandTitle, anIcon(an SF Symbol; iOS 15 and macOS 12 show it, Android shows actions without icons),Destructive(iOS shows it red),Unlock(the device unlocks first: iOS, Android 12 and later),Open(it opens the app) andReply(a text field, with its send button's title and placeholder). notify.OnAction(f)callsfon the main thread with each response, untilstop: a tap (Action"") or an action,Replythe text typed,Notificationthe one answered (asuka/push's too). A response that came before anyf(the one that launched the app, or woke it) waits for the first, so set it as the app starts.- An action without
Openruns in the background: the app's Go code runs (started without a screen if the app isn't running:main, then the root component, which must callOnAction), and the app doesn't come to the front. Work that goes on afterfreturns callsr.Defer()inf, anddonewhen it's finished: the system keeps the app running until then, orctx's deadline, a little before its own (iOS gives about 30 seconds, Android about 10).
Android takes a background action's notification away, as iOS does,
unless the app shows it again (Show, with its ID) by the time it's
done. After a reply, Android wants the notification posted again (its
spinner turns until then): Asuka adds the reply to a conversation's
notification, under the user's name, and takes another away.
Threads and the notifications showing
A Thread groups a chat's notifications: iOS stacks them, Android puts
them in a group under a summary notification, which Asuka keeps as they
come and go.
notify.Showing()lists the app's notifications that show (in Notification Center, the shade), its pushes' too, newest first: eachDeliveredhas itsID,Title,Subtitle,Body,URL,Data,Category,Thread,Time, andRemotefor a push. Not on the main thread.notify.Remove(ids...),notify.RemoveThread(thread)(a chat that's read) andnotify.RemoveAll()take them away. On the main thread.
What shows while the app is open
notify.Present(func(n notify.Delivered) notify.Presentation {
if n.Thread == openChat.Peek() {
return notify.Hide // the chat is on screen
}
return notify.Banner
})notify.Present(f) has f say how each notification that comes while
the app is in the foreground shows: Banner (as with the app closed),
Quiet (in Notification Center, the shade, without a banner or sound)
or Hide. It runs on the main thread as the notification comes, and
must answer at once: the host waits a second at most, then shows it.
nil shows them all again. On the main thread.
Messages in conversations
A message shows as the platform shows messages, with the sender's picture: iOS's communication notification, Android's conversation (the shade's Conversations section, with the conversation's shortcut).
ana := notify.Person{ID: "u7", Name: "Ana", Image: "https://example.com/ana.jpg"}
chat := notify.Conversation{ID: "climbing", Title: "Climbing", Group: true}
go notify.Show(notify.Notification{Body: "Who's in for Saturday?", Category: "message"}.From(ana, chat))n.From(sender, c)makesna message:sendersaidn.Bodyinc. ItsIDandThreaddefault to the conversation'sID(a 1:1 conversation's is the sender's), itsTitleto the sender's name (the group's title, for a group).- A
Personhas anID(the same each time), aName, anImage(asui.Image's sources) andBot. AConversationhas itsID, a group'sTitleandImage,Group,Me(the user as the conversation shows them; Android names their replies with it, "You" by default) andEarliermessages. - Android shows a conversation as one notification, its messages in
it: a message adds to the conversation's notification that shows (or
Earliersets what comes before it). iOS shows each message as a notification of its own, in the conversation's thread. - iOS needs the Communication Notifications capability on the App
ID: an app that calls
Fromis given its entitlement and Info.plist'sNSUserActivityTypes(INSendMessageIntent), and a release build's profile must have it. Without it, a message shows as a plain notification. macOS shows a message as a plain notification.
Android's channels
A channel is a kind of notification the user can turn off or quiet in the app's settings. Android shows a notification with its channel's sound, vibration and importance; iOS has no channels, and ignores them.
notify.SetChannelGroups(notify.ChannelGroup{ID: "chats", Name: "Chats"})
notify.SetChannels(
notify.Channel{ID: "messages", Name: "Messages", Importance: notify.ImportanceHigh, Group: "chats"},
notify.Channel{ID: "digest", Name: "Daily digest", Importance: notify.ImportanceLow, NoBadge: true},
)notify.SetChannels(cs...)makes the channels, renames the ones made before, and deletes those it made before that aren't incs. A channel'sImportance(ImportanceDefault,ImportanceHigh: heads-up,ImportanceLow: no sound,ImportanceMin),Silent,NoVibrationandNoBadgeare its when it's made; after, they're the user's to change.notify.SetChannelGroups(gs...)does the same for groups (deleting a group deletes its channels). On the main thread.- A notification without a
Channel, or with one that isn't, is in the default channel ("Notifications"), which stays. A push names its channel with FCM'sandroid.notification.channel_id, or a data message's"channel".
The badge
notify.SetBadge(n) sets the number on the app's icon; 0 takes it
away. iOS and macOS (the Dock) show it as set. Android has no
badge of its own: launchers that badge count the app's notifications (or
show a dot), so the badge goes as they're read (Remove,
RemoveThread), and SetBadge does nothing; a notification's Badge
is the number a launcher may show. A channel with NoBadge doesn't
count.
On each platform
| iOS | Android | macOS | Windows | Linux | Web | |
|---|---|---|---|---|---|---|
| Show, schedule, cancel | ✓ | ✓ | ✓ | ✓ (toasts) | ✓ (scheduled: a systemd user timer, whether the app runs or not) | ✓ (scheduled: while a page of the app is open) |
| Actions, replies | ✓ | ✓ | ✓ | ✓ (five buttons) | actions (a reply opens the app) | buttons where the browser shows them (Chrome, Edge: two); replies where it has inline replies |
| Background actions | ✓ (about 25 s) | ✓ (about 9 s) | ✓ | ✓ (about 25 s; started for it) | while the app runs | in a page of the app (the worker opens one) |
| Threads | ✓ | ✓ (a group and its summary) | ✓ | not grouped; RemoveThread |
✓ | not grouped; RemoveThread |
| Channels | ignored | ✓ | ignored | ignored | ignored | ignored |
| Conversations | ✓ (communication notifications) | ✓ (MessagingStyle, shortcuts) | plain | plain | plain | plain, the sender's picture its icon |
| Badge | ✓ | the launcher's | ✓ (the Dock) | the taskbar button's overlay | the launcher API's (Dash to Dock, Plasma) | the installed app's (navigator.setAppBadge) |
| Showing, removing | ✓ | ✓ | ✓ | ✓ (the notification center's) | what the app showed this run | ✓ (the service worker's) |
| Present | ✓ | ✓ | ✓ | ✓ (not scheduled ones) | ✓ | ✓ |
macOS needs the app's bundle, which asuka dev and asuka build run it
from: an executable run on its own (go run) has no bundle ID, and its
calls fail with an error that says so. Windows shows toasts, now or scheduled,
with their image, a category's actions as buttons and a reply as a text
box. The app registers itself the first time (a Start menu shortcut with
its AppUserModelID, asuka.json's id, and a COM activator: its executable,
in the user's registry), so that a tap or a button reaches the app,
which Windows starts for it if it isn't running; for a background
action it stays out of sight and quits once OnAction is done. The
badge is a count drawn on the app's taskbar button (a packaged app's,
the system's badge). Windows shows a scheduled toast however Present
would. Linux sends them as GNotifications
when the app has its desktop file (or runs in a sandbox), else to the
desktop's notification server itself (GNOME Shell turns away an app it
has no desktop file for); taps and buttons reach the app while it runs.
A notification due later is a systemd user timer (systemd-run --user),
which runs the app's executable without a window to show it, hands it
to the app if it runs, and launches the app for a tap; without systemd
(a Flatpak, asuka dev) it shows while the app runs. A browser shows
them through the app's service worker, with Web Push for asuka/push:
the web has the details.
Sound: asuka/audio
asuka/audio plays sounds and music with the platform's player (AVPlayer
on iOS and the Mac; Media3's ExoPlayer on Android; MCI on Windows;
GStreamer's playbin on Linux; an audio element in a browser, which plays
only after the user has tapped or clicked in the page). A source is a file in the app's
assets/, a file the app wrote, or an http(s) URL, a file or a stream.
audio.Play(source) plays a sound once, from the start, and lets it go
when it ends: for a chime, a click. For more, audio.Load makes a
*audio.Player:
func Sounds() ui.Node {
music := audio.Load("music/theme.m4a")
music.SetLoop(true)
music.SetVolume(0.5)
signal.OnCleanup(music.Release)
return ui.Column(nil,
ui.Button(ui.ButtonProps{Title: ui.Val("Ding"), OnPress: func() { audio.Play("sounds/ding.wav") }}),
ui.Button(ui.ButtonProps{Title: ui.Val("Play"), OnPress: music.Play}),
ui.Button(ui.ButtonProps{Title: ui.Val("Pause"), OnPress: music.Pause}),
ui.Button(ui.ButtonProps{Title: ui.Val("Restart"), OnPress: func() { music.Seek(0); music.Play() }}),
)
}A Player's methods, all on the main thread: Play (from where it is;
after its end, from the start), Pause, Seek(seconds), SetVolume (0 to
1, the default), SetRate (0.25 to 4, keeping the pitch), SetLoop,
Status(done) (its Position, Duration, Playing and Ended: read it
a few times a second to follow it) and Release, which stops it and frees
what it holds. Release a player when it's no longer needed.
The sound plays with iOS's silent switch on, as a music or game app's does, and doesn't mix with other apps' audio.
Network state: asuka/network
asuka/network is the device's network: whether it's online, over Wi-Fi or
cellular, and whether the connection costs. Use it to download large
files on Wi-Fi only, or to say the app is offline. It's NWPathMonitor on
iOS and the Mac, ConnectivityManager on Android, the Network List
Manager on Windows, GNetworkMonitor and NetworkManager on Linux, and
navigator.onLine in a browser, which says what carries it only where
it can (Chrome on Android).
network.Use() is the network as a signal, for a component: it follows
changes while the component lives.
func Status() ui.Node {
net := network.Use()
return ui.Text(signal.Func(func() string {
n := net.Get()
if !n.Connected {
return "Offline"
}
if n.Metered || n.Constrained {
return n.Type.String() + ": large downloads wait for Wi-Fi"
}
return n.Type.String()
}))
}A network.State has:
Connected.Type:network.None,WiFi,Cellular,EthernetorOther(a VPN alone, Bluetooth...). ItsStringis"none","wifi","cellular","ethernet"or"other".Metered: the connection costs or is limited, as mobile data or a phone's hotspot.Constrained: the user asked apps to use less data (iOS's Low Data Mode, Android's Data Saver, a browser's Data Saver), or, on Windows, the connection is near or over its data limit. Never on Linux.
Until the platform first answers, Use's value is Connected with
Type Other.
network.Current() is the network now (not on the main thread), and
network.Watch(f) calls f on the main thread with the network now and
each time it changes, until stop is called.
Saving to the library: asuka/library
asuka/library saves files where the user and other apps find them: a
video to the gallery, a song to the music, anything to Downloads.
library.Save(file, library.Options{Collection: library.Movies, Folder: "My App", Move: true},
func(where string, err error) {
if err != nil {
status.Set("Couldn't save: " + err.Error())
return
}
status.Set("Saved")
})library.Save(path, o, done) copies the file into o.Collection, off the
main thread (a large video takes a while), then calls done on the main
thread with where it went. It asks for what it needs the first time.
done may be nil.
library.Options:
Collection:library.Downloads(the default),Movies,MusicorPictures.Name: the file's name there (default: its own). A name taken gets a number.Folder: a folder of the collection's for the app's files; empty: the collection itself.MIME: its type (default: from its extension).Move: remove the file once it's saved, for a download in the app's caches: no second copy.
Where each collection goes:
| Collection | Android | iOS | macOS | Windows, Linux |
|---|---|---|---|---|
Downloads |
Downloads | the app's Documents, in the Files app | ~/Downloads |
the user's Downloads |
Movies |
the gallery's videos | Photos | Photos | the user's Videos |
Music |
Music | the app's Documents, in the Files app | ~/Music |
the user's Music |
Pictures |
the gallery's pictures | Photos | Photos | the user's Pictures |
On Windows and Linux, and in ~/Downloads and ~/Music on a Mac, the
file goes under Folder in the user's folder, answering with its path;
Linux's folders are XDG's. A browser downloads the file, whatever the
collection: it lands where the browser puts downloads.
Android uses the MediaStore collections from Android 10, with no
permission, and answers with a content:// URI. Before Android 10, it
writes to the shared folders after asking for the storage permission.
iOS asks to add to Photos (add only: the app can't read the library)
and answers with the asset's identifier; Folder doesn't apply there.
Other collections go to the app's Documents, under Folder, answering
with the file's path; Asuka makes Documents visible in the Files app.
Background work: asuka/background
asuka/background keeps the app working while it's out of sight: a
download, an upload, an export the user started, which takes minutes.
background.Start starts a task: the app keeps running, with a
notification showing its Options, until End. onAction gets the ID
of a notification button the user taps, on the main thread.
task.Update shows new options, its progress as it goes. Start a task
while the app is on screen, as the user asks for the work.
var cancelled atomic.Bool
task := background.Start(background.Options{
Title: "Exporting",
Progress: -1,
Actions: []background.Action{{ID: "cancel", Title: "Cancel"}},
}, func(action string) {
if action == "cancel" {
cancelled.Store(true)
}
})
go func() {
for i := 1; i <= 10 && !cancelled.Load(); i++ {
time.Sleep(time.Second) // the work
p := float64(i) / 10
ui.Post(func() {
task.Update(background.Options{Title: ui.Val("Exporting"), Text: fmt.Sprintf("%.0f%%", p*100), Progress: p,
Actions: []background.Action{{ID: "cancel", Title: "Cancel"}}})
})
}
ui.Post(func() {
task.End()
if cancelled.Load() {
status.Set("Cancelled")
}
})
}()background.Options:
TitleandText.Progress: 0 to 1; negative: under way, how far not known.Actions: the notification's buttons, each anIDand aTitle.
Start, Update and End run on the main thread; do the work in a
goroutine. Each Update replaces all the options, so give the Actions
again. examples/downloader downloads in a
task, with Pause, Resume and Cancel.
Android stops a backgrounded app within minutes. A task keeps it running in a foreground service (of the data sync type), with an ongoing notification for each task, showing its progress and up to three buttons, and keeps the CPU awake. On Android 13 and later, starting a task asks the user to allow notifications if they haven't; the work goes on either way. Android doesn't let an app in the background start one. Android 15 limits a data sync service to six hours a day. Google Play asks, in the Play Console, what the app's data sync foreground service is for: declare it before you upload.
iOS gives a backgrounded app about 30 seconds more while a task
lives, and shows no notification: Update does nothing there, and
onAction is never called. Out of time, iOS ends the task and the work
may stop.
A desktop app keeps running out of sight: a task keeps the system
from slowing or suspending it (App Nap on a Mac, sleep on Windows and
Linux), with no notification: Update does nothing, and onAction is
never called. A browser runs the page while it's open, with or
without a task.
Shares from other apps: ui.OnShare
ui.OnShare(f) calls f with what another app shares with this one
from its share sheet: a link from YouTube or a browser, a text from
Notes, photos from the gallery, a PDF from Files. It's iOS's and
Android's: on the desktops and in a browser the app isn't offered in
other apps' share menus, and f isn't called. asuka.json's
shareTarget says what the app is offered for:
{ "shareTarget": true }takes texts and links. An object takes files too, by MIME type, and at
most max at once (default 1):
{ "shareTarget": { "types": ["text", "image/*", "video/*"], "max": 10 } }A type is "text" (texts and web links) or a MIME type: "image/*",
"video/*", "audio/*", "application/pdf", "*/*" for any file.
func App() ui.Node {
link := signal.New("")
photos := signal.New([]string(nil))
ui.OnShare(func(items []ui.SharedItem) {
for _, it := range items {
switch it.Kind {
case ui.SharedLink:
link.Set(it.Text)
case ui.SharedImage:
photos.Set(append(photos.Peek(), it.Path))
}
}
})
return ui.Text(link)
}Each ui.SharedItem has a Kind: SharedText, SharedLink,
SharedImage, SharedVideo, SharedAudio or SharedFile. A text or a
link is in Text. A file is at Path, the app's own copy in its caches:
move it where the app keeps it, or remove it when you're done. It comes
with Name, its name in the app it came from (IMG_0042.HEIC), its
MIME type and its Size (-1 if not known). The items come in the
order the other app gave them. A page shared from a browser comes as its
title, a text, and its link.
What came before any handler, the share that launched the app, goes to the first one. The handler stops with the component's owner. To show a screen of the app's for what's shared, without opening it, see the share sheet below; to open one in the app, see Navigation.
Android opens the app itself with the share (ACTION_SEND, and
ACTION_SEND_MULTIPLE when max is over 1), for the types' intent
filters. The app copies the files to its caches before OnShare gets
them, while its access to them still holds.
iOS has a share extension in the app, which opens the app with what's
shared, as Android opens it: the extension shows no screen of its own.
Its activation rule comes from the types: Apple's keys for texts,
links, images, videos and any file, which keep max; a predicate on the
attachments' types for others (audio, a PDF), which doesn't. The
extension takes whatever it's given, so check Kind and MIME. Files
go from the extension to the app through an App Group,
group.<app ID>, which a device build's profiles must have (see
Shipping).
Under asuka dev, the player takes shares of anything, whatever the
app's shareTarget. On Android the files' paths are on the device, not
the Mac the Go code runs on: try files with asuka dev --platform android --once.
What a link leads to (a page, an image, a video) is the app's to find
out, with net/http: expo-sharing's "resolved" payloads, in Go.
The app's own share sheet: app.ShareSheet
By default a share opens the app. app.ShareSheet(screen) shows the
app's own screen instead, over the app that shares, as "Save to
Raindrop" or "Add to Notes" does: the user saves, and is back where they
were. Call it before app.Run.
func ShareSheet(s *ui.ShareSheet) ui.Node {
note := signal.New("")
return ui.Column(ui.Style{Grow: 1, Padding: ui.All(16), Gap: 12},
ui.Text(signal.Static(s.Items[0].Text)),
ui.TextField(ui.TextFieldProps{Value: note, Placeholder: ui.Val("Note"), Multiline: true}),
ui.Row(ui.Style{Gap: 12},
ui.Button(ui.ButtonProps{Title: ui.Val("Cancel"), OnPress: s.Cancel}),
ui.Button(ui.ButtonProps{Title: ui.Val("Save"), Variant: ui.ButtonFilled, OnPress: func() {
save(s.Items, note.Peek())
s.Close()
}}),
),
)
}
func main() {
app.ShareSheet(ShareSheet)
app.Run(App)
}s.Items is what's shared, as OnShare has it. The sheet ends with
s.Close() (done), s.Cancel(), or s.OpenApp(), which opens the app
with the items for its OnShare, as without a sheet. Until the items
have come (a video being copied) it shows a spinner.
The sheet is a root of its own, in a process of its own, as iOS runs
share extensions: on iOS the app's share extension, which iOS shows
as a sheet, running the app's Go code and the host from the framework it
shares with the app; on
Android an Activity in a :share process, shown as a Material
bottom sheet over the other app (dragging it down, tapping outside and
back cancel it). The process ends with the sheet: what's pending in
asuka/store is written as it closes, and app.OnLifecycle hears
Background. A file's Path is the sheet's until it closes: copy what
the app keeps.
What the app keeps is the same in its sheet: asuka/store, SQLite files
in store.Dir() (sqlite.Open("app.db")), asuka/secure. Android's
two processes share the app's files. On iOS the app's data moves to the
App Group (group.<app ID>), once, the first time it runs with a sheet,
and its keychain group is shared with the extension. The app doesn't
hear what its sheet wrote while it was running: look again when it
comes back (app.OnLifecycle, db.Changed()).
The CLI sees that the app's Go code calls app.ShareSheet and builds
the sheet instead of the extension that opens the app. On iOS the app's
Go code and the host are then a framework in the app,
Frameworks/AsukaApp.framework, which the app and its extension both
load, as Xcode shares code with an extension: the app isn't bigger for
its sheet. Under asuka dev the player's sheet runs the app's sheet from
the Mac too. examples/bookmarks is a
sheet as Raindrop's: the page's title, description and image, a note, a
collection and tags.
JavaScript: asuka/js
asuka/js runs JavaScript off screen, in the platform's own engine: a web
view that's never shown (WKWebView on iOS and the Mac, Android's WebView,
WebView2 on Windows, WebKitGTK on Linux), or in a browser a hidden,
sandboxed frame. It's for code
that only has a JavaScript form, as a site's player whose functions sign
its video URLs.
js.Eval(ctx, script) runs a script, statements as a <script>'s, in a
fresh page of its own, and returns its value as JSON: the last
statement's value, or what the promise it returns resolves to (null for
undefined). A thrown error, or a rejected promise, is the error, a
*js.Error. The page is gone after: what one script sets isn't there for
the next. Not on the main thread.
go func() {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
v, err := js.Eval(ctx, `const xs = [1, 2, 3].map(x => x * 2); xs`)
fmt.Println(string(v), err) // [2,4,6] <nil>
s, err := js.EvalString(ctx, `new Promise(done => setTimeout(() => done("later"), 100))`)
fmt.Println(s, err) // later <nil>
}()js.EvalString is Eval for a script whose value is a string. A script
may run until ctx's deadline, or for js.DefaultTimeout (a minute) if
it has none; one that runs out fails with "timeout". On Android a
thrown error's message has its stack.
For script in a page the user sees, use ui.WebView
(Web pages).
Push notifications: asuka/push
push.Register() asks the user to allow notifications (the first time),
registers the device with Apple's (APNs) or Google's (Firebase Cloud
Messaging) service, and reports its Token, its Service ("apns",
"fcm") and whether notifications are Allowed. Send the token to your
server, which sends notifications to it; register at each launch, since
the token can change. Not on the main thread. A Mac registers with APNs
as iOS does (with a provisioning profile: see On a Mac). On
the web it's Web Push ("webpush", the token the browser's
subscription; see the web). Windows and Linux have no push
service for an app: Register fails there.
func App() ui.Node {
push.OnOpen(func(m push.Message) { openThread(m.Data["thread"]) })
go func() {
if r, err := push.Register(); err == nil {
api.SaveDeviceToken(r.Service, r.Token)
}
}()
return home()
}push.OnReceive(f)gets each message that comes while the app runs. It's shown as a notification too, in the foreground as well, if it has a title or body. Data messages (silent pushes) come here only.push.OnOpen(f)gets each notification the user taps, including the one that launched the app, iffis set as the app starts. A message's data"url"also goes toui.OnOpenURL, asasuka/notify's do.- A
push.Messagehas itsID,Title,BodyandData(iOS: the payload's keys besideaps; Android: the message's data).
iOS needs the Push Notifications capability on the App ID for a release
build; the simulator gets a token too. Android needs a Firebase project:
add the Android app to it, download its google-services.json, and name
it in asuka.json:
{ "android": { "googleServices": "google-services.json" } }Asuka writes its settings into the app as Google's Gradle plugin would.
Without it, Register fails with what to do.
Categories, threads and channels in a push
A push uses asuka/notify's categories (its actions, notify.OnAction),
threads, channels, badge and Present, named in its payload:
- iOS:
aps'scategory,thread-idandbadge, as APNs has them. - Android: a notification message's
android.notification.channel_idandimage; a data message's"title","body","subtitle","category","thread","summary","channel","image","badge"and"url". A data message with a"title"or"body"shows as a notification (Asuka builds it, with its actions); one without is silent (OnReceiveonly). A notification message that comes while the app is in the background is shown by Firebase, without actions or a thread: send data messages for them.
A push.Message has these too: Subtitle, Category, Thread,
Channel, Image, Badge, Summary.
Rewriting a push before it shows
push.Rewrite(f) has the app's Go code change each push before it
shows: decrypt an end-to-end encrypted payload, set its title and body,
attach an image, make it a message in a conversation with the sender's
picture, set its thread and category. Call it in main, before
app.Run.
func main() {
push.Rewrite(func(m *push.Message) error {
text, from, err := decrypt(m.Data["box"]) // with a key in asuka/secure
if err != nil {
return err // shows the push as it came
}
m.Body = text
m.Category = "message"
m.From(notify.Person{ID: from.ID, Name: from.Name, Image: from.Avatar},
notify.Conversation{ID: m.Data["chat"]})
return nil
})
app.Run(App)
}f runs on its own goroutine, not the main thread. An error, or a
panic, shows the push as it came. m.Image is downloaded and attached;
m.From makes it a message, as notify.Notification's From does.
iOS rewrites in a Notification Service Extension, which asuka build
makes for an app that calls push.Rewrite: it runs the app's Go code in
a process of its own for each push with "mutable-content": 1 in its
aps (and an alert, which shows if the rewrite fails). main runs,
then f; the root component doesn't, so keep main light. iOS gives it
about 30 seconds and 24 MB, and shows the push as it came when time runs
out. What the app keeps is the same there: its data (asuka/store, files
in store.Dir) is in the App Group the two share, its keychain items
(asuka/secure) in the app's group. A release build needs a provisioning
profile for the extension's bundle ID, the app's plus .notification
(asuka.json's ios.notificationProfile names one; by default, the best
match installed), with the App Groups capability and the app's group.
A rewrite can't hide a push on iOS.
Android rewrites FCM's data messages in the app's process: the Go code starts without a screen if the app isn't running, rewrites within about ten seconds, and the notification shows. A data message whose rewrite leaves no title or body doesn't show.
Under asuka dev's player there's no extension (the app's Go code runs on
the computer): pushes show as they came. asuka dev --platform ios --once and a
build have it. The simulator runs the extension only for pushes that
come from APNs, not for xcrun simctl push.
Sign in with Apple: asuka/appleauth
appleauth.Button is Apple's own button (its title, logo and colors, in
the user's language), and appleauth.SignIn shows the system's sheet.
The App Store requires Sign in with Apple of an app that offers another
company's sign-in for its accounts.
appleauth.Button(appleauth.ButtonProps{
Type: appleauth.ContinueButton,
Style: appleauth.Automatic, // black in light mode, white in dark
OnPress: func() {
go func() {
c, err := appleauth.SignIn(appleauth.Options{Scopes: appleauth.FullName | appleauth.Email, Nonce: nonce})
if err == nil {
api.SignInWithApple(c.IdentityToken, c.AuthorizationCode)
}
}()
},
})The Credential's User is the account's stable ID; send its
IdentityToken (a JWT Apple signs) and AuthorizationCode to your
server, which checks them with Apple. Email, GivenName and
FamilyName come only the first time the user signs in to the app: keep
them then. ErrCanceled is the user closing the sheet.
appleauth.CredentialState(user) says whether the user still uses Sign
in with Apple for the app (Revoked: sign them out). On Android there's
none: Button is nothing, Available is false and SignIn fails with
ErrNotAvailable; so on Windows, Linux and the web. Sign in with Apple
there on the web (asuka/webauth). The Mac has it as iOS does, with a
provisioning profile (see On a Mac).
Face ID and fingerprints: asuka/biometric
biometric.Authenticate(options) shows the system's prompt (Face ID,
Touch ID, Android's BiometricPrompt; Touch ID on a Mac, Windows Hello on
Windows, a passkey's WebAuthn prompt in a browser) and returns nil if it's the device's
owner, or ErrCanceled, ErrNotAvailable (no biometry and no passcode,
or locked out) or ErrFailed. Not on the main thread.
go func() {
err := biometric.Authenticate(biometric.Options{Reason: "Unlock your notes"})
ui.Post(func() { unlocked.Set(err == nil) })
}()Reasonsays why (iOS's subtitle; Android's title);Cancelis the cancel button's title.- The user can use the device's passcode, PIN or pattern instead, unless
NoPasscode: biometry only (on Android, a strong one, Class 3). biometric.Check()says what the device has: itsKind(Face,Fingerprint,Iris,Optic), whether one isEnrolled, and whether it has aPasscode.
Linux has no system prompt for the owner: Check says there's nothing,
and Authenticate fails with ErrNotAvailable.
Recording sound: asuka/audio/record
record.Start(options, done) records from the microphone until
record.Stop (or MaxDuration), then calls done with the file: AAC in
an MPEG-4 container (.m4a), in the app's caches, which asuka/audio
plays. AVAudioRecorder records on iOS and the Mac, Media Foundation on
Windows, GStreamer on Linux, and MediaRecorder in a browser (Opus in
WebM where it can't record AAC). It asks for the microphone the first time; refused, done gets
"not allowed".
record.Start(record.Options{Quality: record.Voice, MaxDuration: time.Minute}, func(path string, err error) {
if err == nil {
memo.Set(path)
}
})
// later
record.Stop()Voice is mono at 64 kbit/s; Music, stereo at 128 kbit/s. Pause and
Resume pause and continue the same file. record.Level(), from a
goroutine a few times a second, is the sound's level from 0 to 1, for a
meter. record.CurrentStatus(done), from the main thread, is the level,
whether sound is being kept and how much has been: what a timer should
show, as the system can pause a recording (a call), and iOS goes on after
when it says it may. On iOS the audio session plays and records while it
records (other apps' sound stops), and goes back after.
Options.Stream records an ADTS stream (.aac), whose frames are each whole,
so what's recorded survives the app or phone dying, where an MPEG-4 file is
unreadable until it's closed. Give it a Path to find the file again after a
crash, and make it an .m4a for keeping with record.Finalize(stream, dst),
which seeks properly: after a stop or a crash alike.
Options.Background keeps it recording out of sight and with the screen
locked. iOS needs "ios": {"backgroundModes": ["audio"]} in asuka.json;
Android runs a foreground service of the microphone type, with an ongoing
notification (BackgroundTitle) and a button to stop. Google Play asks
what the service is for: declare it before you upload. A desktop app
records out of sight anyway: there Background changes nothing.
Speech to text: asuka/transcribe
transcribe.File(path, options) turns a recording into text, on the
device: the sound never leaves it. Where the device can't do that, it says
ErrNotAvailable, rather than using a server. Not on the main thread.
go func() {
r, err := transcribe.File(path, transcribe.Options{Language: "en-US"})
ui.Post(func() {
if err == nil {
text.Set(r.Text)
}
})
}()The Result has its Text, its Language, and Segments, words with
their Start and Duration in seconds, on iOS and Android 14. A recording
with no speech is an empty Text. transcribe.Check(language) says what
the device can do, without asking anything of the user. iOS uses the Speech
framework (its models are in iOS, not on the simulator's every runtime); it
asks for speech recognition the first time. Android's on-device recognizer
needs Android 13; it needs the language's model, which it fetches when it's
missing (ErrDownloading: try again later). The Mac uses the Speech
framework too, on the device only. Windows, Linux and the web have no
on-device recognizer an app can use: File fails with ErrNotAvailable.
The screen: asuka/screen
screen.Lock(screen.Landscape) // a video player
screen.SetBars(screen.Bars{Style: screen.Light, HideStatus: true})
screen.PreventCapture(true) // a ticket's code
stop := screen.OnScreenshot(func() { note.Set("That screenshot shows your balance") })screen.Lock(o)turns the app tooand keeps it there:Portrait,PortraitUp,Landscape,LandscapeLeft,LandscapeRight;Anyunlocks it. Only the orientationsasuka.jsonallows. iPads in Split View or Stage Manager, and Android 16's large screens, ignore it.screen.SetBars(bars)sets the status bar'sStyle(Autofollows dark mode;Lightis light icons, for a dark picture under them;Dark, dark ones), and hides the status bar (HideStatus) or Android's navigation bar (HideNavigation; a swipe from the edge shows them a moment). The safe area follows.screen.PreventCapture(on)keeps the app out of screenshots and recordings. Android makes the window secure: captures are black, and the recent apps show it blank. iOS can't stop a screenshot: it covers the app while the screen is recorded, mirrored or on AirPlay.screen.OnScreenshot(f)callsfwhen the user takes a screenshot (iOS; Android 14 and later), untilstop.
A desktop window has no orientation or system bars: Lock and SetBars
do nothing there. PreventCapture keeps the app's windows out of
screenshots and recordings on the Mac and Windows (not on Linux), and
OnScreenshot hears of screenshots as they're saved (Spotlight's on a
Mac, the Screenshots folder on Windows and Linux). A browser locks the
orientation only full screen or in an installed app, hides the bars by
going full screen, and has neither PreventCapture nor OnScreenshot.
These are the app's, not a screen's: a screen that sets one sets it back
as it goes (signal.OnCleanup).
The launch screen: asuka/splash
asuka.json's splash is the launch screen: an image (a square PNG, a
logo), size points wide (default 120, at most 160), in the middle of a
color, in light and dark mode.
{ "splash": { "image": "logo.png", "size": 96, "background": "#4A63B5", "darkBackground": "#1B2440" } }It shows from the tap on the icon until the app's first frame: iOS's
launch screen, then a copy of it over the app until the first frame is
ready; Android 12's splash screen (the image in the middle of the icon's
circle) and, before, the window's background. The desktops and the web
have no launch screen: there Hold and Hide do nothing. To keep it up
while the app loads, call splash.Hold() as it starts (in the root component) and
splash.Hide() once it's ready:
func App() ui.Node {
splash.Hold()
go func() {
load()
ui.Post(splash.Hide)
}()
return home()
}Maps: asuka/maps
maps.View is a map: Apple Maps on iOS and the Mac, Google Maps on
Android, OpenStreetMap's tiles on Windows and in a browser (drawn by the
host) and on Linux (libshumate).
region := signal.New(maps.Region{Center: maps.Coordinate{Lat: 37.77, Lng: -122.42}, LatDelta: 0.05, LngDelta: 0.05})
maps.View(maps.Props{
Region: region,
Markers: ui.Val([]maps.Marker{{ID: "hq", At: maps.Coordinate{Lat: 37.79, Lng: -122.39}, Title: "HQ"}}),
OnRegionChange: func(r maps.Region) { region.Set(r) },
OnMarkerPress: func(id string) { show(id) },
Style: ui.Style{Grow: 1},
})Regionis where it shows, its center and the degrees it spans; setting it moves the map there.OnRegionChangegets the region once the user has moved it.Markersare pins, with aTitle,SubtitleandColor;OnMarkerPressgets a tapped one'sID, andOnPresswhere the user taps the map.TypeisStandard,Satellite,HybridorMuted(iOS);ShowsUsershows the user's location, with the location permission (importasuka/locationand ask for it first);NoInteractionmakes it a still preview.- A map has no size of its own: give it one, or
Grow.
Android needs a Google Maps Platform API key with the Maps SDK for
Android, in asuka.json: {"android": {"mapsKey": "..."}}. Without it,
the map says so, and OnError gets why. On Windows, Linux and the web,
Satellite and Hybrid show the streets (no free imagery); ShowsUser
shows nothing on Windows and Linux, and the user's location in a browser
once the page has the location permission. Muted is quieter streets
on iOS and the web. Linux needs libshumate (libshumate-1.0-1). The
tiles come from tile.openstreetmap.org, as its usage policy asks, with
its attribution on the map; a web app can take them from a server of its
own (the web).
Background refresh: asuka/periodic
asuka/periodic runs the app's Go code now and then while it isn't on
screen: a sync, a fetch of what's new. Handle sets a task's code, in
the root component; Schedule asks the system to run it.
func App() ui.Node {
periodic.Handle("sync", func(ctx context.Context) error {
return syncInbox(ctx)
})
periodic.Schedule("sync", periodic.Options{Every: time.Hour, Network: true})
return home()
}The system decides when, from Every (15 minutes at least), the
battery, the network, and how the user uses the app; it may wait much
longer, and the user can turn it off (iOS's Background App Refresh).
Network runs it only online and Charging only while charging. The
ctx ends before the system's deadline: about 25 seconds on iOS (a
processing task, for Charging, has minutes), 9 minutes on Android
(WorkManager). An error asks Android to retry it.
When the system runs a task with the app closed, the app's Go code
starts without a screen: main runs, and the root component (which
calls Handle), then the task. If the user opens the app then, its
screen appears. Cancel(name) stops a task.
A desktop doesn't launch an app for its tasks: on macOS
(NSBackgroundActivityScheduler), Windows and Linux they run on a timer
while the app runs, and one that wants power waits while on the battery.
A browser runs no page's tasks while it's closed: Schedule fails there.
The stores: asuka/review, asuka/tracking, asuka/integrity, asuka/agerange
review.Request(done) asks the store for its rating prompt (the App
Store's, Google Play's in-app review). The stores decide whether it
shows, on their own quotas, and don't say; ask after a success, never
from a button. For a "Rate us" button, ui.OpenURL(review.AppStoreURL(id))
or review.PlayStoreURL(id).
tracking.Request() shows iOS's App Tracking Transparency prompt, which
an app must before it reads the advertising identifier or shares data
with ad networks, and reports Allowed, Denied, Restricted or
NotAsked; tracking.Current() reads it without asking. Android has no
such prompt: it's Allowed. Not on the main thread.
asuka/integrity proves to your server that a request comes from your
app on a real device. iOS: App Attest. GenerateKey makes a key (keep
its ID), AttestKey(id, challenge) has Apple attest it for your server's
challenge, once, and Assert(id, data) signs each request after. Android:
Play Integrity. Prepare(cloudProjectNumber) at launch, then
Token(requestHash) for each request. The server checks what they
return with Apple or Google. Supported is false on the iOS simulator.
agerange.Request(13, 18) asks for the user's age range without their
birth date, for the laws that ask apps to know a child: Apple's Declared
Age Range (iOS 26) and Play's Age Signals. The platform may ask the user
or their parent to share. The Result is Shared with a Lower and
Upper age (0: open), Declined, or VerificationRequired (Play); its
Source says how it's known. ErrNotAvailable where the platform can't
answer.
Off the phones: the Mac has the App Store's rating prompt, the tracking
prompt, and App Attest (with a provisioning profile: see
On a Mac), but not Declared Age Range. Windows, Linux and
the web have no store prompt (review.Request shows nothing; in a
browser done gets an error), no attestation (integrity.Supported is
false) and no age range (ErrNotAvailable). Tracking needs no prompt
there: Windows answers Allowed while the advertising ID is on in
Settings, Linux Allowed, a browser Denied when it sends Global
Privacy Control or Do Not Track.
Motion and sensors: asuka/sensors
stop := sensors.WatchMotion(50*time.Millisecond, func(m sensors.MotionReading) {
level.Set(m.Attitude.Roll) // a spirit level
})WatchAccelerometer,WatchGyroscopeandWatchMagnetometergive aVector(x, y, z) about every interval: acceleration in g, rotation in radians per second, the magnetic field in microteslas (calibrated).WatchMotionis the fused device motion:Gravityand the user'sAcceleration(in g),Rotation, and theAttitude: aMatrixthat turns device axes into East-North-Up, theHeading(degrees from magnetic north; -1 without a magnetometer), andPitchandRoll.WatchPressureis the barometer (hectopascals, and the change in altitude),WatchLightthe ambient light in lux (Android), andWatchStepsthe steps since watching began.StepsBetween(from, to)is iOS's history (a week); Android keeps none.Available(sensor)says whether the device has one; the simulator and emulators have few. A Mac and a PC have none; Linux has the light sensor and the accelerometer's orientation on convertibles and tablets (iio-sensor-proxy); a browser has the Generic Sensor API or device motion events, without a barometer or pedometer.
The values are the same on both platforms: device axes as Android and the W3C define them (x to the right of the screen, y up it, z out of it), acceleration as the accelerometer measures it (a device lying face up reads z = +1 g; iOS's are turned to match), and the attitude computed the same way on both. Up to 200 readings a second; readings come on the main thread, in batches as the platform delivers them.
Editing images and video frames: asuka/imageedit
imageedit.Edit(source, actions, options) reads an image (HEIC, JPEG,
PNG, WebP: what the camera and the photo picker give), turns it upright
as its orientation says, does the actions in order and writes a JPEG or
PNG to the app's caches. Not on the main thread.
go func() {
r, err := imageedit.Edit(photo, []imageedit.Action{
imageedit.Resize(1080, 0), // 1080 wide; 0 keeps the aspect ratio
imageedit.Crop(0, 0, 1080, 1080),
}, imageedit.Options{Quality: 0.8})
if err == nil {
upload(r.Path, r.Width, r.Height)
}
}()The actions are Resize, Crop, Rotate (clockwise, any angle),
FlipHorizontal, FlipVertical, Fill and Pixelate. Fill(x, y, w, h, r, g, b) paints a rectangle a solid color, and what was there is gone from the file;
Pixelate(x, y, w, h, block) turns one into squares, each the average of what's
under it, aligned to the image, so rectangles that touch look as one. Text under
small squares can sometimes be read back: for what must stay hidden, use Fill.
The file written has none of the source's metadata: no location, no camera. A first Resize decodes the image
near that size, which saves memory on large photos. imageedit.Size
reads an image's size without decoding it. imageedit.VideoFrame(source, at, actions, options) takes a video's frame at a time (a thumbnail, a
poster), upright, and edits it the same way; a remote video is read only
as far as the frame needs. A source is what ui.Image takes: a path, a
file:// or http(s) URL, or an asset.
Each platform decodes with its own codecs: ImageIO on iOS and the Mac, Android's, WIC on Windows, GdkPixbuf's loaders on Linux (HEIC and WebP where their codecs or loaders are installed), a canvas in a browser (JPEG or PNG; a remote image needs CORS).
Text to speech: asuka/speech
speech.Speak(text, options, done) queues text to be read aloud with the
platform's voices; done gets nil once it's spoken, or ErrStopped.
speech.Speak("Turn left in 200 meters", speech.Options{Rate: 1.1}, nil)Options set the Language (a BCP 47 tag), a Voice (an ID from
speech.Voices(), with its name, language and quality), Pitch, Rate
and Volume. Stop drops the queue, Pause and Resume pause at a
word (on Android too, which can't pause: Asuka speaks on from the word).
On iOS other apps' sound ducks while it speaks, and it speaks with the
silent switch on; Android ducks them likewise. The voices are
AVSpeechSynthesizer's on iOS and the Mac, SAPI's on Windows, Speech
Dispatcher's on Linux, and speechSynthesis's in a browser.
Contacts: asuka/contacts
contacts.Pick(done)shows the system's contact picker: the user picks one, and the app sees only that one, with no permission asked (on Android, without the permission, its name only).contacts.Add(contact, done)shows the system's new contact screen, filled in, where the user saves it; no permission asked.contacts.Request()asks for access, andcontacts.List(query)reads the address book (by name, up to a limit): names, organization, phones and emails with their labels, birthdays. iOS 18's limited access lists the contacts the user chose.
On a Mac it's the Contacts framework: Pick is the system's picker, a
popover from the window's top, and Add asks in an alert over the
window (macOS has no new contact screen), then saves. Linux reads and
writes Evolution Data Server's address books, as On Linux
says. Windows gives the contacts to packaged apps only: Request says
denied, Pick fails, and Add opens a vCard in the user's contacts
app. A browser picks one where it has the Contact Picker (Chrome on
Android), and Add downloads a vCard.
Calendars: asuka/calendar
calendar.Add(event, done)shows the system's event editor, filled in, where the user saves the event; no permission asked (iOS 17 and later). iOS says whether it was saved; Android's calendar apps don't.calendar.Request()asks for full access; thencalendar.Calendars()lists the calendars andcalendar.Events(from, to, ids...)the events between two times, each occurrence of a repeating one.- An all-day event's
StartandEndare days: midnight UTC of the first, and of the day after the last.
On a Mac it's EventKit: Add asks in an alert over the window (macOS
has no event editor for apps), then saves. Linux reads and writes
Evolution Data Server's calendars. Windows gives the calendars to
packaged apps only: Request says denied, and Add opens an iCalendar
file in the user's calendar app (saved is false: it doesn't say). A
browser can't read calendars; Add downloads an .ics file.
Home screen widgets: asuka/widget
asuka.json's widgets declares the app's widgets; widget.Set fills
them, from the app's Go code:
{ "widgets": [{ "kind": "today", "name": "Today", "description": "What's next", "sizes": ["small", "medium", "rectangular"] }] }widget.Set("today", widget.Entry{
Title: "Next", Symbol: "calendar", Value: "3 tasks", Body: "Call Ana at 4",
Progress: 0.4, ShowProgress: true, Until: meeting, URL: "myapp://today",
})A widget shows its Entry with the platform's template, laid out for
its size: the Title (with its Symbol, in the Tint), a large Value,
the Body, a Progress bar, a live countdown to Until, a Caption.
It keeps showing it until the app sets another, whether or not the app
runs; tapping it opens the app with its URL (ui.OnOpenURL). Several
entries, each with its At, are a timeline: each shows from its time.
Clear empties it.
Sizes: small, medium, large, and iOS's extraLarge (iPad) and
lock screen circular, rectangular and inline. iOS: a WidgetKit
extension the CLI builds into the app, sharing an App Group with it
(a release needs the App Groups capability, and a profile for the
extension's ID, the app's plus .widgets). Android: an App Widget for
each kind, Material You colors, sized to the launcher's cells. The
desktops and the web have none: widget.Set does nothing there.
Shortcuts: Siri, Spotlight, the launcher
asuka.json's shortcuts are actions the system offers for the app.
Each opens the app with its URL, for ui.OnOpenURL:
{ "shortcuts": [{ "id": "newNote", "title": "New Note", "url": "notes://new", "symbol": "square.and.pencil", "phrases": ["New note in ${app}"] }] }On iOS they're App Shortcuts: Siri hears their phrases (each with
${app}, the app's name), and they're in Spotlight, the Shortcuts app
and on the Action button, with no setup by the user (up to ten). The CLI
compiles them as App Intents and extracts their metadata as Xcode does.
On Android they're the launcher's shortcuts, shown on a long press of
the app's icon, with the symbol in the accent color. The desktops and
the web don't add them.
Simulator builds are signed as Xcode signs them, with the keychain's
Apple Development identity (asuka.json's team's): App Intents only run
in an app with a team. Without one, the build is signed ad hoc, and the
CLI says the shortcuts won't run.
Haptics: ui.Haptic
ui.Haptic(kind) plays haptic feedback (UIFeedbackGenerator;
performHapticFeedback on Android): ui.HapticSelection,
HapticLight, HapticMedium, HapticHeavy, HapticSuccess,
HapticWarning or HapticError. A Mac plays the trackpad's feedback
(a Force Touch trackpad: an alignment for HapticSelection, a level
change for the rest); a browser vibrates where it can (Chrome on
Android); Windows and Linux have no haptics, and do nothing.
ui.Haptic(ui.HapticSuccess)See Motion for feedback that goes with animation.
Crash reports: asuka/crash
An app that imports asuka/crash keeps a report of each crash, and reads
it on its next launch, to send where it likes: its own server, Sentry,
Crashlytics. Asuka uploads nothing.
func App() ui.Node {
crash.OnReport(func(r crash.Report) {
go func() {
if err := send(r); err == nil { // your upload: r is JSON
crash.Delete(r.ID)
}
}()
})
crash.Set("user", userID)
// ...
}
// Elsewhere, as the app goes:
crash.Breadcrumb("opened the cart")
crash.Record(err) // an error handled, reported tooOnReport(f) calls f on the main thread with each report kept, then
with each that comes (a diagnostic iOS delivers a moment after launch,
an error recorded), once each, until its stop. Reports() reads them
all, oldest first (not on the main thread). A report stays until
Delete(id); past MaxReports (50) the oldest go. Its ID is the same
each time it's read, so a server can tell one sent twice.
What's reported, as a Kind, on each platform:
| iOS | Android | macOS | Windows | Linux | Web | |
|---|---|---|---|---|---|---|
Go: a panic not recovered, on any goroutine; a fatal error (a concurrent map write, a deadlock, out of memory) |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ (the host keeps the runtime's output) |
Exception: uncaught |
NSException (Objective-C, Swift's NSExceptions) |
a JVM Throwable |
— | — | — | an uncaught JavaScript error (not fatal) |
Native: a signal, a Mach or Windows exception |
MetricKit | the system's tombstone | MetricKit | the runtime's report | the runtime's report (a signal in native code) | — |
Hang |
MetricKit's hangs (not fatal), the watchdog | ANRs, with their traces | MetricKit | — | — | — |
OutOfMemory |
the memory limit, when MetricKit says | low-memory kills | — | — | — | — |
Resource |
CPU and disk-write exceptions (not fatal) | excessive resource use | MetricKit | — | — | — |
NonFatal: Record(err) |
✓ | ✓ | ✓ | ✓ | ✓ | ✓ |
A Go crash is kept by Go's runtime itself (runtime/debug.SetCrashOutput,
set as the app starts, before its main), with every goroutine's stack.
An uncaught exception is written by the host's handler as the app ends
(a handler set before, a crash SDK's, still runs after). The rest the
system hands over on a later launch: iOS and macOS's MetricKit
diagnostics, and Android's record of how the app's processes ended
(getHistoricalProcessExitReasons, Android 11 and later; a native
crash's tombstone from Android 12). A crash the system reports again
after the host's handler did (iOS's SIGABRT of an uncaught exception,
Android's REASON_CRASH) is left out.
Each report has its Context: the app's version and build, the device's
model and system, the process and how long it had run, whether it was in
the foreground, and the last Breadcrumbs (MaxBreadcrumbs, 100) and
the values Set. They're written to the app's data directory as they
change, a line appended each (no sync, so as cheap as a write), the file
rewritten when it's twice what it needs: they're on disk before the
crash, whatever kills the app. The lifecycle's changes are breadcrumbs
too ("app: background").
A report's Text is what the platform gives (Go's crash output, a stack
trace, a tombstone, an ANR's traces); its Threads are those stacks
read into frames, the crashed one first. A Go frame has its function,
file and line. A native one has its binary, the binary's UUID (Mach-O)
or build ID (ELF), the address and its offset in the binary: what a
service symbolicates with the build's symbols (see
Shipping).
Sending to Sentry or Crashlytics. For Sentry, make an event of each
report (its envelope endpoint, https://<host>/api/<project>/envelope/
with your DSN's key): exception.values[0] with the Kind as its
type, the Message as its value, and the crashed thread's frames as
its stacktrace.frames, outermost first (Sentry's order, the reverse of
a Thread's), each with function, filename and lineno, or
instruction_addr and package for a native frame; and in
debug_meta.images each binary of the frames, {"type": "macho", "debug_id": BinaryID, "image_addr": Address-Offset} ("elf" on
Android), so the dSYMs or symbols you upload with sentry-cli apply.
The Context goes in contexts, tags and breadcrumbs. Crashlytics
has no public endpoint to send to: send the reports to your server, or
record them in Crashlytics' SDK as non-fatal errors from your own native
module (Native code). Either way, App Store review asks
about crash data: if you send it, say so (ios.collected in
asuka.json, {"type": "CrashData", "purposes": ["AppFunctionality"]}).
On each platform. iOS links MetricKit only into an app that reads
its reports (crash.Reports or crash.OnReport), and delivers its
diagnostics to the app, not its share extension. An app with its own
share sheet (app.ShareSheet) keeps the sheet's crashes with its own,
in their App Group, and either reads them. MetricKit has a crash's process ID from iOS 17 on, which ties it
to its run's context; before, it's the run that was last before it. It
doesn't deliver on the simulator. Android needs nothing more; before
Android 11 there's no record of native crashes and ANRs. macOS: MetricKit
from macOS 12; an uncaught Objective-C exception that ends the app is
MetricKit's crash (AppKit catches those of the main thread and logs
them). Windows: Go's runtime handles every exception the process
doesn't (its last handler): it prints the exception and the goroutines,
and exits. Without a console (a release, built for the GUI), Asuka sends
what the runtime prints to a file of the run's, which the next launch
reads into a Native report. Asuka sets no unhandled exception filter of
its own: Go's handler runs first, and replacing it would break Go's.
Windows Error Reporting keeps its record as ever. In a browser, Go's
runtime can't write its crash output to a file (SetCrashOutput needs
dup), so the host keeps what the runtime prints while Go runs and, as
the program ends with a panic or a fatal error, writes it to the run's
record, in the app's files (IndexedDB): the next launch reads it. An
uncaught JavaScript error, the host's or the app's web/*.js, is an
Exception report at once, not fatal (the page goes on), with the run's
context; at most 20 a run, each once. A page marks its run alive in the
origin's localStorage, so the app's other open pages leave its record
alone.
Under asuka dev the Go code runs on your Mac (or PC): its crashes
print in the terminal, as ever, and are kept there, so crash.Reports
on the next run has them. The player's own native crashes aren't the
app's, and aren't reported to it; a build of the app (asuka dev --once,
asuka build) reports everything.