Files & share

Files in and out of the app: openFilePicker lets the user choose a photo or a file, share hands an image or a video to the OS share sheet, files keeps the app's own files on the device. A new photo taken inside the app, with a live preview, is CameraView; an existing photo from the library is openFilePicker({ accept: 'image/*' }). Small values that must survive a restart go in a stored signal (signal(x).stored(key), signals.md) or localStorage; documents and binary files on this device in files (desktop and iOS only); data shared between devices or users in the backend database (server code).

  • pick a photo from the library and show it (openFilePicker)
  • let the user attach documents or several files at once
  • upload a picked photo to a server
  • share an image or a video to other apps (share)
  • save a photo to the camera roll or the gallery
  • download a file and keep it on the device (files)
  • save and load a game or a document as a file
  • list, check and delete the app's saved files

Platforms: iOS, Android, desktop, the web and the web player; files on desktop and iOS only — check files.supported.

At a glance

TypeScript
const upload = async () => {
  const photo = await openFilePicker({ accept: 'image/*' })   // null = the user cancelled
  if (!photo) return
  const form = new FormData()
  form.append('photo', photo)
  await fetch('https://api.example.com/upload', { method: 'POST', body: form })
}

UIScreen(UIButton(UIText('Upload a photo')).onClick(upload)).open()

To keep the photo on the project's own backend, pass the File to a server function instead (stored-files.md).

Pick a photo or a file: openFilePicker

Let the user choose an existing photo from the library, a document, or several files, in the system's own picker. It resolves to File handles — null (or [] with multiple) when the user cancels; a cancel is not an error.

TypeScript
openFilePicker(options?: { accept?: string, multiple?: false }): Promise<File | null>
openFilePicker(options:  { accept?: string, multiple: true  }): Promise<File[]>

accept filters what can be picked, as an HTML <input accept> does: 'image/*', 'video/*', '.pdf', or a comma-separated list. iOS opens the photo library only for exactly 'image/*', 'video/*' or both (a photo arrives as JPEG, a video as H.264); any other accept — 'image/png' included — opens the Files picker there. Android opens the system picker, the web a file input (a phone browser offers its photo library), desktop the OS open-file dialog.

TypeScript
const photo = signal<File | null>(null)

const choose = async () => {
  const file = await openFilePicker({ accept: 'image/*' })
  if (file) photo.value = file                       // null: the user cancelled
}

UIScreen(
  UIButton(UIText('Choose a photo')).onClick(choose),
  UIText(() => (photo.value ? `${photo.value.name}, ${photo.value.size} bytes` : 'No photo yet')),
  UIColumn(() => [photo.value && UIImage(photo.value).style({ width: 240, height: 240, borderRadius: 12 })]),
).open()

A File carries name and size (bytes) only: no path, and its bytes cannot be read in JS — they stay with the host. Hand the File to what takes one: an image source (UIImage(file), Texture2D.load(file)), a FormData upload, share(), files.write() — on desktop and iOS, write it and read the copy back with files.readText / readBytes to get at its contents.

TypeScript
const picked = signal<File[]>([])

const attach = async () => {
  const more = await openFilePicker({ accept: '.pdf,.docx', multiple: true })   // [] on cancel
  picked.value = [...picked.value, ...more]
}

UIScreen(
  UIButton(UIText('Attach documents')).onClick(attach),
  UIColumn(() => picked.value.map((file) => UIText(`${file.name}, ${Math.round(file.size / 1024)} KB`))),
).open()
Note

headless tests have no dialog: a flow's pick answers it ({ "tap": "Choose a photo", "pick": "cat.png" }), and without one it resolves as cancelled — so does the windowless server. On Linux the desktop dialog needs zenity or kdialog; without either the call rejects.

Share a picture or a video to other apps: share

Send a picture or a video to another app — Messages, mail, a social app, "Save Image" — through the OS share sheet. media is a File (picked, taken with CameraView, a Canvas.toFile() snapshot) or a FetchResponse (a download); text is an optional caption. Fire-and-forget: nothing comes back, not even whether the user shared or cancelled.

TypeScript
share(media: File | FetchResponse, text?: string): void
TypeScript
const shareScore = async (score: number) => {
  const card = new Canvas(600, 315)
  card.fillStyle = '#1e2230'
  card.fillRect(0, 0, 600, 315)
  card.fillStyle = '#ffffff'
  card.font = 'bold 64px sans-serif'
  card.textAlign = 'center'
  card.fillText(`Score: ${score}`, 300, 180)
  share(await card.toFile('score.png'), `I scored ${score}!`)   // a PNG File
}

UIScreen(UIButton(UIText('Share my score')).onClick(() => shareScore(1250))).open()

What each host does with it:

  • iOS — the share sheet. An image is offered as an image (Messages, Mail, "Save Image"); any other file — a video, a PDF — goes out as a .bin file with a random name.
  • Android — the share chooser, for an image Android can decode, an MP4 that starts with the bytes 00 00 00 18, or an MP3 that starts with FF FB (one with an ID3 tag does not); anything else does nothing. The file goes into the device's media library first — an image or a video into the gallery (below).
  • desktop — no share sheet: the file is saved to the Downloads folder as lecodes-share-<n>.<ext> and shown in the file manager; the caption only goes to the log.
  • the web — the browser's share sheet where it can share files (a phone browser), otherwise a download; either way the file is named image.png, whatever it holds.
  • the web player — always a download named image.png; the caption is dropped.
  • headless tests and the windowless server — nothing.

Save a photo to the camera roll

No call saves to the photo library directly: share(photo) is the way, and the user finishes it in the system's sheet. On iOS the sheet offers "Save Image" for an image; a video cannot be saved to Photos this way. On Android an image or an MP4 is written into the gallery before the chooser opens, so it is saved even if the user cancels (Android 10 and newer; older versions need a storage permission no LeCodes app declares). The web downloads or shares it; desktop saves it to Downloads. A CameraView photo (takePhoto()) is only in memory: nothing saves it until you share it.

TypeScript
const savePoster = async () => {
  const res = await fetch('https://example.com/poster.jpg')
  if (res.status !== 200) return
  share(res)   // iOS: "Save Image" in the sheet; Android: in the gallery before the chooser opens
}

UIScreen(UIButton(UIText('Save the poster to Photos')).onClick(savePoster)).open()
Note

saving from the iOS sheet needs NSPhotoLibraryAddUsageDescription in the app's Info.plist: the le.codes viewer app declares it; an iOS shell made with lecodes app does not carry it.

Save files on the device: files

Keep documents, downloads and game saves as files the app owns on this device, and read, list and delete them by path. A file written here is also what fetchLocal reads by name (not in a project packaged inside a macOS .app). Every call is a promise: the host does the IO off the JS thread, so a big write never costs a frame.

Note

desktop (Windows, Linux, macOS) and iOS only. On Android, the web, the web player and in headless tests files.supported is false and every call rejects with files.<op>("<path>"): this platform has no local filesystem — keep small data in a stored signal or localStorage there, and send an image to the gallery with share.

TypeScript
files.supported: boolean                              // is there a filesystem here?
files.write(path, data: FileData): Promise<void>      // replace the contents; creates missing folders
files.append(path, data: FileData): Promise<void>     // add to the end; creates the file (and folders)
files.readText(path): Promise<string | null>          // UTF-8; null = no such file
files.readBytes(path): Promise<Uint8Array | null>     // null = no such file
files.mkdir(path): Promise<void>                      // with any missing parents; an existing folder is fine
files.rm(path, opts?: { recursive?: boolean }): Promise<void>   // a file or an empty folder; recursive = any folder
files.ls(path, opts?: { recursive?: boolean }): Promise<Entry[] | null>   // sorted by path; null = no such folder
files.stat(path): Promise<Entry | null>               // null = nothing there — the exists check

// FileData = string | Uint8Array | ArrayBuffer | File | FetchResponse
// Entry    = { name, path, kind: 'file' | 'dir', size /* bytes, 0 for a dir */, modified /* ms since the epoch */ }
  • Paths. A relative path is resolved against the app's own folder, never the process's working directory — on the desktop host the folder of the launched bundle (for a packaged app, the one the exe sits in; for a project packaged inside a macOS .app, a per-user data folder, since the bundle is sealed), on iOS the app's Application Support container (in the le.codes viewer app, one folder for every project opened in it). An absolute path goes wherever it points.
  • Data. A string is written as UTF-8; bytes as they are; a File or FetchResponse (a picked file, a Canvas.toFile() snapshot, a download) is saved from the host's buffer without passing through JS.
  • End states, not errors. Writing into a folder that doesn't exist creates it; removing something already gone resolves. The readers resolve null for what isn't there; a real failure (a non-empty folder without recursive, ls of a file, an unreadable file) rejects with the host's reason.
  • Order. Calls run in call order on one queue: appends fired without awaiting each one still land in order, and a read queued behind a write sees the finished file.
  • Listings. ls entries are sorted by path; path is relative to the folder listed (equal to name unless recursive). An empty folder is [] — (await files.ls(dir)) ?? [] covers both.

