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
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.
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.
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.
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()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.
share(media: File | FetchResponse, text?: string): voidconst 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
.binfile 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 withFF 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.
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()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.
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.
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
FileorFetchResponse(a picked file, aCanvas.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
nullfor what isn't there; a real failure (a non-empty folder withoutrecursive,lsof 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.
lsentries are sorted bypath;pathis relative to the folder listed (equal tonameunlessrecursive). 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.
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.
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, theFilevalue,FormDatauploads,fetchLocal - Storage — small key/value state, stored signals
- CameraView — take a photo inside the app
- Stored files — uploading a
Fileto the app's backend