Download a file and keep it

Fetch the file, then write the response: the bytes go from the download to the disk without passing through JS.

TypeScript
const status = signal('')

const download = async () => {
  if (!files.supported) { status.value = 'No app files on this device'; return }   // Android, the web
  status.value = 'Downloading…'
  const res = await fetch('https://example.com/guide.pdf')
  if (res.status !== 200) { status.value = `Download failed: HTTP ${res.status}`; return }
  await files.write('downloads/guide.pdf', res)              // creates downloads/
  const saved = await files.stat('downloads/guide.pdf')      // null = nothing there
  status.value = `Saved ${saved?.size ?? 0} bytes`
}

UIScreen(UIButton(UIText('Download the guide')).onClick(download), UIText(() => status.value)).open()

fetch holds the whole body in memory and runs only while the app is open — there is no background download (network.md).

Save and load a JSON file

A game save or a document as JSON text: write it, read it back with readText and parse it; ls lists what is saved, rm deletes it.

TypeScript
type Save = { level: number, coins: number }

const saveGame = (state: Save) => files.write('saves/slot1.json', JSON.stringify(state))   // creates saves/
const loadGame = async (): Promise<Save> =>
  JSON.parse((await files.readText('saves/slot1.json')) ?? '{"level":1,"coins":0}')          // null = no file yet

const main = async () => {
  if (!files.supported) return                        // Android / web / headless: every call rejects
  await saveGame({ level: 3, coins: 120 })
  const slots = (await files.ls('saves')) ?? []       // null = no such folder
  console.log(slots.map((e) => `${e.name}: ${e.size} bytes`).join(', '))
  console.log('level', (await loadGame()).level)
  await files.rm('saves/slot1.json')                  // removing it again also resolves
}
main()

See also

  • Networking — fetch, the File value, FormData uploads, fetchLocal
  • Storage — small key/value state, stored signals
  • CameraView — take a photo inside the app
  • Stored files — uploading a File to the app's backend