[{"id":1,"title":"Widgets","pageTitle":"Widgets","text":"This page is a reference for every widget that ships with the system, and a guide to setting each one up. When you finish, you know what each widget shows, what it needs, and how to turn it on for a frame. A widget is one thing a frame can show: the clock, the weather, your calendar, photos, a Home Assistant dashboard, or greeting cards. You choose widgets per frame in the admin.         Widget   Shows   Needs   On when a frame is claimed           Clock   The time and date.   Nothing.   Yes       Weather   Current conditions and a forecast. US locations only.   A location in  Library . The fetcher.   Yes, if a location exists       Calendar   Today's events, and a week view.   A calendar in  Library . The fetcher.   Yes, with no calendars ticked       Photos   A daily set of photos, also the screensaver.   A Nextcloud album or a library album.   No       Home Assistant   Your Home Assistant dashboard.   The address of Home Assistant.   No       Cards   Greeting cards from family, as stamps over the photos.   Covers and stamps, and a sender link.   No","url":"https://calevans.com/picture-frame/docs/widgets/index.html","tags":"","category":""},{"id":2,"title":"Turn widgets on and off","pageTitle":"Widgets","text":"Select  Frames , then the frame.   Open  Widgets  in the rail on the left.   Switch each widget  On  or  Off .   Select  Save .   Once a widget is on and has settings, it gets its own section in the rail, named after the widget. Settings save from that section, with its own  Save  button. If a widget is off, its section says \"Weather is off\" (or the widget's name) and links back to  Widgets . The widgets list shows the enabled widgets first, in the order you switched them on, then the rest.","url":"https://calevans.com/picture-frame/docs/widgets/index.html#turn-widgets-on-and-off","tags":"","category":""},{"id":3,"title":"Where widgets appear on the frame","pageTitle":"Widgets","text":"There is no drag-to-reorder. The home screen has fixed cells, and each widget has its own:   Clock  and  Weather  sit in the top row.   Calendar  fills the lower row.   Photos  and  Cards  sit beside the calendar.   Most widgets also add a page to the frame's navigation rail. A touch on the screen while the photos show slides the rail in from the left. It has a button for each page and a  Dim  button:         Widget   Page on the frame           Weather   Weather       Calendar   Week , shown once a ticked calendar has been fetched       Photos   Pictures , shown only when there are photos       Home Assistant   Home Assistant , shown only when an address is saved       Cards   Mailbox , shown when there are cards         Changes you save reach the frame within a minute or two. The frame polls the server about every 20 seconds.","url":"https://calevans.com/picture-frame/docs/widgets/index.html#where-widgets-appear-on-the-frame","tags":"","category":""},{"id":4,"title":"Clock","pageTitle":"Widgets","text":"Shows the time and date in the frame's time zone. It has no settings and is on by default. The time zone is a property of the frame, not of the widget. New frames use  America/New_York . Change it in the frame's  Schedule  section. See  Use the admin: Schedule and bedtime .","url":"https://calevans.com/picture-frame/docs/widgets/index.html#clock","tags":"","category":""},{"id":5,"title":"Weather","pageTitle":"Widgets","text":"Shows current conditions, the next 24 hours, and the next 7 days from the  US National Weather Service  ( api.weather.gov ).   Warning     Weather works for  US locations only . The National Weather Service has no data elsewhere.","url":"https://calevans.com/picture-frame/docs/widgets/index.html#weather","tags":"","category":""},{"id":6,"title":"Set it up","pageTitle":"Widgets","text":"In the admin, select  Library , then  Add location  under  Locations .   Enter a  Name , a  Latitude  and a  Longitude . Leave  Stations  empty at first.   Select  Add location .   Open the frame, then  Widgets , switch  Weather  on, and select  Save .   Open the  Weather  section and pick the  Location . If you have no location yet, select  New location  there.   Select  Save .   By default the server picks the nearest three observation stations. Fill  Stations  with up to five comma-separated four-character station IDs (for example  KJFK ) only if you want specific ones. When you claim a frame, Weather turns itself on and uses your first location (the one with ID 1), if that location exists. Otherwise Weather stays off until you set it up.","url":"https://calevans.com/picture-frame/docs/widgets/index.html#set-it-up","tags":"","category":""},{"id":7,"title":"How it refreshes","pageTitle":"Widgets","text":"The fetcher downloads from the National Weather Service. It needs the cron job from  Install the frame server .         Data   Refreshed about every           Current conditions   10 minutes       Hourly and daily forecasts   30 minutes       The grid point for a location   24 hours, or sooner if you move the location         The admin refuses to delete a location that a frame's Weather widget uses.","url":"https://calevans.com/picture-frame/docs/widgets/index.html#how-it-refreshes","tags":"","category":""},{"id":8,"title":"Calendar","pageTitle":"Widgets","text":"Shows today's events on the home screen, and a week view on its  Week  page. It reads calendars shared as public iCal links, such as Nextcloud.","url":"https://calevans.com/picture-frame/docs/widgets/index.html#calendar","tags":"","category":""},{"id":9,"title":"Set it up","pageTitle":"Widgets","text":"Add the calendar's host to  calendar_hosts  in  config.php . The server downloads only from listed hosts, over HTTPS, with no port and no user name in the address.   In the admin, select  Library , then  Add calendar  under  Calendars .   Enter a  Name  and the calendar's  Share URL . Add a  Color  like  #6cb6ff  if you want a colored dot.   Select  Add calendar .   Open the frame, then  Widgets , switch  Calendar  on, and select  Save .   Open the  Calendar  section, tick the calendars to show, and select  Save .   A Nextcloud share link in either form works:   https://cloud.example.org/apps/calendar/p/<token>   https://cloud.example.org/remote.php/dav/public-calendars/<token>   The server turns either form into Nextcloud's export address. Any other address is fetched as given, so it must return an iCal file. A calendar with nothing ticked shows no events, and the  Week  page does not appear. There is no \"show all\" shortcut. The  Library  table shows each calendar's status:  OK ,  Error  with the message from the last attempt, or  Not fetched  if the fetcher has not run yet. A calendar's pencil button renames it and sets its color. Calendars refresh about every 15 minutes.","url":"https://calevans.com/picture-frame/docs/widgets/index.html#set-it-up-1","tags":"","category":""},{"id":10,"title":"Photos","pageTitle":"Widgets","text":"Shows a daily set of photos on the screensaver and on the  Pictures  page. It has two sources, a Nextcloud public album and a photo library on your own server, and the library adds family drop links. Settings, in the frame's  Photos  section:         Setting   What it does           Photos come from   Nextcloud link  or  Photo library .       Album link   For Nextcloud: the public album link.       Album   For the library: the album to show.       Show each photo for   5 to 3600 seconds. Default 30.       New photos daily at   The time of day the frame picks its next set.         All of it is explained in  Photos .","url":"https://calevans.com/picture-frame/docs/widgets/index.html#photos","tags":"","category":""},{"id":11,"title":"Home Assistant","pageTitle":"Widgets","text":"Opens a Home Assistant dashboard on the frame. You set it per frame, and the address and token are stored on your server, not on the page you see.","url":"https://calevans.com/picture-frame/docs/widgets/index.html#home-assistant","tags":"","category":""},{"id":12,"title":"What you need","pageTitle":"Widgets","text":"A running Home Assistant that the frame can reach on your network, or over HTTPS.   Optional: the  Kiosk Mode  add-on from HACS. The URL the frame opens ends in  ?kiosk , which hides the Home Assistant header and sidebar when Kiosk Mode is installed.   A long-lived access token, or Home Assistant's trusted networks. See  Sign-in options .","url":"https://calevans.com/picture-frame/docs/widgets/index.html#what-you-need","tags":"","category":""},{"id":13,"title":"Set it up","pageTitle":"Widgets","text":"In Home Assistant, create a  dedicated user for the frame  that is not an administrator.   Sign in as that user. Open the profile (bottom of the sidebar), then  Security , and under  Long-lived access tokens  create a token. Copy it. Home Assistant shows it once.   In the admin, open the frame, then  Widgets , switch  Home Assistant  on, and select  Save .   Open the  Home Assistant  section.   Enter the  Home Assistant address : the address only, no path, for example  http://homeassistant.local:8123 .   Paste the token into  Access token . The admin never shows it again. It shows  Token set , and you can replace or remove it.   Select  Save .   Choose the  Dashboard  once the frame has reported its dashboards. Until then the admin says \"Waiting for the frame to report its dashboards.\" The default is  Default (lovelace) .   Changing the address removes the saved token, so enter the token again. A token never follows a new host.","url":"https://calevans.com/picture-frame/docs/widgets/index.html#set-it-up-2","tags":"","category":""},{"id":14,"title":"Address rules","pageTitle":"Widgets","text":"Rule   Detail           Form   scheme://host  or  scheme://host:port . No path, no query, no user name or password. At most 300 characters.       https   Allowed for any host.       http   Allowed only for private network addresses ( 10.x.x.x ,  172.16.x.x  to  172.31.x.x ,  192.168.x.x ) and names ending in  .local . The admin shows a warning that an  http  address sends the token and everything else unencrypted on your network.       Not allowed   localhost , loopback and link-local addresses, IPv6 literals.","url":"https://calevans.com/picture-frame/docs/widgets/index.html#address-rules","tags":"","category":""},{"id":15,"title":"Sign-in options","pageTitle":"Widgets","text":"A long-lived access token  is the default. The token is sent only to the frame's own app code, through the frame's authenticated connection. It is never part of the dashboard page and never shown again in the admin. It is stored in the server's database. Use a dedicated non-admin Home Assistant user, so a stolen frame can do no more than that user can. Trusted networks  are the alternative. Leave  Access token  empty, and let Home Assistant trust the frame's address. Without a token, the frame relies on Home Assistant's trusted networks.   Not verified     The trusted-networks setup was not tested for this page. It lives in Home Assistant's  configuration.yaml  under  homeassistant:  and  auth_providers: . Follow Home Assistant's own documentation for the  trusted_networks  provider, give the frame a fixed address with a DHCP reservation, and choose a non-admin user for it.     Both options keep the frame limited to one place: the kiosk allows navigation only to the Home Assistant address you saved.","url":"https://calevans.com/picture-frame/docs/widgets/index.html#sign-in-options","tags":"","category":""},{"id":16,"title":"Cards","pageTitle":"Widgets","text":"Cards are greeting cards that people send to a frame from their phones. A relative opens a private link, picks a cover, writes a message, and signs it with a finger or by typing a name. The card arrives on the frame as a postage stamp over the photos. Nobody needs an account.","url":"https://calevans.com/picture-frame/docs/widgets/index.html#cards","tags":"","category":""},{"id":17,"title":"Set it up","pageTitle":"Widgets","text":"Open  Card designs  and upload covers and stamps. See  Card designs .   Open the frame, then  Widgets , switch  Cards  on, and select  Save .   Open the  Cards  section.   Under  Sender links , select  New sender link .   Enter the sender's  Name , which is printed on every card their link sends, then select  Create link .   Select  Copy link  and send it to that person. It looks like  https://frames.example.com/frames/notes/<43 characters> .     Warning     The link is shown  once . The server keeps only a hash of it. If you lose it, make a new one with  New link .","url":"https://calevans.com/picture-frame/docs/widgets/index.html#set-it-up-3","tags":"","category":""},{"id":18,"title":"What the relative sees","pageTitle":"Widgets","text":"Opening the link shows a page for sending a card to the frame's recipient (\"Cards for Grandma\"). They:   Pick a cover. The covers are grouped by category.   Write a message of up to 400 characters and 8 lines.   Sign it, by drawing with a finger or by typing a name of up to 40 characters.   Select  Send card .   There is no approval step. A card goes straight to the frame, and the sender link is your control over who can send. This is different from  drop link photos , which wait for your approval.","url":"https://calevans.com/picture-frame/docs/widgets/index.html#what-the-relative-sees","tags":"","category":""},{"id":19,"title":"What shows on the frame","pageTitle":"Widgets","text":"A new card appears as a stamp on the photo screensaver. Up to five stamps show, plus a \"+N more\" stamp. Tap a stamp to open the card.   Without the Photos widget there is no screensaver for the stamps. Cards then appear only in the  Mailbox .   Opened cards move to the  Mailbox , where they stay for the number of days you set. Cards nobody has opened never expire.   The home screen has a Mailbox cell that shows how many new cards there are.","url":"https://calevans.com/picture-frame/docs/widgets/index.html#what-shows-on-the-frame","tags":"","category":""},{"id":20,"title":"Settings per frame","pageTitle":"Widgets","text":"In the frame's  Cards  section, under  Settings :         Setting   What it does           Name on the sender page   Senders see \"Cards for Grandma\". Up to 24 characters. Leave empty to use the frame's name.       Keep opened cards for   1 to 30 days. Default 30.       Categories offered   The cover categories senders can choose from: Thinking of you, Birthday, Thank you, Funny, Sweet, Get well, Holidays. Offer at least one.","url":"https://calevans.com/picture-frame/docs/widgets/index.html#settings-per-frame","tags":"","category":""},{"id":21,"title":"Sender links","pageTitle":"Widgets","text":"Each sender gets their own link. The  Sender links  table shows each one's name, whether it is active or revoked, when it was created, when it was last used, and how many cards it sent.         Action   What it does           Rename   Changes the name on future cards. Cards already sent keep the old name.       New link   Replaces the link. The old link stops working at once. Asks for your password.       Revoke   Stops the link. Cards already sent stay on the frame. Asks for your password.       Restore   Un-revokes the link and makes a  new  link. The old one stays dead.       Delete unopened   Deletes every card from that sender that has not been opened yet. Opened cards stay. Asks for your password.","url":"https://calevans.com/picture-frame/docs/widgets/index.html#sender-links","tags":"","category":""},{"id":22,"title":"Cards sent","pageTitle":"Widgets","text":"Cards sent  lists the cards for this frame. Filter by status ( All ,  Not opened ,  Opened ) or by sender. You can delete a card that has not been opened.  Show older  loads more.","url":"https://calevans.com/picture-frame/docs/widgets/index.html#cards-sent","tags":"","category":""},{"id":23,"title":"Privacy","pageTitle":"Widgets","text":"Messages are private from the admin.  You see who sent a card, when, and whether it was opened. You do not see the message, and nobody does in the admin. The message is readable on the frame and by the sender through their own link.   The database holds the message in plain text.  Anyone who can read  var/frames.db  can read the cards. Protect the file and its backups.   Links are secrets.  A sender link is 43 random characters. The server stores only its hash, and shows the link once.   Revoking works at once.  The old link stops working the moment you revoke or replace it.","url":"https://calevans.com/picture-frame/docs/widgets/index.html#privacy","tags":"","category":""},{"id":24,"title":"Limits","pageTitle":"Widgets","text":"Limit   Value           Cards one link can send   6 per hour and 20 per day       Cards from one phone's address   30 per day       Unopened cards from one sender   10       Unopened cards on one frame   30. After that, senders see that the mailbox is full.       Message   400 characters, 8 lines       Signature text   40 characters","url":"https://calevans.com/picture-frame/docs/widgets/index.html#limits","tags":"","category":""},{"id":25,"title":"Card designs","pageTitle":"Widgets","text":"Card designs  in the top bar holds the art for every frame's cards. Covers  are the card fronts.   Select  Upload covers .   Choose images. JPEG, PNG or WebP, up to 4 MB, 400 to 4096 pixels on each side.   Choose a  Category  for each cover.   Select the add button. It shows the count, for example  Add 3 covers .   The server crops each cover to 16:10 and shrinks it to 1280x800. Upload it as it is. A cover already in the library is not added again. In the covers grid you can filter by category and by  On  and  Off . Select covers to turn them  On  or  Off , to  Set category , or to  Delete . Open one for a larger view and its details. Stamps  are the small pictures on each card. Each card gets a random stamp from those that are on.   Select  Upload stamps .   Choose images. JPEG, PNG or WebP, up to 4 MB, 100 to 4096 pixels on each side.   Select the add button. It shows the count, for example  Add 5 stamps .   The server crops stamps and shrinks them to 288x366. If no stamp is on, new cards get a plain stamp. A cover or stamp that a sent card uses cannot be deleted. The admin offers to turn it off instead. Back up  var/notes/  together with  var/frames.db . See  Back up the server .","url":"https://calevans.com/picture-frame/docs/widgets/index.html#card-designs","tags":"","category":""},{"id":26,"title":"How widgets are built","pageTitle":"Widgets","text":"Each widget is a folder under  web/app/widgets/  with its own settings, data, fetch code, frame code and admin code. Add or remove a folder and the rest of the system does not change. If you remove a widget's folder, frames that had it on stop showing it. To write your own, see  Developer documentation: widgets .","url":"https://calevans.com/picture-frame/docs/widgets/index.html#how-widgets-are-built","tags":"","category":""},{"id":27,"title":"Related","pageTitle":"Widgets","text":"Photos   Use the admin   Install the frame server   A guide for the family , for the people who use the frame and send cards   Next:  Photos .","url":"https://calevans.com/picture-frame/docs/widgets/index.html#related","tags":"","category":""},{"id":28,"title":"Unlock your PF1007L","pageTitle":"Unlock your PF1007L","text":"This guide is for owners of a PF1007L photo frame who can use a Linux shell and PowerShell. When you finish, the frame boots normally but accepts root ADB connections, and you have a verified backup of its storage. No soldering, and you never open the case. Do this guide first. After it, go to  Build the kiosk and set up a frame . Provisioning also needs a running server, which you set up with  Install the frame server . The server does not depend on this guide, so you can do it before or after.","url":"https://calevans.com/picture-frame/docs/unlock/index.html","tags":"","category":""},{"id":29,"title":"Before you start","pageTitle":"Unlock your PF1007L","text":"Warning     This can brick the frame. You write to its storage. Follow the backup step before any write, and check each write the way the guide says.       The backup holds the frame's private credentials.  The image of the storage includes the vendor's device certificate. Keep  backups/  out of Git, and never upload or share it. The repository's  .gitignore  already ignores it.   Root ADB has no password.  After this guide, anyone who can reach the frame's network port 5555 has full control.  Provisioning  shows how to restrict it with  ADB_ALLOW .   Only the January 2026 firmware was tested.  The one-sector ADB method was verified on firmware  AEEZO/astar_m86_8723cs:6.0.1/MOB30R/20260113:eng/test-keys . Other firmware may store files in different places. The patch is made from your own backup, so it adapts, but nothing here is verified on other builds. If  tools/derive-sector.sh  stops with an error, stop and do not guess.   Only modify hardware you own.","url":"https://calevans.com/picture-frame/docs/unlock/index.html#before-you-start","tags":"","category":""},{"id":30,"title":"What you need","pageTitle":"Unlock your PF1007L","text":"A PF1007L frame, with its USB-C cable. The frame takes power from USB.   Windows 10 or 11 with WSL2 (Ubuntu 22.04 or later). The scripts call  powershell.exe  and PowerShell disk commands, so this guide does not work on a plain Linux machine without changes.   usbipd-win  4 or later on Windows.   A microSD card, any size. Its contents survive. The guide changes only bytes 8 KiB to 16 KiB.   About 16 GB of free disk space for the backup, and patience. The backup takes about 90 minutes.   In this guide,  WSL  means a shell in your Linux distribution.  PowerShell  means an Administrator PowerShell on Windows.  <repo>  means the folder holding this repository, written the way Windows sees it.   Note     The  .ps1  scripts write  backups\\  next to themselves and read  patch\\  there. If your clone lives inside the WSL file system, the Windows path looks like  \\\\wsl.localhost\\Ubuntu\\home\\you\\frame . Writing a 15 GB image over that path is slow. Cloning to a Windows drive such as  /mnt/d/frame  gives both sides the same fast folder. Neither layout has been compared.","url":"https://calevans.com/picture-frame/docs/unlock/index.html#what-you-need","tags":"","category":""},{"id":31,"title":"The map","pageTitle":"Unlock your PF1007L","text":"Nine steps. The backup in step 6 is not optional, and nothing writes to the frame's storage before it. Each step ends with a  You are here  box. If what you see does not match, stop and use  If something goes wrong .","url":"https://calevans.com/picture-frame/docs/unlock/index.html#the-map","tags":"","category":""},{"id":32,"title":"Step 1: Set up your computer","pageTitle":"Unlock your PF1007L","text":"In WSL, install the tools:     sudo apt install -y sunxi-tools adb e2fsprogs fdisk          Allow your user to talk to the frame in FEL mode (the Allwinner USB recovery mode):     echo 'SUBSYSTEM==\"usb\", ATTR{idVendor}==\"1f3a\", ATTR{idProduct}==\"efe8\", MODE=\"0666\"' | sudo tee /etc/udev/rules.d/61-sunxi-fel.rules sudo udevadm control --reload          In PowerShell, tell usbipd it may share the frame's two USB identities with WSL automatically:     usbipd policy add --effect Allow --operation AutoBind --hardware-id 1f3a:efe8 usbipd policy add --effect Allow --operation AutoBind --hardware-id 1f3a:1001      1f3a:efe8  is FEL mode.  1f3a:1001  is Android with ADB on.       In PowerShell, stop Windows from mounting the frame's partitions. First note the current value so you can restore it later:     Get-StorageSetting | Select NewDiskPolicy Set-StorageSetting -NewDiskPolicy OfflineAll      Write down the value the first command prints. You put it back in  When you finish .       Copy  .env.example  to  .env  in the repository root. You fill in  USBIP_BUSID  in step 5.  Provisioning  explains every key.     cp .env.example .env          You are here     Your computer has the tools, the udev rule and the usbipd policy. Windows no longer mounts new disks automatically. Nothing has touched the frame.","url":"https://calevans.com/picture-frame/docs/unlock/index.html#step-1-set-up-your-computer","tags":"","category":""},{"id":33,"title":"Step 2: Build U-Boot","pageTitle":"Unlock your PF1007L","text":"The frame's storage is not visible to your PC while Android runs. You temporarily boot a small program called U-Boot that shows the storage as a USB disk. The repository holds the patch for it but no binaries, so you build them. The build needs an ARM cross-compiler.     Install the build tools (Debian or Ubuntu package names):     sudo apt install -y gcc-arm-linux-gnueabihf bison flex swig device-tree-compiler \\ python3-pyelftools python3-setuptools libgnutls28-dev uuid-dev git make      The  python3-  packages are U-Boot's own build dependencies. Project code uses no Python.       Build it, using a work directory outside the repository:     uboot/build.sh ~/frame-uboot-build        The script clones U-Boot v2025.01, applies the patch and writes  u-boot-sunxi-with-spl.bin  and  sunxi-spl-ck5.bin  into  uboot/ . It prints their SHA-256 hashes. The first run needs network access to  source.denx.de . See  uboot/README.md  for what the patch changes.   Note     The build is not bit-for-bit reproducible. The binaries embed a build timestamp, so two builds from the same source have different hashes.       You are here     uboot/  holds  u-boot-sunxi-with-spl.bin  and  sunxi-spl-ck5.bin , and the script printed a SHA-256 for each.","url":"https://calevans.com/picture-frame/docs/unlock/index.html#step-2-build-u-boot","tags":"","category":""},{"id":34,"title":"Step 3: Get fel-sdboot.sunxi","pageTitle":"Unlock your PF1007L","text":"fel-sdboot.sunxi  is a tiny boot program that puts the frame into FEL mode and keeps it there. It comes from  linux-sunxi/sunxi-tools . Its license differs from this repository's, so it is not included.     Get the file from the sunxi-tools repository. If the repository does not hold a ready-made copy, build it. The on-device programs are not built by default, and need an ARM bare-metal compiler ( arm-none-eabi-gcc ). Install it the same way as the U-Boot tools in  step 2 :     git clone https://github.com/linux-sunxi/sunxi-tools ~/sunxi-tools make -C ~/sunxi-tools target-tools CROSS_COMPILE=arm-none-eabi-        Not verified     This build command was not checked. The linux-sunxi wiki page on  sunxi-tools  describes  fel-sdboot .           Copy the file to  uboot/fel-sdboot.sunxi .       Check its SHA-256:     sha256sum uboot/fel-sdboot.sunxi      write-sd.ps1  accepts only this hash, the one the procedure was tested with:     19db8994363b1464a1de82e1c90ab63c5a985d1c96b57403d039d69bfff39f14      If your hash differs, the script stops with  fel-sdboot.sunxi hash mismatch . A different build of sunxi-tools produces a different file. Check that your file is the real  fel-sdboot  from the upstream source, then edit  $ImageSha  at the top of  write-sd.ps1  to your hash. Do not skip the check.       You are here     uboot/fel-sdboot.sunxi  exists, and its hash is either the one above or a hash you checked against upstream and wrote into  write-sd.ps1 .","url":"https://calevans.com/picture-frame/docs/unlock/index.html#step-3-get-fel-sdbootsunxi","tags":"","category":""},{"id":35,"title":"Step 4: Make the FEL card","pageTitle":"Unlock your PF1007L","text":"The frame's boot ROM looks at the microSD slot before the internal storage. With this card in the slot, pressing RESET puts the frame into FEL mode and keeps it there. Pressing RESET alone also enters FEL, but it times out after a second or two, which is too short for this procedure.     Put the microSD card in your PC.       In PowerShell, find the card:     Get-Disk      Note its  Number  and its exact  Size  in bytes. Check this twice. The script writes to the disk number you give it.       Write the file:     powershell -ExecutionPolicy Bypass -File <repo>\\write-sd.ps1 -DiskNumber <number> -ExpectSize <size in bytes>      The script refuses to run unless the disk is a USB disk of exactly that size. It first backs up the card's first 16 MiB to  backups\\sd-<size>-first-16MiB.bin , writes the file at the 8 KiB offset, and reads it back. You see  OK: fel-sdboot.sunxi written at offset 8192 and verified.       Put the card in the frame's microSD slot.     From now on:  RESET with the card in enters FEL mode  ( 1f3a:efe8 ).  RESET with the card removed boots Android normally.   You are here     The script printed  OK: fel-sdboot.sunxi written at offset 8192 and verified.  and the card is in the frame.","url":"https://calevans.com/picture-frame/docs/unlock/index.html#step-4-make-the-fel-card","tags":"","category":""},{"id":36,"title":"Step 5: Show the frame's storage to Windows","pageTitle":"Unlock your PF1007L","text":"Plug the frame's USB-C port directly into your PC, not through a hub.       In PowerShell, find the frame's bus ID:     usbipd list      The frame is  1f3a:efe8  if it is in FEL mode. Press the RESET pinhole with the card in if it is not.       Put the bus ID in  .env :     USBIP_BUSID=<bus id from usbipd list>      A bus ID looks like  1-13 . The script refuses anything else.       In WSL, run:     scripts/boot-ums.sh          Press the frame's RESET button.     The script waits up to 30 minutes for FEL mode and attaches the frame to WSL. It runs a first DRAM set-up pass, because the first memory set-up after power-on fails and the second succeeds. Then it loads U-Boot. U-Boot shows the storage as a USB disk named  Linux UMS disk 0 . The script ends with  eMMC visible to Windows  and a table with a disk number and size. Rules for this step:     Never attach  1f3a:1010  (U-Boot's disk) with usbipd.  The disk drops and does not recover. Never run  usbipd attach --auto-attach  on this bus ID.    Cancel every \"format this disk\" prompt.  The partition table mislabels the Android data partition as FAT32.   If the disk disappears,  U-Boot returns to FEL mode on its own, or its 16 second watchdog resets the frame into FEL mode. Run  scripts/boot-ums.sh  again. Press RESET only if FEL does not answer.     You are here     The script printed  eMMC visible to Windows  and a table with a disk number and a size of 15758000128. Windows shows the disk offline. You have not written anything to it.","url":"https://calevans.com/picture-frame/docs/unlock/index.html#step-5-show-the-frames-storage-to-windows","tags":"","category":""},{"id":37,"title":"Step 6: Back up the storage","pageTitle":"Unlock your PF1007L","text":"Warning     Do not go on until the backup is verified.     In PowerShell, while the disk is up:   powershell -ExecutionPolicy Bypass -File <repo>\\backup-emmc.ps1    The script marks the disk read-only, writes  backups\\emmc-full.img , then reads the whole disk a second time and compares the SHA-256 of both passes. At about 5.7 MB/s, each pass takes around 45 minutes. It ends with  OK: ... verified by two independent reads.  and writes  backups\\emmc-full.img.sha256 . The script expects a 15,758,000,128 byte disk, the size of the tested unit. If yours differs, pass  -ExpectSize <bytes> . If the second pass is interrupted, run  verify-backup.ps1  instead. It compares the image with the disk up to the user-data partition (about 2 GB, 7 minutes). If the disk drops, run it again with  -Start <byte offset>  from the last progress line:   powershell -ExecutionPolicy Bypass -File <repo>\\verify-backup.ps1    Then check the system partition read-only in WSL. Expect only the message  padding at end of inode bitmap , which is normal for Android images:   fdisk -l backups/emmc-full.img    Find the start sector of partition 7 ( system ), then:   dd if=backups/emmc-full.img of=system.img bs=512 skip=<start sector> count=<sectors> e2fsck -fn system.img rm system.img      You are here     backups\\emmc-full.img  and  backups\\emmc-full.img.sha256  exist, the script printed  OK: ... verified by two independent reads.  (or  verify-backup.ps1  passed), and  e2fsck -fn  found nothing but  padding at end of inode bitmap . Copy  backups/  somewhere private. Do not continue without this.","url":"https://calevans.com/picture-frame/docs/unlock/index.html#step-6-back-up-the-storage","tags":"","category":""},{"id":38,"title":"Step 7: Make the patch from your backup","pageTitle":"Unlock your PF1007L","text":"Android decides whether to turn ADB on from one line in  /system/build.prop :  persist.sys.usb.config=mass_storage . The patch changes  mass_storage  to  adb , followed by a newline and  #padding . The file stays the same length and no file-system structures change. tools/derive-sector.sh  finds that one 512-byte sector in your own backup and writes the original and patched copies to  patch/ .     In WSL, run:     tools/derive-sector.sh      It reads  backups/emmc-full.img  by default. Pass another image path as an argument. If the system partition is not number 7, set  SYSTEM_PART=<number> .       Check the output. You expect something like:     bytes changed (expect up to 12): 12 sector <n>, in-sector offset <m>; wrote patch/sector-{number.txt,orig.bin,adb.bin}      If the script prints  'persist.sys.usb.config=mass_storage' not found , the line is already patched or your firmware differs. Stop.       Confirm the data partition has no override. A file at  /property/persist.sys.usb.config  on the data partition would beat  build.prop . On the tested firmware, the data partition starts at byte 2,235,564,032 (sector 4,366,336):     debugfs -R 'stat /property/persist.sys.usb.config' 'backups/emmc-full.img?offset=2235564032'      You expect  File not found . The  ?offset=  form needs e2fsprogs 1.43 or later. On other firmware, take the start of the last partition from  fdisk -l .       You are here     patch/  holds  sector-number.txt ,  sector-orig.bin  and  sector-adb.bin , and the data partition has no override file.","url":"https://calevans.com/picture-frame/docs/unlock/index.html#step-7-make-the-patch-from-your-backup","tags":"","category":""},{"id":39,"title":"Step 8: Apply the patch","pageTitle":"Unlock your PF1007L","text":"With the storage still showing as a disk, in PowerShell:   powershell -ExecutionPolicy Bypass -File <repo>\\patch-sector.ps1    The script:   refuses to write unless the live sector is identical to  patch\\sector-orig.bin ,   writes the 512-byte sector,   reads it back and compares it,   marks the disk read-only again.   It ends with  OK: sector <n> now holds the ADB-enabled build.prop line, verified by readback.  If the sector is already patched it says so and does nothing. If your disk is not 15,758,000,128 bytes, pass  -ExpectSize <bytes> . To put the stock sector back later, run the same command with  -Restore  while U-Boot shows the disk.   You are here     The script printed  OK: sector <n> now holds the ADB-enabled build.prop line, verified by readback.  The frame's storage differs from the backup by one sector.","url":"https://calevans.com/picture-frame/docs/unlock/index.html#step-8-apply-the-patch","tags":"","category":""},{"id":40,"title":"Step 9: First boot with ADB","pageTitle":"Unlock your PF1007L","text":"Take the microSD card out of the frame.       Press RESET. The frame boots Android as usual, with the stock home app.       In PowerShell, find the frame. It now reports  1f3a:1001 :     usbipd list usbipd attach --wsl --busid <bus id>          In WSL:     adb devices adb shell id        You see the frame in  adb devices , and  adb shell id  prints  uid=0(root) . ADB asks for no authorization. You are done unlocking. Keep the microSD card. It is your way back into FEL mode.   You are here     adb shell id  prints  uid=0(root) . The frame is unlocked. The backup is safe. Next you build the kiosk.","url":"https://calevans.com/picture-frame/docs/unlock/index.html#step-9-first-boot-with-adb","tags":"","category":""},{"id":41,"title":"When you finish","pageTitle":"Unlock your PF1007L","text":"Put Windows back to the disk policy you wrote down in step 1. Use the value the first command printed:     Set-StorageSetting -NewDiskPolicy <the value you noted>          Keep  backups/  and  patch/  private and backed up. Do not add them to Git.       Keep the FEL microSD card. You use it for any later recovery.","url":"https://calevans.com/picture-frame/docs/unlock/index.html#when-you-finish","tags":"","category":""},{"id":42,"title":"If something goes wrong","pageTitle":"Unlock your PF1007L","text":"Frame will not enter FEL mode.  Check that the card is in, and that  usbipd list  shows  1f3a:efe8 . Press RESET again.     boot-ums.sh  says  warm-up SPL did not return .  Press RESET to restart FEL, and run the script again.      boot-ums.sh  says  eMMC disk did not appear in Windows .  Check that Windows has not set the disk offline for another reason, then run the script again.      boot-ums.sh  says  timeout .  It waited 30 minutes without seeing FEL. Check the card, the cable, and that the frame is not plugged into a hub.    A write script says it expected exactly one disk.  U-Boot's disk is not up, or its size differs from the default. Pass  -ExpectSize .     patch-sector.ps1  says the sector does not match.  Do not force it. Check that  patch/  was made from this frame's own backup.    Undo the ADB change.  Show the disk with  scripts/boot-ums.sh , then run  patch-sector.ps1 -Restore .   The frame will not boot after a write.  Follow step 4 of  The frame does not boot . It restores your backup.     adb devices  shows nothing.  Run  usbipd list , find the frame ( 1f3a:1001 ), and attach it again.    More recovery steps, including a frame that does not boot and a lost PIN, are in  Troubleshooting and recovery .","url":"https://calevans.com/picture-frame/docs/unlock/index.html#if-something-goes-wrong","tags":"","category":""},{"id":43,"title":"Related","pageTitle":"Unlock your PF1007L","text":"Hardware and requirements   Install the frame server   U-Boot patch and build   Next:  Install the frame server , if you have not already. Then  Build the kiosk and set up a frame .","url":"https://calevans.com/picture-frame/docs/unlock/index.html#related","tags":"","category":""},{"id":44,"title":"Update the kiosk","pageTitle":"Update the kiosk","text":"This page is for the person who builds and sends new versions of the kiosk app. When you finish, you can put a new kiosk build on a frame, roll it out to the rest, and recover when a build goes wrong, even when the frame is hundreds of miles away. You install the first kiosk on a frame over ADB. After that, the frame can update itself from a release you upload to your server. The server only stores the file and tells each frame which build it should have.  The frame decides  whether to install it.","url":"https://calevans.com/picture-frame/docs/kiosk-updates/index.html","tags":"","category":""},{"id":45,"title":"The ways to update a frame","pageTitle":"Update the kiosk","text":"Method   Use it for   Needs   Section           ADB, first install   A frame that has no kiosk yet.   USB or network ADB.   Provisioning, step 7       ADB, manual update   A frame next to you, or one that cannot self-update.   ADB, the same keystore.   Update over ADB       Admin upload   The normal way.   The admin, a built APK and its  .sig .   Upload in the admin       Shell on the server   A server where you have a shell, or for scripting.   bin/kiosk-release.php .   Add a release from the shell         The admin upload and the shell command both store a release. You then choose which frame gets it in the admin.","url":"https://calevans.com/picture-frame/docs/kiosk-updates/index.html#the-ways-to-update-a-frame","tags":"","category":""},{"id":46,"title":"How an update travels","pageTitle":"Update the kiosk","text":"Four things keep this safe to send and safe to get wrong:   Two signatures.  Android checks the APK certificate. The frame also checks a second, separate signature from an offline ECDSA key that never touches the server. A stolen admin password cannot push a build you did not sign.   The frame decides.  It refuses downgrades, other packages, mismatched certificates, and files with anything glued in front of the ZIP.   Staged rollout.  A release is marked  Proven  only after a frame has downloaded it and reported running it. Only then can you roll it out to everyone.   A root watchdog.  If a new build never becomes healthy, the frame reinstalls the last one that was, on its own. A crashing build costs roughly 15 minutes.","url":"https://calevans.com/picture-frame/docs/kiosk-updates/index.html#how-an-update-travels","tags":"","category":""},{"id":47,"title":"Two keys, two jobs","pageTitle":"Update the kiosk","text":"APK keystore   Release key           What it is   An RSA key in a Java keystore, default  ~/.android/frame-kiosk.keystore   An EC P-256 key as a PEM file, default  ~/.android/frame-release-key.pem       What it signs   The kiosk app (the APK)   The text  <sha256>:<versionCode>  of each build       Who checks it   Android, when installing an update   The kiosk, from the public key baked into the app. The server too, if you set  kiosk_release_pubkey .       Lose it, and   Every frame needs the kiosk uninstalled and installed again   Every frame needs one more  adb install  with a new public key       Keep it   Backed up, private, off the server   Backed up, private,  offline , off the server         Make both before your first build. The commands are in  Provisioning, step 5 , and the  .env  keys are  KEYSTORE ,  KEYSTORE_PASS ,  RELEASE_KEY  and  RELEASE_PUBKEY . The release signature is ECDSA with SHA-256 over the ASCII text  <sha256 hex of the APK>:<versionCode> , for example  ab12...ef:15 . The  .sig  file holds the base64 of its DER encoding. To let the server reject bad signatures early, put the public key in  config.php :   php   'kiosk_release_pubkey'  =>  \"-----BEGIN PUBLIC KEY-----\\n...\\n-----END PUBLIC KEY-----\\n\" ,    Without it the server stores the signature as given, and only the frame checks it.","url":"https://calevans.com/picture-frame/docs/kiosk-updates/index.html#two-keys-two-jobs","tags":"","category":""},{"id":48,"title":"Build a release","pageTitle":"Update the kiosk","text":"Raise the version code. Every release needs a higher number than the last. Either edit  android:versionCode  in  kiosk/AndroidManifest.xml , or pass it as an option.       Build:     cd kiosk lando build-kiosk -- --version-code 16 --version-name 16        The build prints:   built kiosk/build/frame-kiosk.apk versionCode 16 sha256 <64 hex characters> signature kiosk/build/frame-kiosk.apk.sig (upload with the APK)    You now have two files in  kiosk/build/ :  frame-kiosk.apk  and  frame-kiosk.apk.sig . Warnings that begin  WARNING:  mean a release key setting is missing, and the build then cannot self-update.  Provisioning, step 6  lists every build option. Optional: check the signature yourself with OpenSSL. This is standard OpenSSL and is not part of the project's scripts:   printf '%s' '<sha256 from the build>:16' > msg.txt base64 -d kiosk/build/frame-kiosk.apk.sig > sig.der openssl dgst -sha256 -verify ~/.android/frame-release-pub.pem -signature sig.der msg.txt    It prints  Verified OK .","url":"https://calevans.com/picture-frame/docs/kiosk-updates/index.html#build-a-release","tags":"","category":""},{"id":49,"title":"Rules for every release","pageTitle":"Update the kiosk","text":"A higher version code than the last.  Android and the kiosk both refuse a lower one. To go back, see  Roll back by rebuilding .   Data compatibility.  A rollback keeps the app's saved data. A release must not write preferences or files that the previous release cannot read, or a rollback will crash.   Sign it with the same keystore.  A different key makes the frame refuse the update ( cert ).   Never upload a test build.  See  Keystore and release key hygiene .","url":"https://calevans.com/picture-frame/docs/kiosk-updates/index.html#rules-for-every-release","tags":"","category":""},{"id":50,"title":"Update over ADB","pageTitle":"Update the kiosk","text":"For a frame next to you, or one that cannot self-update, install a newly built APK over the old one. It must be signed with the same keystore:   adb install -r kiosk/build/frame-kiosk.apk    Use  -s <serial>  to pick a device. This keeps the app's data and the device owner setting. Use the same command when you change the baked-in release public key,  KIOSK_PIN  or  FRAME_SERVER_URL , because each is built into the app. A frame updated over ADB has no  .sig  check. Nothing but you and Android is involved.","url":"https://calevans.com/picture-frame/docs/kiosk-updates/index.html#update-over-adb","tags":"","category":""},{"id":51,"title":"Upload in the admin","pageTitle":"Update the kiosk","text":"In the admin, select  Library .   Under  Kiosk app , select  Upload release .   Choose  frame-kiosk.apk  as the  APK file  (up to 16 MiB).   Choose  frame-kiosk.apk.sig  as the  Release signature .   Add  Notes (optional)  if you like, up to 500 characters.   Select  Upload . If you have not entered your password in the last five minutes, the admin asks for it.   You see \"Release uploaded\". The table lists the release with its size, who uploaded it, how many frames run it, and whether it is  Proven  or  Not yet proven . The server checks the file before it keeps it:   It is a plain ZIP, with nothing hidden before or after the entries (Janus-safe).   Its package is  com.calevans.frame .   Its version code is a number from 1 to 2,100,000,000.   If you set  kiosk_release_pubkey , the signature verifies.   No release with the same file hash or version code already exists.   The server does not check the APK's signing certificate. The frame does. Uploaded builds are stored in  var/kiosk/ , named by their SHA-256 hash. Back the folder up with  var/frames.db . If the upload fails, the box shows the reason. A 413 means a size limit. See  Troubleshooting .","url":"https://calevans.com/picture-frame/docs/kiosk-updates/index.html#upload-in-the-admin","tags":"","category":""},{"id":52,"title":"Add a release from the shell","pageTitle":"Update the kiosk","text":"On the server, you can add a release without the admin. Run it as the web user (it refuses root), from the folder that holds  app/ :   sudo -u www-data php app/bin/kiosk-release.php add frame-kiosk.apk [frame-kiosk.apk.sig] [--notes \"text\"] sudo -u www-data php app/bin/kiosk-release.php list      The signature file defaults to  <apk>.sig .   --notes  takes up to 500 characters.   The web user must be able to read both files. Copy them to a folder it can read first.   It makes the same checks as the admin upload, refuses symlinks and files that are not regular files, and stops at 16 MiB.   On success it prints  version_code ,  version_name ,  sha256  and  id .   Failures go to standard error with the admin's codes:  duplicate_sha256 ,  duplicate_version_code ,  not_apk  and others.   The upload is logged as  cli:<your login>  (the  SUDO_USER  name when you use sudo).   list  prints one line per release, with tabs between the columns: ID, version code, version name, SHA-256, size, upload time (UTC), uploaded by, and  proven  or  unproven . Choosing which frame gets the release is still done in the admin.","url":"https://calevans.com/picture-frame/docs/kiosk-updates/index.html#add-a-release-from-the-shell","tags":"","category":""},{"id":53,"title":"Choose which frame gets a release","pageTitle":"Update the kiosk","text":"Always target one frame first.   Open the frame in the admin, then  Details .   Find the  Kiosk version  row. It shows the build the frame runs (\"Build 15\").   In the list, choose the release. Each entry reads like  16 (build 16) .  No update  cancels. It asks for your password.   You see \"Update queued\". The frame installs the release the next time its screen is quiet. The row shows the state:         Pill   Meaning           Up to date   The frame runs the target, or newer.       Update pending   The frame has been offered the release and has not installed it yet.       Waiting for a report   The frame has not yet said which build it runs.       Update failed   See  Failure states .       No rollback watchdog   The frame's boot script has no watchdog. See below.         A frame with no rollback watchdog can only be sent a release that has already run on another frame. The server refuses with \"this frame has no rollback watchdog, so it can only take a release that already runs on another frame\". Send the first copy to a frame that has the watchdog. Install the boot script on every frame before you use self-update on it.","url":"https://calevans.com/picture-frame/docs/kiosk-updates/index.html#choose-which-frame-gets-a-release","tags":"","category":""},{"id":54,"title":"What the frame checks","pageTitle":"Update the kiosk","text":"The frame installs a release only if  all  of these hold. Otherwise it holds a failure that the admin shows.   The detached release signature verifies against the public key baked into the app.   The file's SHA-256 and size match the offer, it is at most 32 MiB, and it is a plain ZIP.   It is the kiosk package. Its version code is the offered one and is higher than the installed one. A frame never downgrades itself.   Its signing certificates are exactly the installed ones.   Nobody is using the frame: the network is up, the PIN panel and Wi-Fi setup are closed, and the screen is dimmed (bedtime or  Dim ) or untouched for 30 minutes. The frame checks every 30 seconds.   The server lets each frame download a release at most 12 times an hour.","url":"https://calevans.com/picture-frame/docs/kiosk-updates/index.html#what-the-frame-checks","tags":"","category":""},{"id":55,"title":"Staged rollout and proven releases","pageTitle":"Update the kiosk","text":"A release is  Proven  once a frame has downloaded it in full and then reported running that version or newer. Only a proven release can be rolled out to everyone.   Target one frame (above).   Wait. Check that its  Kiosk version  row shows  Up to date , and look at the frame's  Screen  to see the dashboard is fine.   In  Library , under  Kiosk app , the release now says  Proven .   Select  Roll out to all  on that release. It asks for your password.   The admin says how many frames will update, and how many are already on it or queued. The button is disabled before then, with the hint \"Target one frame first and let it prove this release\". Roll out to all  offers the release to every active frame that reports an older build, unless that frame is already pointed at this release or a newer one. When you claim a new frame, the server points it at the newest release that has already run on a frame. If no release has run yet, the frame stays on the build you installed.","url":"https://calevans.com/picture-frame/docs/kiosk-updates/index.html#staged-rollout-and-proven-releases","tags":"","category":""},{"id":56,"title":"The rollback watchdog","pageTitle":"Update the kiosk","text":"The boot script  device/frame-fixmac.sh  carries the line  # frame-watchdog: 1 . The kiosk reports it, and the admin shows  No rollback watchdog  for frames without it. How it works:   The kiosk writes  healthy  (its version code) after it has been in front for 5 minutes without crashing, or after its first good server answer plus a loaded dashboard.   Root copies the installed APK to  /data/misc/frame/kiosk-good.apk  (root-only) each time  healthy  equals the installed version. On the first boot with the script, the installed build is copied.   If a new version was installed and is still not healthy after about 15 minutes, root reinstalls that copy with  pm install -r -d , once.   The admin shows  Update failed  with \"the new build did not start properly and was rolled back\".   A crashing build therefore costs you roughly 15 minutes, then the frame is back on the last build that worked. Root reads only the digits of  healthy  and the  to=  line of  files/update/state  from the app's folder, ignores symlinks and anything else, and never writes into the app's folder. The server can therefore at most cause a rollback to the last build that ran healthy on that frame.","url":"https://calevans.com/picture-frame/docs/kiosk-updates/index.html#the-rollback-watchdog","tags":"","category":""},{"id":57,"title":"Failure states","pageTitle":"Update the kiosk","text":"When a frame cannot install a release, the  Kiosk version  row says  Update failed  and gives the reason.         Reason in the admin   What it means   What to do           the download failed   The frame could not fetch the file.   Check that the frame is online and  var/kiosk/  is readable by the web user. Select  Clear and retry .       the file was the wrong size   The size does not match the offer.   Delete the release and upload it again.       the file did not match its hash   The stored file differs from the offer.   Delete the release and upload it again.       the file is not a plain APK   The ZIP has extra data in it.   Rebuild and upload again.       the file is for another app   The package is not the kiosk.   Upload the right APK.       the file is not newer than the installed app   The version code is not higher.   Rebuild with a higher version code.       the file's version is not the one offered   The APK's version code differs from the release record.   Delete the release and upload again.       the signing certificate differs from the installed app   The build was signed with a different keystore.   Rebuild with the original keystore. If the original is lost, see  Lost keystore .       the release signature did not verify   The signature does not match, or the frame has a different public key baked in.   Check  RELEASE_KEY  and  RELEASE_PUBKEY . A frame built with another public key needs an  adb install -r  of a build with the right one.       the new build did not start properly and was rolled back   The watchdog reinstalled the old build.   Find out why the new build failed. Fix it, raise the version code, and release again.       the app is not the device owner   The kiosk lost its device owner status.   Redo  Provisioning, step 7 .       Android refused the install (status N)   Android returned an install error.   Read  adb logcat  on the frame.         When you have fixed the cause, select  Clear and retry  on the  Kiosk version  row. It asks for your password and says \"Cleared. The frame will try again.\" Until then the frame does not try the same file again. A failure belongs to one file. A new release with a different file is offered afresh.","url":"https://calevans.com/picture-frame/docs/kiosk-updates/index.html#failure-states","tags":"","category":""},{"id":58,"title":"Delete a release","pageTitle":"Update the kiosk","text":"In  Library , under  Kiosk app , select  Delete  on the release. It asks for your password, then removes the record and the file. The admin refuses while a frame still targets the release: \"a frame still targets this release; clear its target first\". Set that frame's  Kiosk version  to  No update  first.","url":"https://calevans.com/picture-frame/docs/kiosk-updates/index.html#delete-a-release","tags":"","category":""},{"id":59,"title":"Roll back by rebuilding","pageTitle":"Update the kiosk","text":"Android and the kiosk both refuse a lower version code, so you cannot send an old build as it was. To go back, build the old code again with a  higher  number and release that:   git checkout <old commit> -- kiosk/src kiosk/res cd kiosk && lando build-kiosk -- --version-code 21 --version-name 21-rollback    ( KIOSK_VERSION_CODE  and  KIOSK_VERSION_NAME  in the environment work too when you build without Lando.) The new APK has a new SHA-256 and its own signature file. Upload it, target one frame, and roll it out as usual.","url":"https://calevans.com/picture-frame/docs/kiosk-updates/index.html#roll-back-by-rebuilding","tags":"","category":""},{"id":60,"title":"Keystore and release key hygiene","pageTitle":"Update the kiosk","text":"Back up both keys  somewhere private. Keep the release key off the server, out of the repository and out of any container image. Do not give either key to the server, to CI, or to anyone else.   Never sign a throwaway or test build with the real keys, and never upload one as a release.  The only exception is the  --test-crash  build, used for the watchdog test on your own frame.   Lost the release key:  every frame needs one more  adb install -r  of a build with a new public key.   Lost the keystore:  you cannot update in place. On every frame you must undo device owner, uninstall the kiosk, and start again. See  Provisioning .","url":"https://calevans.com/picture-frame/docs/kiosk-updates/index.html#keystore-and-release-key-hygiene","tags":"","category":""},{"id":61,"title":"After a watchdog test","pageTitle":"Update the kiosk","text":"A  --test-crash  build ( lando build-kiosk -- --test-crash --version-code <N> ) crashes on purpose, but only while root has run  setprop debug.frame.testcrash 1 . That property is cleared at reboot. With SELinux off, any app on the frame could set it, so the gate means only that a compromised server alone cannot trigger the crash. The build is signed with your real keys, so retire it as soon as the test is done:   Delete that release in the admin.   Remove the build:  rm -rf kiosk/build , and any copy of the crash APK and its  .sig  that you saved.   Ship a real release with a higher version code to every frame. The crash build is then refused as  not_newer  everywhere. Do this before any frame leaves your control.","url":"https://calevans.com/picture-frame/docs/kiosk-updates/index.html#after-a-watchdog-test","tags":"","category":""},{"id":62,"title":"What the server logs","pageTitle":"Update the kiosk","text":"Uploads, targets, roll-outs and cleared failures go to the PHP error log, for example:   frame admin: kiosk release uploaded user=yourname ip=203.0.113.9 version_code=15 sha256=... frame admin: kiosk target set user=yourname ip=203.0.113.9 frame=1a2b3c4d version_code=15 sha256=... frame admin: kiosk rollout user=yourname ip=203.0.113.9 version_code=15 sha256=... frames=3 frame admin: kiosk failure cleared user=yourname ip=203.0.113.9 frame=1a2b3c4d sha256=... reason=hash    Other events:  release deleted ,  target cleared ,  target set on claim , and  upload rejected  (with the reason).","url":"https://calevans.com/picture-frame/docs/kiosk-updates/index.html#what-the-server-logs","tags":"","category":""},{"id":63,"title":"Related","pageTitle":"Update the kiosk","text":"Build the kiosk and set up a frame   Use the admin   Troubleshooting and recovery   Security   Next:  Troubleshooting and recovery .","url":"https://calevans.com/picture-frame/docs/kiosk-updates/index.html#related","tags":"","category":""},{"id":64,"title":"Use the admin","pageTitle":"Use the admin","text":"This page is for the person who runs the server. It covers how to create admin logins, how sign-in and two-step codes work, which actions ask for your password again, and a tour of the admin screens. When you finish, you can sign in safely and find everything you need to run your frames day to day. The admin is a desktop web page at  https://frames.example.com/frames/admin/ . It is built for a computer, not a phone.","url":"https://calevans.com/picture-frame/docs/admin/index.html","tags":"","category":""},{"id":65,"title":"Manage admin users","pageTitle":"Use the admin","text":"Admin logins live in the database. You create them from the command line on the server, as the web user. Nobody can sign in until a user exists.   sudo -u www-data php /srv/frame/app/bin/user.php <command> [user name]            Command   What it does           add <name>   Creates a user. Asks for the password twice.       passwd <name>   Sets a new password. Signs that user out of every browser.       remove <name>   Deletes the user. Their sessions stop working.       list   Prints every user with the date created, the last sign-in time (UTC), and whether two-step sign-in is on.       totp-enable <name>   Turns on two-step sign-in. See  Two-step sign-in .       totp-disable <name>   Turns two-step sign-in off. Use it if you lose your authenticator.         passwd ,  totp-enable  and  totp-disable  also sign that user out of every browser at once and make that user's trusted-device cookie stop working. Rules:   The command refuses to run as root. Run it as the web user, as shown, so the database keeps the right owner.   User names are lowercase letters, digits, dot, underscore and hyphen, up to 64 characters, and start with a letter or digit.   The sign-in form ignores capital letters and spaces around the name.   A password must be at least 12 characters. It cannot match the user name, and it cannot be on the bundled list of commonly used passwords. The limit is 1024 bytes, or 72 bytes if your PHP has no Argon2 support and the server uses bcrypt.   The password is read from the terminal with echo off. To pass it without a terminal, pipe one line into the command. Never put a password on the command line.   If the database is missing or out of date, the command stops and tells you to run  migrate.php  first.   Passwords are stored as Argon2id hashes.","url":"https://calevans.com/picture-frame/docs/admin/index.html#manage-admin-users","tags":"","category":""},{"id":66,"title":"Two-step sign-in","pageTitle":"Use the admin","text":"A password alone is the only thing between the internet and your frames. Add a one-time code from an authenticator app. It is strongly recommended.     Run:     sudo -u www-data php /srv/frame/app/bin/user.php totp-enable yourname          The command prints a  secret:  and an  otpauth://  link. In your authenticator app, choose to add an account by entering a setup key, and type the secret. The link holds the same secret.       At the prompt, enter the six-digit code your app shows.     Nothing is saved until the code checks out. If two-step sign-in is already on for that user, run  totp-disable  first. After that, the sign-in form needs the password and the current code together. A wrong password and a wrong code give the same message, so nobody can tell which was wrong. Details:   The codes are standard time-based codes: six digits, a new one every 30 seconds, SHA-1. The app name shown is \"Frames admin\".   The server accepts the current code and the ones just before and after it, to allow for clock drift. Your server's clock must be right, so run NTP on it.   A code works once. Entering the same code a second time fails.   If you lose the authenticator, you recover from the shell, which only you have:   sudo -u www-data php /srv/frame/app/bin/user.php totp-disable yourname","url":"https://calevans.com/picture-frame/docs/admin/index.html#two-step-sign-in","tags":"","category":""},{"id":67,"title":"Sign in and out","pageTitle":"Use the admin","text":"Open  https://frames.example.com/frames/admin/ . You land on the sign-in page.   Enter your  User name  and  Password .   If two-step sign-in is on, enter the  Authentication code . Leave it empty otherwise.   Select  Sign in .   You land on  Frames . To sign out, open the account menu in the top-right corner (it shows your user name) and select  Sign out . Messages you might see on the sign-in page:         Message   Meaning           Sign-in failed. Check your details and try again.   The user name, password or code is wrong. The message is the same for all three.       Too many attempts. Try again later.   A  lockout  is active.       The page expired. Try again.   The form's token or the request's origin did not check out. See  Troubleshooting .       That request was not understood.   The browser sent the form in an unexpected format. Reload the page.","url":"https://calevans.com/picture-frame/docs/admin/index.html#sign-in-and-out","tags":"","category":""},{"id":68,"title":"Sessions and timeouts","pageTitle":"Use the admin","text":"What   Limit           Idle time before you are signed out   30 minutes without a request       Longest session   12 hours after you sign in       The session ID changes   About every 15 minutes, with a short overlap so open requests do not fail       The sign-in form stays valid   15 minutes         When a session ends, the admin shows a  Signed out  box with a  Sign in  button. Unsaved edits on that page are lost. A password change, a two-step change, or removing the user ends that user's sessions at once.","url":"https://calevans.com/picture-frame/docs/admin/index.html#sessions-and-timeouts","tags":"","category":""},{"id":69,"title":"Trusted device","pageTitle":"Use the admin","text":"When you sign in successfully, your browser keeps a trusted-device cookie for 90 days. It does exactly one thing: it lifts the per-user lockout described below for that browser. It never skips the password or the code. The cookie stops working when that user's password changes, two-step sign-in is turned on or off, or the user is removed. The key that signs these cookies is  var/device.key , created the first time someone signs in. Deleting it invalidates every trusted-device cookie and changes nothing else.","url":"https://calevans.com/picture-frame/docs/admin/index.html#trusted-device","tags":"","category":""},{"id":70,"title":"Confirming a risky action","pageTitle":"Use the admin","text":"Some actions ask for your password (and your code, if two-step is on) again, unless you entered it in the last five minutes. Signing in counts. When it asks, the  Confirm it's you  box appears with  Password  and  Authentication code  fields and a  Confirm  button. After you confirm, the action goes ahead. If you select  Cancel , the admin says \"Not confirmed. Nothing was changed.\" These actions ask:         Area   Actions           Frames   Revoke a frame. Restore a revoked frame. Delete a frame. Dismiss unclaimed frames. Set a frame's PIN. Reset a frame's PIN to the factory PIN.       Kiosk app   Upload a release. Edit a release's notes. Delete a release. Choose the release a frame should install ( Kiosk version ).  Roll out to all .  Clear and retry  after a failed update.       Photos   Delete an album. Delete photos, including  Reject . Revoke or restore a drop link. Make a new link ( New link ). Delete a drop link.  Delete their photos .       Cards   Revoke or restore a sender link. Make a new link. Delete a sender link. Delete unopened cards from a sender.","url":"https://calevans.com/picture-frame/docs/admin/index.html#confirming-a-risky-action","tags":"","category":""},{"id":71,"title":"Lockouts","pageTitle":"Use the admin","text":"The sign-in page counts failed attempts. Every attempt counts, including ones with a wrong code.         Limit   What happens           10 failed sign-ins from one address in 15 minutes   That address is locked out for the rest of the window.       20 failed sign-ins for one user name in an hour   That user name is locked out, except for a browser that signed in as that user before and still holds its trusted-device cookie.         While a lockout is active, a correct password is refused too. Wait it out. The sign-in page says \"Too many attempts. Try again later.\" Re-confirming your password inside the admin uses the per-address limit only, so someone guessing at your user name cannot stop you from confirming an action. If a proxy hides client addresses, everyone shares one address and a few failures lock the page for all. Set  client_ip_header  and  trusted_proxies . See  Install the frame server .","url":"https://calevans.com/picture-frame/docs/admin/index.html#lockouts","tags":"","category":""},{"id":72,"title":"What the server logs","pageTitle":"Use the admin","text":"Sign-ins and failures go to the PHP error log, one line each, without passwords:   frame admin: login ok user=yourname ip=203.0.113.9 frame admin: login failed user=yourname ip=203.0.113.9 frame admin: login throttled ip=203.0.113.9 frame admin: login rejected origin ip=203.0.113.9 frame admin: login rejected token ip=203.0.113.9 frame admin: logout user=yourname frame admin: reauth failed user=yourname ip=203.0.113.9 frame admin: reauth throttled ip=203.0.113.9    Kiosk release changes, such as uploads and roll-outs, are logged the same way. See  Update the kiosk . You can point fail2ban at the  login failed ,  login throttled ,  login rejected  and  reauth failed  lines.   Not verified     A fail2ban filter for these lines was not tested. A starting point for  filter.d/frame-admin.conf  is  failregex = frame admin: (login (failed|throttled|rejected \\w+)|reauth (failed|throttled)).* ip=<HOST> . Check it against your log before you rely on it.","url":"https://calevans.com/picture-frame/docs/admin/index.html#what-the-server-logs","tags":"","category":""},{"id":73,"title":"A tour of the admin","pageTitle":"Use the admin","text":"The top bar has the  Frames admin  name, four links, and your account menu.         Link   What it holds           Frames   Your frames, and the  Claim a frame  button.       Library   Locations, calendars, and the kiosk app releases.       Photos   Your photo library: albums, uploads, drop links. See  Photos .       Card designs   Covers and stamps for Cards. See  Widgets: Cards .         Every page uses a hash address, such as  #/photos  or  #/frame/<id>/widgets , so you can bookmark any screen.","url":"https://calevans.com/picture-frame/docs/admin/index.html#a-tour-of-the-admin","tags":"","category":""},{"id":74,"title":"Frames","pageTitle":"Use the admin","text":"Frames  shows one tile per claimed frame. Each tile has the last screenshot, the frame's name, and a status:  Online ,  Offline  (with when it was last seen),  Never connected , or  Revoked . A  Weak Wi-Fi  badge shows when the signal is below -75 dBm. An  Update available  badge shows when another frame runs a newer kiosk. The page refreshes every minute.","url":"https://calevans.com/picture-frame/docs/admin/index.html#frames","tags":"","category":""},{"id":75,"title":"Claim a frame","pageTitle":"Use the admin","text":"A new frame shows a six-digit pairing code on its screen. A banner on  Frames  says \"A frame is waiting to be claimed.\" with  Claim it  and  Dismiss  buttons.   Select  Claim a frame .   Enter the six-digit code from the frame's screen in  Code . The box finds the frame as soon as you type six digits. You can also select  Find frame .   Check the frame ID the admin shows. Confirm it is the one in front of you.   Enter a  Name  for the frame. It is shown in the admin and nowhere else.   Select  Claim frame .   You see \"Claimed. Turn on the widgets you want.\" The admin opens the frame's  Widgets  section. The code is valid for 24 hours. After five wrong codes in 10 minutes, the admin stops accepting codes for a while. The message is \"That code was wrong or has expired.\" When you claim a frame, the server also:   turns on the Clock and Calendar widgets, and Weather if a location already exists,   picks a time for the daily photo set, between 04:00 and 06:00 in the frame's time zone, and   points the frame at the newest kiosk release that has already run on a frame, if there is one. See  Update the kiosk .   Dismiss  deletes unclaimed frames. It asks you to confirm your password. Any that are still powered on enroll again.","url":"https://calevans.com/picture-frame/docs/admin/index.html#claim-a-frame","tags":"","category":""},{"id":76,"title":"The frame page","pageTitle":"Use the admin","text":"Select a tile to open a frame. The header shows the frame's name (use the pencil button to rename it) and its status. The  More  menu has  Revoke  (or  Restore ) and  Delete .   Revoke  stops a frame working at once. You can restore it later.   Restore  brings a revoked frame back.   Delete  removes the frame and its settings. If the frame is still powered on, it enrolls again as unclaimed. The dialog offers  Revoke instead .   All three ask for your password again. A rail on the left lists the frame's sections:  Screen ,  Schedule ,  Widgets , one section per widget that has settings (shown while the widget is on),  Security  and  Details . A dot next to a section means unsaved changes there. Each section saves on its own. A bar at the bottom shows \"Unsaved changes\" with  Discard  and  Save . If you try to leave with unsaved changes, the admin asks \"Discard unsaved changes?\"","url":"https://calevans.com/picture-frame/docs/admin/index.html#the-frame-page","tags":"","category":""},{"id":77,"title":"Live view","pageTitle":"Use the admin","text":"Screen  shows what the frame is showing. Select  Refresh  to ask the frame for a new screenshot.   The frame picks up the request on its next check-in. This usually takes a few seconds, and the admin waits up to 45 seconds. After 25 seconds it says \"Taking longer than usual.\"   You can ask again 10 seconds after the last request.   If the screen was off, the picture is a \"Screen off\" placeholder and the caption says when it turned off and when it comes back.   If the frame is offline, the admin says \"Offline, so no live view.\" and offers  Check again .   Frames running an older kiosk say \"This frame's software is too old for live view.\"   Only the latest screenshot of each frame is kept, as a JPEG of up to 600 KiB.","url":"https://calevans.com/picture-frame/docs/admin/index.html#live-view","tags":"","category":""},{"id":78,"title":"Schedule and bedtime","pageTitle":"Use the admin","text":"Schedule  controls when the screen turns fully off how long the frame waits before it starts the screensaver, and which time zone the frame uses.   Turn on  Turn the screen off overnight .   Set  Bedtime  and  Wake time . Type a time into each, or drag the moon and sun handles on the 24-hour bar. With the keyboard, the arrow keys move a handle by 5 minutes and Page Up and Page Down move it by an hour.   Select  Save .   Time zone.  New frames start with  America/New_York . Bedtime, wake time, the daily photo pick time and the clock all use the frame's time zone. To change it, type in  Time zone  to search the list, for example  America/Chicago , or select  Use this computer's zone . Select  Save . The frame picks it up on its next check-in, within a minute. Rules:   Bedtime and wake time must differ. The 24-hour bar keeps its two handles at least 15 minutes apart. Dragging snaps to 15 minutes, or 5 minutes while you hold Shift.   When the switch is off, the bar says \"Screen stays on all day.\"   A line under the bar says how long the screen is off and on, and whether it is on or off right now.   The schedule is saved on the frame, so it works when the frame is offline.   At bedtime the backlight goes fully off, not just dim. A tap wakes it.","url":"https://calevans.com/picture-frame/docs/admin/index.html#schedule-and-bedtime","tags":"","category":""},{"id":79,"title":"Screensaver","pageTitle":"Use the admin","text":"On the same page,  Start screensaver after  sets how long the frame waits without a touch before it starts the photo screensaver. The choices are 30 seconds, 1, 2, 5, 10, 15 and 30 minutes, and 1 hour. The default is 2 minutes. If the frame has no photos, it returns to the home screen instead. The photos come from the frame's daily set. See  Photos .","url":"https://calevans.com/picture-frame/docs/admin/index.html#screensaver","tags":"","category":""},{"id":80,"title":"Widgets","pageTitle":"Use the admin","text":"Widgets  has an  On  and  Off  switch for each widget. Turn switches on or off and select  Save . Settings for each widget appear in their own section once it is on. See  Widgets .","url":"https://calevans.com/picture-frame/docs/admin/index.html#widgets","tags":"","category":""},{"id":81,"title":"Security","pageTitle":"Use the admin","text":"Security  sets the frame's PIN, the one the frame asks for after you hold the top-left corner for 10 seconds. The panel says whether the frame uses a custom PIN or the factory PIN, which is the  KIOSK_PIN  baked into the build.   Enter the  New PIN , 4 to 8 digits. Use the eye button to see what you typed.   Select  Set PIN . It asks for your password.   Reset to factory PIN  clears the custom PIN. The frame picks up the change on its next check-in.","url":"https://calevans.com/picture-frame/docs/admin/index.html#security","tags":"","category":""},{"id":82,"title":"Details","pageTitle":"Use the admin","text":"Details  is a read-only summary: the Wi-Fi network and signal, the local and public IP, the MAC address, when it last checked in, the versions it runs, its uptime, its short ID, its time zone and when it enrolled. The  Kiosk version  row is where you send it a new kiosk. See  Update the kiosk .","url":"https://calevans.com/picture-frame/docs/admin/index.html#details","tags":"","category":""},{"id":83,"title":"Library","pageTitle":"Use the admin","text":"Library  holds three lists. Locations.  Weather needs at least one. Select  Add location , then fill in:         Field   What to enter           Name   A label, up to 64 characters. Names are unique.       Latitude   -90 to 90.       Longitude   -180 to 180.       Stations (optional)   Up to five comma-separated four-character station IDs, such as  KJFK .         Leave  Stations  empty at first. The pencil edits a location and  Delete  removes it. The admin refuses to delete a location a frame's Weather widget uses. Calendars.  Select  Add calendar  and enter:         Field   What to enter           Name   A label, up to 64 characters.       Share URL   The calendar's public iCal link. Its host must be in  calendar_hosts .       Color (optional)   A color like  #6cb6ff .         The table shows each calendar's host and a status:  OK ,  Error  (with the message), or  Not fetched .  Rename  and  Delete  are on each row. The admin refuses to delete a calendar a frame uses. Kiosk app.  The list of kiosk releases, with  Upload release . See  Update the kiosk .","url":"https://calevans.com/picture-frame/docs/admin/index.html#library","tags":"","category":""},{"id":84,"title":"Photos and Card designs","pageTitle":"Use the admin","text":"These pages are covered where the feature is explained:   Photos :  Photos .   Card designs :  Widgets: Cards .","url":"https://calevans.com/picture-frame/docs/admin/index.html#photos-and-card-designs","tags":"","category":""},{"id":85,"title":"Related","pageTitle":"Use the admin","text":"Install the frame server   Widgets   Photos   Update the kiosk   Security   Next:  Build the kiosk and set up a frame .","url":"https://calevans.com/picture-frame/docs/admin/index.html#related","tags":"","category":""},{"id":86,"title":"Build the kiosk and set up a frame","pageTitle":"Build the kiosk and set up a frame","text":"This guide is for people whose PF1007L already has root ADB (see  Unlock your PF1007L ) and whose server is running (see  Install the frame server ). When you finish, the frame boots to your logo, then shows your dashboard full screen, and you have claimed it in the admin. You do the setup once per frame. You build the kiosk app once per change.","url":"https://calevans.com/picture-frame/docs/provisioning/index.html","tags":"","category":""},{"id":87,"title":"Before you start","pageTitle":"Build the kiosk and set up a frame","text":"Warning     Keep the signing keystore forever. Updates to the kiosk install only if they are signed with the same key. If you lose the keystore, you must uninstall the kiosk (undo device owner first) on every frame and start again.       Warning     Make the release key before your first build. The key that approves self-updates is baked into the app, so a build made without it cannot self-update. See  Step 5 .       Root ADB has no password.  Anyone who can reach port 5555 on the frame controls it. Set  ADB_ALLOW  in step 1.   Every write is yours to confirm.   scripts/provision.sh  prints what it will do and waits for  y  before the first write. Read it.   Use the tested firmware.  Package names and file paths below come from the January 2026 firmware ( AEEZO/astar_m86_8723cs:6.0.1/MOB30R/20260113 ). Check each one on other builds.   You need on your computer:  adb , Docker and  Lando  for the included kiosk build (or a JDK and the Android build tools, see below),  openssl ,  unzip  and  php . Run everything in a WSL shell, in the repository root.","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#before-you-start","tags":"","category":""},{"id":88,"title":"Step 1: Fill in .env","pageTitle":"Build the kiosk and set up a frame","text":"Copy  .env.example  to  .env  in the repository root if you have not already:   cp .env.example .env chmod 600 .env    .env  holds your keystore password, so keep it private. Git ignores it. The file is  KEY=value , one per line. These rules come from the strict reader in  scripts/lib/env.sh :   Blank lines and lines that start with  #  are allowed. Anything else must be  KEY=value  for a known key.   A value may be wrapped in one pair of single or double quotes. The quotes are removed.   There is no variable expansion, no backticks and no escapes.   A line with an unknown key stops the script with  unknown key . A line with a Windows line ending stops it with  control characters (CRLF line endings?) .   KEYSTORE ,  RELEASE_KEY  and  RELEASE_PUBKEY  may start with  ~/ .           Key   Read by   Needed   What it does           FRAME_SERVER_URL   kiosk/build.sh   Always, for a build   The address of your server. It must be HTTPS, end with a slash, and match  frames_url  in the server's  config.php . Example:  https://frames.example.com/frames/ .       KIOSK_PIN   kiosk/build.sh   Always, for a build   The factory PIN for the frame's on-screen panel, 4 to 8 digits. Only a salted hash goes into the app. You can set a different PIN per frame in the admin later.       KEYSTORE   kiosk/build.sh   Optional   Path to the signing keystore. Default:  ~/.android/frame-kiosk.keystore . Keep it under your home folder: Lando sees only that.       KEYSTORE_PASS   kiosk/build.sh   Always, for a build   Password for the keystore and its key (both use the same one). Choose your own.       RELEASE_PUBKEY   kiosk/build.sh   For self-update   Path to the public half of your release key. It is built into the app. Without it, the app refuses every self-update.       RELEASE_KEY   kiosk/build.sh   For self-update   Path to the private half of your release key. The build uses it to write a  .sig  file next to the APK. Without it, no  .sig  is written.       OWNER_NAME   kiosk/build.sh   Optional   Name shown on the pairing screen, in \"Read this code to ...\".       ADB_ALLOW   scripts/provision.sh boot-script   For the boot script   IPv4 addresses or CIDR ranges that may reach ADB over Wi-Fi, separated by commas or spaces. Example:  192.0.2.20,198.51.100.0/28 . Empty means no limit.       NTP_SERVER   scripts/provision.sh time   For  time   The time server the frame uses before it has a clock. Example:  pool.ntp.org . Letters, digits, dots and hyphens only.       TZ   scripts/provision.sh time   For  time   The frame's time zone. Example:  America/New_York .       USBIP_BUSID   scripts/boot-ums.sh   Unlocking only   The USB/IP bus ID of the port the frame is plugged into. Not needed here.         ADB_ALLOW  takes effect only when the boot script is installed (step 9). Include the address of the computer you use for ADB. USB ADB is never limited, so you can always recover over the cable. Here is a filled-in example:   FRAME_SERVER_URL=https://frames.example.com/frames/ KIOSK_PIN=4821 KEYSTORE=~/.android/frame-kiosk.keystore KEYSTORE_PASS=a-long-password-of-your-own RELEASE_KEY=~/.android/frame-release-key.pem RELEASE_PUBKEY=~/.android/frame-release-pub.pem OWNER_NAME=Alex ADB_ALLOW=192.0.2.20,198.51.100.0/28 NTP_SERVER=pool.ntp.org TZ=America/New_York      Note     TZ  in  .env  sets the time zone of the Android system on the frame. The time zone the admin uses for the schedule and photo pick time is a separate setting:  Time zone  in the frame's  Schedule  section. See  Use the admin .","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#step-1-fill-in-env","tags":"","category":""},{"id":89,"title":"Step 2: Get the files you provide","pageTitle":"Build the kiosk and set up a frame","text":"The repository does not carry third-party binaries. Download these:         File   Why   Where to get it           WebView 106 APK (arm, 32-bit)   The frame's stock WebView 44 is too old for modern pages. 106 is the newest that runs on Android 6.   The LineageOS prebuilt WebView for ARM:  LineageOS/android_external_chromium-webview_prebuilt_arm . The version tested is  106.0.5249.126 , from the prebuilt commit  4b0ae494a7dd , with SHA-256  e2edc1f89ec1608b169da9e7ffc037190b70c7825f0be29694f4cae719980580 . Check the hash, whatever source you use.       A launcher APK (optional)   A second home app for maintenance. Nothing in this guide needs it, and the kiosk has no way to open it. Skip it and use ADB.   Any launcher that runs on Android 6. The author tested Rootless Pixel Launcher ( amirz.rootless.nexuslauncher ) and does not provide a download.       ISRG Root X1 certificate   Android 6.0.1 does not trust Let's Encrypt. Without this, a Let's Encrypt site fails to load on the frame.   letsencrypt.org/certs/isrgrootx1.pem         Put the files in a folder outside the repository, for example  ~/frame-files/ . Check the WebView hash:   sha256sum ~/frame-files/webview.apk    The WebView prebuilts and the Let's Encrypt root keep their own licenses.","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#step-2-get-the-files-you-provide","tags":"","category":""},{"id":90,"title":"Step 3: Connect to the frame","pageTitle":"Build the kiosk and set up a frame","text":"Plug the frame into your computer and attach it to WSL ( usbipd attach --wsl --busid <id>  from PowerShell, as in the unlock guide). Then in WSL:   adb devices adb shell id    You expect  uid=0(root) . All steps below work over USB ADB. After the kiosk is installed and the frame is on Wi-Fi (step 11), you can connect over the network instead:   adb connect 192.0.2.50:5555    If both USB and network connections are open, add  -s <serial>  to  adb  and to  scripts/provision.sh .","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#step-3-connect-to-the-frame","tags":"","category":""},{"id":91,"title":"Step 4: Prepare the system partition","pageTitle":"Build the kiosk and set up a frame","text":"Wrap every  /system  change in a read-write remount and a read-only remount, and keep a  .bak  of any file you replace.","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#step-4-prepare-the-system-partition","tags":"","category":""},{"id":92,"title":"Turn on ADB over Wi-Fi","pageTitle":"Build the kiosk and set up a frame","text":"adb shell 'mount -o remount,rw /system cp -p /system/build.prop /system/build.prop.bak echo service.adb.tcp.port=5555 >> /system/build.prop mount -o remount,ro /system'","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#turn-on-adb-over-wi-fi","tags":"","category":""},{"id":93,"title":"Disable the vendor apps","pageTitle":"Build the kiosk and set up a frame","text":"com.yhk.qeota  is the over-the-air updater. Do it first, and never press \"Check for updates\" in the stock app: an update could overwrite  /system  and undo your work.  com.DeviceTest  and  com.android.gl2jni  are factory leftovers.   for p in com.yhk.qeota com.DeviceTest com.android.gl2jni; do adb shell \"am force-stop $p; pm disable $p; pm hide $p\" done    Do not disable the stock home app,  com.efercro.os.aimor , yet. Step 7 does it, after the kiosk is installed.","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#disable-the-vendor-apps","tags":"","category":""},{"id":94,"title":"Install WebView 106","pageTitle":"Build the kiosk and set up a frame","text":"Android 6 does not unpack native libraries for apps in  /system , and WebView 106 stores them compressed. So you unpack them on your computer and push them.   mkdir -p ~/frame-files/webview-libs unzip -j ~/frame-files/webview.apk 'lib/armeabi-v7a/*.so' -d ~/frame-files/webview-libs adb push ~/frame-files/webview.apk /data/local/tmp/webview.apk adb push ~/frame-files/webview-libs /data/local/tmp/webview-libs adb shell 'mount -o remount,rw /system cp -p /system/app/webview/webview.apk /system/app/webview/webview.apk.bak cp /data/local/tmp/webview.apk /system/app/webview/webview.apk chmod 644 /system/app/webview/webview.apk rm -rf /system/app/webview/oat mkdir -p /system/app/webview/lib/arm cp /data/local/tmp/webview-libs/*.so /system/app/webview/lib/arm/ chmod 755 /system/app/webview/lib /system/app/webview/lib/arm chmod 644 /system/app/webview/lib/arm/* chown -R root:root /system/app/webview/lib mount -o remount,ro /system rm -r /data/local/tmp/webview.apk /data/local/tmp/webview-libs'","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#install-webview-106","tags":"","category":""},{"id":95,"title":"Trust the Let's Encrypt root","pageTitle":"Build the kiosk and set up a frame","text":"Android looks up certificates by a hash of the subject, so the file name matters. Compute it:   openssl x509 -inform PEM -subject_hash_old -noout -in ~/frame-files/isrgrootx1.pem    It prints  6187b673 . Copy the certificate to that name with  .0  on the end and install it:   cp ~/frame-files/isrgrootx1.pem ~/frame-files/6187b673.0 adb push ~/frame-files/6187b673.0 /data/local/tmp/ adb shell 'mount -o remount,rw /system cp /data/local/tmp/6187b673.0 /system/etc/security/cacerts/ chmod 644 /system/etc/security/cacerts/6187b673.0 mount -o remount,ro /system rm /data/local/tmp/6187b673.0'    If your server uses a certificate from another authority that Android 6 does not trust, install that root the same way, with the name from its own hash.","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#trust-the-lets-encrypt-root","tags":"","category":""},{"id":96,"title":"Restart and check","pageTitle":"Build the kiosk and set up a frame","text":"adb reboot adb wait-for-device adb shell dumpsys package com.android.webview | grep versionName    You expect  versionName=106.0.5249.126 . Reconnect with  adb disconnect  and  adb connect  if you use ADB over the network: the old session shows \"device offline\" after a reboot.   Keep a record     Write down each change you make to a frame, with its undo. The  .bak  files above are the undo for build.prop and WebView.","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#restart-and-check","tags":"","category":""},{"id":97,"title":"Step 5: Create your keys","pageTitle":"Build the kiosk and set up a frame","text":"Two keys, kept apart. You make both once, on your own computer, before your first build.         Key   What it is   What it protects           APK keystore   An RSA key in a Java keystore. Signs the kiosk app.   Android accepts an app update only if it is signed with the same key as the installed app.       Release key   An offline ECDSA P-256 key. Signs each release's SHA-256 and version code.   A stolen server or admin login cannot push a build you did not sign.         Update the kiosk  explains how they work together.","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#step-5-create-your-keys","tags":"","category":""},{"id":98,"title":"The APK keystore","pageTitle":"Build the kiosk and set up a frame","text":"If the keystore is missing, the build creates it with  keytool  on first use. To create it yourself first (same result), run this with your own password, which must equal  KEYSTORE_PASS :   mkdir -p ~/.android keytool -genkeypair -keystore ~/.android/frame-kiosk.keystore -alias frame \\ -keyalg RSA -keysize 2048 -validity 10000 -dname \"CN=Frame Kiosk\"    keytool  asks for the password. Use it for both the keystore and the key. Then:   Back up the keystore file somewhere private. It is the only thing that lets you update the kiosk in place.   Set it to  chmod 600 , and keep it out of the repository.","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#the-apk-keystore","tags":"","category":""},{"id":99,"title":"The release key","pageTitle":"Build the kiosk and set up a frame","text":"The private key must be an unencrypted PKCS#8 PEM, which is what  openssl genpkey  writes:   openssl genpkey -algorithm EC -pkeyopt ec_paramgen_curve:P-256 -out ~/.android/frame-release-key.pem openssl pkey -in ~/.android/frame-release-key.pem -pubout -out ~/.android/frame-release-pub.pem chmod 600 ~/.android/frame-release-key.pem    Then set  RELEASE_KEY  and  RELEASE_PUBKEY  in  .env  to those two paths. The public key is baked into every build.   If  RELEASE_PUBKEY  is missing, that build refuses every self-update. It fails closed and reports  release_sig .   If  RELEASE_KEY  is missing, no signature file is written.   Either way the build itself works.   If the two keys are not a pair, the build stops with  RELEASE_KEY and RELEASE_PUBKEY are not a pair.   To let the server check signatures too, put the contents of the public key file in  config.php  as  kiosk_release_pubkey . It is optional. Keep the private key offline. Back it up somewhere private. Never put it on the server, in the repository, or in a container image. Losing it means one more  adb install  per frame with a new public key.","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#the-release-key","tags":"","category":""},{"id":100,"title":"Step 6: Build the kiosk","pageTitle":"Build the kiosk and set up a frame","text":"Start the build container. The first run downloads and installs the tools:     cd kiosk lando start          Build:     lando build-kiosk        The script reads  .env  in the repository root, then:   writes the server address, your owner name and the PIN hash into the app,   creates the signing keystore at  KEYSTORE  if it does not exist,   builds, aligns and signs  kiosk/build/frame-kiosk.apk  (with both the v1 and v2 signature schemes),   if  RELEASE_KEY  is set, signs the build's SHA-256 and version code and writes  kiosk/build/frame-kiosk.apk.sig .   It prints  built kiosk/build/frame-kiosk.apk , then the  versionCode  and  sha256 . A line starting  WARNING:  means a release key setting is missing. That is fine for a first install, but set the release key before you rely on  self-update .","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#step-6-build-the-kiosk","tags":"","category":""},{"id":101,"title":"Build options","pageTitle":"Build the kiosk and set up a frame","text":"Lando does not pass your shell's environment through, so give options as arguments after  -- :   lando build-kiosk -- --version-code 15 --version-name 15            Option   Environment variable   What it does           --version-code N   KIOSK_VERSION_CODE   The Android  versionCode . A whole number from 1 to 2,100,000,000, no leading zero. Without it the build uses the value in  kiosk/AndroidManifest.xml . Every release needs a higher number than the one before.       --version-name S   KIOSK_VERSION_NAME   The label shown in the admin. Letters, digits,  . ,  _  and  - , up to 64 characters. Default: the version code.       --test-crash   KIOSK_TEST_CRASH=1   A build that crashes on purpose, for testing the rollback watchdog on your own frame. Never upload it as a real release. See  Update the kiosk .         The environment variables work only when you build without Lando.","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#build-options","tags":"","category":""},{"id":102,"title":"Build without Lando","pageTitle":"Build the kiosk and set up a frame","text":"The build also works on a machine that has the tools. They are the Ubuntu packages the Lando container installs:   sudo apt-get install --no-install-recommends aapt android-sdk-platform-23 apksigner dalvik-exchange zipalign openjdk-17-jdk-headless bash kiosk/build.sh --version-code 15    The native route was not tested for this page.","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#build-without-lando","tags":"","category":""},{"id":103,"title":"Step 7: Install the kiosk and set device owner","pageTitle":"Build the kiosk and set up a frame","text":"scripts/provision.sh kiosk kiosk/build/frame-kiosk.apk    Use  -s <serial>  before  kiosk  to pick a device, for example  scripts/provision.sh -s 192.0.2.50:5555 kiosk ... . The script checks that the frame answers as root, then shows what it will do, and waits for  y :   adb install -r  the APK,   stop, disable and hide the stock home app, so the kiosk is the only home screen,   make the kiosk the  device owner , which turns on lock-task mode and blocks factory reset, safe boot and new users. If the kiosk is already the owner, it skips this.   It stops with an error if there is more than one user or any account. To clear an account, run  adb shell dumpsys account . It names the account type and the app that owns it. Remove the account in that app, or disable that app and reboot (Android drops accounts whose app is gone), then run the script again. It does not reboot the frame. Reboot it yourself, then check:   adb reboot adb shell dumpsys device_policy | grep -A2 'Device Owner' adb shell dumpsys activity activities | grep -i locktask    You expect the kiosk package and  mLockTaskModeState=LOCKED . To update later, run  adb install -r  with a newly built APK signed with the same keystore, or  from the admin . Uninstalling needs  Undo device owner  first. If the Wi-Fi picker shows no networks, Android 6 may be hiding scan results because location is off. The kiosk normally turns it on. If not:   adb shell settings put secure location_providers_allowed +network","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#step-7-install-the-kiosk-and-set-device-owner","tags":"","category":""},{"id":104,"title":"Step 8: Set the clock source","pageTitle":"Build the kiosk and set up a frame","text":"The frame has no battery-backed clock. Tell it which time server and time zone to use:   scripts/provision.sh time    It needs  NTP_SERVER  and  TZ  in  .env . After confirmation it runs  settings put global ntp_server  and sets  persist.sys.timezone , then prints both values back.","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#step-8-set-the-clock-source","tags":"","category":""},{"id":105,"title":"Step 9: Install the boot script","pageTitle":"Build the kiosk and set up a frame","text":"The boot script is  device/frame-fixmac.sh . It runs as root on every boot. Install it after the kiosk, because it hands the backlight to the kiosk's user ID.","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#step-9-install-the-boot-script","tags":"","category":""},{"id":106,"title":"Optional: keep a specific MAC address","pageTitle":"Build the kiosk and set up a frame","text":"The Wi-Fi chip has no MAC address of its own, so the script gives each frame a fixed one: on first run it generates a random locally administered address and stores it in  /data/misc/frame/mac . To use a particular address instead (for an existing DHCP reservation), set it before you install the script:   adb shell 'mkdir -p /data/misc/frame; chmod 700 /data/misc/frame echo <mac in lowercase hex, like 02:aa:bb:cc:dd:ee> > /data/misc/frame/mac chmod 600 /data/misc/frame/mac'    A factory reset deletes the file. Reserve the frame's address in your DHCP server either way.","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#optional-keep-a-specific-mac-address","tags":"","category":""},{"id":107,"title":"Install the script","pageTitle":"Build the kiosk and set up a frame","text":"scripts/provision.sh boot-script    The command stops early if  ADB_ALLOW  holds anything other than IPv4 addresses and ranges, or if the frame has no  timeout  command (the rollback watchdog needs it). After confirmation it:   writes  /system/etc/frame.conf  with the kiosk package and your  ADB_ALLOW ,   keeps any older script as  /system/bin/frame-fixmac.sh.v<N> ,   installs  device/frame-fixmac.sh  as  /system/bin/frame-fixmac.sh  (0755 root:shell, so the kiosk can read its watchdog marker),   backs up  install-recovery.sh  as  .bak  and adds one line:  exec /system/bin/sh /system/bin/frame-fixmac.sh . Android runs that file as root at every boot. The line must be  exec , because Android kills background children,   checks the installed script against the repository copy.   It ends with \"Installed and verified.\" The script takes effect after a reboot. Run it again after you change  ADB_ALLOW . To undo it, restore the old  frame-fixmac.sh.v<N>  and remove  /system/etc/frame.conf .","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#install-the-script","tags":"","category":""},{"id":108,"title":"What the boot script does","pageTitle":"Build the kiosk and set up a frame","text":"Watch it work with  adb logcat -d -s frame-fixmac .         Feature   What it does           Pins the Wi-Fi MAC   The Wi-Fi driver ignores a configured address and picks a random one on every load. The script sets the stored one, checks every second for the first two minutes, then every 10 seconds. Android restarts Wi-Fi once during boot, so the log shows two corrections and two short Wi-Fi drops.       Hands the backlight to the kiosk   It gives the kiosk's user ID ownership of  /sys/class/disp/disp/attr/lcdbl  on every boot. Without it, the Dim button only lowers brightness to 1 percent, under a black screen, instead of turning the backlight off. Check with  adb shell stat -c %u /data/data/com.calevans.frame . The log line is  lcdbl -> uid N . If you uninstall and reinstall the kiosk (not  install -r ), it gets a new user ID. Reboot afterwards.       Restores the clock   After a power cut the frame starts in 1969. The script saves the time to  /data/misc/frame/clock  (every minute for the first two minutes after boot, then about every 10 minutes). If the frame starts with a clock before 2024, it sets the saved time, so HTTPS works before NTP answers. With no saved time, it restarts the kiosk once NTP has set the year. Look for  clock restored  in the log.       Limits ADB over Wi-Fi   With  ADB_ALLOW  set, it builds an  iptables  chain that lets only those IPv4 addresses reach TCP port 5555 and drops the rest, including all of IPv6. Android's network service clears the rule when it starts, so the script puts it back whenever it goes missing. USB ADB is never filtered. With  ADB_ALLOW  empty, the list is off.       Fails closed on a bad entry   If one  ADB_ALLOW  entry is not a valid IPv4 address or range, the script allows nobody over Wi-Fi (only the frame itself), logs  bad ADB_ALLOW entry , and you must use USB ADB to fix it.       Recovers from the Home chooser   Every 30 seconds, it checks whether Android is showing the \"Complete action using\" chooser (which appears if the default home app is lost) and starts the kiosk if so.       Rolls back a bad kiosk update   The watchdog. See  Update the kiosk . It needs  timeout  and  dd  from the frame's toolbox.           Note     The clock restore step is unverified on the tested firmware: the exact  date  syntax is assumed.","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#what-the-boot-script-does","tags":"","category":""},{"id":109,"title":"Step 10: Make the boot logo and animation (optional)","pageTitle":"Build the kiosk and set up a frame","text":"Start from a 1280x800 PNG. Back up the stock files first:   adb pull /system/media/bootanimation.zip stock-bootanimation.zip adb pull /bootloader/bootlogo.bmp stock-bootlogo.bmp","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#step-10-make-the-boot-logo-and-animation-optional","tags":"","category":""},{"id":110,"title":"Animation","pageTitle":"Build the kiosk and set up a frame","text":"Android shows the animation while it starts:   php tools/make-bootanimation.php my-logo-1280x800.png bootanimation.zip adb push bootanimation.zip /data/local/tmp/ adb shell 'mount -o remount,rw /system cp /data/local/tmp/bootanimation.zip /system/media/bootanimation.zip chmod 644 /system/media/bootanimation.zip chown root:root /system/media/bootanimation.zip mount -o remount,ro /system rm /data/local/tmp/bootanimation.zip'    The script writes an uncompressed zip, which Android's player requires. It shows one image until the frame has booted.","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#animation","tags":"","category":""},{"id":111,"title":"Logo","pageTitle":"Build the kiosk and set up a frame","text":"The bootloader shows the logo before Android starts. The panel is 800x1280 and used rotated, so rotate the image first. With ImageMagick on your own computer:   convert my-logo-1280x800.png -resize 1280x800^ -gravity center -extent 1280x800 -rotate 90 -type TrueColor -alpha off BMP3:bootlogo.bmp    The result is an 800x1280, 24-bit BMP of 3,072,054 bytes. Copy it over  /bootloader/bootlogo.bmp  on the small FAT partition mounted at  /bootloader :   adb push bootlogo.bmp /data/local/tmp/ adb shell 'cp /data/local/tmp/bootlogo.bmp /bootloader/bootlogo.bmp; rm /data/local/tmp/bootlogo.bmp'    If  /bootloader  is read-only, remount it read-write first. This was not needed or checked. Do not leave a  System Volume Information  folder there if you ever mounted that partition on Windows: delete it. To go back, push your saved  stock-bootanimation.zip  and  stock-bootlogo.bmp  the same way.","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#logo","tags":"","category":""},{"id":112,"title":"Step 11: Connect to Wi-Fi and claim the frame","pageTitle":"Build the kiosk and set up a frame","text":"Reboot the frame:  adb reboot .   The kiosk opens its Wi-Fi picker on its own when the frame has no saved network and stays offline for 30 seconds. Select your network and enter the password.  Other network...  lets you type a hidden network's name.   When the frame reaches your server, it shows a six-digit  pairing code  (\"Read this code to ...\", using  OWNER_NAME ).   In the admin at  https://frames.example.com/frames/admin/ , sign in and select  Claim a frame . Enter the six-digit code, select  Find frame , enter a name for the frame, then select  Claim frame . You see \"Claimed. Turn on the widgets you want.\" The code is valid for 24 hours. See  Use the admin  for the details.   Open the frame in the admin and turn on the widgets you want under  Widgets . The frame loads its display within a minute.  Widgets  covers each one.   To give the frame its own PIN, open the frame, go to  Security , enter a  New PIN  and select  Set PIN . Without one, the frame uses the  KIOSK_PIN  from the build.  Reset to factory PIN  goes back to it.   To connect over the network from now on, find the frame's address in your router and run  adb connect 192.0.2.50:5555 .","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#step-11-connect-to-wi-fi-and-claim-the-frame","tags":"","category":""},{"id":113,"title":"Updates after you claim","pageTitle":"Build the kiosk and set up a frame","text":"When you claim a frame, the server points it at the newest release that has already run on a frame, and the frame moves itself to that release when its screen is quiet. If no release has run on a frame yet (a brand-new install), the frame stays on the build you installed. See  Update the kiosk .","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#updates-after-you-claim","tags":"","category":""},{"id":114,"title":"The maintenance panel","pageTitle":"Build the kiosk and set up a frame","text":"The kiosk has no on-screen exit by design. A hidden panel offers the few things you need on site.   Hold one finger in the top-left corner of the screen (the first 120x120 pixels) for 10 seconds. A PIN pad opens. The hold works while the screen is dark.   Enter the PIN. An info panel opens with the frame ID, app version, state, last server contact, network details and uptime.   The panel offers  Wi-Fi ,  Reload  and  Back , and nothing else.   After five wrong PINs in a row the pad locks for 5 minutes. Each further five wrong PINs lengthens the lock: 30 minutes, 2 hours, then 8 hours. A reboot during a lock restarts the lock from the beginning.","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#the-maintenance-panel","tags":"","category":""},{"id":115,"title":"What the kiosk does","pageTitle":"Build the kiosk and set up a frame","text":"For reference, so nothing surprises you:   It is the home app. It runs full screen, stays on, and stays landscape.   It shows \"Setting the clock...\" until the year is set, and a \"Can't reach ...\" screen with the network name, address and frame ID while offline.   It opens the Wi-Fi picker by itself, with no PIN, when it has no saved network and has been offline for 30 seconds after boot, or when saved networks have not connected for five minutes. It closes the picker again if the old network returns while nobody is touching it.   Dim  (the moon on the dashboard) turns the backlight fully off. The next tap wakes it and does not reach the page. The 10 second top-left hold works while dark.   It follows the frame's bedtime and wake schedule from the admin, in the frame's time zone, and saves it so it works offline.   It keeps the day's photos in its own storage, so they keep showing when the network or the photo host is down.","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#what-the-kiosk-does","tags":"","category":""},{"id":116,"title":"Undo device owner","pageTitle":"Build the kiosk and set up a frame","text":"dpm remove-active-admin  refuses to remove a device owner on Android 6, and there is no screen for it. The kiosk has a hidden receiver that only root can call:   adb shell am broadcast -n com.calevans.frame/.AdbReceiver -a com.calevans.frame.action.RELEASE_DEVICE_OWNER    The result line says  device owner released . After that,  adb shell pm uninstall com.calevans.frame  works. If the app is broken and cannot receive the broadcast, delete the policy files as root and reboot. This is untested. Restrictions the owner set may remain in  /data/system/users/0.xml , so check  adb shell dumpsys user  afterwards:   adb shell rm /data/system/device_owner.xml /data/system/device_policies.xml","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#undo-device-owner","tags":"","category":""},{"id":117,"title":"Give a frame away","pageTitle":"Build the kiosk and set up a frame","text":"The kiosk has your server's address built in. Handing over the hardware does not hand over independence. Choose what the new owner gets:   The frame stays on your server.  Claim it for them in your admin. They never need ADB.   The new owner runs their own server.  They need their own kiosk build, signed with their own keystore. Android will not replace your build with theirs, so first  undo device owner  and uninstall the kiosk, then they run this guide from  step 5 . USB ADB is always open for this.   Either way, before you hand it over:     In your admin, revoke the frame ( More , then  Revoke ) or delete it, so your server stops serving it.       Remove your saved Wi-Fi networks. This ends ADB over Wi-Fi, so USB ADB must still work:     adb shell am broadcast -n com.calevans.frame/.AdbReceiver -a com.calevans.frame.action.FORGET_WIFI      The result line says  removed N of N saved networks . Within about a minute the kiosk opens its Wi-Fi picker, which is what the new owner sees.       Power the frame off with  adb shell reboot -p .     If Wi-Fi is off, the broadcast says so. Run  adb shell svc wifi enable , wait a few seconds, and try again.","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#give-a-frame-away","tags":"","category":""},{"id":118,"title":"Related","pageTitle":"Build the kiosk and set up a frame","text":"Unlock your PF1007L   Install the frame server   Update the kiosk   Troubleshooting and recovery , including common ADB gotchas   Next:  Widgets .","url":"https://calevans.com/picture-frame/docs/provisioning/index.html#related","tags":"","category":""},{"id":119,"title":"Hardware and requirements","pageTitle":"Hardware and requirements","text":"This page is for anyone deciding whether to start. It says which frame this project supports, what the frame is, and everything you need to gather before you begin. When you finish reading, you know whether your frame qualifies and what to have on hand.","url":"https://calevans.com/picture-frame/docs/hardware/index.html","tags":"","category":""},{"id":120,"title":"Supported frame","pageTitle":"Hardware and requirements","text":"The project supports one frame model: the  PF1007L .         Property   Value           Model   PF1007L       Screen   10.1 inch, 1280 x 800 landscape. The panel itself is 800 x 1280 and used rotated.       Processor   Allwinner A33       Operating system   Android 6.0.1       Storage   eMMC. The tested unit is 15,758,000,128 bytes.       Power   USB-C. The same cable carries data to your PC.       Case   You never open it. You never solder.","url":"https://calevans.com/picture-frame/docs/hardware/index.html#supported-frame","tags":"","category":""},{"id":121,"title":"Firmware that was tested","pageTitle":"Hardware and requirements","text":"The unlock was verified on one firmware build only:   AEEZO/astar_m86_8723cs:6.0.1/MOB30R/20260113:eng/test-keys    That is a January 2026 build. Other builds may store files in different places. The unlock procedure builds its patch from a backup of your own frame, so it adapts to small differences. Nothing is verified on other builds. The tools stop with an error when they do not recognize what they find. If one stops, stop too and work out why before you go on. Do not guess. After the unlock, you can read your own build with  adb shell getprop ro.build.fingerprint . This is a standard Android property. It was not checked as part of writing this page.","url":"https://calevans.com/picture-frame/docs/hardware/index.html#firmware-that-was-tested","tags":"","category":""},{"id":122,"title":"Brand names","pageTitle":"Hardware and requirements","text":"The PF1007L is sold under more than one name. Names seen so far include  Humblestead  and  AiMOR . The stock home app on the frame is the AiMOR launcher ( com.efercro.os.aimor ), which the kiosk setup later disables. If you are unsure your frame is a PF1007L, the unlock guards against a mismatch. The scripts that write to the frame accept only a USB disk of the expected size, and the patch step refuses to write unless the sector on the frame is exactly the one it expects.","url":"https://calevans.com/picture-frame/docs/hardware/index.html#brand-names","tags":"","category":""},{"id":123,"title":"Where to buy","pageTitle":"Hardware and requirements","text":"The frames this project was built and tested on came from this Amazon listing, bought in October 2026:   Digital Picture Frame 10.1 inch, 32GB, 1280 x 800 IPS touchscreen, Wi-Fi, AiMOR app   This is a plain link: the project earns nothing from it. Listings change, and a seller can ship a different board or firmware under the same page, so check the frame when it arrives, before you start:   The label on the back says  PF1007L , and the FCC ID is  2AW9M-PF1007 .   The stock app is  AiMOR .   The screen is 1280 x 800 and the frame runs Android 6.   If any of these differ, stop: the unlock may not fit your frame. See  Firmware that was tested  and  Other frames .","url":"https://calevans.com/picture-frame/docs/hardware/index.html#where-to-buy","tags":"","category":""},{"id":124,"title":"What changes on the frame","pageTitle":"Hardware and requirements","text":"Understand these before you begin:   You give up vendor support.  The vendor's over-the-air updater is disabled and the vendor app is replaced. There is no going back to a warranty claim.   The frame becomes a kiosk.  It runs one app, full screen, with no status bar, no navigation bar and no on-screen exit.   Root ADB has no password.  After the unlock, anyone who can reach port 5555 on the frame controls it. The boot script can limit that to addresses you list. See  Provisioning, step 9 .   The frame talks to your server only.  It does not contact the manufacturer once the vendor updater is disabled.","url":"https://calevans.com/picture-frame/docs/hardware/index.html#what-changes-on-the-frame","tags":"","category":""},{"id":125,"title":"For the unlock","pageTitle":"Hardware and requirements","text":"You need   Used for           A PF1007L and its USB-C cable   The frame itself.       A Windows 10 or 11 PC with WSL2 (Ubuntu 22.04 or later)   The unlock scripts call  powershell.exe  and PowerShell disk commands, so they do not run on a plain Linux machine without changes.       usbipd-win  4 or later   Passing the frame's USB connection into WSL.       A microSD card, any size   Booting the frame into its USB recovery mode (FEL). Its contents survive. The guide changes only bytes 8 KiB to 16 KiB.       About 16 GB of free disk space   The verified backup of the frame's storage.       About 90 minutes of waiting   The backup alone takes that long.       A PowerShell and a WSL shell, both open   You switch between them.         These Linux packages are installed during the unlock:  sunxi-tools ,  adb ,  e2fsprogs ,  fdisk , and an ARM cross-compiler with other U-Boot build tools (listed in the guide).","url":"https://calevans.com/picture-frame/docs/hardware/index.html#for-the-unlock","tags":"","category":""},{"id":126,"title":"For the server","pageTitle":"Hardware and requirements","text":"You need   Used for           A Linux host with Apache 2.4   The server runs on any Linux host.       A host name with a valid HTTPS certificate   The kiosk refuses plain HTTP, and the admin refuses to run without an  https://  address.       PHP 8.5 with  pdo_sqlite ,  curl ,  mbstring ,  gd ,  dom ,  xmlreader ,  xmlwriter ,  zlib   The server code. See  Install the frame server  for optional extensions.       Composer   One library,  sabre/vobject , which reads calendars.       A cron user and the web server's user   Running the fetcher and the web app.       sudo  rights   Creating folders and running commands as the web user.       Optional:  bubblewrap ,  libheif-examples ,  libheif-plugin-libde265 ,  prlimit ,  nice   The local photo library. See  Photos .","url":"https://calevans.com/picture-frame/docs/hardware/index.html#for-the-server","tags":"","category":""},{"id":127,"title":"For the kiosk","pageTitle":"Hardware and requirements","text":"You need   Used for           Docker and  Lando   Building the kiosk app with the included Lando setup (or install the JDK and Android build tools yourself).       adb ,  openssl ,  unzip ,  php   Provisioning and release signing.       A WebView APK, a Let's Encrypt root certificate, and optionally a launcher   Files you download yourself. See  Provisioning, step 2 .","url":"https://calevans.com/picture-frame/docs/hardware/index.html#for-the-kiosk","tags":"","category":""},{"id":128,"title":"Other things worth having","pageTitle":"Hardware and requirements","text":"A DHCP reservation  for the frame in your router. The boot script gives the frame a fixed Wi-Fi MAC address so a reservation holds.   A Nextcloud or Home Assistant , if you want to use those widgets. Neither is required.   Somewhere private to keep two keys.  The kiosk signing keystore and the release key must be backed up and kept off the server. See  Update the kiosk .","url":"https://calevans.com/picture-frame/docs/hardware/index.html#other-things-worth-having","tags":"","category":""},{"id":129,"title":"Know before you start","pageTitle":"Hardware and requirements","text":"Weather is US-only.  It comes from the US National Weather Service.   You need a terminal.  You run shell and PowerShell commands, build an Android app, run a PHP site on Apache with a real HTTPS certificate, and add a cron job.   The backup is private.  It holds the vendor's device certificate. Keep  backups/  out of Git and never share it.   Never redistribute vendor firmware or dumps.  This repository ships none.","url":"https://calevans.com/picture-frame/docs/hardware/index.html#know-before-you-start","tags":"","category":""},{"id":130,"title":"Other frames","pageTitle":"Hardware and requirements","text":"This project is built for the PF1007L. The U-Boot patch is specific to this board, the kiosk is written for Android 6 on this screen, and the unlock was checked on one firmware build. None of it has been tested on other frames, and you should not expect it to work on them. If you have a different frame,  wesbos/photo-frame-dashboard  covers other models. It inspired this project. No code or text is copied from it. Next:  Unlock your PF1007L .","url":"https://calevans.com/picture-frame/docs/hardware/index.html#other-frames","tags":"","category":""},{"id":131,"title":"Photos","pageTitle":"Photos","text":"This page is for the person who decides which photos a frame shows. It covers all three ways to get photos onto a frame, how the frame picks each day's set, and how the server protects itself when it opens pictures that strangers send. When you finish, a frame is showing photos from the source you chose.","url":"https://calevans.com/picture-frame/docs/photos/index.html","tags":"","category":""},{"id":132,"title":"Choose a method","pageTitle":"Photos","text":"A frame's Photos widget has one source at a time. Pick one:           Method 1: Nextcloud album   Method 2: photo library   Method 3: family drop links           Where photos live   Your Nextcloud   Your frame server   Your frame server, in a library album       Who adds photos   Anyone who can add to the Nextcloud album   You, from your computer   Relatives, from their phones, with your approval       Extra server software   None   GD, and ideally a sandbox. HEIC support needs libheif.   The same as method 2       Needs the fetcher   Yes   No   No       Setup effort   A few minutes   An album and a decoder check   Method 2, plus a link per person         Methods 2 and 3 share the library. Drop links add photos to a library album, so you need method 2 first. You can use methods 1 and 2 on different frames. You can also switch a frame between them. The frame keeps the Nextcloud link while it points at the library, and finds it again when you switch back.","url":"https://calevans.com/picture-frame/docs/photos/index.html#choose-a-method","tags":"","category":""},{"id":133,"title":"How a frame picks its photos","pageTitle":"Photos","text":"The frame shows a  daily set . Understanding the rules explains why a new photo may not show up straight away.   50 photos a day.  If the album has 50 photos or fewer, the frame shows all of them every day.   The newest first.  Up to 25 of the set are the newest photos added in the last 30 days.   No repeats until the album is used up.  The rest are chosen at random from photos this frame has not shown in the current pass through the album. When none are left, a new pass begins.   Approved photos only.  Photos waiting for your approval never reach a frame.   Once a day.  The set is fixed until the next pick time. Photos you add after the pick can appear in the next day's set.   Pick time.  Each frame gets its own time between 04:00 and 06:00 in its time zone, chosen so no two frames pick at the same moment. You can change it with  New photos daily at . If another frame already uses that exact minute, the admin says \"Another frame uses this exact time. Move it a minute earlier or later.\"   Kept on the frame.  The frame downloads each photo (up to 4 MB each) and stores it. They keep showing when the network or the photo host is down. The frame skips downloads while it has less than 200 MB free.   Where they show.  On the screensaver, which starts after the idle time you set under  Schedule , and on the  Pictures  page.   In the admin, the frame's  Photos  section shows the day's picks as a contact sheet:  New today  and  Carried over , with the time of the next pick. Setting a different source or album for a frame starts a new pass and a new pick.","url":"https://calevans.com/picture-frame/docs/photos/index.html#how-a-frame-picks-its-photos","tags":"","category":""},{"id":134,"title":"Method 1: A Nextcloud public album","pageTitle":"Photos","text":"The Photos widget shows a public Nextcloud Photos album. The fetcher on your server keeps a list of the album's photos. The frame loads the images from Nextcloud itself.","url":"https://calevans.com/picture-frame/docs/photos/index.html#method-1-a-nextcloud-public-album","tags":"","category":""},{"id":135,"title":"What you need","pageTitle":"Photos","text":"A Nextcloud with the Photos app.   The Nextcloud host in  photo_hosts  in  config.php , for example  'cloud.example.org' . See  Install the frame server .   The fetcher cron job running. See  Install the frame server .   A Nextcloud that the frame can reach. The frame loads previews straight from the Nextcloud host, so it must be reachable from the frame's network.","url":"https://calevans.com/picture-frame/docs/photos/index.html#what-you-need","tags":"","category":""},{"id":136,"title":"Set it up","pageTitle":"Photos","text":"In Nextcloud, share the album with a  public link  and copy the link. It looks like  https://cloud.example.org/apps/photos/public/<token> . A link with  /index.php/apps/photos/public/<token>  also works.   In the admin, open the frame, then  Widgets , switch  Photos  on, and select  Save .   Open the  Photos  section.   Under  Photos come from , choose  Nextcloud link .   Paste the link into  Album link .   Check  New photos daily at , and  Show each photo for .   Select  Save .   Under  Album link  the admin shows a status: the number of photos once the fetcher has run,  Not fetched yet  until then, or an error message. The link is stored on the server and never shown again. The field shows \"A link is saved. Paste a new one to replace it.\" Use  Remove link  to delete it, or  Keep link  to cancel the removal.","url":"https://calevans.com/picture-frame/docs/photos/index.html#set-it-up","tags":"","category":""},{"id":137,"title":"What it uses","pageTitle":"Photos","text":"The fetcher lists the album over WebDAV and refreshes the list about every 15 minutes.   It keeps up to 2000 photos from an album.   It sees JPEG, PNG, and HEIC or HEIF files. Other file types in the album are ignored.   The frame loads 1280x800 previews from Nextcloud.   The photos' \"newest\" order uses when the server first saw each photo in the album.","url":"https://calevans.com/picture-frame/docs/photos/index.html#what-it-uses","tags":"","category":""},{"id":138,"title":"Method 2: The photo library","pageTitle":"Photos","text":"The photo library keeps photos on your server instead of on Nextcloud. You make  albums , add photos from your computer, and point a frame at an album. The server keeps only frame-sized copies. Each photo is shrunk to fit 1280x800 (never enlarged), its metadata (location, camera, date) is removed, and a 320x200 thumbnail is made.  The original is not kept. You can skip the library if you use Nextcloud only. It is off until its host requirements are met, and nothing else on the server depends on it.","url":"https://calevans.com/picture-frame/docs/photos/index.html#method-2-the-photo-library","tags":"","category":""},{"id":139,"title":"What the server needs","pageTitle":"Photos","text":"Image decoding is the risky part of any upload feature. The server decodes in a separate, limited process, and refuses anything but JPEG, PNG, WebP and HEIC. It needs:     PHP's  GD  extension for the command-line PHP, with JPEG, PNG and WebP support (package  php8.5-gd ; a plain  php-gd  can pull in another PHP version's GD). Check:     php -n -d extension=gd -r 'print_r(gd_info());'      It must show  JPEG Support ,  PNG Support  and  WebP Support  as  1 .       For iPhone (HEIC) photos: libheif's command-line converter and its HEVC decoder:     sudo apt install libheif-examples libheif-plugin-libde265 heif-dec --version || heif-convert --version      heif-convert , the older name of the same tool, also works. Ubuntu 24.04 (libheif 1.17) ships only  heif-convert . Without them everything else still works and HEIC uploads are refused.       prlimit  (package  util-linux ) and  nice  (package  coreutils ). Both are on most Linux servers.       A PHP command-line binary. The worker runs  php , not the web server's PHP. The server looks for the one running this command, then  /usr/bin/php8.5 ,  /usr/bin/php ,  /usr/local/bin/php . If yours is elsewhere, set  'php_binary' . See  Limits and config keys .       A sandbox, unless you choose to run without one. See  Sandbox modes .       Minimum versions of the libraries that read untrusted images. Older ones have known memory-safety bugs a hostile image can reach. Debian and Ubuntu fix them under the old version number, so a lower number can still be patched. Keep security updates on.           Library   Minimum           libheif   1.17.6 (Ubuntu 24.04 ships it in main)       libde265   A version with the current security fixes. Keep updates on.       libwebp   1.3.2       libjpeg, libpng (inside PHP's GD)   Current distribution security updates.               If  php_binary  or  heif_dec  points outside  /usr , its installation prefix (the directory two levels up, for example  /opt/php  for  /opt/php/bin/php ) is bound read-only into the bubblewrap sandbox. That prefix must not contain secrets.     bin/migrate.php  checks all of this. See  Troubleshooting .","url":"https://calevans.com/picture-frame/docs/photos/index.html#what-the-server-needs","tags":"","category":""},{"id":140,"title":"Why no ImageMagick","pageTitle":"Photos","text":"The library does not use ImageMagick or  php-imagick . On Ubuntu 24.04, ImageMagick and  php-imagick  sit in the  universe  repository, and  universe  gets security updates only with Ubuntu Pro. The decoders the library uses instead ( libheif ,  libde265 ,  libwebp , GD) come from  main , which gets free security updates. Check where a package lives with  apt-cache policy libheif-examples . The line for your release says  main  or  universe . If nothing else on the host needs ImageMagick,  sudo apt remove --simulate php8.5-imagick  shows what you could remove.","url":"https://calevans.com/picture-frame/docs/photos/index.html#why-no-imagemagick","tags":"","category":""},{"id":141,"title":"How the decoder works","pageTitle":"Photos","text":"Every upload goes through the same pipeline, from the admin or from a drop link:   The server decides the file type from the first bytes, not from the file name. SVG, AVIF and similar are refused.   JPEG, PNG and WebP are decoded by PHP's GD in a separate process with no network. With a sandbox on, it sees only system libraries and its scratch folder. It runs under  prlimit  (1 GiB of address space, 60 CPU seconds, 32 open files),  nice , and a wall-clock kill after 45 seconds.   Image sizes are read from the file headers first. Anything over 12,000 pixels on a side or 64 megapixels is refused before decoding.   HEIC takes three steps in the same sandbox: a header check,  heif-dec  converting to a PNG in the scratch folder (it applies the photo's rotation itself), and GD reading that PNG.   Each decoder step is killed after 45 seconds. A whole HEIC upload (header check, conversion and resize) shares one 60-second deadline.   The photo's EXIF orientation is applied to JPEGs.   Photos are always saved as plain sRGB baseline JPEGs with no metadata. Color profiles are  not  converted. A Display P3 photo (iPhone) is shown as sRGB and looks slightly less saturated.   CMYK JPEGs are refused ( unsupported_color : export as sRGB), and so are animated WebPs ( unsupported_animation ). Cut-off JPEG files are refused.","url":"https://calevans.com/picture-frame/docs/photos/index.html#how-the-decoder-works","tags":"","category":""},{"id":142,"title":"Sandbox modes","pageTitle":"Photos","text":"The decoder runs a program on files that strangers can send. The sandbox limits the damage if that program has a bug. You choose the mode with the  sandbox  option under  widgets  >  photos  in  config.php . There are four modes.         Mode   Setting   Use it when           bubblewrap   'sandbox' => 'bwrap'  (the default)   bwrap  works on your server. It is the simplest strong option.       Dedicated user   'sandbox' => 'user'   bwrap  does not work, for example on Ubuntu 24.04. Recommended there.       Automatic   'sandbox' => 'auto'   You want  bwrap  when it works and no sandbox otherwise. The migration prints a  NOTE  when it falls back. Not for drop links.       None   'sandbox' => 'none'   You are the only one who uploads, and you accept the risk. Not for drop links.         With a mode that is required ( bwrap  or  user ) and not working, uploads are refused with  sandbox_unavailable . They are never run unprotected. Frames keep showing photos already stored.","url":"https://calevans.com/picture-frame/docs/photos/index.html#sandbox-modes","tags":"","category":""},{"id":143,"title":"Sandbox mode bwrap: bubblewrap","pageTitle":"Photos","text":"bwrap  is the default. Install it:   sudo apt install bubblewrap    The image worker then runs in its own namespaces with no network. It sees only system libraries, its own code and one scratch folder. It cannot see  config.php ,  frames.db  or sessions.","url":"https://calevans.com/picture-frame/docs/photos/index.html#sandbox-mode-bwrap-bubblewrap","tags":"","category":""},{"id":144,"title":"When bwrap works","pageTitle":"Photos","text":"It works where the kernel lets unprivileged programs create user namespaces. Most Debian and Ubuntu 22.04 servers allow it.","url":"https://calevans.com/picture-frame/docs/photos/index.html#when-bwrap-works","tags":"","category":""},{"id":145,"title":"When Ubuntu 24.04 blocks it","pageTitle":"Photos","text":"Ubuntu 24.04 turns unprivileged user namespaces off for programs without an AppArmor profile ( kernel.apparmor_restrict_unprivileged_userns=1 ).  bwrap  then fails with  setting up uid map: Permission denied . To tell whether this is your problem:     Run the migration and read the output:     sudo -u www-data php /srv/frame/app/bin/migrate.php      A line like  WARNING (photos): photo uploads are off: the bubblewrap sandbox is required ... but cannot run here  means  bwrap  is not usable.       Check the kernel setting:     sysctl kernel.apparmor_restrict_unprivileged_userns      = 1  means the restriction is on. The setting does not exist on older kernels.       Look in the PHP error log for  frame photos: worker failed . The line includes the first 300 characters of the error, which would hold  uid map .     Instead of loosening the system for every program, use the dedicated-user mode on Ubuntu 24.04. The result of the  bwrap  self-test is remembered for 10 minutes if it fails and an hour if it works. After you fix something, delete  /srv/frame/var/photos/.sandbox-state  and  .probe-ok  to check again at once.","url":"https://calevans.com/picture-frame/docs/photos/index.html#when-ubuntu-2404-blocks-it","tags":"","category":""},{"id":146,"title":"Sandbox mode user: a dedicated decoder account","pageTitle":"Photos","text":"Instead of namespaces, run the decoder as a separate, powerless Unix user. Set  'sandbox' => 'user'  under  widgets  >  photos  in  config.php , and do the one-time setup below as root ( sudo -i ). What the setup creates:    A system user  framephoto  (no login shell, no home,  not  in the web user's group) and a group  photowork  that the web user joins. The group is used for one thing only: the per-upload work folder (mode 2770) and the uploaded file (0640) under  var/photos/tmp/ .    A root-owned script,  /usr/local/bin/frame-photo-run  (copied from  app/widgets/photos/bin/ ), which is the only thing the web user may run as  framephoto . It accepts nothing but a time limit (1 to 45 seconds) and paths inside one per-upload folder (32 hex characters under  var/photos/tmp/ ). It runs the PHP worker or the HEIC converter with fixed programs, under  prlimit  and  timeout -k 2 -s KILL . One call runs at a time (a lock file,  /etc/frame-photo-run.lock ). When it ends, or is stopped, it kills  every  process still owned by  framephoto , so a hijacked decoder cannot leave a daemon behind. Where PHP, the worker and  var/  are comes from  /etc/frame-photo-run.conf  (root-owned  KEY=/path  lines; parsed, never run as shell).   A  sudoers  rule with no wildcards:  www-data ALL=(framephoto) NOPASSWD: /usr/local/bin/frame-photo-run .   ACLs:  framephoto  may walk through  var/  and  var/photos/  (execute only: it cannot list or read them), and the  photowork  group may walk through  var/photos/tmp/ . It may read  only  the photo widget's own files in the app ( app/widgets/photos/ ), through an ACL (plus a default ACL for files a later deploy adds), so your deploy permissions stay unchanged. It cannot read  config.php ,  frames.db , sessions, stored photos or other uploads in progress.   A firewall rule that rejects every connection made by  framephoto  (IPv4 and IPv6, loopback included), kept across reboots. The script  never installs or removes a firewall manager . With  ufw  active, the rule goes into ufw's own  before.rules  and  before6.rules  (before its loopback accept) and ufw is reloaded. If  netfilter-persistent  is already installed, it saves the  iptables  rule. Otherwise a small systemd unit ( frame-photo-firewall.service ) inserts it at boot. The server tests this: its self-test makes the decoder user try to connect to a port the web side listens on, and anything but a refusal turns uploads off.   If any part is missing, uploads are refused ( sandbox_unavailable ) rather than run unprotected, and  bin/migrate.php  says what failed. Change the paths in the first lines of the script if yours differ. You can run the script again safely.   sh   #!/bin/sh   # Run as root (sudo -i). Safe to run again. Paths are the only thing to adjust.  set -eu APP=/srv/frame/app VAR=/srv/frame/var PHP=/usr/bin/php8. 5  HEIF=/usr/bin/heif-dec  # /usr/bin/heif-convert on Ubuntu 24.04 (libheif 1.17 has no heif-dec)  WEB=www-data  # 1. packages: sudo, ACL tools, util-linux (flock, runuser), iptables, and the HEIC converter. This never installs or removes a firewall manager.   export  DEBIAN_FRONTEND=noninteractive apt-get install -y  sudo  acl util-linux iptables libheif-examples libheif-plugin-libde265  # 2. the decoder user and the shared group (the web user joins the group; the decoder user is not in the web user's group)  getent group photowork >/dev/null || groupadd --system photowork id framephoto >/dev/null  2 >& 1  || useradd --system --no-create-home --home-dir /nonexistent --shell /usr/sbin/nologin --gid photowork framephoto id -nG  \"$WEB\"  | tr  ' '   '\\n'  | grep -qx photowork || usermod -aG photowork  \"$WEB\"   # 3. the wrapper (root-owned, so the web user cannot change what runs as framephoto), its config and its lock file  install -o root -g root -m  0755   \"$APP/widgets/photos/bin/frame-photo-run\"  /usr/local/bin/frame-photo-run cat > /etc/frame-photo-run.conf <<CONF VAR_DIR=$VAR APP_DIR=$APP PHP=$PHP HEIF_DEC=$HEIF CONF chown root:root /etc/frame-photo-run.conf chmod  0644  /etc/frame-photo-run.conf [ -e /etc/frame-photo-run.lock ] || : > /etc/frame-photo-run.lock chown root:root /etc/frame-photo-run.lock chmod  0644  /etc/frame-photo-run.lock  # 4. sudoers: exactly one command, nothing else (checked with visudo before it is installed)  tmp=$(mktemp) cat >  \"$tmp\"  <<SUDO Defaults!/usr/local/bin/frame-photo-run env_reset $WEB ALL=(framephoto) NOPASSWD: /usr/local/bin/frame-photo-run SUDO visudo -cf  \"$tmp\"  install -o root -g root -m  0440   \"$tmp\"  /etc/sudoers.d/frame-photo rm -f  \"$tmp\"   # 5. directories. framephoto may walk through var/ and var/photos (execute only: it cannot list or read them) and sees nothing but the   # per-run directories it is handed inside var/photos/tmp. It may read ONLY the photo widget's own files in the app (not the rest of   # the app, not config.php): an ACL, so the deploy permissions (owner:www-data, 0640) stay as they are. The default ACL gives files   # that a later deploy creat","url":"https://calevans.com/picture-frame/docs/photos/index.html#sandbox-mode-user-a-dedicated-decoder-account","tags":"","category":""},{"id":147,"title":"Sandbox mode auto","pageTitle":"Photos","text":"'sandbox' => 'auto'  uses  bwrap  when it works and falls back to no sandbox when it does not. The migration prints a  NOTE  when it falls back. The admin album page also says \"Image processing runs without the extra sandbox.\" The same advice as  none  applies when it falls back.","url":"https://calevans.com/picture-frame/docs/photos/index.html#sandbox-mode-auto","tags":"","category":""},{"id":148,"title":"Sandbox mode none","pageTitle":"Photos","text":"'sandbox' => 'none'  is an explicit opt-out. The worker runs without a sandbox. The migration prints:   NOTE (photos): widgets.photos.sandbox is 'none': the image worker runs without the bubblewrap sandbox (resource limits still apply)    What stays: the  prlimit  limits,  nice , the 45-second kill, the header checks and size caps, PHP's  open_basedir , and a PHP configuration for the worker that turns off  exec ,  proc_open , network functions and other dangerous functions. What goes: the wall around the process. If a hostile image exploited a bug in GD or libheif, the exploit would run as the web user, which can read  config.php  and  frames.db . Use  none  only when you alone upload photos, from your own computer, and you do not use drop links. Do not use it when strangers or relatives can send photos.","url":"https://calevans.com/picture-frame/docs/photos/index.html#sandbox-mode-none","tags":"","category":""},{"id":149,"title":"Limits and config keys","pageTitle":"Photos","text":"All keys go under  widgets  >  photos  in  config.php . The integer limits are all positive whole numbers.         Key   Default   What it limits           library_bytes   5 GiB   All stored photos together.       album_photos   5000   Photos in one album, including those waiting for approval.       upload_bytes   30 MiB   One photo. Keep the  .htaccess   LimitRequestBody  (31457280) in step.       pending_album   500   Photos waiting for approval, per album.       pending_bytes   512 MiB   Photos waiting for approval, all albums together.       min_free_bytes   1 GiB   Uploads are refused when the disk has less free. The decoder may also spill 1.2 GiB while it works.       drop_per_hour   100   Photos one drop link can send per hour.       drop_per_day   300   Photos one drop link can send per day.       drop_ip_per_day   500   Photos one visitor address can send per day, across links.       php_binary   Found automatically   The only PHP used for the worker. Must be named  php ,  php8  or  php8.5 . A configured path is the only one used, with no fallback.       heif_dec   Found automatically   The only HEIC converter. Must be named  heif-dec  or  heif-convert .       sandbox   'bwrap'   'bwrap' ,  'user' ,  'auto'  or  'none' . See  Sandbox modes .         An unknown key stops the server with  widgets.photos: unknown option . A value that is not a positive whole number stops it with  widgets.photos.<key> must be a positive integer .   php   'widgets'  => [  'photos'  => [  'library_bytes'  =>  5  *  1024  **  3 ,  'album_photos'  =>  5000 ,  'upload_bytes'  =>  30  *  1024  *  1024 ,  'pending_album'  =>  500 ,  'pending_bytes'  =>  512  *  1024  *  1024 ,  'min_free_bytes'  =>  1024  **  3 ,  'drop_per_hour'  =>  100 ,  'drop_per_day'  =>  300 ,  'drop_ip_per_day'  =>  500 ,  // 'php_binary' => '/usr/bin/php8.5',   // 'sandbox' => 'bwrap',   // 'heif_dec' => '/usr/bin/heif-dec',  ], ],    If you raise  upload_bytes , raise  LimitRequestBody  in both  .htaccess  exceptions and  post_max_size  to match. See  Install the frame server .","url":"https://calevans.com/picture-frame/docs/photos/index.html#limits-and-config-keys","tags":"","category":""},{"id":150,"title":"Where photos are stored","pageTitle":"Photos","text":"var/photos/ files/ab/abcdef….jpg frame-sized photo, named by its content hash files/ab/abcdef….thumb.jpg thumbnail tmp/ work area for uploads; leftovers older than an hour are removed during uploads      Photos are named by the hash of their bytes. The same picture in two albums is stored once.   var/photos/  must be on the same server and  not  under the web root. The app refuses to start the library if  var_dir  is inside the document root.   Photos are served only through the app, after a check that the requester is the right frame or a signed-in admin.   Back it up together with  var/frames.db . Without the files the albums show blank photos. See  Back up the server .","url":"https://calevans.com/picture-frame/docs/photos/index.html#where-photos-are-stored","tags":"","category":""},{"id":151,"title":"Make an album and add photos","pageTitle":"Photos","text":"In the admin, select  Photos  in the top bar.   Select  New album . Enter a  Name , up to 60 characters, such as \"Family\". Select  Create album .   The album opens. It has these sections:  Waiting for approval  (only when something waits),  Add photos ,  Photos , and  Drop links . At the top are  All albums ,  Rename  and  Delete album .","url":"https://calevans.com/picture-frame/docs/photos/index.html#make-an-album-and-add-photos","tags":"","category":""},{"id":152,"title":"Add photos in bulk","pageTitle":"Photos","text":"Add photos  takes files, folders, and ZIP files. Photos you add yourself are approved at once.   Drag photos, folders or ZIP files onto the box, or select  Choose photos or ZIPs  or  Choose a folder .   Look over the list. Remove anything with the  x  button.   Select  Upload  (it shows the count, for example  Upload 40 photos ).   While it runs, each row shows progress.  Cancel  stops after the current photo.  Retry failed  retries photos that failed in a way that may pass on a second try.  Clear list  empties the list. The summary line reads \"12 added, 3 duplicates, 0 failed\". Details:   Accepted: JPEG, PNG, WebP and HEIC, up to 30 MiB each (the  upload_bytes  limit).   Photos upload one at a time, in order.   The list holds up to 10,000 photos. Hidden files and folders (names starting with a dot, and  __MACOSX ) are skipped, and so is anything that is not a photo.   Folders  are read in full, including sub-folders, down to 25 levels.   ZIP files are opened in your browser.  The browser unpacks one photo at a time as its turn to upload comes, so memory use stays at one photo.   ZIP limits, set in the browser:         Limit   Value           Files in one ZIP   5000       Photos inside one ZIP, all together   2 GB       Size one file may unpack to, compared with its packed size   At most 200 times larger       ZIP64 archives   Not supported. Unzip on your computer and add the folder.       Archives split across several parts   Not supported       Password-protected files   Skipped       Compressed ZIPs   Need a browser with  DecompressionStream : Chrome, Edge, Firefox, or Safari 16.4 or newer         If a photo upload stops with a message, the rows that were not tried stay in the list.","url":"https://calevans.com/picture-frame/docs/photos/index.html#add-photos-in-bulk","tags":"","category":""},{"id":153,"title":"Duplicates","pageTitle":"Photos","text":"A photo already in the same album is recognized and skipped: the row says \"Already in this album\". The server compares the file's bytes and also the processed picture, so a re-saved copy of the same photo is caught too. The same photo can exist in two different albums.","url":"https://calevans.com/picture-frame/docs/photos/index.html#duplicates","tags":"","category":""},{"id":154,"title":"What the rows can say","pageTitle":"Photos","text":"Row says   What it means and what to do           Too large (max 30 MB)   The file is bigger than  upload_bytes . Shrink it, or raise the limit.       Not a JPEG, PNG, WebP or HEIC photo   The file type is not accepted.       CMYK images aren't supported - save as RGB JPEG   Export the photo as sRGB.       Animated images aren't supported   Use a still image.       iPhone HEIC photos aren't supported on this server yet   heif-dec  is missing or not working. See  Troubleshooting .       Photo processing is unavailable on the server   The decoder or sandbox check failed. Uploads stop. See  Troubleshooting .       This album is full   The album reached  album_photos . Uploads stop.       The server is out of space   The library reached  library_bytes , or the disk has less than  min_free_bytes  free. Uploads stop.       The server is busy. Try again in a moment.   Another upload holds the lock. The admin retries three times on its own.       Can't reach the server   Check your connection. Uploads stop.","url":"https://calevans.com/picture-frame/docs/photos/index.html#what-the-rows-can-say","tags":"","category":""},{"id":155,"title":"Review and delete","pageTitle":"Photos","text":"The  Photos  grid shows thumbnails, 48 at a time.  Show more  loads the next page. Select a photo to see it larger, with its size, when it was added and who added it. To delete photos:   Tick the photos, or  Select all shown .   Select  Delete .   Confirm. It asks for your password. The admin sends large selections in batches of 500.   Deleting cannot be undone. Frames that are showing a deleted photo drop it within a few minutes. To delete an album, select  Delete album  and confirm. This removes the album, all its photos, and its drop links. The admin refuses while a frame still uses the album: point that frame at something else first.","url":"https://calevans.com/picture-frame/docs/photos/index.html#review-and-delete","tags":"","category":""},{"id":156,"title":"Point a frame at the album","pageTitle":"Photos","text":"Open the frame, then  Widgets , switch  Photos  on, and select  Save .   Open the  Photos  section.   Under  Photos come from , choose  Photo library .   Choose the  Album . The list shows the photo count.   Check  New photos daily at , and  Show each photo for .   Select  Save .","url":"https://calevans.com/picture-frame/docs/photos/index.html#point-a-frame-at-the-album","tags":"","category":""},{"id":157,"title":"Method 3: Family drop links","pageTitle":"Photos","text":"A drop link lets a relative add photos to one album from their phone, with no account. Their photos wait for your approval. They cannot see anything in the album. You need method 2 working first: the library, an album, and a sandbox you trust.","url":"https://calevans.com/picture-frame/docs/photos/index.html#method-3-family-drop-links","tags":"","category":""},{"id":158,"title":"Create a link","pageTitle":"Photos","text":"In the admin, select  Photos , then open the album.   In  Drop links , select  New drop link .   Enter a  Name , up to 40 characters, for the person it is for, such as \"Aunt Sue\". The admin shows it in your list, and the page they open greets them with it (\"Hi, Aunt Sue.\"), so use a name they would recognize.   Select  Create link .   In the box that opens, select  Copy link  and send it to that person.   The link looks like  https://frames.example.com/frames/drop/<43 characters> .   Warning     The link is shown  once . The server keeps only a hash. If you lose it, make a new link.     Treat links as secrets. They also appear in your web server's access log. Rotate or delete logs on a short schedule.","url":"https://calevans.com/picture-frame/docs/photos/index.html#create-a-link","tags":"","category":""},{"id":159,"title":"What relatives see","pageTitle":"Photos","text":"The link opens a page titled \"Send photos to the frame\" with their name (\"Hi, Aunt Sue.\"). It says: \"Your photos will appear on the frame after they're approved.\" They tap  Choose photos , pick photos from their phone, and the page sends them one by one. It shows how many more photos they can send right now. It gives them nothing else: no album, no other photos, no gallery.  If the link has expired, been revoked, or been replaced, the page says \"This link isn't working anymore. Ask   for a new one.\" Your name is  owner_name  from  config.php .","url":"https://calevans.com/picture-frame/docs/photos/index.html#what-relatives-see","tags":"","category":""},{"id":160,"title":"Approve what arrives","pageTitle":"Photos","text":"Photos from a link wait in  Waiting for approval , at the top of the album. Nothing waiting ever reaches a frame. In the album list, a  Waiting  column shows \"N to approve\".   Open the album.   In  Waiting for approval , tick the photos. Each shows who sent it.   Select  Approve  to add them to the album, or  Reject  to delete them.","url":"https://calevans.com/picture-frame/docs/photos/index.html#approve-what-arrives","tags":"","category":""},{"id":161,"title":"Manage links","pageTitle":"Photos","text":"The  Drop links  table shows each link's name, status ( Active ,  Revoked  or  Expired ), expiry, when it was last used, and how many photos it sent (with how many are waiting).         Action   What it does           Rename   Changes the name.       Extend   Adds 90 days to the expiry, up to one year from today.       New link   Replaces the link. The old address stops working at once. Shows the new one once. Asks for your password.       Revoke   Turns the link off at once. Photos already sent stay in the album. Asks for your password.       Restore   Turns a revoked link back on. The same address works again. Asks for your password.       Delete their photos   Deletes every photo that link sent, waiting and approved. Cannot be undone. Asks for your password.       Delete link   Removes the link. Photos it sent stay in the album. Asks for your password.         Links expire after 90 days. A link that expires shows  Expired  and stops working. Use  Extend  to renew it.","url":"https://calevans.com/picture-frame/docs/photos/index.html#manage-links","tags":"","category":""},{"id":162,"title":"Quotas","pageTitle":"Photos","text":"Limit   Default   If a relative hits it           Photos per link per hour   100 ( drop_per_hour )   The page waits and retries, then says \"You've sent a lot of photos. Please wait a while, then tap Try again.\"       Photos per link per day   300 ( drop_per_day )   The same.       Photos per visitor address per day   500 ( drop_ip_per_day )   The same.       Photos waiting in one album   500 ( pending_album )   \"The frame can't take photos right now. Please try again later.\"       Waiting photos, all albums   512 MiB ( pending_bytes )   The same.       Size of one photo   30 MiB ( upload_bytes )   \"Too big (the limit is 30 MB).\"         Clear a full pending queue by approving or rejecting photos.","url":"https://calevans.com/picture-frame/docs/photos/index.html#quotas","tags":"","category":""},{"id":163,"title":"Logs","pageTitle":"Photos","text":"The application logs one  frame photos: ingest ...  line per upload: album, size, type and result. It never writes a link. The only IP address it writes is the rate-limit key.","url":"https://calevans.com/picture-frame/docs/photos/index.html#logs","tags":"","category":""},{"id":164,"title":"Troubleshooting","pageTitle":"Photos","text":"bin/migrate.php  checks the photo library and prints a  WARNING (photos)  line for each problem and a  NOTE (photos)  line for information. They never stop the migration. Run it as the web user:   sudo -u www-data php /srv/frame/app/bin/migrate.php    The migration runs the same self-test the admin uses. It decodes small test photos through the real worker, including a HEIC through  heif-dec . While the self-test fails, the admin shows a banner in the album, \"Photo uploads are off. The server's image decoder isn't working, so new photos can't be added.\", and turns uploads off. Frames keep showing photos already stored.         Line   What it means   What to do           photo uploads are off: prlimit and nice (util-linux and coreutils) must be installed   One of the two programs is missing.   Install  util-linux  and  coreutils .       photo uploads are off: widgets.photos.php_binary is '...', which is not an executable file named php, php8 or php8.5 (there is no fallback)   The configured PHP path is wrong.   Fix  php_binary , or remove it to let the server find PHP.       photo uploads are off: no PHP command line binary found for the image worker (set 'php_binary' ...)   The server could not find a command-line PHP.   Install the CLI package, or set  php_binary .       photo uploads are off: the image worker cannot run PHP GD with JPEG, PNG and WebP support ...   GD is missing from the command-line PHP, or lacks a format.   Install  php8.5-gd  for the CLI. Check with the  gd_info  command in  What the server needs .       photo uploads are off: widgets.photos.sandbox is 'user' but the framephoto setup does not work ...   Something in the  dedicated user  setup is missing or broken.   Run the checks in that section. Re-run the setup script. Read  frame photos: framephoto setup does not work  in the PHP error log.       photo uploads are off: the bubblewrap sandbox is required ... but cannot run here   bwrap  is missing or blocked.   Install  bubblewrap . On Ubuntu 24.04, use  'sandbox' => 'user' . See  When Ubuntu 24.04 blocks it .       NOTE (photos): widgets.photos.sandbox is 'none' ...   You chose no sandbox.   Nothing, if you meant it.       NOTE (photos): the image worker runs without the bubblewrap sandbox (bwrap is missing or cannot create namespaces here) ...   'sandbox' => 'auto'  fell back.   Install  bubblewrap , switch to  user , or set  'none'  to silence it.       HEIC (iPhone) photos are refused: heif-dec is not installed (apt install libheif-examples libheif-plugin-libde265)   No HEIC converter. Everything else still works.   Install the two packages.       HEIC (iPhone) photos are refused: heif-dec ran but could not decode the self-test photo ...   heif-dec  exists but failed.   Install  libheif-plugin-libde265 . Check that  heif-dec  works inside the sandbox.       libheif X is below the security floor ...   The libheif library is older than 1.17.6.   Update it. A distribution may fix it under the old number, so check your security updates.       libwebp library X is below the security floor ...   libwebp is older than 1.3.2 (shared object 7.1.8).   Update it.       WARNING (photos): the host check failed: ...   The check itself crashed.   Read the message and the PHP error log.         The checks are cached. A failed sandbox check is remembered for 10 minutes and a passed one for an hour. The admin's upload check is remembered for an hour. After a fix, delete  var/photos/.sandbox-state ,  var/photos/.sandbox-state-user  and  var/photos/.probe-ok  to test again now. Other lines in the PHP error log that help:   frame photos: worker failed exit=... stderr=... . The worker crashed, was killed, or printed something unexpected.   frame photos: image self-test failed code=... .   frame photos: heif-dec failed ... .   frame photos: ingest ... result=... . One line per upload.","url":"https://calevans.com/picture-frame/docs/photos/index.html#troubleshooting","tags":"","category":""},{"id":165,"title":"Related","pageTitle":"Photos","text":"Widgets   Install the frame server   Use the admin   Security   Troubleshooting and recovery   Next:  Update the kiosk .","url":"https://calevans.com/picture-frame/docs/photos/index.html#related","tags":"","category":""},{"id":166,"title":"Troubleshooting and recovery","pageTitle":"Troubleshooting and recovery","text":"Use this page when something is not working. Each entry starts with what you see, says why it happens, and then lists what to do. The most common problems come first in each group. Find your symptom:   Server and admin   Uploads   Photos   Calendar, weather, Home Assistant and Cards   The frame   ADB   Recovery   Gotchas   Most problems leave a line in the PHP error log. Look there first. On many servers that is the Apache error log. Lines that start  frame: ,  frame admin:  or  frame photos:  come from this system.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html","tags":"","category":""},{"id":167,"title":"Every request answers 503 maintenance","pageTitle":"Troubleshooting and recovery","text":"You see:  The admin or a frame gets  {\"error\":\"maintenance\",\"code\":\"maintenance\"}  with HTTP 503 and a  Retry-After: 30  header. The log has  frame: frames.db is at schema N, code expects M . Why:  You deployed new code, and the database has not been brought up to date. What to do:     Run the migration as the web user:     sudo -u www-data php /srv/frame/app/bin/migrate.php          Check that it ends with  schema at <number> . Run it again after every update of  app/ .     Frames and the admin recover on their own once the schema matches.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#every-request-answers-503-maintenance","tags":"","category":""},{"id":168,"title":"Every request answers 500 on a new install","pageTitle":"Troubleshooting and recovery","text":"You see:  HTTP 500 with  {\"error\":\"internal error\",\"code\":\"internal\"}  before you have run any command. Why:  The database does not exist yet. What to do:  Run the migration as above. Create your admin login next. See  Install the frame server .","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#every-request-answers-500-on-a-new-install","tags":"","category":""},{"id":169,"title":"The server answers 500 \"server not configured\"","pageTitle":"Troubleshooting and recovery","text":"You see:  HTTP 500 with  {\"error\":\"server not configured\",\"code\":\"not_configured\"} . Why:   config.php  is missing, unreadable, or has a bad value. The log line says which, after  frame: config error: . What to do:  Run the check as the web user and read the message:   sudo -u www-data php -r 'require \"/srv/frame/app/bootstrap.php\"; echo \"ok\\n\";'            Message   Fix           ...config.php is missing or unreadable   Copy  config.example.php  to  config.php  next to  app/ . Make it readable by the web user (group  www-data , mode 0640).       unknown key: ...   Remove or fix the key. See  the configuration reference .       frames_url is required ...   Set it, ending in  / .       owner_name is required ...   Set a short name: at most 40 characters, with no  < ,  > ,  &  or quotes.       calendar_hosts: each entry must be a lowercase host name   Use lowercase host names only, as a list.       client_ip_header needs trusted_proxies ...   Set  trusted_proxies  too, or remove the header.       kiosk_release_pubkey must be a PEM EC public key (needs the openssl extension)   Install  php-openssl  support, or fix the key. It must be an EC P-256 public key in PEM form.       widgets.photos: unknown option ...   Remove the option. See  Photos: limits and config keys .","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#the-server-answers-500-server-not-configured","tags":"","category":""},{"id":170,"title":"The admin answers 500","pageTitle":"Troubleshooting and recovery","text":"You see:   https://frames.example.com/frames/admin/  shows HTTP 500, either as plain Apache or as the  not_configured  JSON. Why:  One of two things.   frames_url  does not start with  https:// . The admin refuses to run and logs  frame admin: refusing to run: frames_url must be https .   Apache does not allow the  .htaccess  files to work. The Apache error log says a directive \"not allowed here\".   What to do:  Set  frames_url  to an  https://  address. Check the  AllowOverride  line in your virtual host includes all four classes:  AuthConfig FileInfo Indexes Options . See  Install the frame server .","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#the-admin-answers-500","tags":"","category":""},{"id":171,"title":"The login page says \"The page expired\"","pageTitle":"Troubleshooting and recovery","text":"You see:  After you enter your details, the sign-in page shows \"The page expired. Try again.\" The log has  frame admin: login rejected origin  or  login rejected token . Why:  The sign-in form posts only when the request's origin matches the page, and the form's token matches its cookie. One of these failed:   The browser sent  Origin: null . This happens when a response header  Referrer-Policy: no-referrer  is set. The admin sends  Referrer-Policy: same-origin  itself, but a header added by Apache or a proxy for the whole site can replace it.   A proxy or CDN changes the  Host  header, so it no longer matches the  Origin .   You opened the page by another name than the host in  frames_url , such as an IP address or  www. . The sign-in page sends you to the  frames_url  host if the names differ.   The form was open for more than 15 minutes, or the browser blocks cookies for the site. The form's token is a cookie that lasts 15 minutes.   The site is not on HTTPS. The admin's cookies start with  __Host- , so a browser stores them only over HTTPS.   What to do:   Open the admin at exactly the address in  frames_url , over HTTPS.   Reload the sign-in page and try again.   Look at the response headers (browser developer tools, Network tab). If  Referrer-Policy  says  no-referrer , change your server or proxy to leave the header alone for  /frames/admin/ , or to use  same-origin .   If a proxy sits in front, make it pass the original  Host  header to Apache.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#the-login-page-says-the-page-expired","tags":"","category":""},{"id":172,"title":"Sign-in failed","pageTitle":"Troubleshooting and recovery","text":"You see:  \"Sign-in failed. Check your details and try again.\" Why:  The user name, the password or the code is wrong. The message is the same for all three on purpose. A reused code also fails: each code works once. What to do:   Check the user name. Capital letters and surrounding spaces do not matter.   If two-step sign-in is on, wait for the next code and enter it.   Check your server's clock. Codes depend on it. Run NTP.   If you cannot get in, reset from the shell:  user.php passwd yourname , or  user.php totp-disable yourname . See  Use the admin .","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#sign-in-failed","tags":"","category":""},{"id":173,"title":"Too many attempts","pageTitle":"Troubleshooting and recovery","text":"You see:  \"Too many attempts. Try again later.\" Why:  A  lockout  is active: 10 failed sign-ins from your address in 15 minutes, or 20 for the user name in an hour. A correct password is refused while the lockout lasts. What to do:  Wait for the window to pass. Behind a proxy, make sure  client_ip_header  and  trusted_proxies  are set, or all visitors share one address.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#too-many-attempts","tags":"","category":""},{"id":174,"title":"The admin shows \"Signed out\"","pageTitle":"Troubleshooting and recovery","text":"You see:  A box that says \"Your session has ended. Sign in again to continue.\" Why:  You were idle for 30 minutes, 12 hours passed since you signed in, or your password or two-step setting changed. What to do:  Select  Sign in . Unsaved edits on that page are lost.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#the-admin-shows-signed-out","tags":"","category":""},{"id":175,"title":"Changes I make do not reach the frame","pageTitle":"Troubleshooting and recovery","text":"You see:  You saved a setting, and the frame still shows the old thing. What to do:   Wait a minute or two. Frames pick up changes on their next check-in.   Check that the frame is  Online  on  Frames .   Use  Screen  and  Refresh  to see what the frame shows now.   Check the page shows no \"Unsaved changes\" bar at the bottom.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#changes-i-make-do-not-reach-the-frame","tags":"","category":""},{"id":176,"title":"An upload fails with 413","pageTitle":"Troubleshooting and recovery","text":"You see:  An upload fails with \"Too large\", or the response is HTTP 413 \"Request Entity Too Large\". Why:  A request is bigger than a limit. There are four places a limit can live: the app, the  .htaccess  files, PHP, and anything in front of Apache.         Upload   Where the limit is   Value           Photo in the admin   public/admin/.htaccess  and  upload_bytes   30 MiB       Photo from a drop link   public/.htaccess  and  upload_bytes   30 MiB       Card cover or stamp   public/admin/.htaccess   4 MiB       Kiosk release   public/admin/.htaccess   16 MiB       Anything else   The default  LimitRequestBody   16 KiB in the admin, 64 KiB elsewhere         What to do:   Check  post_max_size  and  upload_max_filesize  are at least  31M  (see  PHP settings ). Restart PHP-FPM or Apache after a change.   If you copied the  .htaccess  rules into the virtual host, copy the  <If>  blocks too. They match on  %{THE_REQUEST} , not  REQUEST_URI , because Apache evaluates the request-size limit on an internal subrequest where  REQUEST_URI  is the front controller. A rule written against  REQUEST_URI  never matches, and the small default limit applies.   If a proxy or CDN sits in front, check its upload limit too (for nginx,  client_max_body_size ).   If you raised  upload_bytes , raise  LimitRequestBody  in both  .htaccess  exceptions to match.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#an-upload-fails-with-413","tags":"","category":""},{"id":177,"title":"Photo uploads are off","pageTitle":"Troubleshooting and recovery","text":"You see:  A red banner in the album: \"Photo uploads are off. The server's image decoder isn't working, so new photos can't be added.\" Uploads stop with \"Photo processing is unavailable on the server\". Why:  The photo library's self-test failed. The cause is on the server, in the decoder or the sandbox. What to do:  Run the migration as the web user and read the  WARNING (photos)  lines:   sudo -u www-data php /srv/frame/app/bin/migrate.php    Photos: troubleshooting  explains each line. On Ubuntu 24.04 the usual cause is that  bwrap  is blocked. Switch to  'sandbox' => 'user' . Frames keep showing photos already stored.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#photo-uploads-are-off","tags":"","category":""},{"id":178,"title":"iPhone photos are refused","pageTitle":"Troubleshooting and recovery","text":"You see:  \"iPhone HEIC photos aren't supported on this server yet\". What to do:  Install  libheif-examples  and  libheif-plugin-libde265 , then run the migration to check. See  Photos: troubleshooting .","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#iphone-photos-are-refused","tags":"","category":""},{"id":179,"title":"\"The server is out of space\" or \"This album is full\"","pageTitle":"Troubleshooting and recovery","text":"Why:  The library reached  library_bytes , the disk has less than  min_free_bytes  free, or the album reached  album_photos . What to do:  Delete photos you no longer need, make room on the disk, or raise the limit in  config.php . See  Photos: limits and config keys .","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#the-server-is-out-of-space-or-this-album-is-full","tags":"","category":""},{"id":180,"title":"A new photo does not show on the frame","pageTitle":"Troubleshooting and recovery","text":"Why:  The frame shows a daily set, picked once a day at the frame's pick time. A photo added after the pick appears in the next day's set. A photo waiting for approval never reaches a frame. What to do:   Open the frame's  Photos  section. The contact sheet says when the set was picked and when the next pick is.   Check the photo is approved, not under  Waiting for approval .   See  How a frame picks its photos .","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#a-new-photo-does-not-show-on-the-frame","tags":"","category":""},{"id":181,"title":"The Nextcloud album shows \"Not fetched yet\" or an error","pageTitle":"Troubleshooting and recovery","text":"You see:  Under  Album link : \"Not fetched yet\", or \"Error: ...\". What to do:     Check the cron job runs. Run it by hand as the cron user:     php /srv/frame/app/fetch/fetch.php      It prints nothing when all is well.  cache/cache.db  should exist.       Check the Nextcloud host is in  photo_hosts  in  config.php . If it is not, the admin refuses to save the link with \"album_url must be a Nextcloud Photos public album link\".       Check the link looks like  https://cloud.example.org/apps/photos/public/<token> .       Check your server can reach the Nextcloud host, and that the album is shared with a public link.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#the-nextcloud-album-shows-not-fetched-yet-or-an-error","tags":"","category":""},{"id":182,"title":"The admin refuses my Nextcloud link","pageTitle":"Troubleshooting and recovery","text":"You see:  \"album_url must be a Nextcloud Photos public album link\". Why:  The link's host is not in  photo_hosts , or the address is not a public album link. What to do:  Add the host in lowercase to  photo_hosts  and use the album's public link.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#the-admin-refuses-my-nextcloud-link","tags":"","category":""},{"id":183,"title":"A calendar shows Error","pageTitle":"Troubleshooting and recovery","text":"You see:  In  Library , the calendar's status is  Error , with a message. What to do:   Check the calendar's host is in  calendar_hosts , in lowercase. The admin refuses to save a calendar whose host is not listed (\"host not allowed\").   Check the link is HTTPS with no port, and that it returns an iCal file. Nextcloud's  /apps/calendar/p/<token>  and  /remote.php/dav/public-calendars/<token>  forms are handled for you.   Check the fetcher runs. Run it by hand as in the entry above.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#a-calendar-shows-error","tags":"","category":""},{"id":184,"title":"The calendar widget shows nothing","pageTitle":"Troubleshooting and recovery","text":"Why:  A calendar with nothing ticked shows no events. There is no \"show all\". What to do:  Open the frame's  Calendar  section, tick the calendars, and save.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#the-calendar-widget-shows-nothing","tags":"","category":""},{"id":185,"title":"Weather is missing or empty","pageTitle":"Troubleshooting and recovery","text":"What to do:   Check the location is in the US. The National Weather Service has no data elsewhere.   Check the frame's  Weather  widget is on and has a  Location  chosen.   Check the fetcher runs. Weather refreshes about every 10 to 30 minutes.   If you entered  Stations , check each is a valid four-character ID, or clear the field to use the nearest three.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#weather-is-missing-or-empty","tags":"","category":""},{"id":186,"title":"Home Assistant shows nothing or asks me to sign in","pageTitle":"Troubleshooting and recovery","text":"What to do:   Check the address.  http  works only for private addresses ( 10.x ,  172.16-31.x ,  192.168.x ) and  .local  names. Use  https  for anything else. The address has no path.   Check the token is saved. The admin shows  Token set . Changing the address removes the token, so enter it again.   Without a token, Home Assistant must trust the frame's address. See  Widgets: Home Assistant .   The  Dashboard  list fills in after the frame reports its dashboards. Until then pick the default.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#home-assistant-shows-nothing-or-asks-me-to-sign-in","tags":"","category":""},{"id":187,"title":"A relative says their link is not working","pageTitle":"Troubleshooting and recovery","text":"You see:  \"This link isn't working anymore. Ask   for a new one.\" Photo drop links also say when a link has expired or been turned off.   Why:  The link was replaced ( New link ), revoked, or has expired. A drop link lasts 90 days by default. What to do:  Open the sender link or drop link in the admin.  Extend  an expired drop link,  Restore  a revoked drop link, or make a  New link  and send it again.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#a-relative-says-their-link-is-not-working","tags":"","category":""},{"id":188,"title":"A card sender sees \"mailbox is full\"","pageTitle":"Troubleshooting and recovery","text":"Why:  The frame already holds 30 unopened cards, or the sender has 10 unopened ones. What to do:  Wait until someone opens cards on the frame, or delete unopened cards from the sender in the  Cards  section.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#a-card-sender-sees-mailbox-is-full","tags":"","category":""},{"id":189,"title":"The frame never shows a pairing code","pageTitle":"Troubleshooting and recovery","text":"You see:  The frame shows a \"Can't reach ...\" screen, or \"Setting the clock...\", or no pairing code. What to do:   Check the frame is on Wi-Fi. Hold the top-left corner for 10 seconds, enter the PIN, and open  Wi-Fi .   Check the frame can reach your server. The \"Can't reach ...\" screen shows the network name, address and frame ID.   Check the kiosk was built with the right  FRAME_SERVER_URL . It must match  frames_url  on the server, over HTTPS.   If the site uses a Let's Encrypt certificate, check you installed the ISRG Root X1 certificate on the frame. Without it Android 6 cannot load the page. See  Provisioning, step 4 .   If the screen says \"Setting the clock...\", the clock is wrong. See the next entry.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#the-frame-never-shows-a-pairing-code","tags":"","category":""},{"id":190,"title":"The pairing code is rejected","pageTitle":"Troubleshooting and recovery","text":"You see:  \"That code was wrong or has expired.\" Why:  The code was mistyped, it is older than 24 hours, or you entered five wrong codes in 10 minutes. What to do:  Read the code on the frame again (it can change), wait a few minutes if you hit the limit, and try again.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#the-pairing-code-is-rejected","tags":"","category":""},{"id":191,"title":"The frame's clock is wrong (it says 1969)","pageTitle":"Troubleshooting and recovery","text":"Why:  The frame has no battery-backed clock. After a power cut it starts in 1969 until it reaches a time server. What to do:   Check you ran  scripts/provision.sh time  and installed the boot script. The boot script restores the last saved time.   Check the  NTP_SERVER  is reachable from the frame's network.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#the-frames-clock-is-wrong-it-says-1969","tags":"","category":""},{"id":192,"title":"The frame goes offline or has a weak signal","pageTitle":"Troubleshooting and recovery","text":"You see:   Offline  or a  Weak Wi-Fi  badge on  Frames . What to do:  Move the frame or the access point. The badge shows below -75 dBm. Check the frame's  Details  for the network and signal. Reserve the frame's address in your DHCP server. The boot script holds its MAC address fixed.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#the-frame-goes-offline-or-has-a-weak-signal","tags":"","category":""},{"id":193,"title":"The Dim button only makes the screen dark","pageTitle":"Troubleshooting and recovery","text":"Why:  The boot script did not hand the backlight to the kiosk. Dim then only lowers brightness. What to do:  Check the boot script is installed and has run. Look for  lcdbl -> uid N  in  adb logcat -d -s frame-fixmac . If you uninstalled and reinstalled the kiosk, reboot the frame. See  Provisioning, step 9 .","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#the-dim-button-only-makes-the-screen-dark","tags":"","category":""},{"id":194,"title":"The Wi-Fi picker shows no networks","pageTitle":"Troubleshooting and recovery","text":"Why:  Android 6 hides scan results when location is off. What to do:  The kiosk normally turns location on. If it did not:   adb shell settings put secure location_providers_allowed +network","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#the-wi-fi-picker-shows-no-networks","tags":"","category":""},{"id":195,"title":"The frame did not take an update","pageTitle":"Troubleshooting and recovery","text":"See  Update the kiosk: failure states .","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#the-frame-did-not-take-an-update","tags":"","category":""},{"id":196,"title":"adb devices shows nothing","pageTitle":"Troubleshooting and recovery","text":"adb devices What to do:  On WSL, attach the frame from PowerShell:  usbipd list , then  usbipd attach --wsl --busid <id> . A running frame shows as  1f3a:1001 . A frame in FEL mode shows as  1f3a:efe8 .","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#adb-devices-shows-nothing","tags":"","category":""},{"id":197,"title":"adb says the device is offline","pageTitle":"Troubleshooting and recovery","text":"adb Why:  After  adb reboot , an old network session is stale. What to do:  Run  adb disconnect , then  adb connect 192.0.2.50:5555  again.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#adb-says-the-device-is-offline","tags":"","category":""},{"id":198,"title":"I cannot connect over the network","pageTitle":"Troubleshooting and recovery","text":"Why:  The boot script limits ADB over Wi-Fi to the addresses in  ADB_ALLOW . If one entry is not a valid IPv4 address or range, the script allows nobody over Wi-Fi (it logs  bad ADB_ALLOW entry ). Wi-Fi ADB is also off on IPv6 when a list is set. What to do:   Connect over USB. USB ADB is never limited.   Fix  ADB_ALLOW  in  .env . Include your computer's address.   Run  scripts/provision.sh boot-script  again, and reboot.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#i-cannot-connect-over-the-network","tags":"","category":""},{"id":199,"title":"Several devices are connected","pageTitle":"Troubleshooting and recovery","text":"Add  -s <serial>  to  adb  and to  scripts/provision.sh . Use the address ( 192.0.2.50:5555 ) for a network device.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#several-devices-are-connected","tags":"","category":""},{"id":200,"title":"The frame does not boot","pageTitle":"Troubleshooting and recovery","text":"You see:  The frame stays on a black screen, or on the boot logo, and never reaches the kiosk or the stock app. What to do, in this order:   Power-cycle it.  Unplug the USB-C power, wait, and plug it in.   Boot Android with the microSD card out.  The FEL card makes RESET enter FEL mode. Take the card out and press RESET. See  Unlock: first boot .     If Android starts but the kiosk is wrong,  connect with ADB (USB works even when Wi-Fi is limited) and fix it:    Reinstall the kiosk:  adb install -r kiosk/build/frame-kiosk.apk .   Undo the boot script: restore the  frame-fixmac.sh.v<N>  backup and remove  /system/etc/frame.conf .   Restore a  .bak  of a system file you changed, for example  build.prop.bak .         If Android does not start,  put the frame in FEL mode and talk to its storage:    Put the FEL microSD card in and press RESET.  usbipd list  shows  1f3a:efe8 .   Run  scripts/boot-ums.sh  in WSL.   To undo the ADB patch only, run  patch-sector.ps1 -Restore  in PowerShell.   To put the whole storage back, write  backups/emmc-full.img  to the disk. No script does this for you. It is the reverse of  backup-emmc.ps1 , and needs a raw disk write from an Administrator PowerShell. Double-check the disk number: this overwrites the whole disk.       The backup from the unlock is what makes step 4 possible. Keep it.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#the-frame-does-not-boot","tags":"","category":""},{"id":201,"title":"I lost the PIN","pageTitle":"Troubleshooting and recovery","text":"You see:  The maintenance panel does not accept the PIN. What to do:   In the admin, open the frame, then  Security . Enter a new PIN and select  Set PIN , or select  Reset to factory PIN  to go back to the  KIOSK_PIN  from the build. The frame picks it up on its next check-in.   The frame must reach your server to get a new PIN. If it is offline, get it back onto a network it knows first, then set the PIN in the admin.   A rebuilt kiosk with a new  KIOSK_PIN  changes only the factory PIN. It applies when the frame has no custom PIN. A custom PIN stays on the frame, through  adb install -r , until you change or reset it in the admin. After five wrong PINs the pad locks for 5 minutes, then 30 minutes, 2 hours and 8 hours for each further five. A reboot restarts a lock, so do not use it to try again.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#i-lost-the-pin","tags":"","category":""},{"id":202,"title":"I lost the APK keystore or the release key","pageTitle":"Troubleshooting and recovery","text":"Lost the release key.  Frames keep running. You cannot send them self-updates any more, because they check the old public key.   Make a new release key. See  Provisioning, step 5 .   Build a kiosk with the new  RELEASE_PUBKEY , signed with the  same keystore .   Install it on every frame with  adb install -r .   After that, self-updates work again.   Lost the APK keystore.  Android refuses an update signed with another key, even over ADB. On every frame you must:   Undo device owner .   Uninstall the kiosk:  adb shell pm uninstall com.calevans.frame .   Make a new keystore and release key, and build the kiosk.   Run  Provisioning, step 7  again and claim the frame again.   Back up both keys somewhere private so this does not happen. See  Update the kiosk .","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#i-lost-the-apk-keystore-or-the-release-key","tags":"","category":""},{"id":203,"title":"A frame shows as Revoked","pageTitle":"Troubleshooting and recovery","text":"Why:  Someone revoked the frame in the admin, which stops it working at once. What to do:  Open the frame, select  More , then  Restore . It asks for your password. If you revoked it because it was lost or stolen, leave it revoked.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#a-frame-shows-as-revoked","tags":"","category":""},{"id":204,"title":"I cannot sign in and need a new password or code","pageTitle":"Troubleshooting and recovery","text":"What to do:  From a shell on the server, as the web user:   sudo -u www-data php /srv/frame/app/bin/user.php passwd yourname sudo -u www-data php /srv/frame/app/bin/user.php totp-disable yourname    Both sign the user out of every browser. See  Use the admin .","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#i-cannot-sign-in-and-need-a-new-password-or-code","tags":"","category":""},{"id":205,"title":"I need to restore the server","pageTitle":"Troubleshooting and recovery","text":"Install the server as in  Install the frame server .   Put back  config.php , and the contents of  var/  from your backup:  frames.db ,  photos/ ,  notes/  and  kiosk/ .   Run the migration as the web user.   Frames reconnect on their own. If you restored an older  frames.db , a frame claimed after that backup shows up as unclaimed. Claim it again.   See  Back up the server .","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#i-need-to-restore-the-server","tags":"","category":""},{"id":206,"title":"Gotchas","pageTitle":"Troubleshooting and recovery","text":"Small things that cost time:   Use  adb exec-out , not  adb shell , to read binary files or compare bytes.  shell  turns  \\n  into  \\r\\n .   The frame's  sed -i  mangles replacement text with  & . Rebuild the file from its  .bak  plus  echo >>  instead.   Do not run  grep -r /  on the frame. It hangs in  /proc .   After  adb reboot , run  adb disconnect  and  adb connect  again. The old session shows \"device offline\".   adb exec-out screencap -p > shot.png  shows what the kiosk is displaying.   Keep a record of each change you make to a frame, with its undo.   Never attach  1f3a:1010  (U-Boot's disk) with usbipd, and cancel every \"format this disk\" prompt in Windows. See  Unlock, step 5 .   Do not run  scripts/provision.sh  steps out of order. Install the boot script after the kiosk, because it hands the backlight to the kiosk's user ID.   The  .env  reader is strict. A line ending in a carriage return, or an unknown key, stops every script that reads it.","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#gotchas","tags":"","category":""},{"id":207,"title":"Related","pageTitle":"Troubleshooting and recovery","text":"Install the frame server   Unlock your PF1007L   Build the kiosk and set up a frame   Photos   Update the kiosk   Back to:  Start here .","url":"https://calevans.com/picture-frame/docs/troubleshooting/index.html#related","tags":"","category":""},{"id":208,"title":"Security","pageTitle":"Security","text":"This page explains how the Picture Frame System defends itself: who and what it expects to be attacked by, every control that matters, and why each one exists. It is for people who run a server, review the code, or want to report a problem. If you only want to install the system, start with the  server install guide  and come back here when you want to know why it asks for what it asks for. When you finish this page you will know what a leaked link, a hostile photo, a stolen password, a compromised server and a stolen frame can and cannot do, and which parts of the design you must not loosen.","url":"https://calevans.com/picture-frame/docs/security/index.html","tags":"","category":""},{"id":209,"title":"Report a vulnerability","pageTitle":"Security","text":"Open a confidential issue on the project's GitLab:   Go to  gitlab.com/calevans/picture-frame-system/-/issues/new .   Give the issue a short, neutral title. Do not put the vulnerability in the title.   Select the  This issue is confidential  checkbox before you submit.   In the description, say what you found, how to reproduce it, which version or commit you tested, and what an attacker gains.   Please do not post details in a public issue, a merge request or a public chat. This is a volunteer project and offers no response-time guarantee, but a confidential report is read and answered as soon as a maintainer can. Only the current default branch ( master ) is supported. Fixes are not back-ported.","url":"https://calevans.com/picture-frame/docs/security/index.html#report-a-vulnerability","tags":"","category":""},{"id":210,"title":"How the system is split","pageTitle":"Security","text":"Everything below follows from four trust boundaries.   Browser and sender to server.  Everything arrives over HTTPS. The admin needs a login. Senders hold a secret in their link and can only write.   Frame to server.  A frame proves itself with a 256-bit key. A frame can read its own data and nothing else.   Web to worker.  Photos from strangers are decoded in a separate, resource-limited, optionally sandboxed process. The web process never decodes an upload itself.   Server to device.  The server can offer a kiosk update. The device decides whether to install it.","url":"https://calevans.com/picture-frame/docs/security/index.html#how-the-system-is-split","tags":"","category":""},{"id":211,"title":"Where each secret lives","pageTitle":"Security","text":"Secret   Created by   Stored   Who can read it           Admin password   Command line   frames.db , as an Argon2id hash   The web user       Two-step sign-in (TOTP) secret   Command line   frames.db , as plain base32 text   The web user, and anyone who can read  frames.db       Admin session   Login   var/sessions/  (directory mode 0700)   The web user       Trusted-device signing key   First successful login   var/device.key  (mode 0600)   The web user       Frame key   The kiosk, on first run   On the device: app-private storage. On the server: only its SHA-256 hash   Root on the device; the server holds no usable key       Pairing code   The server   frames.db  until claimed (24 hours)   The web user       Drop link and card link secrets   The server   Shown once; only a SHA-256 hash is stored   The person you gave the link to       Calendar addresses and Nextcloud album link   The admin   frames.db ; never returned by an admin API call   The web user       Home Assistant token   The admin   frames.db  as plain text inside the widget settings; write-only in the admin   The web user; the kiosk's native code through its authenticated request       Kiosk PIN   The admin   frames.db , as a salted PBKDF2 hash   The web user; the kiosk       APK signing key   You   Your keystore, outside the repository   You       Release signing key   You   Offline. Never on the server   You         config.php  holds no secrets. The release  public  key may appear in it.","url":"https://calevans.com/picture-frame/docs/security/index.html#where-each-secret-lives","tags":"","category":""},{"id":212,"title":"Threat model","pageTitle":"Security","text":"Threat   What the attacker has   What stops them   What they can still do           A drop link leaks   The URL of one album's upload page   The link only accepts uploads. Photos wait in an approval queue. Rate limits, quotas, expiry, revoke and rotate   Fill the pending queue up to its caps; you reject the photos       A card link leaks   The URL of one sender page   Per-sender and per-address send limits; revoke and rotate; the sender sees only their own cards   Send cards, up to the limits, until you revoke the link. Cards reach the frame without approval       A hostile image is uploaded   A crafted JPEG, PNG, WebP or HEIC   Magic-byte check, size caps before decoding, a separate worker with resource limits, a sandbox, output rebuilt and checked   A decoder bug would run inside the sandbox with no network and no access to your data (if the sandbox is on)       The admin password is stolen   The password, not the second factor   Two-step sign-in, rate limits, session limits   Nothing, if two-step sign-in is on. If it is off, they are the admin: re-entering the password does not help against someone who has it       The server is compromised   Code execution as the web user, or write access to  frames.db   The device checks every update itself, against two independent keys that never touch the server   Read all server data, change settings, show content on frames. Not: install code you did not sign       A frame is stolen or its ADB is reached   Root on one frame   Frame keys are per frame; revoke ends access at once   Impersonate that one frame, read what it can read, including its Home Assistant token       Someone on the frame's LAN reaches ADB   A network path to port 5555   An address allow-list in the boot script, applied by firewall rules   Anything, if their address is allowed or the list is turned off       Script runs on the frames web origin   A script injected into a page   No inline script,  script-src 'self' , no  innerHTML , the frame cookie is  HttpOnly , secrets travel only in a header   See below       Someone sniffs the LAN   A position on the frame's network   HTTPS to the server   The one plain-http exception: Home Assistant on a private address","url":"https://calevans.com/picture-frame/docs/security/index.html#threat-model","tags":"","category":""},{"id":213,"title":"What a script on the frames origin can do","pageTitle":"Security","text":"The frames pages, the admin and the public pages share one origin ( https://<host>/frames/ ). That is deliberate: it keeps the install to one virtual host. It also means a cross-site-scripting hole would be serious, so the code goes to some length to prevent one:   Every page sends  default-src 'none'  with  script-src 'self'  and  style-src 'self' . No page allows  unsafe-inline  or  unsafe-eval . Tests fail if a widget fragment contains  style= , an  on…=  handler or a  <script> .   Widgets never use  innerHTML ,  document.write  or  eval . Text goes in with  textContent . A test fails if a widget script contains them.   The frame cookie is  HttpOnly , so page script cannot read it. If a script makes requests, it gets only what the cookie path allows: the data the frame's own page already shows. Secrets, the PIN hash and update offers are sent only to requests that carry the  Authorization  header, which only the kiosk's native code can add.   Even on the admin origin, script without a signed-in admin session gets a  401 . Script  with  an admin session in the same browser is as powerful as the admin. This is why the install guide recommends a dedicated host name for the frame site.","url":"https://calevans.com/picture-frame/docs/security/index.html#what-a-script-on-the-frames-origin-can-do","tags":"","category":""},{"id":214,"title":"What a compromised server cannot do","pageTitle":"Security","text":"A kiosk installs an update only if  all  of these hold, and the checks run on the device:   The offered file's hash and size match the offer.   A detached ECDSA signature from your offline  release key  verifies against the public key built into the kiosk.   The file is a plain ZIP (no prepended or hidden data).   It is the kiosk's own package, with the version the offer promised, and a  higher  version than the one installed.   Its signing certificates match the installed ones exactly.   The server holds neither key. A server attacker can withhold updates, or offer a newer build you have already signed. They cannot make a frame run code you did not sign, and they cannot downgrade a frame. See  Kiosk updates .","url":"https://calevans.com/picture-frame/docs/security/index.html#what-a-compromised-server-cannot-do","tags":"","category":""},{"id":215,"title":"What a compromised server can do","pageTitle":"Security","text":"Be clear-eyed about the rest. With control of the web user, an attacker can read  frames.db  (calendar addresses, the Home Assistant token and the frames' key  hashes ), edit any setting, change kiosk PINs, and make frames show arbitrary content from the frame site, because the kiosk trusts that origin. They can see frame key hashes but not keys, so they cannot pose as a frame without the device. Keep the server patched, keep its backups private, and treat a server compromise as a reason to revoke every frame and replace the Home Assistant token.","url":"https://calevans.com/picture-frame/docs/security/index.html#what-a-compromised-server-can-do","tags":"","category":""},{"id":216,"title":"Admin login","pageTitle":"Security","text":"The admin has its own login. There is no HTTP basic auth and no dependency on a proxy. Every request first passes the checks below; only then does any route run.","url":"https://calevans.com/picture-frame/docs/security/index.html#admin-login","tags":"","category":""},{"id":217,"title":"Requirements before it runs","pageTitle":"Security","text":"Control   What it does   Why           HTTPS only   The admin returns a  500  (\"server not configured\") unless  frames_url  starts with  https:// . The PHP development server is the one exception.   The login form and the session cookie must never cross the wire in clear text. A  __Host-  cookie also requires  Secure .       Canonical host   A  GET  of the login page on any other  Host  is answered with a  303  to the address in  frames_url . Every  Location  header the admin sends is built from  frames_url , never from the request.   Stops host-header tricks and open redirects.       Optional HSTS   Set  'hsts' => true  and the admin sends  Strict-Transport-Security: max-age=31536000 .   Off by default so you can test. Turn it on once the host stays HTTPS.","url":"https://calevans.com/picture-frame/docs/security/index.html#requirements-before-it-runs","tags":"","category":""},{"id":218,"title":"Passwords","pageTitle":"Security","text":"Control   What it does   Why           Argon2id   password_hash  with  memory_cost  19456 KiB (about 19 MiB),  time_cost  2,  threads  1. If PHP has no Argon2id, bcrypt with cost 12.   Memory-hard hashing slows offline guessing. The parameters are the OWASP minimum for Argon2id. They are explicit, so a PHP upgrade cannot quietly weaken them.       Rehash on login   If the stored hash uses older parameters, a successful login replaces it.   Parameter increases take effect without a password reset.       Dummy hash   When the user name is unknown or malformed, the code verifies the password against a hard-coded hash made with exactly the live parameters.   Both cases cost the same time, so response time does not reveal which names exist. A test asserts that the dummy hash would not be rehashed. Change the parameters and the dummy hash together.       Password policy   At least 12 characters; at most 1024 bytes (72 for bcrypt); no NUL bytes; not equal to the user name; not on a bundled list of about 2,000 common passwords (taken from the MIT-licensed SecLists project).   Stops the weakest passwords and keeps hashing time bounded. No online password-breach lookups, so nothing leaves your server.       Name rules   User names are lower-case, 1 to 64 characters from  a-z 0-9 . _ - , and checked before any database lookup or log line.   A hostile name never reaches SQL or the log.","url":"https://calevans.com/picture-frame/docs/security/index.html#passwords","tags":"","category":""},{"id":219,"title":"Rate limits and the flood lock","pageTitle":"Security","text":"Control   Value   Why           Per-address failures   10 per 15 minutes, keyed by IPv4 address or IPv6  /64   A subscriber cannot rotate through IPv6 addresses to get around the limit.       Per-user failures   20 per hour, keyed by a hash of the user name   Slows a guess on one account from many addresses.       Charge first, refund on success   Each attempt is counted inside one  BEGIN IMMEDIATE  transaction  before  the password is checked, and the count is deleted if the login succeeds.   Parallel guesses cannot all slip in before the counters move.       Locked-out correct password   While a limit is exceeded, even the right password is refused with a  429  and  Retry-After .   Otherwise an attacker could keep guessing and read the lockout from the result.       Hash lock   A non-blocking  flock  on  var/login.lock  wraps the hashing. If another login is hashing, the request is refused at once with a  429  and  Retry-After: 1 .   A flood of logins cannot run many 19 MiB hashes in parallel and exhaust memory. The lock file is mode 0640 so other local users cannot hold it.       Purge on failure   Expired rate events are deleted on the failure path.   The table stays small.","url":"https://calevans.com/picture-frame/docs/security/index.html#rate-limits-and-the-flood-lock","tags":"","category":""},{"id":220,"title":"Forms, cookies and sessions","pageTitle":"Security","text":"Control   What it does   Why           Double-submit form token   GET  of the login page sets the cookie  __Host-frameadm-login  (32 random bytes, 15 minutes). The form carries the same value in a hidden field; the  POST  must match ( hash_equals ).   Cross-site login attempts fail, and an anonymous visitor creates no session file.       Origin check   Every state-changing request, and every admin API request including reads, must carry  Sec-Fetch-Site: same-origin , or an  Origin  header that is  https  and matches the host.   The session cookie is  SameSite=Strict ; this is the second layer.       Order of checks on  POST /login   Content type ( 415 ), origin ( 403 ), form token ( 403 ), rate limits ( 429 ), credentials ( 401 ).   Cheap checks run before anything expensive.       __Host-  cookies   The session ( __Host-frameadm ), form token and trusted-device cookies are  Secure ,  HttpOnly ,  SameSite=Strict ,  Path=/ , with no  Domain .   The  __Host-  prefix makes browsers refuse a cookie that a sibling subdomain or a plain-http page tries to plant or overwrite.       No anonymous sessions   A session starts only when the session cookie is present and well formed, or on a successful login.   An unauthenticated flood leaves no files behind.       Strict session mode   PHP  use_strict_mode  and  use_only_cookies  are on; sessions live in  var/sessions/ , outside the web root.   A visitor cannot choose a session id.       Limits   30 minutes idle, 12 hours total. The user's row is read on  every  request.   Removing a user, or changing their password, ends their sessions on the next request.       Session epoch   Each user has a random  session_epoch  that is copied into the session. It changes on password change, on enabling or disabling two-step sign-in, and when a user is removed.   One value revokes every session and trusted device for that user.       Id rotation   The session id changes at login and every 15 minutes. The old id stays valid for 60 seconds, but only as a pointer to the new one.   Limits the life of a stolen id without failing requests that are already in flight.       CSRF token   A 256-bit token in the session; non- GET  requests must send it in  X-CSRF-Token .   Defense in depth against forged writes.","url":"https://calevans.com/picture-frame/docs/security/index.html#forms-cookies-and-sessions","tags":"","category":""},{"id":221,"title":"Two-step sign-in (TOTP)","pageTitle":"Security","text":"Two-step sign-in is optional per user and strongly recommended. You enable it with  php app/bin/user.php totp-enable <user> .         Control   What it does   Why           Standard codes   RFC 6238: HMAC-SHA1, six digits, 30-second steps, one step of drift allowed either way.   Works with every authenticator app.       Constant-time check   All three candidate codes are always compared with  hash_equals .   No timing signal.       Replay guard   The last accepted step is stored. A code for the same or an earlier step is refused. The update is a conditional write, so two racing requests cannot both win.   A code seen by someone over your shoulder is worth nothing once you have used it.       One generic error   The login form always shows the code field. Password and code are checked together and fail with the same message.   The form does not reveal whether an account has two-step sign-in.       Safe enrollment   Enabling needs a valid code first, so a typo cannot lock you out. It rotates the session epoch. Recovery is  totp-disable  from the command line.   A lost phone cannot lock you out, because the shell is the recovery path.         The TOTP secret is stored in the database as plain text. Anyone who can read  frames.db  can read it. That is the same trust level as the password hash, but know that it is not encrypted.","url":"https://calevans.com/picture-frame/docs/security/index.html#two-step-sign-in-totp","tags":"","category":""},{"id":222,"title":"Trusted device","pageTitle":"Security","text":"After a successful login the browser receives the cookie  __Host-frameadm-dev , valid for 90 days. It is an HMAC-SHA256 over the user id, the session epoch and the expiry, signed with  var/device.key . It does one thing: while valid, it exempts that user from the  per-user  lockout, so that someone who sprays wrong passwords at your user name cannot lock you out of your own admin. The per-address limit still applies. It does  not  skip the password or the code. Changing the password or the two-step setting changes the epoch and kills every device cookie.","url":"https://calevans.com/picture-frame/docs/security/index.html#trusted-device","tags":"","category":""},{"id":223,"title":"Re-entering the password","pageTitle":"Security","text":"Destructive actions need the password (and code, if enabled) entered within the last five minutes. A fresh login counts. If the time has passed the API answers  403  with code  reauth_required ; the admin page asks for the password through  POST /api/session/reauthentications  and retries. Actions that require it:   Revoking or un-revoking a frame, deleting a frame, deleting all unclaimed frames, changing a frame's display kind or URL, setting or clearing a frame's PIN.   Every kiosk-release write: upload, edit notes, delete, roll out, set a frame's target release, clear an update failure.   Deleting an album, deleting photos in bulk, revoking, un-revoking, rotating or deleting a drop link, deleting every photo from a drop link.   Revoking, rotating or deleting a card link, and deleting all unopened cards from a sender.   Why: a stolen session cookie or a cross-site script should not be able to do irreversible damage, or push software to the frames, without the password.","url":"https://calevans.com/picture-frame/docs/security/index.html#re-entering-the-password","tags":"","category":""},{"id":224,"title":"Response headers","pageTitle":"Security","text":"Every admin response carries:         Header   Value   Why           Cache-Control   no-store   Nothing from the admin lands in a shared cache or the browser's history cache.       Referrer-Policy   same-origin   See below.       X-Frame-Options   DENY  (and CSP  frame-ancestors 'none' )   No clickjacking.       Cross-Origin-Opener-Policy   same-origin   No cross-window handles to the admin.       X-Content-Type-Options   nosniff   The browser trusts the declared type.       Vary   Cookie   Caches never mix signed-in and signed-out responses.       Content-Security-Policy   The login page:  default-src 'none'; style-src 'self'; form-action 'self'; base-uri 'none'; frame-ancestors 'none'; upgrade-insecure-requests , with no script at all. The admin page:  default-src 'none' ,  script-src 'self' ,  style-src 'self' ,  connect-src 'self' ,  img-src 'self'  (plus the image hosts widgets declare),  base-uri 'none' ,  form-action 'none' ,  frame-ancestors 'none' .   The strictest policy that still works.           Why  Referrer-Policy  is  same-origin  and not  no-referrer .  With  no-referrer , browsers send  Origin: null  on the login form's  POST . The origin check then refuses the login, because it needs a real origin.  same-origin  sends nothing to other sites, which is the goal of  no-referrer , and still lets the form work. Do not change it back.","url":"https://calevans.com/picture-frame/docs/security/index.html#response-headers","tags":"","category":""},{"id":225,"title":"Audit log","pageTitle":"Security","text":"Login events are written with  error_log , in a fixed format that fail2ban can match. Passwords are never logged, and an unknown or malformed user name is logged as  (unknown) .   text   frame admin: login ok user=<name> ip=<address> frame admin: login failed user=<name>|(unknown) ip=<address> frame admin: login throttled ip=<address> frame admin: login rejected origin ip=<address> frame admin: login rejected token ip=<address> frame admin: logout user=<name> frame admin: reauth failed user=<name> ip=<address> frame admin: reauth throttled ip=<address>    Kiosk-release actions also log  frame admin: kiosk <event> user=… ip=… …  with the version code and hash. Frame keys and  Authorization  headers are never logged.","url":"https://calevans.com/picture-frame/docs/security/index.html#audit-log","tags":"","category":""},{"id":226,"title":"The command-line tool","pageTitle":"Security","text":"app/bin/user.php  is the only way to create users and reset passwords.   It refuses to run as root, so the database and key files keep the web user as owner.   The password is read twice from a terminal with echo off (restored on exit and on  SIGINT  or  SIGTERM ), or one line from standard input. It is never read from an argument or an environment variable, where other users on the machine could see it.   passwd ,  totp-enable ,  totp-disable  and  remove  change or delete the session epoch, which signs the user out everywhere.","url":"https://calevans.com/picture-frame/docs/security/index.html#the-command-line-tool","tags":"","category":""},{"id":227,"title":"Accepted trade-offs","pageTitle":"Security","text":"A known user name can be locked out for up to an hour by someone spraying wrong passwords. The trusted-device cookie protects your own browser from this.   SameSite=Strict  means a link from another site lands on the login page even if you are signed in.   Behind a proxy, set  client_ip_header  and  trusted_proxies . Without them the client address is the proxy's, and the per-address limit becomes one limit for everybody. That fails closed: it locks people out sooner, not later.","url":"https://calevans.com/picture-frame/docs/security/index.html#accepted-trade-offs","tags":"","category":""},{"id":228,"title":"Frame authentication","pageTitle":"Security","text":"A frame is identified by a 128-bit id and proves itself with a 256-bit random key. The kiosk makes both with  SecureRandom  on first run and keeps them in app-private storage with backups turned off.         Control   What it does   Why           Only a hash on the server   The server stores  SHA-256  of the raw key and compares with  hash_equals .   A stolen database cannot be used to pose as a frame. A fast hash is right for 256 random bits; a slow one would only cost CPU on every request.       Revocation is instant   The frame's status is read from the database on every request.   Revoking in the admin ends access at once.       One frame, one id   A frame may only ask for its own id ( 403 id_mismatch  otherwise).   A leaked key opens one frame.       Two ways to present the key   The  Authorization: Frame <id>.<key>  header, sent by the kiosk's native code, or the  fk  cookie, set by native code for the page. The header wins if both are present.   The page needs a credential it cannot read. The native code needs one that cannot be attached by page script.       The cookie   Secure; HttpOnly; SameSite=Lax; Path=/frames/ , host-only, one year.   HttpOnly  hides it from script. Host-only plus  Secure  means the browser never sends it to the plain-http Home Assistant address.       Secrets only on the header path   On the  self  request the PIN hash,  widget_secrets  (the Home Assistant token) and  kiosk_update  are present only when the request used the header. The kiosk-release download answers  403 header_required  to the cookie.   A page script can make the browser send the cookie but cannot read or forge the header. Anything it could trigger with the cookie must be harmless.       Short last-seen writes   last_seen_at  and  last_ip  are written at most once a minute.   Polling does not turn into database writes.","url":"https://calevans.com/picture-frame/docs/security/index.html#frame-authentication","tags":"","category":""},{"id":229,"title":"Enrolling and claiming a frame","pageTitle":"Security","text":"The kiosk enrolls with  POST /api/v1/frames  and receives a six-digit pairing code from  random_int , unique among unclaimed frames, valid for 24 hours. The body is limited to 1 KB.   Enrollment is limited to 3 per hour per address (an IPv6  /64  counts as one), 3 unclaimed frames per address, and 50 unclaimed frames in total. Expired unclaimed rows are deleted the next time a frame enrolls.   Only a signed-in admin can claim a frame, and the code is checked again inside the claiming transaction. Wrong codes are throttled: 5 failures per 10 minutes, counted across all addresses.   A frame that is revoked, deleted or whose pairing expired gets a  403  or  401  and goes back to enrolling or to its \"disconnected\" screen.","url":"https://calevans.com/picture-frame/docs/security/index.html#enrolling-and-claiming-a-frame","tags":"","category":""},{"id":230,"title":"The kiosk's own defenses","pageTitle":"Security","text":"PIN.  The on-screen panel is protected by a four to eight digit PIN, stored as PBKDF2-HMAC-SHA1 with 10,000 iterations (the only variant Android 6 provides). After 5 wrong tries the panel locks for 5 minutes, then 30 minutes, 2 hours and 8 hours, and the lockout survives a reboot. The PIN keeps people at the frame out of the panel. It does not protect against someone with ADB, who can brute-force the hash offline in seconds.   Lockdown.  Provisioning makes the kiosk the device owner. It runs in lock-task mode with factory reset, safe boot and adding users blocked. It never adds a restriction that would block installing apps, because that would also block self-updates.   WebView.  File and content access off, mixed content never allowed, no third-party cookies, no JavaScript-to-Java bridge ( addJavascriptInterface  is not used).   Navigation lock.  The page may go only to the frame site, to the display address if the frame shows a URL, or to the single Home Assistant origin the server lists. Anything else, including form posts that skip the usual callback, is stopped.   Broadcast permission.  The two intents that release device owner and forget Wi-Fi need a signature-level permission, so only root (ADB) can send them.","url":"https://calevans.com/picture-frame/docs/security/index.html#the-kiosks-own-defenses","tags":"","category":""},{"id":231,"title":"Public links: drop links and card links","pageTitle":"Security","text":"Two features give a link to someone who has no account.         Control   Drop link (Photos)   Card link (Cards)           Secret   256 random bits in the URL path   256 random bits in the URL path       Stored   Only its SHA-256 hash. The link is shown once.   Same       Revoke, rotate   Immediate. Revoking, un-revoking, rotating and deleting need the password again. Un-revoking makes the old link work again, so rotate if it may have leaked.   Same. Restoring a revoked card link rotates its token, so a link that leaked while revoked stays dead.       Expiry   90 days by default, at most 366. The admin can extend.   None       What it can do   Add photos to  pending . Nothing else.   Send cards to one frame, read only its own cards       Cross-site defense   Requests that say  Sec-Fetch-Site  is cross-site, or carry a foreign  Origin , get  403   Same       Page headers   Referrer-Policy: no-referrer ,  X-Robots-Tag: noindex, nofollow , a strict CSP with no inline script or style   Same       Guessing   20 unknown-link tries per 10 minutes per address. Only wrong guesses count, so a valid sender is not locked out by someone else on their network   Same       Request budget   120 requests per 10 minutes per link   120 per 10 minutes per sender       Send budget   100 photos per hour and 300 per day per link; 500 per day per address; 10 rejected files per hour per link   6 cards per hour and 20 per day per sender; 30 per day per address; 10 unopened cards per sender; 30 per frame       Reading back   None. Nothing lists what is in the album   The sender sees only their own cards         Because tokens appear in URLs, they also appear in your web server's access log. Rotate logs, restrict who can read them, and do not ship them to a third party. The admin never reads the text of a card. The admin API returns metadata only, so a hostile card can be deleted, but only without reading it.","url":"https://calevans.com/picture-frame/docs/security/index.html#public-links-drop-links-and-card-links","tags":"","category":""},{"id":232,"title":"Outbound requests","pageTitle":"Security","text":"Only the cron fetcher ( app/fetch/fetch.php ) makes outbound requests. A test fails if any widget file calls cURL or opens a socket directly, if a widget file other than a  fetch.php  requires anything from  app/fetch/ , or if anything but the fetcher loads a  fetch.php . Fetchers go out through one function,  http_get() .   HTTPS only; no port, no credentials, no redirects.   The host must be on the list:  calendar_hosts  and  photo_hosts  in  config.php , or the fixed weather host.   3 seconds to connect, 8 seconds in total, 5 MB at most (2 MB for weather JSON, 4 MB for album listings).   Error text never contains a URL path or query, because those can hold secrets.   Album listings are parsed with entity expansion and network loading off, and a document type declaration is refused.   Calendar parsing is booked as a failure  before  it starts, so a file that crashes the parser leaves a back-off behind instead of a retry loop.   Calendar addresses never reach a frame; frames get merged \"today\" events. A Nextcloud album is different: the kiosk loads the photo previews straight from the Nextcloud host, and each preview address contains the album token. That host is added to the page's  img-src , and nothing else is.","url":"https://calevans.com/picture-frame/docs/security/index.html#outbound-requests","tags":"","category":""},{"id":233,"title":"Kiosk updates","pageTitle":"Security","text":"A frame can update its own app from the admin. The full sequence is in the  update protocol ; this section is the reasoning. The principle:  the server only offers; the device decides.  Neither signing key is ever on the server.         Control   What it does   Why           Two independent signatures   Android requires the new APK to be signed by the same certificate as the installed one. Separately, an offline ECDSA P-256  release key  signs the exact text  <sha256>:<versionCode> ; the kiosk verifies it against a public key compiled into the app.   A signed APK alone is not enough: a leaked or misused APK key, or an old signed build, still needs your explicit per-release approval. The release key alone is not enough either, because Android checks the certificate.       Fail closed   A kiosk built without a release public key refuses every update.   A missing key must never mean \"skip the check\".       Hash and size   The download is hashed while it is written and must match the offer exactly. It stops at the offered size.   Truncation and swapped files are caught.       Janus check   The file must start with a local file header; the central directory must end exactly at the end record; no data may sit before or between entries. The server checks the same at upload ( 415 ).   Android 6 verifies only the old JAR signature, which allowed APKs with extra data glued in (CVE-2017-13156, \"Janus\"). The kiosk is built with both v1 and v2 signatures.       Never downgrade   The new version code must exceed the installed one. The device refuses anything else. To roll back, you ship the old code under a  higher  number.   A server or a leaked old build cannot push a frame to a known-vulnerable version.       The device builds the URL   The kiosk takes the host from its own build and the path from its own id and the offered hash. It ignores any address the server sends, does not follow redirects and insists on HTTPS.   The server cannot send the device somewhere else.       Header-only   The offer and the download need the  Authorization  header. The download works only for the file the frame is currently told to install, and the file is chosen by a database row, never by the URL.   Page script cannot request or enumerate releases.       Download limits   At most 32 MiB; free space of twice the size plus 32 MiB must be available; 12 downloads per frame per hour; 10-minute deadline; the server sends the type  application/vnd.android.package-archive  and  nosniff , and does not offer byte ranges.   Bounded cost and no surprises.       Server-side upload checks   Streamed with a running 16 MiB cap, hashed as it streams, parsed by a bounds-checked PHP reader that needs no zip extension: 10,000 entries at most, only the manifest is inflated (1 MiB cap), string-pool sizes are checked against the chunk. The package must be  com.calevans.frame .   A hostile APK cannot exhaust memory or confuse the parser. The server does  not  judge the signing certificate; the device does.       Staged rollout   A release is  proven  only after a frame has downloaded it in full and then reported running that version or newer. A frame without the rollback watchdog, and \"roll out to everyone\", may only target a proven release.   The first install of any release goes to a frame that can roll itself back.       Failure telemetry is sanitized   A frame reports  {sha256, reason} . The reason must be one of a fixed list or  install_<number> ; anything else is dropped. A report about a release that is not the frame's current target is ignored. The admin shows these with  textContent .   A frame cannot inject text into the admin or poison another release's status.       Writes need the password   Every kiosk-release write needs re-entry of the password, and is logged.   See  Re-entering the password .       Receivers not exported   The two update receivers are  exported=\"false\" .   No other app can fake an install result.","url":"https://calevans.com/picture-frame/docs/security/index.html#kiosk-updates","tags":"","category":""},{"id":234,"title":"The rollback watchdog","pageTitle":"Security","text":"A script that runs as root at boot ( device/frame-fixmac.sh ) rolls the kiosk back if a new version never proves healthy. It treats the app's files as hostile.   It keeps its own copy of the last good build  under  /data/misc/frame/  (directory mode 0700, root only). It copies the installed APK there only after the app has reported that exact version healthy (or once, when the watchdog is first installed and no update is in progress). It never installs a file the app wrote.   It never writes inside the app's data directory.  The rollback marker is a non-persistent system property.   It reads two numbers from app files, nothing more:  the  healthy  version and the  to  version of the update state. Each file must be a regular file, not a symlink, owned by the app's user id, and read through  timeout dd  so a named pipe cannot hang the script. Each value must be digits only, no leading zero, at most 10 digits and at most 2,100,000,000. This check happens before any arithmetic.   It rolls back only when  the installed version equals the version the update promised, the app has not reported it healthy, the saved good version is lower, and 90 consecutive 10-second checks (about 15 minutes) agree. Then it copies its own saved APK to a fresh file in  /data/local/tmp , runs  pm install -r -d , and deletes the copy. Android still enforces the signature.   The most a server can cause  is a rollback to the last build that ran healthy on that frame.   Health does not depend on the server.  The kiosk marks itself healthy after five minutes of running with no crash, or earlier after the first good request and a loaded dashboard.   The kiosk reports  watchdog: true  only if the script carries a marker line  and  root has published a property saying the watchdog can really work. A world-readable file alone is not trusted.   Keep your APK keystore clean: never sign throwaway or test builds with the real key. The release key is the second line of defense, not a reason to be careless with the first.","url":"https://calevans.com/picture-frame/docs/security/index.html#the-rollback-watchdog","tags":"","category":""},{"id":235,"title":"Photo handling","pageTitle":"Security","text":"Photos from strangers are the most hostile input the system accepts. The pipeline assumes a decoder can be exploited, and limits what an exploit can do.","url":"https://calevans.com/picture-frame/docs/security/index.html#photo-handling","tags":"","category":""},{"id":236,"title":"Before decoding","pageTitle":"Security","text":"The body is streamed to  var/photos/tmp  under a random 32-character name, created exclusively with mode 0600. A running count stops it at  upload_bytes  (30 MiB by default) with a  413 . A  Content-Length  over the cap is refused before reading.   The  Content-Type  must be  image/*  or  application/octet-stream . It is only a filter: the  bytes  decide the type.   The first bytes are matched to JPEG, PNG, WebP or HEIC. Anything else, including SVG, PostScript, AVIF and unknown formats, is a  415 . The decoder is chosen from this table, never from the file name or the header the client sent.   HEIC brand rules.  The file must be an  ftyp  box whose major brand is  heic ,  heix ,  heim ,  heis  or  mif1 , and whose compatible brands include one of  heic ,  heix ,  heim ,  heis . If  avif  or  avis  appears anywhere, it is refused. This keeps AVIF, which uses a different codec path, out.   Dimensions are read from the file header and capped at 12,000 pixels a side and 64 megapixels  before any decoding . For HEIC the worker parses the primary image's size from the file boxes (the first 2 MiB only).   Animated WebP, JPEG with anything but one or three color components (CMYK), and truncated JPEG are refused with their own codes.","url":"https://calevans.com/picture-frame/docs/security/index.html#before-decoding","tags":"","category":""},{"id":237,"title":"The worker","pageTitle":"Security","text":"Decoding runs in  worker.php , started as a separate process with an argument list (no shell). The web request never decodes an upload.         Layer   Value           Resource limits   prlimit : 1 GiB address space, 60 seconds CPU, 1.125 GiB largest file, 32 open files;  nice -n 10       Wall clock   One 60-second deadline across all steps; the process is killed with  SIGKILL  at the deadline. Each decoder step is killed after 45 seconds, and a whole HEIC upload (header check, conversion, resize) shares the one 60-second deadline.       PHP settings   Its own  photo-worker.ini : no  exec ,  proc_open ,  popen ,  curl_exec ,  mail ,  fsockopen  or sockets;  allow_url_fopen  and  allow_url_include  off;  enable_dl  off; 700 MB memory;  open_basedir  set to the widget folder, the one work directory and the one input file       Environment   Standard input is  /dev/null ; the environment is  PATH  only; output is capped (64 KiB out, 4 KiB errors)       Serialization   One decode at a time, under  var/photos/.ingest.lock . A second request waits up to 20 seconds, then gets  503 busy  with  Retry-After: 5       HEIC   heif-dec  runs in the same limits and sandbox and writes a PNG into the work directory; the worker then reads the PNG with GD         GD does the decoding for JPEG, PNG and WebP. No ImageMagick and no Ghostscript are used, so none of their coder and delegate attack surface applies.","url":"https://calevans.com/picture-frame/docs/security/index.html#the-worker","tags":"","category":""},{"id":238,"title":"The sandbox","pageTitle":"Security","text":"The  sandbox  option under  widgets.photos  in  config.php  chooses how the worker is isolated.         Mode   What it does   When to use it           bwrap  (default)   Runs the worker inside bubblewrap with no network, its own process, IPC and user namespaces, a new session, and the system directories read-only. It sees the widget code, one writable work directory and the input file.  config.php ,  frames.db , sessions and the rest of  var/  do not exist inside.   Most hosts.  Required by default : if bubblewrap cannot run, uploads are refused ( sandbox_unavailable ) rather than run unprotected.       user   Runs the worker as a dedicated unix user through one  sudo  rule and a root-owned wrapper. The wrapper reads its paths from a root-owned file, accepts only paths of the exact per-run shape, takes a lock, applies the limits and the kill, and kills every process left over by that user. A firewall rule stops that user making any network connection, and the self-test proves it.   Ubuntu 24.04, where a default AppArmor setting blocks bubblewrap.       auto   Uses bubblewrap if it works, otherwise runs unsandboxed with the limits only.   Development and tests. Not for drop links, because it can fall back to no sandbox.       none   No sandbox; limits still apply.   An explicit opt-out. The admin shows a note.         A self-test runs the worker (and the HEIC path) on built-in samples and is repeated at least hourly and after any deploy. If it fails, uploads answer  503 , the admin shows a banner, and  bin/migrate.php  prints a warning. Photo uploads are therefore a feature that can switch itself off; serving stored photos needs no decoder and keeps working. bin/migrate.php  also warns when it finds a  libheif  older than 1.17.6 or a  libwebp  older than 1.3.2. Those versions carry known decoder bugs.","url":"https://calevans.com/picture-frame/docs/security/index.html#the-sandbox","tags":"","category":""},{"id":239,"title":"What leaves the worker","pageTitle":"Security","text":"The worker re-encodes everything as a new baseline JPEG (quality 82, 4:2:0). Nothing from the original is copied.   Metadata is gone.  GD never writes EXIF, GPS, XMP, IPTC or color profiles, and its own comment segment is stripped. The EXIF orientation is read by a small bounded parser and applied to the already-shrunk image.   A JPEG segment allow-list is checked twice , in the worker and again in the web process. The only segments allowed are start of image, one 16-byte JFIF block, quantization tables, a baseline frame header, Huffman tables, restart interval, the scan with its restart markers, and end of image, with  no byte after the end marker . Anything else, such as a comment,  APP1  or a trailing payload, is a  422 .   The output must be a regular file (no symlink, no pipe) within size limits and within the 1280 × 800 and 320 × 200 boxes.   No color-profile conversion is done: Display P3 photos look slightly less saturated.","url":"https://calevans.com/picture-frame/docs/security/index.html#what-leaves-the-worker","tags":"","category":""},{"id":240,"title":"Storage and delivery","pageTitle":"Security","text":"Files are named by the hash of the  output , in  var/photos/files/<2 characters>/ , with directories at 0750 and files at 0640. The data directory must not be inside the web root; the code refuses if it is.   The serving code checks the resolved real path stays under  var/photos/files/ .   A photo is served as  image/jpeg  with  nosniff ,  Content-Security-Policy: default-src 'none'; sandbox ,  Cross-Origin-Resource-Policy: same-origin ,  Content-Disposition: inline  and a strong  ETag  from the content hash.   Every write or delete that touches stored files holds the ingest lock, and the \"is anything still using this file\" check and the delete happen together, so an upload cannot race a delete.   Each album's duplicate check uses the hash of the uploaded bytes, before decoding for the admin, and  after  decoding for drop links (see below).","url":"https://calevans.com/picture-frame/docs/security/index.html#storage-and-delivery","tags":"","category":""},{"id":241,"title":"Drop links give nothing away","pageTitle":"Security","text":"A drop link is public and write-only, and it must not become a way to learn about your library.   Approval queue.  Photos from a drop link are  pending . They are never put in a frame's catalog and never served to a frame, because every frame-side query and the serve route require  status = 'approved' . Admin uploads are approved at once.   Identical answers.  A new photo, a duplicate of a pending photo and a duplicate of an approved photo all return  202 {\"received\": true} . For drop links the decode runs first and the duplicate check second, so a duplicate costs about the same time as a new photo.   No capacity leaks.  Album full, library full, pending queue full and disk low all return a generic  507 unavailable . A broken decoder returns  503 unavailable . The real reason goes to the log only.   Ordering.  The server checks the token, then the origin, then the rate limits (charged for every attempt, even rejected ones), then the caps, and only then reads the body.   Quota information is minimal.  The info call returns the link's own remaining quota and the names it needs to greet the sender, nothing about other links or the album's contents.","url":"https://calevans.com/picture-frame/docs/security/index.html#drop-links-give-nothing-away","tags":"","category":""},{"id":242,"title":"Quotas","pageTitle":"Security","text":"All values can be changed under  widgets.photos  in  config.php . The defaults:         Setting   Default   Limits           library_bytes   5 GiB   Total stored photos       album_photos   5,000   Photos in one album       upload_bytes   30 MiB   One upload       pending_album   500   Pending photos in one album       pending_bytes   512 MiB   Pending photos in total       min_free_bytes   1 GiB   Free disk below which uploads stop (plus room for the decoder's scratch space)       drop_per_hour   100   Uploads per drop link per hour       drop_per_day   300   Uploads per drop link per day       drop_ip_per_day   500   Uploads per address per day, across links","url":"https://calevans.com/picture-frame/docs/security/index.html#quotas","tags":"","category":""},{"id":243,"title":"Cards use a different, lighter path","pageTitle":"Security","text":"The Cards widget resizes its cover and stamp art with GD inside the web process. That is intentional: only the admin can upload card art, so the risk is the admin's own files, not strangers'. Do not extend that path to public uploads.","url":"https://calevans.com/picture-frame/docs/security/index.html#cards-use-a-different-lighter-path","tags":"","category":""},{"id":244,"title":"Network ADB","pageTitle":"Security","text":"The frame runs a root  adbd  on TCP port 5555. Root ADB gives access to the frame key, the Home Assistant token and the saved Wi-Fi settings. It is the biggest single risk on the LAN, so the boot script puts an address allow-list in front of it. How the allow-list works ( ADB_ALLOW  in  /system/etc/frame.conf ):   The script builds an  iptables  chain  frame_adb  and an  ip6tables  chain with the same name. The chain accepts loopback, accepts each address or CIDR in the list, and drops everything else. IPv6 gets loopback only.   A jump to the chain is kept at the top of  INPUT  for port 5555. Android's network daemon flushes  INPUT  when it starts, so the script checks every second and puts the jump back, and removes duplicates.   A bad entry fails closed.  If any entry is not a valid IPv4 address or CIDR, the chain is built with loopback only, and the error is logged. You must fix the file and reboot to get ADB back over Wi-Fi.   An empty list is the explicit opt-out.  There is no limit and the script logs \"allow-list off\". Setting  ADB_ALLOW  is a decision you have to make; the provisioning guide asks you to.   If the firewall commands themselves fail, the list is  not applied  and the failure is logged. Read the log after provisioning.   USB ADB is never touched.  It is the recovery path. It needs physical access.   An allow-list based on addresses stops casual attackers, not an attacker who can take over an allowed address. Put the frame on a guest or IoT network where you can. Root ADB also exposes the frame's Wi-Fi password to anyone who reaches it.","url":"https://calevans.com/picture-frame/docs/security/index.html#network-adb","tags":"","category":""},{"id":245,"title":"The boot script","pageTitle":"Security","text":"device/frame-fixmac.sh  runs as root at every boot. Beyond the watchdog above, it:   Pins a per-frame, random, locally-administered Wi-Fi MAC address stored in  /data/misc/frame/  (mode 0700), because the Wi-Fi chip would otherwise pick a new one each time.   Saves the clock regularly and tries to restore it after a power loss, then restarts the kiosk when the clock is trustworthy. The frame has no battery-backed clock; with a wrong date every HTTPS connection fails. The exact  date  syntax for setting the clock is marked unverified in the script.   Hands the backlight control to the kiosk's user id, but only if that id is in the app range (10000 to 19999). It never hands it to root or a system user.   Validates  KIOSK_PKG  from  frame.conf  and falls back to the built-in name if it holds anything unexpected.   Starts the kiosk again if Android ever shows the Home chooser.","url":"https://calevans.com/picture-frame/docs/security/index.html#the-boot-script","tags":"","category":""},{"id":246,"title":"Offline use","pageTitle":"Security","text":"The kiosk keeps its last photos on disk and shows them if the server is unreachable. Photos from your own library are fetched with the frame's key; the kiosk sends the  Authorization  header only to URLs that start with its own photo route, rejects  ..  and encoded dots, and does not follow redirects. Nextcloud preview addresses get no credentials.","url":"https://calevans.com/picture-frame/docs/security/index.html#offline-use","tags":"","category":""},{"id":247,"title":"Repository hygiene","pageTitle":"Security","text":"The repository is public. The risk is not secrets in code; it is private files that happen to sit in the working folder.         Control   What it does   Why           Allow-list  .gitignore   /*  is ignored. Only listed paths are re-included:  docs ,  scripts ,  tools ,  kiosk ,  web ,  uboot , and a few named files. Inside them, keys, certificates, databases, APKs, images,  .env ,  config.php ,  var/ ,  cache/  and  vendor/  are ignored again.   A new file in a new place stays private by default.       Leak check   scripts/leak-check.sh  refuses staged files of private types ( .env , databases,  .htpasswd ,  config.php , APKs, keystores, keys, certificates, disk images,  .bin ,  .sunxi ) and any file whose content matches a pattern in your private  .leak-patterns .   Your own names, hosts and addresses never reach the history. The pattern list is itself gitignored, so the list of what is private is never published.       Pre-commit hook   scripts/pre-commit.sh  deletes  :Zone.Identifier  files that Windows adds, then runs the leak check. Never bypass it with  --no-verify .   The check only works if it always runs. See  Developer start .       Never redistribute firmware or dumps   Vendor firmware, the eMMC image, the boot files and anything extracted from a frame stay out of the repository and out of issues and merge requests.   They contain vendor code you cannot relicense and your frame's credentials and Wi-Fi settings. The  unlock guide  shows how to make the patch from  your own  backup.         Never put keys or passwords in an issue, a merge request, a log excerpt or a screenshot.","url":"https://calevans.com/picture-frame/docs/security/index.html#repository-hygiene","tags":"","category":""},{"id":248,"title":"Things you must not undo","pageTitle":"Security","text":"If you change one of these, you reopen a hole the project has already closed. Tests cover many of them.     __Host-  admin cookies with  SameSite=Strict ,  Secure ,  HttpOnly .  Weakening the prefix lets other hosts plant cookies.      Referrer-Policy: same-origin  on admin responses.   no-referrer  breaks the login (see above).  unsafe-url  leaks.    Charge-then-refund rate limiting before password checking, and the hash lock.  Moving the count after the check lets parallel guesses through.   The dummy hash matching the live Argon2id parameters.  Change them together.     Secrets only on the  Authorization  header path.  Never add the PIN hash, a token or an update offer to the cookie path, the dashboard JSON or an admin response.    The server never holds a signing key, and the kiosk builds the download address itself.  Never trust a URL from the server.   No downgrade by the app.  Only the root watchdog may install an older version, and only its own saved copy.     No  DISALLOW_INSTALL_APPS  and no other restriction that blocks package installs.  It would also block updates.    Root never writes into the app's data directory, and reads only validated digits from it.   The worker stays a separate process with the limits and the segment allow-list.  Never call GD,  heif-dec  or any decoder inside a web request. Never build a shell command from request data; use argument lists.   Drop links answer identically and queue everything.  Do not add \"duplicate\" or \"album full\" detail to the public answers, and do not show pending photos to frames.     Every frame-side photo query keeps  status = 'approved' .     The photo data directory stays outside the web root.     Content Security Policy without  unsafe-inline  or  unsafe-eval ; no  innerHTML , inline styles or inline handlers in widgets.     The core never names a widget, and widgets make no outbound requests.  Outbound calls go through  http_get  with HTTPS, a host allow-list, no redirects and a size cap.   Tokens are stored as hashes and are never logged.   Client addresses come from a proxy header only when the TCP peer is a listed trusted proxy.     The narrow  LimitRequestBody  exceptions in  .htaccess  keyed on  THE_REQUEST .  Widen one and every endpoint can take a large body.      The allow-list  .gitignore , the leak check and the pre-commit hook.       An invalid  ADB_ALLOW  entry fails closed.","url":"https://calevans.com/picture-frame/docs/security/index.html#things-you-must-not-undo","tags":"","category":""},{"id":249,"title":"Checklist for people who run a server","pageTitle":"Security","text":"[ ] Serve the frame site on its  own host name , over HTTPS, and turn on  hsts  once that is stable.   [ ] Create your admin user and turn on  two-step sign-in .   [ ] Set  client_ip_header  and  trusted_proxies  if you are behind a proxy or CDN.   [ ] Watch the log for the  frame admin:  lines and feed them to fail2ban.   [ ] Keep access logs private. They contain drop and card link tokens.   [ ] Give every frame its own PIN in the admin.   [ ] Set  ADB_ALLOW  on every frame, and put frames on a guest or IoT network.   [ ] Keep the APK keystore and the release key  off the server , backed up, and out of the repository.   [ ] Keep the sandbox on. Keep  libheif ,  libde265  and  libwebp  patched;  bin/migrate.php  warns about old versions.   [ ] Run  php app/bin/migrate.php  after every deploy and read its warnings.   [ ] Back up  var/  (it holds  frames.db , photos and card art) and keep the backups private.","url":"https://calevans.com/picture-frame/docs/security/index.html#checklist-for-people-who-run-a-server","tags":"","category":""},{"id":250,"title":"Known limits","pageTitle":"Security","text":"The admin and frame pages share an origin. A script-injection bug on any of them is as serious as the admin session in that browser.   The TOTP secret and the Home Assistant token are stored unencrypted in the database.   Home Assistant on a private address can use plain  http , so its token and traffic cross the LAN unencrypted. The admin warns you when you pick  http .   Root ADB on the LAN is only as protected as the allow-list. USB ADB is open to anyone with physical access.   A card from a leaked sender link reaches the frame without approval, within the limits above.   Android 6 receives no further security updates. The kiosk limits what the WebView can reach, but the platform underneath is old.","url":"https://calevans.com/picture-frame/docs/security/index.html#known-limits","tags":"","category":""},{"id":251,"title":"Related","pageTitle":"Security","text":"Install the frame server : permissions, Apache settings, the login and the photo decoder   Build the kiosk and set up a frame :  ADB_ALLOW , PINs and the release key   Kiosk update protocol : the update sequence in detail   API reference : every route, its authentication and its limits   Developer start : how to run the tests that enforce much of this page","url":"https://calevans.com/picture-frame/docs/security/index.html#related","tags":"","category":""},{"id":252,"title":"Install the frame server","pageTitle":"Install the frame server","text":"This guide is for people who can run a PHP site on Apache and edit a crontab. When you finish, you have a server that frames connect to and an admin page where you claim frames and choose what they show. You do not need a frame to follow it. The server is plain PHP and SQLite. It has no framework and no database server. You need an HTTPS address for it: the kiosk app refuses plain HTTP. This page also covers the server's settings, updates, backups and file permissions. Photos, admin logins, and kiosk updates have their own pages:  Photos ,  Use the admin  and  Update the kiosk .","url":"https://calevans.com/picture-frame/docs/server-install/index.html","tags":"","category":""},{"id":253,"title":"What you need","pageTitle":"Install the frame server","text":"A Linux server (or a small VPS) with Apache 2.4 and a valid HTTPS certificate on a host name.   PHP 8.5 with these extensions:  pdo_sqlite ,  curl ,  mbstring ,  gd  (with JPEG and PNG),  dom ,  xmlreader ,  xmlwriter  and  zlib . PHP 8.5 is the tested version. The extension list comes from reading the code, so a missing one shows up as an error in the PHP log.   Optional extensions:  openssl , which you need only if you set  kiosk_release_pubkey ;  pcntl , which lets  bin/user.php  restore your terminal if you press Ctrl+C at a password prompt; and PHP built with Argon2 support. Without Argon2 the server falls back to bcrypt for admin passwords, which accepts passwords of at most 72 bytes.   Optional, for the local photo library only:  bubblewrap ,  libheif-examples  and  libheif-plugin-libde265  (for iPhone photos),  prlimit  and  nice .  Photos  lists the checks.   Composer  to install one library ( sabre/vobject , for calendars).   A user that runs cron jobs (called the  cron user  below) and the web server's user ( www-data  on Debian and Ubuntu).   sudo  rights for creating directories and running commands as the web user.   Apache needs these modules:  mod_alias ,  mod_env ,  mod_dir  and  mod_setenvif , and a way to run PHP ( mod_php  or PHP-FPM). On Debian and Ubuntu:   sudo a2enmod alias env dir setenvif     The server must answer at the path  /frames/  of your host name, for example  https://frames.example.com/frames/ . The path  /frames  is fixed in the code.","url":"https://calevans.com/picture-frame/docs/server-install/index.html#what-you-need","tags":"","category":""},{"id":254,"title":"Layout","pageTitle":"Install the frame server","text":"The guide uses  /srv/frame  as the install root. Use another path if you like.   /srv/frame/ app/ the PHP code (from web/app) public/ the web root for /frames/ (from web/public) var/ frames.db, uploaded card art (notes/), kiosk updates (kiosk/), the photo library (photos/), admin sessions, device.key cache/ cache.db, written by the fetcher vendor/ Composer packages config.php your settings    Only  public/  is reachable from the web.","url":"https://calevans.com/picture-frame/docs/server-install/index.html#layout","tags":"","category":""},{"id":255,"title":"HTTPS is required","pageTitle":"Install the frame server","text":"The admin holds passwords and a session cookie, so it works only over HTTPS. Three things enforce it:   The admin refuses to run (HTTP 500) unless  frames_url  starts with  https:// .   The admin's cookies use the  __Host-  prefix. Browsers accept those only over HTTPS, with no  Domain  setting and the path  / .   The kiosk app refuses plain HTTP.   A dedicated host name ( frames.example.com  rather than a path on a site that also runs other applications) is the safest set-up. Nothing else on the host can then read or overwrite the admin's cookies. If your certificate comes from Let's Encrypt, the frame needs one extra step because Android 6 does not trust Let's Encrypt. See  Provisioning, step 4 . If a proxy or CDN sits in front of Apache, read  client addresses behind a proxy  below. The proxy must pass the original  Host  header through, or the admin login fails. See  Troubleshooting .","url":"https://calevans.com/picture-frame/docs/server-install/index.html#https-is-required","tags":"","category":""},{"id":256,"title":"Step 1: Copy the files","pageTitle":"Install the frame server","text":"On the server, as the cron user, create the install root and make it yours:   sudo install -d -o $USER -g $USER /srv/frame    Then on your workstation, from the repository:   rsync -rlpt --chmod=D2750,F0640 --chown=<cron user>:www-data web/app/ you@server:/srv/frame/app/ rsync -rlpt --chmod=D2750,F0640 --chown=<cron user>:www-data web/public/ you@server:/srv/frame/public/ scp web/composer.json web/composer.lock web/config.example.php you@server:/srv/frame/    Do not use  rsync -a . It copies your local owner and permissions, which breaks the model in  Step 3 . The options above set the modes to 0640 for files and 2750 for folders, and the group to the web user's group. Keep the  p : without it rsync drops the setgid bit, and folders end up 0750. --chown  needs root on the server. As any other user, rsync skips it without an error, so check with  ls -l /srv/frame/app . If the group is not  www-data , run this on the server:   sudo chgrp -R www-data /srv/frame/app /srv/frame/public    Do not add  -A  or  -X . Existing ACLs stay as they are, and new files in  app/widgets/photos/  inherit the folder's default ACL, so the photo decoder user keeps read access.","url":"https://calevans.com/picture-frame/docs/server-install/index.html#step-1-copy-the-files","tags":"","category":""},{"id":257,"title":"Step 2: Install the library","pageTitle":"Install the frame server","text":"On the server, as the cron user:   cd /srv/frame composer install --no-dev --optimize-autoloader chmod 700 vendor chmod 0640 composer.json composer.lock config.example.php    Only the cron job loads  vendor/ . The web server never needs it.","url":"https://calevans.com/picture-frame/docs/server-install/index.html#step-2-install-the-library","tags":"","category":""},{"id":258,"title":"Step 3: Set the permissions","pageTitle":"Install the frame server","text":"Two users touch the data. The web user writes  frames.db . The cron user writes  cache.db . Each reads the other's file through the group. The setgid bit ( 2 ) in the mode keeps new files in the right group. Replace  $USER  with your cron user and  www-data  with your web user if they differ:   sudo install -d -o www-data -g $USER -m 2750 /srv/frame/var sudo install -d -o $USER -g www-data -m 2750 /srv/frame/cache    The files this guide creates have these owners and modes:         Path   Owner : group   Mode   Why           var/   www-data  : cron user   2750   The web user writes. The cron user reads.       cache/   cron user :  www-data   2750   The cron user writes. The web user reads.       var/photos/   www-data  : cron user   2750   Library photos and the upload work area. Only the web user writes.       app/ ,  public/   cron user :  www-data   2750 folders, 0640 files   The web user reads. The cron user reads and updates. Set by the Step 1 rsync options.       config.php   cron user :  www-data   0640   Both read it.       vendor/   cron user   0700   Only cron loads it.         Keep  cache/  read-only for the web user. Do not make it group-writable. How permissions work  explains the model.","url":"https://calevans.com/picture-frame/docs/server-install/index.html#step-3-set-the-permissions","tags":"","category":""},{"id":259,"title":"Step 4: Write the config","pageTitle":"Install the frame server","text":"Copy the example and open it:     cd /srv/frame cp config.example.php config.php sudo chgrp www-data config.php chmod 0640 config.php          Set at least  frames_url  and  owner_name . The  configuration reference  explains every key.       Check the file as the web user. A mistake shows as an error that names the key:     sudo -u www-data php -r 'require \"/srv/frame/app/bootstrap.php\"; echo \"ok\\n\";'      You expect  ok . A bad value prints  frame: config error:  followed by the problem.","url":"https://calevans.com/picture-frame/docs/server-install/index.html#step-4-write-the-config","tags":"","category":""},{"id":260,"title":"Configuration reference","pageTitle":"Install the frame server","text":"config.php  returns a PHP array. It holds no secrets: every secret lives in the database or in files under  var/ . Unknown keys are an error, so a typo is caught the first time the file loads.         Key   Required   Type and default   What it does           frames_url   Yes   Text. No default.   The public address, ending in  /frames/ . It must start with  https:// . Example:  https://frames.example.com/frames/ . It must have no query or fragment. The kiosk's  FRAME_SERVER_URL  must match it.       owner_name   Yes   Text, up to 40 characters. No default.   Your name, shown to people who use your links. For example, \"Ask Alex for a new link\". No  < ,  > ,  &  or quote marks.       user_agent   No   Printable ASCII text. Default  photo-frame/1.0 .   Sent with every download the server makes. Put a contact URL in it.       calendar_hosts   No   List of lowercase host names. Default empty.   The only hosts calendars may be downloaded from. HTTPS only.       photo_hosts   No   List of lowercase host names. Default empty.   The only hosts Nextcloud photo albums may be loaded from. HTTPS only.       client_ip_header   No   An HTTP header name, such as  CF-Connecting-IP . Default none.   Use it only behind a reverse proxy or CDN. It names the header that carries the real client address.       trusted_proxies   No   List of CIDR ranges and the preset name  'cloudflare' . Default empty.   The proxies allowed to set  client_ip_header . The header is believed only when the connection comes from a listed proxy. Setting the header without proxies is an error.       hsts   No   true  or  false . Default  false .   true  makes the admin send  Strict-Transport-Security: max-age=31536000 . Turn it on once you are sure the whole host name stays on HTTPS.       kiosk_release_pubkey   No   PEM text of an EC P-256 public key. Default none.   When set, an uploaded kiosk release whose signature does not verify is rejected. Needs the PHP  openssl  extension. See  Update the kiosk .       var_dir   No   Absolute path. Default  var/  next to  app/ .   Where  frames.db , photos, card art and kiosk releases live.       cache_dir   No   Absolute path. Default  cache/  next to  app/ .   Where the fetcher writes  cache.db .       widgets   No   Map of widget ID to options. Default empty.   Per-widget options. The photos widget takes the limits and the sandbox settings in  Photos .         The server fetches nothing from a host that is not in  calendar_hosts  or  photo_hosts . Add the host of the cloud service where your calendar and photo album live, for example  'cloud.example.org' . A minimal file:   php   <?php  declare (strict_types= 1 );  return  [  'frames_url'  =>  'https://frames.example.com/frames/' ,  'owner_name'  =>  'Alex' ,  'user_agent'  =>  'photo-frame/1.0 (+https://frames.example.com)' ,  'calendar_hosts'  => [ 'cloud.example.org' ],  'photo_hosts'  => [ 'cloud.example.org' ], ];","url":"https://calevans.com/picture-frame/docs/server-install/index.html#configuration-reference","tags":"","category":""},{"id":261,"title":"Client addresses behind a proxy","pageTitle":"Install the frame server","text":"If the server sits behind a proxy, set  client_ip_header  (for example  'CF-Connecting-IP' ) and  trusted_proxies  (CIDR ranges, or the preset  'cloudflare' ):   php   'client_ip_header'  =>  'CF-Connecting-IP' ,  'trusted_proxies'  => [ 'cloudflare' ],    Behind a proxy with no  client_ip_header , every visitor looks like the proxy's address. The admin's per-address login limit (10 failed sign-ins in 15 minutes) then counts everyone together, so a few failed attempts lock the login for all. That fails closed and is safe, but set the header so one bad client cannot lock you out. The  cloudflare  preset lists Cloudflare's published address ranges at the time of the release. If Cloudflare changes them, update the list by hand.","url":"https://calevans.com/picture-frame/docs/server-install/index.html#client-addresses-behind-a-proxy","tags":"","category":""},{"id":262,"title":"What a config error looks like","pageTitle":"Install the frame server","text":"On the web, a bad config answers HTTP 500 with  {\"error\":\"server not configured\",\"code\":\"not_configured\"} . The reason is in the PHP error log on a line that starts  frame: config error: . The command-line tools print the same line on standard error.","url":"https://calevans.com/picture-frame/docs/server-install/index.html#what-a-config-error-looks-like","tags":"","category":""},{"id":263,"title":"Step 5: Configure Apache","pageTitle":"Install the frame server","text":"Add this to the virtual host that serves your HTTPS site. Change the paths if you used a different root:   SetEnv FRAME_APP_DIR /srv/frame/app Alias /frames /srv/frame/public <Directory /srv/frame/public> AllowOverride AuthConfig FileInfo Indexes Options Require all granted </Directory>    The  .htaccess  files in  public/  need those four override classes. Without any one of them Apache answers 500:   AuthConfig : the rules that block stray files.   FileInfo : passes the frame's  Authorization  header to PHP.   Indexes :  DirectoryIndex  and the fallback to  index.php .   Options : turns off directory listings.   If you cannot allow  .htaccess , copy the rules from  public/.htaccess  and  public/admin/.htaccess  into the virtual host. That includes the  <If>  blocks that raise the request-size limit for uploads (see  PHP settings ). PHP files must run through your PHP handler in the aliased directory. Then reload Apache:   sudo apachectl configtest && sudo systemctl reload apache2    If  app/  sits next to  public/ ,  SetEnv FRAME_APP_DIR  is optional. Set it when  app/  is elsewhere.","url":"https://calevans.com/picture-frame/docs/server-install/index.html#step-5-configure-apache","tags":"","category":""},{"id":264,"title":"What the .htaccess files do","pageTitle":"Install the frame server","text":".htaccess         File   Effect           public/.htaccess   Copies the  Authorization  header into the environment so PHP sees the frame's key. Turns off directory listings. Sends every unknown path to  /frames/index.php . Limits request bodies to 64 KiB, with larger limits for two upload addresses (frame screenshots and drop link photos). Denies direct access to any file that is not  index.php  and has an extension such as  php ,  sql ,  db ,  json ,  log ,  phar  or  bak .       public/admin/.htaccess   The same, with the fallback  /frames/admin/index.php  and a 16 KiB default limit. Larger limits for card art, kiosk releases and admin photo uploads. It also holds a commented-out block that refuses plain HTTP at the origin.         The optional block in  public/admin/.htaccess  is safe only when Apache itself sees TLS. Behind a CDN or proxy that talks HTTP to the origin, use the  X-Forwarded-Proto  variant described in the file, or you lock yourself out.","url":"https://calevans.com/picture-frame/docs/server-install/index.html#what-the-htaccess-files-do","tags":"","category":""},{"id":265,"title":"Step 6: Create the database","pageTitle":"Install the frame server","text":"Run the migration as the web user, so  frames.db  belongs to it:   sudo -u www-data php /srv/frame/app/bin/migrate.php    You see  applied 1 ,  applied 2  and so on, ending with  schema at 14  (the number grows with each new migration). Run the migration again after every update of  app/ : it applies only what is new. Until the migration has run:   On a fresh install there is no database, so requests fail with an internal error (HTTP 500).   After you deploy a release that needs a newer schema, requests answer  503 maintenance .   After the schema line, the command may print  WARNING (photos): ...  or  NOTE (photos): ...  lines on standard error. They tell you whether the photo library's requirements are met. They do not stop the migration and do not affect frames, Nextcloud sources or anything else. Only uploading photos is switched off until you fix a warning.  Photos  explains each line. Check the owner:   ls -l /srv/frame/var/    frames.db  should be  -rw-r----- www-data <cron user> .","url":"https://calevans.com/picture-frame/docs/server-install/index.html#step-6-create-the-database","tags":"","category":""},{"id":266,"title":"Step 7: Create your admin login","pageTitle":"Install the frame server","text":"Nobody can sign in to the admin until a user exists. Create yours right after the migration, as the web user:   sudo -u www-data php /srv/frame/app/bin/user.php add yourname    It asks for the password twice and does not show it. The password must be at least 12 characters.  Use the admin  covers the password rules, two-step sign-in, and the other  user.php  commands.","url":"https://calevans.com/picture-frame/docs/server-install/index.html#step-7-create-your-admin-login","tags":"","category":""},{"id":267,"title":"Step 8: Start the fetcher","pageTitle":"Install the frame server","text":"One cron job downloads weather, calendars and Nextcloud photo lists. The web server itself never makes outbound requests.     Run it once by hand, as the cron user, to see errors. It runs for about 45 seconds:     php /srv/frame/app/fetch/fetch.php      It prints nothing when all is well. Afterwards  ls -l /srv/frame/cache/  shows  cache.db  and  fetch.lock .       Add the cron line with  crontab -e :     * * * * * /usr/bin/timeout -s KILL 58 /usr/bin/php /srv/frame/app/fetch/fetch.php >/dev/null      The  timeout  kills a run that hangs on a bad calendar, so it never overlaps the next run. Check your  php  path with  command -v php .     The fetcher only downloads what an enabled widget on a claimed frame asks for. A server with no claimed frames, or with frames that use no weather, calendars or Nextcloud albums, leaves it idle. How often each source is refreshed is in  Widgets .","url":"https://calevans.com/picture-frame/docs/server-install/index.html#step-8-start-the-fetcher","tags":"","category":""},{"id":268,"title":"Step 9: Check that it works","pageTitle":"Install the frame server","text":"Replace the host with yours:   curl -s -o /dev/null -w '%{http_code} %{redirect_url}\\n' https://frames.example.com/frames/admin/ curl -s -w '\\n%{http_code}\\n' https://frames.example.com/frames/admin/api/session curl -s -w '\\n%{http_code}\\n' https://frames.example.com/frames/api/v1/nope curl -s -o /dev/null -w '%{http_code}\\n' https://frames.example.com/frames/    You expect:   303  to the login page ( .../frames/admin/login ).   A JSON body with  \"code\":\"auth_required\"  and  401 .   A JSON body with  \"code\":\"not_found\"  and  404 .   401 , because frames need their own key.   Then confirm the server refuses to run stray PHP files. Create throwaway files, test, and delete them:   cd /srv/frame/public echo '<?php echo 1;' > x.php && cp x.php x.phar curl -s -o /dev/null -w '%{http_code}\\n' https://frames.example.com/frames/x.php curl -s -o /dev/null -w '%{http_code}\\n' https://frames.example.com/frames/x.phar rm -f x.php x.phar    Both must print  403 . A  200  or  500  means the  .htaccess  rules are not active. Fix this before you leave the site up.","url":"https://calevans.com/picture-frame/docs/server-install/index.html#step-9-check-that-it-works","tags":"","category":""},{"id":269,"title":"Step 10: Sign in and add your sources","pageTitle":"Install the frame server","text":"Open  https://frames.example.com/frames/admin/  and sign in with the user you made. The top navigation has  Frames ,  Library ,  Photos  and  Card designs . A frame appears under  Frames  after its kiosk enrolls.  Provisioning  covers claiming one. Before you claim a frame, you can add what it will show:   Weather.  In  Library , add a location. See  Widgets: Weather .   Calendars.  In  Library , add a calendar share link. See  Widgets: Calendar .   Photos.  Link a Nextcloud album or build a local library. See  Photos .   Cards.  Upload covers and stamps in  Card designs . See  Widgets: Cards .   Home Assistant.  You set it per frame. See  Widgets: Home Assistant .","url":"https://calevans.com/picture-frame/docs/server-install/index.html#step-10-sign-in-and-add-your-sources","tags":"","category":""},{"id":270,"title":"PHP settings","pageTitle":"Install the frame server","text":"Set these in the PHP that serves the web app (the  php.ini  for Apache's PHP or PHP-FPM):         Setting   Value   Why           post_max_size   At least  31M   A photo can be 30 MiB. The app refuses larger ones itself.       upload_max_filesize   At least  31M   The same reason.         Uploads are sent as raw request bodies, not as forms, and have two limits of their own:   LimitRequestBody  in the  .htaccess  files. They raise it to the right size for each upload address only. They match on  %{THE_REQUEST} , because Apache evaluates the limit on an internal subrequest where  REQUEST_URI  is the front controller. If you copied the rules into the virtual host instead, copy the  <If>  blocks too. Everything else keeps its small limit.   The size the app itself enforces.           Upload   .htaccess  limit   App limit           Photo in the admin   30 MiB   upload_bytes , 30 MiB by default       Photo from a drop link   30 MiB   upload_bytes , 30 MiB by default       Card cover or stamp   4 MiB   4 MiB       Kiosk release (APK)   16 MiB   16 MiB       Frame screenshot   600 KiB (614,400 bytes)   600 KiB       Everything else in the admin   16 KiB   none       Everything else for frames and phones   64 KiB   none         Set  post_max_size  to at least  17M  if you upload kiosk releases and never upload photos. If you raise  upload_bytes , raise  LimitRequestBody  in both  .htaccess  exceptions and  post_max_size  to match.","url":"https://calevans.com/picture-frame/docs/server-install/index.html#php-settings","tags":"","category":""},{"id":271,"title":"Update the server","pageTitle":"Install the frame server","text":"Copy the new  app/  and  public/  over the old ones with  rsync . Do not overwrite  config.php .     rsync -rlpt --chmod=D2750,F0640 --chown=<cron user>:www-data web/app/ you@server:/srv/frame/app/ rsync -rlpt --chmod=D2750,F0640 --chown=<cron user>:www-data web/public/ you@server:/srv/frame/public/      Use the same rsync options as in  Step 1 . Files that a release removed stay behind. Add  --delete  to both commands to remove them. That is safe when  app/  and  public/  hold only code you copied from the repository.       If  web/composer.lock  changed, copy it and  composer.json  and run  composer install --no-dev --optimize-autoloader  again in  /srv/frame .       Run the migration again as the web user:     sudo -u www-data php /srv/frame/app/bin/migrate.php      Until it finishes, requests answer  503 maintenance .       Read what the migration prints. New  WARNING  lines mean something the photo library needs has changed.       If you use the photo decoder user ( 'sandbox' => 'user' ) and the release changed  app/widgets/photos/bin/frame-photo-run , install the new copy. See  Photos, sandbox mode user .       Tell the frames to reload:     touch /srv/frame/app/bootstrap.php      Frames reload when the newest modification time under  app/  changes. rsync keeps the source file times, which can be older than the server's, so run this after each update.     Updating the server does not update the kiosk app on the frames. For that, see  Update the kiosk .","url":"https://calevans.com/picture-frame/docs/server-install/index.html#update-the-server","tags":"","category":""},{"id":272,"title":"Fixing an older install's permissions","pageTitle":"Install the frame server","text":"If you installed before the Step 1 options existed, or  --chown  could not run, set the group and modes by hand:   sudo chgrp -R www-data /srv/frame/app /srv/frame/public && sudo find /srv/frame/app /srv/frame/public -type d -exec chmod 2750 {} + && sudo find /srv/frame/app /srv/frame/public -type f -exec chmod 0640 {} +    If you use the photo decoder user, run the  find ... -print  check from  Photos  afterward.","url":"https://calevans.com/picture-frame/docs/server-install/index.html#fixing-an-older-installs-permissions","tags":"","category":""},{"id":273,"title":"Back up the server","pageTitle":"Install the frame server","text":"Back up these. Without them, a restored database shows blank photos, blank cards, or no kiosk releases.         What   Where   Why           The database   var/frames.db   Frames, widget settings, admin users, links (as hashes), cards, album and photo records, kiosk release records.       The photo library   var/photos/   The stored photos. The server keeps no originals, so these files are the only copy. Skip  var/photos/tmp/ .       Card art   var/notes/   Covers and stamps. A database restored without these leaves cards with blank covers.       Kiosk releases   var/kiosk/   The uploaded APKs.       Your settings   config.php   Your host names and options.         Take the database with SQLite's own backup command, so you never copy a file in the middle of a write. It needs the  sqlite3  command-line tool ( sudo apt install sqlite3 ) and a destination folder the web user can write to:   sudo -u www-data sqlite3 /srv/frame/var/frames.db \".backup '/path/to/backup/frames-$(date +%F).db'\"      Note     The  .backup  command is standard SQLite and was not run as part of writing this page.     You do not need to back up these:   cache/ . The fetcher rebuilds it.   var/sessions/ . Admins sign in again.   var/device.key . Deleting it makes every trusted-device cookie invalid. Nothing else changes.   var/photos/tmp/ . Work files.     Warning     These secrets are never on the server and cannot be restored from a server backup: the  APK keystore  ( ~/.android/frame-kiosk.keystore  by default) and the  release key  ( ~/.android/frame-release-key.pem ). They live on the computer where you build the kiosk. Back them up somewhere private. See  Update the kiosk .     To restore: put the files back, then run the migration as the web user. Drop links carry a secret in the address, so they appear in your web server's access log. Rotate or delete logs on a short schedule, keep them readable by root only, and revoke any link you suspect. See  Create a link  and  Public links .","url":"https://calevans.com/picture-frame/docs/server-install/index.html#back-up-the-server","tags":"","category":""},{"id":274,"title":"How permissions work","pageTitle":"Install the frame server","text":"The server splits its work between two users so a bug in one cannot damage the other's data.   The web user  handles every request. It writes  var/  and reads  cache/ . It never makes outbound requests and never loads  vendor/ .   The cron user  runs the fetcher, the only code that makes outbound requests. It writes  cache/  and reads  var/  through the group.   The setgid bit  (the leading  2  in  2750 ) makes new files in  var/  and  cache/  inherit the folder's group, so the other user can read them.     config.php  is 0640: owned by the cron user, group  www-data . No other local user can read it.      vendor/  is 0700. Only the cron user needs it.      Nothing in  var/  is reachable from the web.  The app refuses to start the photo library if  var_dir  is inside the document root.    The photo decoder  can run as a third, powerless user.  Photos  adds ACLs and a firewall rule for it.   Code in  app/  and  public/  is owned by the cron user, with group  www-data , mode 0640 for files and 2750 for folders. Other local users cannot read it. The photo decoder user's ACLs are designed for these modes.   Note     This was not verified on every host. If you use the decoder user sandbox, run the  find ... -print  check from  Photos  after a deploy.","url":"https://calevans.com/picture-frame/docs/server-install/index.html#how-permissions-work","tags":"","category":""},{"id":275,"title":"Related","pageTitle":"Install the frame server","text":"Use the admin   Photos   Update the kiosk   Troubleshooting and recovery   Security   Next:  Use the admin .","url":"https://calevans.com/picture-frame/docs/server-install/index.html#related","tags":"","category":""},{"id":276,"title":"Write a widget","pageTitle":"Write a widget","text":"This page is for developers who want to add a feature to the frame, or understand how the existing ones (Clock, Weather, Calendar, Photos, Home Assistant and Cards) are built. When you finish it you will have a working widget, and you will know every hook the server, the frame page and the admin offer, and the rules that keep widgets independent of each other and of the core. A  widget  is one folder,  web/app/widgets/<id>/ . Put the folder there and the widget exists on the server, in the cron fetcher, on the frame and in the admin. Delete it and it is gone from all four. No core file names a widget, and a test makes sure none ever does.","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html","tags":"","category":""},{"id":277,"title":"How a widget is wired in","pageTitle":"Write a widget","text":"The registry ( widget_names() ) is  glob(app/widgets/*/widget.php) , sorted, with the id checked against  ^[a-z][a-z0-9_]{0,31}$ . Everything else is looked up by that id. File paths are built only by  widget_path($id, $file) , which throws for an unknown id, so no request can reach a path outside a registered widget.","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#how-a-widget-is-wired-in","tags":"","category":""},{"id":278,"title":"Build your first widget","pageTitle":"Write a widget","text":"You will make a widget called  Greeter  that shows a line of text sent by the server. It needs five files and no core change. Before you start, set up a  development copy  and sign in to the admin with a frame claimed (see the  API reference  for enrolling a frame without a device).     Create the folder  web/app/widgets/greeter/  and, inside it,  widget.php :     php   <?php  declare (strict_types= 1 );  return  [  'label'  =>  'Greeter' ,  'description'  =>  'Shows a line of text.' ,  'home'  => [ 'row'  =>  2 ,  'order'  =>  99 ,  'span'  =>  1 ],  'settings'  => [],  'needs'  =>  fn (array $cfg): array => [],  'data'  =>  fn (array $cfg, Cache $c, Frame $f): array => [ 'text'  =>  'Hello from Greeter' ], ];          Add  icon.svg . It holds only the  inside  of a 24 by 24 stroke icon, with no  <svg>  wrapper:     xml   <circle cx=\"12\" cy=\"12\" r=\"9\"/><path d=\"M8 12h8\"/>          Create  frame/home.php , the markup that goes inside the widget's home cell:     php   <span id= \"greeter-text\"   class = \"greeter-text\" ></span>          Create  frame/widget.js . The shape is fixed: strict mode, one function that calls itself, one  F.widget({...}) .     js   \"use strict\" ; (() => {  const  F = window.Frame; F. widget ({ id:  \"greeter\" ,  update (data) {  if  (data ===  null )  return   false ; F.$( \"greeter-text\" ).textContent = data.text; }, }); })();          Create  frame/widget.css . Every selector starts with  .w-greeter :     css   .w-greeter .greeter-text { font-size: 28px; color: var(--soft); }          Run  php tests/run.php  and  vendor/bin/phpstan analyse  from  web/ . The registry, scope, security and isolation tests pick up the new folder by themselves.       In the admin, open the frame and its  Widgets  panel and switch on  Greeter . Saving bumps the frame's  config_version , so the dashboard reloads on its next poll (within 20 seconds). A  Greeter  card appears in the second row of the home grid with your text.     You just added a widget without editing a core file. Switch it off in the admin, and the card goes. Delete the folder, and the frame, the admin and the cron job forget it; its leftover  frame_widgets  rows are ignored.","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#build-your-first-widget","tags":"","category":""},{"id":279,"title":"Directory layout","pageTitle":"Write a widget","text":"Path under  widgets/<id>/   Needed   Loaded by   What it holds           widget.php   Yes   Web, admin, cron   The definition: returns an array of hooks (see  The definition )       icon.svg   Yes   Dashboard and admin views   Inner markup of a 24 by 24 stroke icon. Becomes the sprite symbol  #icon-w-<id>       lib.php   No   Your own  require s   Domain functions. No outbound requests       routes.php   No   Frame front controller   Public and frame-authenticated routes       admin_routes.php   No   Admin front controller   Admin API routes       handlers.php   No   routes.php , tests   Handlers for the routes, so tests can load them without registering routes       fetch.php   No   Cron loop only   The fetcher definition       fetch_lib.php   No   fetch.php , tests   Fetch functions, so tests can load them without the definition       frame/home.php   If  home   views/dashboard.php   Markup for the home cell       frame/pages.php   If the widget has pages   views/dashboard.php   One or more  <section class=\"page\" data-page=\"…\">       frame/overlay.php   No   views/dashboard.php   Markup placed after the main stage (for example a dialog)       frame/widget.js   No   The frame page   Registers with  Frame.widget()       frame/*.css   No   The frame CSS bundle   Styles, scoped to the widget       admin/widget.js   No   The admin page   Registers with  Admin.widget()       admin/*.css   No   The admin CSS bundle   Styles for the widget's admin panel       public/   No   Your own routes   Public page files (the sender and drop pages)       tests/*_test.php   No   tests/run.php   The widget's tests. They vanish with the widget       sources/<id>/   Photos only   The photos widget   Photo sources (see  Photo sources )         Fragments ( home.php ,  pages.php ,  overlay.php ) are plain PHP files that print HTML. They may not contain  style= , an  on…=  handler or  <script> . Element  id s must be unique across the whole page, so prefix yours with the widget id. A widget that needs its own table must add a  core  migration. Per-widget migrations do not exist yet.","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#directory-layout","tags":"","category":""},{"id":280,"title":"The definition: widget.php","pageTitle":"Write a widget","text":"widget.php widget.php  returns an array. Only a few keys are required; the rest are optional hooks. A hook that is absent is not called.         Key   Required   Shape   What it does           label   Yes   string   Name in the admin       description   Yes   string   One line under the name in the admin       settings   Yes   array<string, string>   Setting key to setting  type .  []  for none. Keys match  ^[a-z][a-z0-9_]{0,31}$       needs   Yes   fn(array $cfg): list<string>   Cache keys the cron fetcher must keep fresh for a frame that has this widget on       data   Yes   fn(array $cfg, Cache $c, Frame $f): array   The widget's slice of the dashboard JSON       types   No   array<string, array>   Widget-local setting types       default   No   ['position' => int, 'enabled' => fn(PDO): bool, 'config' => fn(PDO): array]   Row seeded when a frame is claimed       home   No   See  Home cell   Where the widget's card sits on the home screen       page   No   [string $label, string $icon]   A rail item that opens the widget's page       pageIf   No   fn(array $cfg, Cache $c): bool   Show the rail item only when true       info   No   fn(array $cfg, PDO $db, string $frameId): array   Extra facts for the admin, returned as  info  on the widget row       self   No   fn(array $cfg, PDO $db, array $frameRow): array   Values added to the kiosk's self GET       origins   No   fn(array $cfg): list<string>   Origins the kiosk may navigate to.  Only one widget may declare it       secrets   No   fn(array $cfg): array   Values for the kiosk's native code only       on_save   No   fn(array $incoming, array $stored): array   Adjusts a config before it is validated and saved       csp   No   array<string, list<string>>   Extra Content Security Policy sources       checks   No   fn(): list<string>   Host problems for  bin/migrate.php  to print         Hooks run with these guarantees:   needs ,  data ,  self ,  origins ,  secrets  and  pageIf  run only for  enabled  widgets of  active  frames whose id is still in the registry.   $cfg  is the widget's saved settings for that frame, decoded from JSON.   self  and  secrets  are wrapped so that an exception is logged and the widget contributes nothing; the page and the other widgets are unaffected.   data  returning  []  is sent as  {} . A widget that is off is  absent  from the dashboard JSON, which is how the frame page tells \"off\" from \"on but empty\".","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#the-definition-widgetphp","tags":"","category":""},{"id":281,"title":"Settings and setting types","pageTitle":"Write a widget","text":"settings  maps each key to a  type . A type says how a value is validated and what its default is. Three core types are always available:         Type   Stored as   Rule   Default           location   integer   The id of an existing Library location   none (required)       calendars   list of integers   At most 50 ids of existing Library calendars   []       seconds   integer   5 to 3600   30         A widget adds its own types under  types :   php   'types'  => [  'my_text'  => [  'default'  =>  '' ,  'secret'  =>  false ,  // optional   'check'  =>  fn (mixed $v, string $key, PDO $db): string =>  is_string ($v) &&  mb_strlen ($v) <=  40  ? $v :  throw   new   FieldError ( 'config' ,  \"$key must be at most 40 characters\" ), ], ],    check  returns the clean value or throws  FieldError('config', $message) , which the admin API turns into a  422 . Type names are global: two widgets declaring the same name throws a  LogicException . A new type also needs a control in the widget's admin module, or the admin shows no field for it. How a save is processed ( PUT /api/frames/{id}/widgets/{widget} ):   on_save($incoming, $stored)  runs first, if the widget has it.   Secrets that the request left out are copied back from the stored config.   Every declared setting is validated: a missing key takes its type's default and is checked like any other value, so a type whose default fails its own check makes that setting required. An undeclared key is a  422 .   The result is stored and the frame's  config_version  goes up by one, but only if something changed.   Secret settings  ( 'secret' => true ) are never returned by the admin API. Omit the key in a  PUT  to keep the stored value; send  \"\"  to clear it. Use the  info  hook to tell the admin whether one is set (for example  token_set ). The Home Assistant token and the Nextcloud album link work this way.","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#settings-and-setting-types","tags":"","category":""},{"id":282,"title":"Seeding on claim: default","pageTitle":"Write a widget","text":"default When an admin claims a frame, every widget with a  default  hook gets a  frame_widgets  row ( ON CONFLICT DO NOTHING , so it never overwrites).  enabled  and  config  are functions of the database, so a default can depend on existing Library records. Clock, Weather and Calendar use it. Widgets without it start absent, which is off.","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#seeding-on-claim-default","tags":"","category":""},{"id":283,"title":"Home cell","pageTitle":"Write a widget","text":"home  places the widget's card on the home screen and needs  frame/home.php . The grid has four columns and two rows.         Row   Keys   Behavior           1   col  (1 to 4),  span   A fixed slot. With no data the slot stays as an empty card, so the layout does not jump. Two widgets may not claim the same column       2   order ,  span   Flows left to right by  order , packed against the right edge. Hidden while the widget has nothing to show       2   grow => true   Takes whatever columns the other row 2 cells leave, on their left. Only one cell can grow","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#home-cell","tags":"","category":""},{"id":284,"title":"Pages: page and pageIf","pageTitle":"Write a widget","text":"page pageIf 'page' => ['Week', 'calendar']  adds a rail item labelled \"Week\" with the icon  calendar . The item opens the page whose  data-page  equals the  widget id , so  frame/pages.php  needs a  <section data-page=\"<widget id>\"> . A widget can instead register a  launch  hook, which gives a rail item with no page of its own (Home Assistant does this).  pageIf  hides the rail item when it returns false; the dashboard JSON lists the visible items in  pages  as  {widget, label, icon} . A widget may have more pages than the one on the rail (the calendar has a second one that its home card opens). Page ids must be unique across widgets and may not be  home .","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#pages-page-and-pageif","tags":"","category":""},{"id":285,"title":"Facts for the admin: info","pageTitle":"Write a widget","text":"info Whatever  info  returns is added to the widget's row in  GET /api/frames/{id}/widgets  as  info . Use it for state the admin should show but the config does not hold: the effective URL, whether a secret is set, a count, an error.","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#facts-for-the-admin-info","tags":"","category":""},{"id":286,"title":"Values for the kiosk: self, origins, secrets","pageTitle":"Write a widget","text":"self origins secrets These feed the kiosk's  GET /api/v1/frames/{id}  (the \"self GET\").     self  returns keys merged into the body. A  null  value omits the key. The core default for  photos  is  [] , meaning \"clear your cache\", so a deleted or disabled photos widget degrades the same way. Changing the shape of the self GET is a change to a frozen contract: add keys, never rename or remove them. Shipped kiosks ignore keys they do not know.      origins  returns the origins the kiosk may navigate to besides the frames site. The kiosk takes the  first  entry as the Home Assistant origin, so only one widget may declare this hook. A test fails if a second does.      secrets  returns values placed under  widget_secrets.<id>  in the self GET, and  only  when the request carried the  Authorization  header. They never appear in the dashboard JSON or in any admin response. The kiosk reads only the Home Assistant token today; a new key needs a kiosk change before it does anything.","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#values-for-the-kiosk-self-origins-secrets","tags":"","category":""},{"id":287,"title":"Page security policy: csp","pageTitle":"Write a widget","text":"csp csp  adds sources to the dashboard and admin Content Security Policy, for example  ['img-src' => ['https://photos.example.org']] . A test limits this to  img-src ,  media-src  and  connect-src , and each source must be a plain  https  origin. A widget can never loosen  script-src  or  style-src .","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#page-security-policy-csp","tags":"","category":""},{"id":288,"title":"Host checks: checks","pageTitle":"Write a widget","text":"checks bin/migrate.php  calls each widget's  checks  and prints the strings as  WARNING (<id>): … . A string that starts with  NOTE:   prints as information. Use it for a missing system tool or an unsupported setting. It does not fail the migration.","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#host-checks-checks","tags":"","category":""},{"id":289,"title":"Options from config.php","pageTitle":"Write a widget","text":"config.php An operator can pass options to a widget under  widgets.<id>  in  config.php . They arrive untouched in the constant  WIDGET_CONFIG['<id>'] . Validate them yourself when you read them, and throw  ConfigError  for a bad value, as  photos_limits()  does.","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#options-from-configphp","tags":"","category":""},{"id":290,"title":"The frame page","pageTitle":"Write a widget","text":"The core page ( frame.js ) owns the rail, the  Dim  button, the Back button, the idle timer, the offline badge, the 20-second poll, reloading when the code changes, and the home grid. A widget supplies markup and a small script.","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#the-frame-page","tags":"","category":""},{"id":291,"title":"What the server composes","pageTitle":"Write a widget","text":"views/dashboard.php  walks the registry and writes, for each widget that has the files:         File   Becomes           frame/home.php   <button id=\"cell-<id>\" class=\"card home-cell w-<id> shortcut\" data-part=\"home\" data-widget=\"<id>\" data-row … data-span …>  containing your markup       frame/pages.php   <div class=\"w-<id>\" data-part=\"pages\" data-widget=\"<id>\">  inside the stage; its  <section data-page>  elements are the pages       frame/overlay.php   <div class=\"w-<id>\" data-part=\"overlay\" data-widget=\"<id>\">  after the stage       frame/widget.js   <script src=\"/frames/assets/w/<id>/widget.js?v=…\" defer>       icon.svg   <symbol id=\"icon-w-<id>\">  in a hidden sprite       frame/*.css   Part of  /frames/assets/frame.css?v=…         Every script is  defer , so all of them have run before the core starts on  DOMContentLoaded .","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#what-the-server-composes","tags":"","category":""},{"id":292,"title":"Registering: Frame.widget","pageTitle":"Write a widget","text":"Frame.widget   js   F. widget ({ id:  \"photos\" , pages: { photos: { fullscreen:  true , sticky:  true  } },  init (ctx) {},  update (data, ctx) {},  tick (now) {},  onShow (pageId) {},  onHide (pageId) {},  homeTarget () {},  idlePage () {},  busy () {},  railItems () {},  launch (pageId) {},  layout (span) {}, });    Every hook is optional, and every call is wrapped in  try / catch . A widget that throws is marked failed: its cell goes empty and the other widgets keep running. Check the browser console on the device or in a desktop browser for  widget <id> failed in <hook> .         Hook   Called   Notes           init(ctx)   Once, when the core starts   ctx  has  id ,  home  (the cell),  root  (the pages wrapper),  page(id)  (a page you own) and  slot(pageId)  (your slot in another widget's page;  null  if that page does not exist, which is how soft dependencies work)       update(data, ctx)   Every poll   data  is  dashboard.widgets[id] , or  null  when the widget is off or absent. Return  false  to hide the cell       tick(now)   Every second   now  is server-corrected milliseconds. Keep it cheap       onShow(pageId) ,  onHide(pageId)   After a page of yours becomes visible or hidden         homeTarget()   When the cell is tapped   Return the page id to open, or  null  for none. The default is the first page in  pages       idlePage()   When the idle timer expires   Return a page id to show instead of Home. The first non-null answer wins       busy()   Idle and reload checks   Return  true  to block returning home and reloading (for example while a dialog is open)       railItems()   Each poll   Extra rail entries:  [{id, label, icon, badge}]       launch(pageId)   A rail item with no page is tapped   Navigate away (for example  location.assign(url) ); return  false  to cancel       layout(span)   When your cell's width changes   span  is the number of columns the cell now has         pages  is an object keyed by page id with options:         Option   Effect           fullscreen   The rail is hidden while the page shows; a tap brings it back for five seconds. No Back button       sticky   The page survives idle return and a reload, and stays until someone picks another page       listed   The page is open only while the server's  pages  list names this widget       avail   A function that returns whether the page may open","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#registering-framewidget","tags":"","category":""},{"id":293,"title":"Helpers on window.Frame","pageTitle":"Write a widget","text":"window.Frame         Helper   What it gives you           $ ,  el ,  reflow   getElementById , a  createElement  shortcut that sets class and  textContent , and a forced layout       svgIcon(key, size) ,  icons.add(key, nodes)   An SVG icon. Unknown keys fall back to your sprite ( icon-w-<key> )       nowMs() ,  tz() ,  parts() ,  clock12() ,  ymd() ,  parseYmd() ,  utcFmt() ,  deg()   Time in the frame's zone and small formatters       changed(key, signature)   true  once when the signature differs from the last call. Use it to skip redundant redraws       touch() ,  idleMs()   Reset or read the idle timer       toast(text)   A brief message       frameId ,  api(path)   The frame id and a URL under  /frames/api/v1/frames/<id>/       showPage(id, fromCell) ,  current() ,  available(id) ,  busy() ,  loaded() ,  refresh() ,  slot(pageId, ownerId)   Page control and state         Rules that tests enforce on  frame/widget.js :   The file is  \"use strict\"; , then one  (() => { … })(); , then ends.   It contains exactly one  .widget(  call (do not write that text in a comment), and it registers  id: \"<the folder name>\" .   It does not use  document.write ,  eval(  or  innerHTML .   Core  frame.js  never names a widget.   Write text with  textContent  and build elements with  createElement . Everything the server sends is data, not markup.","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#helpers-on-windowframe","tags":"","category":""},{"id":294,"title":"CSS","pageTitle":"Write a widget","text":"All  frame/*.css  files are concatenated into one  frame.css : the core file first, then each widget's files, widgets and files sorted by name. The version is a hash over every part's path, modification time and size, so a change reaches the frame on its next reload. Because a rule in one widget must not change another, a test checks that:   Every selector starts with  .w-<id>  (not followed by a word character or hyphen) or  body.w-<id>-<name> .   @keyframes  are named  w-<id>-<name> . No other at-rule is allowed.   No rule uses  !important .   You may use the core variables  --bg ,  --surface ,  --line ,  --fg ,  --dim ,  --past ,  --accent ,  --accent-bg ,  --warn ,  --warn-bg ,  --soft ,  --font , and the core classes  .card ,  .sect-label ,  .page-title ,  .page-empty ,  .hscroll  and  .xfade .  body.page-fullscreen  and  .offline  are hooks you can style against.","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#css","tags":"","category":""},{"id":295,"title":"The admin","pageTitle":"Write a widget","text":"Most widgets need no admin code. The admin builds a settings panel for each widget from its  settings , using its own controls for the three core types. You need  admin/widget.js  only when a widget has a  custom control , an  extra panel , or its  own top-level view .   js   ( function  () {  \"use strict\" ;  const  { h, rowField } = Admin; Admin. widget ({ id:  \"photos\" , controls: { my_text: (w, key, ctx) => control },  // one per widget-local setting type  load: (frame) => Promise,  // extra data per frame; arrives as ctx.extra[id]  panel: (frame, w, ctx) => Panel,  // replaces the generic settings panel  views: [{ path:  \"/photos\" , nav:  \"Photos\" , render:  async  () => ({ title, nodes }) }], }); })();            Part   What it does           controls   Maps a setting type name to a function that builds a control.  w  is the widget row (with  config ,  settings ,  info ,  enabled ). A control is  { key, node, field, val(), reset(), commit(), validate() } .  val()  returns the value to save; returning  undefined  omits the key, which keeps a stored secret. Set  fn.secret = true  on the function of a secret type       load   Runs when a frame page opens. The result is  ctx.extra[<your id>]       panel   Returns a  Panel  that replaces the generic one       views   Adds a top-level page with a navigation link.  render  returns  { title, nodes }         A control that is missing for a widget-local type renders nothing, so the admin cannot set that setting. Add one. window.Admin  is the frozen toolbox the panels use:  h  (element builder),  fill ,  append ,  btn ,  pill ,  svgIcon ,  icons ,  uid ,  api ,  act ,  toast ,  modal ,  confirmDialog ,  formField ,  groupField ,  rowField ,  chipField ,  panelHead ,  Panel ,  showSwitch ,  makeForm ,  formDialog ,  ago ,  when ,  fmtTz ,  clockTime ,  dayLabel ,  widgetsHref ,  addLocationDialog ,  addCalendarDialog ,  route ,  upload  and  control . Read the existing modules for examples:  ha/admin/widget.js  for custom controls,  photos/admin/widget.js  and  notes/admin/widget.js  for panels and views. The same rules apply as on the frame: build DOM with  h() , never  innerHTML , no inline styles. Admin CSS goes in  admin/*.css , is bundled into  admin.css  the same way, and should be scoped to your widget.","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#the-admin","tags":"","category":""},{"id":296,"title":"Routes","pageTitle":"Write a widget","text":"A widget can add HTTP routes.  routes.php  and  admin_routes.php  each return a function that builds a list of routes. A route is  [METHOD, regex, handler] , and a handler takes the regex matches and  never returns : it ends with  json_out() ,  empty_out() , an asset response or an exception.         File   Signature   Registered with           routes.php   function (PDO $db, string $frameIdRegex): array   frames_dispatch()  under  /frames . Paths are relative to  /frames .  $frameIdRegex  is  ([0-9a-f]{32}) , already a capture group       admin_routes.php   function (PDO $db, string $base): array   admin_api()  under  /frames/admin . Paths start with  /api/           php   <?php  declare (strict_types= 1 );  return   function  (PDO $db, string $base): array {  return  [ [ 'GET' ,  '#^/api/greeters$#' ,  fn () =>  json_out ( 200 , [ 'items'  => []])], [ 'POST' ,  '#^/api/greeters$#' ,  fn () =>  greeter_create ($db, $base)], ]; };    Rules:   Core routes match first.  A test checks that no widget route regex matches any core path, for any method.   Admin routes are protected before your code runs.   admin_api()  has already required a signed-in session and a same-origin request, and requires the CSRF token for anything that is not a  GET . You must still call  admin_require_reauth()  yourself for destructive actions, and use  admin_json_body() ,  admin_invalid()  and  admin_unique()  for input and errors.   Frame routes authenticate themselves.  Call  frame_auth($db) , then  frame_require_self($row, $id)  and  frame_require_active($row) . Public pages that use a secret link do their own token check and rate limits; copy  photos/handlers.php  or  notes/handlers.php .     Read input with  request_json($allowed, $maxBytes) ,  request_body()  or  request_image() .  They give you  415 ,  413  and  422  for free.      Return errors by throwing  HttpError .  See the  API conventions .    New routes need a security review , and a review of the REST shape. See the  security page .   The Apache  .htaccess  files cap request bodies at 64 KiB (frames) and 16 KiB (admin). An endpoint that takes a large upload needs a narrow  LimitRequestBody  exception keyed on  THE_REQUEST , the way the photo and kiosk-release endpoints have. Keep it as narrow as they are.","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#routes","tags":"","category":""},{"id":297,"title":"Cron fetchers","pageTitle":"Write a widget","text":"A widget that needs data from the internet declares cache keys in  needs  and supplies a  fetch.php . Only the cron loop ( app/fetch/fetch.php ) loads  fetch.php , and only fetch code makes outbound requests. A test fails if any widget file calls cURL or opens a socket directly, or if a widget file other than  fetch.php  requires something from  app/fetch/ . Go out through  http_get()  (below).   php   <?php  declare (strict_types= 1 ); require_once __DIR__ .  '/fetch_lib.php' ;  return  [  'prefix'  =>  'gr' ,  'expand'  =>  fn (string $need): array =>  preg_match ( '/^gr:(\\d+)$/' , $need, $m) ===  1  ? [ \"gr:{$m[1]}:now\" ] : [],  'ttl'  =>  fn (string $key): int =>  600 ,  'fetch'  =>  fn (string $key, PDO $cache, PDO $frames): mixed =>  gr_fetch ($key),  'due'  =>  null ,  // optional   'everyTick'  =>  false ,  'crashGuard'  =>  false ,  'backoff'  =>  60 , ];            Key   Meaning           prefix   The text before the first  :  in your keys. It must be unique across widgets       expand(need)   Turns one need from  needs  into the concrete cache keys. Return  []  for a need you do not understand       ttl(key)   Seconds a cached value stays fresh       fetch(key, $cache, $frames)   Returns the value to store (any JSON-encodable value). Throwing marks the key failed       due(key, $cache, $frames, $now)   Optional. Return the key's last fetch time (0 if never) when it should be fetched now, or  null . Call  key_due()  for the standard rule       everyTick   Fetch on every 15-second tick when due, ahead of the one-item budget       crashGuard   Record a failure  before  fetching, so a parser that dies on hostile input leaves a back-off instead of a retry loop       backoff   Base seconds for exponential back-off on failure, capped at one hour         Each 15-second tick runs every  everyTick  key that is due and at most one other stale key, the one fetched longest ago. Read stored values in  data  with  $c->get($key) , which returns  ['v' => decoded, 'at' => time]  or  null . To go out, use  http_get($url, $hosts, [], $accept, $maxBytes)  or  http_get_json()  from  app/fetch/http.php . They enforce HTTPS, a host allow-list, no redirects, a size cap and timeouts. Take the host list from  config.php  ( CALENDAR_HOSTS ,  PHOTO_HOSTS ) or declare a fixed one for a public API.","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#cron-fetchers","tags":"","category":""},{"id":298,"title":"Photo sources","pageTitle":"Write a widget","text":"The photos widget shows pictures from one  source  per frame. A source is a folder,  widgets/photos/sources/<id>/ , found the same way widgets are. The widget merges every source's  settings ,  types  and  csp , so no other file names a source. Delete a source folder and the frames that used it show no photos. Today the sources are  nextcloud  (a public album link) and  local  (an album in the photo library). sources/<id>/source.php  returns:         Key   Shape   Meaning           label   string   Name in the source picker       settings   array<string, string>   Setting key to type, merged into the photos widget's settings       types   array   Setting types, like a widget's  types       key   fn(array $cfg): ?string   The catalog key stored with the frame's daily set.  null  means not configured       catalog   fn(array $cfg, Cache $c, PDO $frames): ?array   ['ids' => list<int>, 'added' => array<int, int>]  (photo id to the time it became visible), or  null  when not ready       url   fn(array $cfg, int $id, string $size, string $frameId): string   Address of a photo.  $size  is  frame  or  thumb       serve   ?fn(array $cfg, int $id): never   Streams a photo from this server.  null  means the frame loads  url()  directly       needs   fn(array $cfg): list<string>   Cron keys       csp   array<string, list<string>>   Extra  img-src  origins       info   fn(array $cfg, PDO $frames): array   Admin facts:  album_set ,  host ,  name ,  count ,  pending ,  error         Photo ids are integers. A source that needs the cron fetcher also adds  sources/<id>/fetch.php  returning  ['fetch' => fn(string $key, array $cfg, PDO $cache, ?callable $http): list<array{id: int, mtime: ?int}>] . Its keys must look like  ph:<16 lowercase hex> , because the photos widget's cron prefix is  ph  and it accepts only that shape. The widget picks 50 photos a day per frame from the catalog, favoring recently added ones. A source that serves photos itself ( serve ) must keep every query to  status = 'approved' . See the  security page .","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#photo-sources","tags":"","category":""},{"id":299,"title":"Isolation rules","pageTitle":"Write a widget","text":"The point of the design is that you can remove or add a widget without touching anything else. These tests keep it true. They run with  php tests/run.php :         Test   What it proves           Registry integrity   Every widget has a label, description,  needs ,  data  and a valid icon; page ids are unique and not  home ; setting types and fetch prefixes are unique; only one widget declares  origins       Core names no widget   No quoted widget id in  app/*.php ,  routes/ ,  fetch/ ,  views/ ,  bin/  or  public/ , apart from two documented exceptions       No outbound calls   No widget file calls cURL or sockets directly; only a widget's  fetch.php  may require from  app/fetch/ ; only the cron loop requires  fetch.php       Route shadowing   No widget route matches a core path       Fragments, scripts, CSS   The rules in this page for fragments,  widget.js ,  icon.svg  and CSS scoping       Deletion   For each widget, a copy of the app without that widget starts a server over a database that still holds its rows. The frame, the dashboard, the admin and cron must all work, and the error log must stay empty       Addition   A new widget folder, with no core edit, shows up in the admin list, the dashboard JSON and HTML, the CSS bundle and the icon sprite       Photo source deletion   Removing  sources/local  leaves frames showing no photos and everything else working         When you delete a widget, expect: the frame page reloads (the code version includes the  widgets  folder's modification time), its rail item and pages vanish, its home cell is empty (row 1) or closes up (row 2), its admin row and panel disappear, cron stops fetching its keys, and the kiosk's self GET falls back to the core defaults. Its  frame_widgets  rows, tables and cache rows stay and are ignored; delete them by hand if you want them gone. The checklist for a new widget:   Create  widgets/<id>/  with  widget.php  and  icon.svg .   Add  frame/widget.js ,  frame/widget.css  and  frame/home.php  and/or  frame/pages.php  as needed.   Add  lib.php ,  fetch.php ,  routes.php ,  admin_routes.php  or  admin/widget.js  only if you need them.   Put the tests in  widgets/<id>/tests/ .   Run the tests and PHPStan. Turn the widget on per frame in the  Widgets  panel.   The only core file you may touch is  config.php  for deployment values.","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#isolation-rules","tags":"","category":""},{"id":300,"title":"Related","pageTitle":"Write a widget","text":"Developer start : set up, tests and the build   API reference : the dashboard JSON, the self GET and the widget endpoints   Database : the  frame_widgets  table   Security : the rules that apply to routes, uploads and secrets","url":"https://calevans.com/picture-frame/docs/dev/widgets/index.html#related","tags":"","category":""},{"id":301,"title":"Database","pageTitle":"Database","text":"This page describes the server's storage: the two SQLite databases, every table and which migration made it, how migrations run, and the conventions to follow when you change the schema. It is for developers who add a table or a column, or who need to read the data directly. When you finish it you will be able to write a migration that fits in, and know what happens on a server when the code and the database disagree.","url":"https://calevans.com/picture-frame/docs/dev/database/index.html","tags":"","category":""},{"id":302,"title":"Two databases and some files","pageTitle":"Database","text":"text   var/ FRAME_VAR_DIR (default: next to app/; set var_dir in config.php) ├── frames.db Your decisions. The web user writes it; cron opens it read-only ├── sessions/ Admin sessions (directory mode 0700) ├── device.key Signing key for trusted-device cookies (mode 0600) ├── login.lock Lock that serializes password hashing ├── kiosk/<sha256>.apk Kiosk releases, named by hash ├── notes/<sha256>.<ext> Card covers and stamps, named by hash └── photos/ ├── files/<xx>/<sha256>.jpg and .thumb.jpg Stored photos and thumbnails ├── tmp/ Uploads and decoder scratch space (cleaned automatically) └── .ingest.lock One writer at a time cache/ FRAME_CACHE_DIR ├── cache.db Fetched data. Only cron writes it; the web user opens it read-only └── fetch.lock Keeps two fetcher runs apart    Back up  var/  (it holds  frames.db , the photos, the card art and the kiosk releases) and keep the backup private.  cache/  can be rebuilt by the fetcher.           frames.db   cache.db           Holds   Frames, widget settings, Library records, admin users, albums, photos, cards, kiosk releases, rate-limit events   Fetched weather, calendar occurrences and photo album listings; fetch status       Writer   The web user (and the command-line tools)   The cron fetcher       Schema   Versioned by migrations   Created by the fetcher with  CREATE TABLE IF NOT EXISTS , not versioned       If it is lost   Everything you configured is gone   The next fetch fills it again         Every connection turns on  PRAGMA foreign_keys = ON  and waits up to 5 seconds for a lock. The code never switches to write-ahead logging, so the default rollback journal is used. That keeps a read-only reader working without write access to a side file. Check-then-write sequences run inside  db_tx() , which opens the transaction with  BEGIN IMMEDIATE  so that no other writer can slip in between the check and the write.","url":"https://calevans.com/picture-frame/docs/dev/database/index.html#two-databases-and-some-files","tags":"","category":""},{"id":303,"title":"How migrations run","pageTitle":"Database","text":"Migrations are the numbered files  web/app/migrations/NNN_name.sql .  bin/migrate.php  applies them.   The version of the database is SQLite's  user_version . There are no down-migrations and no record of which file did what beyond that number.   Each file runs in one transaction together with the version bump. SQLite's schema changes are transactional, so a failed migration leaves the database as it was.   SCHEMA_VERSION  in  app/bootstrap.php  is the number the code expects. It must equal the highest migration.   Web requests never migrate.  On the first connection of a request,  db_frames()  compares  user_version  with  SCHEMA_VERSION . A mismatch throws  SchemaMismatch , and the visitor sees  503  with  Retry-After: 30  and  {\"error\": \"maintenance\", \"code\": \"maintenance\"} . After a deploy that adds a migration, the site answers  503  until you run  php app/bin/migrate.php . Frames keep their current page and retry.   The command-line tools ( user.php ,  kiosk-release.php ) refuse to run on a mismatched database and tell you to run  migrate.php  first.   migrate.php  runs with  umask(0027)  so new files are not world-readable, and needs the  var  directory to exist.","url":"https://calevans.com/picture-frame/docs/dev/database/index.html#how-migrations-run","tags":"","category":""},{"id":304,"title":"Add a migration","pageTitle":"Database","text":"Create  NNN_short_name.sql  with the next number. Never edit a migration that has been applied anywhere.   Write SQL only. A migration cannot call PHP.   Raise  SCHEMA_VERSION  in  app/bootstrap.php  to  NNN .   Update  tests/migrate_test.php  (it checks the version and what each migration does).  fresh_db()  in the test runner applies every file in order, so every test sees the new schema.   If existing rows need a value, backfill in the same file, as migrations 005, 007 and 011 did.   Run the tests and PHPStan.   A widget that needs its own table must add a  core  migration today; widgets cannot ship migrations of their own.","url":"https://calevans.com/picture-frame/docs/dev/database/index.html#add-a-migration","tags":"","category":""},{"id":305,"title":"Conventions","pageTitle":"Database","text":"Rule   Why           Every table is  STRICT . Column types are  INTEGER ,  TEXT ,  REAL  or  BLOB   SQLite then rejects a value of the wrong type instead of storing it. A  BLOB  column rejects  TEXT , so bind images with  PDO::PARAM_LOB       Timestamps are  INTEGER  Unix seconds, UTC   One format; no time-zone ambiguity. Times that are meant in the frame's own zone ( pick_time ,  bedtime ,  wake_time ) are  TEXT   HH:MM       Booleans are  INTEGER   0  or  1  with a  CHECK         Hashes and tokens are lowercase hex  TEXT  with  CHECK (length(x) = 64 AND x NOT GLOB '*[^0-9a-f]*')   The database refuses malformed values even if the application has a bug       CHECK  constraints repeat the application's rules (lengths, allowed values)   Defense in depth       Settings that vary by widget are JSON in a  TEXT  column   frame_widgets.config , validated by the widget on save       INTEGER PRIMARY KEY  for most tables   A rowid may be reused after the newest row is deleted       INTEGER PRIMARY KEY AUTOINCREMENT  where an id must  never  be reused   kiosk_releases ,  photo_albums ,  photo_drop_links ,  photos . Rate limits and history are keyed by these ids; a reused id would inherit another record's limits       Foreign keys say what happens on delete:  CASCADE  for owned rows,  SET NULL  for a record that outlives its source,  RESTRICT  where deleting would break a reference   kiosk_releases  cannot be deleted while a frame targets it       WITHOUT ROWID  for small tables with a composite key         Secrets are stored as hashes where the server never needs the original   Frame keys, link tokens, admin passwords, PINs","url":"https://calevans.com/picture-frame/docs/dev/database/index.html#conventions","tags":"","category":""},{"id":306,"title":"001 init (the original schema)","pageTitle":"Database","text":"init         Table   Holds           frames   One row per frame:  id  (32 hex),  key_hash ,  status  ( unclaimed ,  active ,  revoked ), pairing code and expiry,  name ,  display_kind  and  display_url ,  layout ,  tz ,  config_version , enrollment and last-seen details, and the last telemetry ( app_version ,  local_ip ,  mac ,  ssid ,  rssi ,  reported_at ). Indexes: a unique index on  pairing_code  (unclaimed rows only) and two for expiry and per-address limits       frame_widgets   (frame_id, widget)  primary key,  enabled ,  position ,  config  (JSON).  ON DELETE CASCADE  from  frames       locations   Library places:  name  (unique),  lat ,  lon , optional  stations       calendars   Library calendars:  label ,  url  (unique, a secret),  color ,  created_at       settings   Key/value pairs. Reserved; the code does not use it now       rate_events   (bucket, at)  rows that every rate limit counts. Index on  (bucket, at)       admins   Dropped by 002","url":"https://calevans.com/picture-frame/docs/dev/database/index.html#001-init-the-original-schema","tags":"","category":""},{"id":307,"title":"002 to 008 (frames grow)","pageTitle":"Database","text":"Migration   Change           002  drop_admins   Drops the  admins  table from 001. The built-in login arrived later, as  admin_users  in 012       003  idle_home   frames.idle_home_seconds  (default 120)       004  uptime_ipkey   frames.uptime ;  frames.enrolled_ip_key  (the address, or the IPv6  /64 ) with an index, for enrollment limits       005  frame_pin   frames.pin_hash  (JSON  {alg, iter, salt, hash} ). Moves the old global PIN into active frames and deletes it from  settings       006  ha_dashboards   frames.ha_dashboards  (JSON list of  {path, title} ) and  ha_dashboards_at       007  photo_set_bedtime   frames.pick_time ,  bedtime ,  wake_time  (with  HH:MM  checks; existing active frames get pick times two minutes apart from 05:00). Tables  frame_photo_sets  (the stored daily set:  album_key ,  picked_for ,  picked_at ,  ids ) and  frame_photo_shown  (photos already shown in this pass through an album)       008  screenshots_photoset   frames.screenshot_request_id  (32 hex or null) and  screenshot_requested_at ; table  frame_screenshots  (the latest capture per frame,  jpeg  as a  BLOB );  frame_photo_sets.new_ids","url":"https://calevans.com/picture-frame/docs/dev/database/index.html#002-to-008-frames-grow","tags":"","category":""},{"id":308,"title":"009 to 011 (cards and widget order)","pageTitle":"Database","text":"Migration   Change           009  notes   The cards feature:  frame_notes  (per-frame settings:  keep_days  1 to 30,  recipient_label ,  categories  JSON or null for all),  note_senders  (a person's link:  token_hash  unique,  revoked_at ,  last_used_at , unique  (frame_id, name) ),  note_designs  (covers) and  note_stamps  (file name is the hash plus extension, with a  CHECK  on its characters),  note_cards  ( message  1 to 400 characters; exactly one of  signature  JSON or  signature_text ;  opened_at ; unique  (sender_id, client_id)  so a repeated send is the same card)       010  notes_widget   Makes cards a normal widget: copies each  frame_notes.enabled  into a  frame_widgets  row ( position  7).  frame_notes.enabled  stays but is no longer read       011  widget_enabled_at   frame_widgets.enabled_at  (when the widget was switched on, null while off). Existing enabled rows get  1735689600 + position  so they keep their order. The admin lists widgets in this order","url":"https://calevans.com/picture-frame/docs/dev/database/index.html#009-to-011-cards-and-widget-order","tags":"","category":""},{"id":309,"title":"012 admin_users (the built-in login)","pageTitle":"Database","text":"admin_users admin_users :  username  (unique;  GLOB '[a-z0-9]*' , 1 to 64 characters),  password_hash ,  created_at ,  password_changed_at ,  last_login_at ,  session_epoch  (16 characters; changes to sign the user out everywhere),  totp_secret  (base32, null when two-step sign-in is off) and  totp_last_step  (the replay guard). Users are different from card senders. See  Security .","url":"https://calevans.com/picture-frame/docs/dev/database/index.html#012-adminusers-the-built-in-login","tags":"","category":""},{"id":310,"title":"013 kiosk_releases (self-update)","pageTitle":"Database","text":"kiosk_releases         Table or column   Holds           kiosk_releases   version_code  (unique, 1 to 2,100,000,000),  version_name  (up to 64),  sha256  (unique),  size ,  signature  (the detached release signature, base64),  notes  (up to 500),  uploaded_at ,  uploaded_by .  AUTOINCREMENT       kiosk_downloads   (frame_id, release_id)  primary key,  at . A frame that fetched a release in full. With a later report of that version it proves the release       frames.kiosk_target_id   The release this frame should run (foreign key to  kiosk_releases ,  ON DELETE RESTRICT ), and  kiosk_target_at       frames.kiosk_version_code   The version the kiosk reported       frames.kiosk_watchdog   1  or  0 : whether it reported a working rollback watchdog       frames.kiosk_update_failure   JSON  {sha256, reason}  of a held failure       frames.kiosk_attempt   Rises when an admin clears a failure, so the kiosk may retry","url":"https://calevans.com/picture-frame/docs/dev/database/index.html#013-kioskreleases-self-update","tags":"","category":""},{"id":311,"title":"014 photo_library (local photos)","pageTitle":"Database","text":"photo_library         Table   Holds           photo_albums   name  (unique, 1 to 60),  created_at .  AUTOINCREMENT       photo_drop_links   album_id  (cascade),  name  (unique within the album, 1 to 40),  token_hash  (unique, 64 hex),  created_at ,  expires_at ,  revoked_at ,  last_used_at .  AUTOINCREMENT       photos   album_id  (cascade),  src_sha256  (hash of the uploaded bytes; unique with the album, for duplicate detection),  file  (hash of the stored JPEG; unique with the album),  width ,  height ,  bytes ,  thumb_bytes ,  added_at ,  status  ( pending  or  approved ),  approved_at ,  drop_link_id  ( SET NULL ),  added_by . A  CHECK  ties  status = 'approved'  to  approved_at  being set. Indexes on album and time, album and status,  file , and  drop_link_id .  AUTOINCREMENT         Stored photos live in  var/photos/files/ , named by  file . Several albums may share one file; it is deleted only when no row refers to it.","url":"https://calevans.com/picture-frame/docs/dev/database/index.html#014-photolibrary-local-photos","tags":"","category":""},{"id":312,"title":"cache.db (created by the fetcher)","pageTitle":"Database","text":"cache.db         Table   Holds           cache   k  (key),  v  (JSON),  fetched_at . Keys look like  wx:1:obs ,  wx:1:hourly ,  cal:3 ,  ph:0123456789abcdef       fetch_state   k ,  last_ok ,  last_err  (up to 200 characters),  fail_count ,  next_at  (the earliest next try). Drives back-off       photo_seen   (album_key, file_id)  primary key,  added_at . When the fetcher first saw each Nextcloud photo. Rows are never deleted, so a bad listing cannot make an album look new","url":"https://calevans.com/picture-frame/docs/dev/database/index.html#cachedb-created-by-the-fetcher","tags":"","category":""},{"id":313,"title":"How the tables relate","pageTitle":"Database","text":"Two relationships are not foreign keys. A widget's  config  JSON names a location or calendar by id, and the application checks that on save and before it deletes a Library record ( 409 in_use ). A frame's local-album setting names an album by id the same way.","url":"https://calevans.com/picture-frame/docs/dev/database/index.html#how-the-tables-relate","tags":"","category":""},{"id":314,"title":"Rate-limit buckets","pageTitle":"Database","text":"rate_events  is one table for every limit. A bucket is a name; the limit counts rows with that name inside a window.  rate_purge()  deletes rows older than a day, and several request paths call it.         Bucket   Counts           enroll:<address key>   Enrollments from an address       pairing_fail   Wrong pairing codes (all addresses together)       login_fail_ip:<address key> ,  login_fail_user:<16 hex of a hash of the name>   Admin sign-in attempts, refunded on success       kiosk_dl:<frame id>   Kiosk release downloads       drop_auth_fail:<address key> ,  drop_req:<link id> ,  drop_up:<link id> ,  drop_up_ip:<address key> ,  drop_decode_fail:<link id>   Drop link guesses, requests, uploads and rejected files       note_auth_fail:<address key> ,  note_req:<sender id> ,  note_send:<sender id> ,  note_send_ip:<address key>   Card link guesses, requests and sends         The address key is the IPv4 address, or the IPv6  /64  as  <16 hex>::/64 .","url":"https://calevans.com/picture-frame/docs/dev/database/index.html#rate-limit-buckets","tags":"","category":""},{"id":315,"title":"Reading the data by hand","pageTitle":"Database","text":"Open the file read-only, and do it as a user who may read it (the web user):   sh   sudo  -u www-data sqlite3 -readonly /srv/frame/var/frames.db \\  \"SELECT id, name, status, kiosk_version_code FROM frames;\"     Do not write to  frames.db  while the site is up. If you must, stop the web server first, or use the admin, which holds the right locks. Never copy the live file with  cp  and expect a consistent copy; use  sqlite3 … \".backup '<file>'\" .","url":"https://calevans.com/picture-frame/docs/dev/database/index.html#reading-the-data-by-hand","tags":"","category":""},{"id":316,"title":"Related","pageTitle":"Database","text":"Developer start : run the tests and a development copy   API reference : the endpoints that read and write these tables   Writing a widget :  frame_widgets  and the settings JSON   Install the frame server : create the database and set its permissions","url":"https://calevans.com/picture-frame/docs/dev/database/index.html#related","tags":"","category":""},{"id":317,"title":"Kiosk update protocol","pageTitle":"Kiosk update protocol","text":"This page describes, from end to end, how a new version of the kiosk app gets from your build machine onto a frame: what the server offers, what the frame checks before it installs, what happens if the new version does not work, and what the frame reports back. It is for developers who change the updater, the release endpoints or the boot script, and for reviewers who want to check the security claims. If you only want to publish an update, follow  Update the kiosk . For why each check exists, read  Security .","url":"https://calevans.com/picture-frame/docs/dev/kiosk-update-protocol/index.html","tags":"","category":""},{"id":318,"title":"The idea in four lines","pageTitle":"Kiosk update protocol","text":"The server only offers.  It stores signed builds and tells each frame which one to take.   The device decides.  The frame installs a build only if it passes every check on the device itself.   Two keys, both off the server.  Android's package signing key keeps the app lineage. A separate, offline release key approves each specific build.   A build that does not prove itself is undone.  A root script on the frame reinstalls the last build that ran well.","url":"https://calevans.com/picture-frame/docs/dev/kiosk-update-protocol/index.html#the-idea-in-four-lines","tags":"","category":""},{"id":319,"title":"Who does what","pageTitle":"Kiosk update protocol","text":"Part   Where   Role           Build script ( kiosk/build.sh )   Your machine   Builds and signs the APK; writes the detached release signature       Release endpoints ( routes/admin_kiosk.php ,  kiosk_update.php )   Server   Check, store and offer releases; track what frames report       Updater  ( Updater.java )   Frame, app   Downloads, verifies, installs, reports       UpdateResultReceiver ,  ReplacedReceiver   Frame, app (not exported)   Learn the installer's verdict; restart the app after a replace       Boot script ( device/frame-fixmac.sh )   Frame, root   The rollback watchdog","url":"https://calevans.com/picture-frame/docs/dev/kiosk-update-protocol/index.html#who-does-what","tags":"","category":""},{"id":320,"title":"Build","pageTitle":"Kiosk update protocol","text":"kiosk/build.sh  makes the APK and, when  RELEASE_KEY  is set, a detached signature in  frame-kiosk.apk.sig .   The APK is signed with  both  the v1 (JAR) and v2 schemes, with  --min-sdk-version 23 , and the script verifies both. Android 6 checks only v1; newer versions prefer v2.   The release signature is ECDSA P-256 with SHA-256 over these ASCII bytes:  <sha256 hex of the APK>:<versionCode in decimal> , for example  ab12…ef:15 . The file holds the base64 of the signature's DER encoding.   The matching public key ( RELEASE_PUBKEY ) is compiled into the kiosk as  BuildCfg.RELEASE_PUBKEY  (the base64 of its X.509 encoding). With no public key in the build, the kiosk refuses every update.   versionCode  only ever goes up. To roll back, rebuild the old code with a  higher  number.","url":"https://calevans.com/picture-frame/docs/dev/kiosk-update-protocol/index.html#build","tags":"","category":""},{"id":321,"title":"Upload","pageTitle":"Kiosk update protocol","text":"POST /frames/admin/api/kiosk-releases  (admin session, CSRF token, password re-entered in the last five minutes) with:   Content-Type: application/vnd.android.package-archive   X-Release-Signature: <base64 DER>   the raw APK as the body, at most 16 MiB ( Content-Length  over the cap is refused before reading)   The server ( kiosk_release_store() ):   Streams the body to a temp file in  var/kiosk/  (mode 0640) with a running byte count, hashing as it goes.   Reads the file with a pure-PHP reader. It must start with a ZIP local header; the central directory must end exactly at the end-of-central-directory record; every entry's header must lie inside the file body and one must be at offset 0; at most 10,000 entries. That blocks \"Janus\" tricks. Only the manifest is inflated (1 MiB cap) and its binary XML is read with every offset checked. A file that is not a plain ZIP is  415 not_apk ; one that is a ZIP but unusable is a  422 .   Requires the package name  com.calevans.frame , a version code from 1 to 2,100,000,000, and a version name of 1 to 64 bytes without control characters.   If  kiosk_release_pubkey  is set in  config.php , verifies the signature against it and rejects the upload on failure. Without it the signature is stored as given, and the kiosk verifies it.   In one transaction, rejects a hash or version code that already exists ( 409 duplicate_sha256 ,  409 duplicate_version_code ), inserts the row, and atomically renames the file to  var/kiosk/<sha256>.apk .   The server never judges the APK's signing certificate. The device does.","url":"https://calevans.com/picture-frame/docs/dev/kiosk-update-protocol/index.html#upload","tags":"","category":""},{"id":322,"title":"Point frames at it","pageTitle":"Kiosk update protocol","text":"PATCH /api/frames/{id}  with  kiosk_target_id  sets or clears one frame's target.   POST /api/kiosk-releases/{id}/rollouts  targets every active frame that reports a lower version and is not already aimed at this version or a newer one.   Claiming a new frame aims it at the newest  proven  release, unless the same request names one.   Staged rollout.  A release is  proven  when some frame fetched it in full (a row in  kiosk_downloads )  and  later reported a  version_code  at least as high. A frame that has  no rollback watchdog , and any \"roll out to everyone\", may only target a proven release ( 409 release_not_proven ). So a release first goes to a frame that can undo it.","url":"https://calevans.com/picture-frame/docs/dev/kiosk-update-protocol/index.html#point-frames-at-it","tags":"","category":""},{"id":323,"title":"2. The offer","pageTitle":"Kiosk update protocol","text":"The frame's self GET carries a  kiosk_update  object when all of these hold:   the request used the  Authorization  header (never the cookie),   the frame has a target release,   the frame has not reported a  version_code , or reported a lower one.     json   \"kiosk_update\" : {  \"version_code\" :  15 ,  \"version_name\" :  \"15\" ,  \"sha256\" :  \"ab12…\" ,  \"size\" :  2201344 ,  \"signature\" :  \"MEUCIQ…\" ,  \"attempt\" :  0  }    There is  no URL  in the offer, on purpose.  attempt  rises each time an admin clears a failure for the frame. The kiosk calls  Updater.offer()  after every successful self GET. A missing object clears any prepared update. A malformed object, or a  version_code  that is not above the installed one, is ignored. An offer is also ignored while an install is being committed, when the same hash already failed permanently and the offer's  attempt  is not higher than the one recorded with the failure, or when that release is already prepared.","url":"https://calevans.com/picture-frame/docs/dev/kiosk-update-protocol/index.html#2-the-offer","tags":"","category":""},{"id":324,"title":"3. Download and verify, on the device","pageTitle":"Kiosk update protocol","text":"Updater.process()  runs on its own thread, in this order. The first failing step ends it.         #   Check   Failure reason   Permanent           1   The hash was marked failed and the  attempt  is not higher   (skip)         2   The previous attempt is still backing off for this hash   (skip)         3   size  is 1 byte to 32 MiB   size   Yes       4   The build has a release public key   release_sig   Yes       5   ECDSA P-256 signature over  <sha256>:<version_code>  verifies   release_sig   Yes       6   The app is the device owner   not_owner   No       7   Old  *.part  and  *.apk  files for other hashes are deleted           8   Download  (below), unless  <sha256>.apk  already exists   download ,  size ,  hash   See below       9   Verify  (below)   See below   Yes","url":"https://calevans.com/picture-frame/docs/dev/kiosk-update-protocol/index.html#3-download-and-verify-on-the-device","tags":"","category":""},{"id":325,"title":"Download","pageTitle":"Kiosk update protocol","text":"The kiosk builds the address itself:  SERVER_URL  from its build, then  api/v1/frames/<its own id>/kiosk-releases/<sha256> . Any address the server sent is ignored.   Requires free space of at least twice the size plus 32 MiB, and an  https://  server address.   Authorization: Frame <id>.<key> ,  Accept-Encoding: identity , 15-second connect and 30-second read timeouts,  no redirects , no cache.   The status must be  200  (on  429  it honors  Retry-After ), the content type must be exactly  application/vnd.android.package-archive , and a declared length must equal the offered size.   The body goes to  <sha256>.part  while the SHA-256 is computed. It stops with  size  if more than the offered size arrives, and with  download  after a 10-minute deadline or if fewer bytes arrive. After  fsync , a hash mismatch is  hash , and a match is renamed to  <sha256>.apk .","url":"https://calevans.com/picture-frame/docs/dev/kiosk-update-protocol/index.html#download","tags":"","category":""},{"id":326,"title":"Verify","pageTitle":"Kiosk update protocol","text":"verify()  runs after the download and  again just before the commit :   The file length equals the offered size ( size ) and its SHA-256 equals the offer ( hash ).   The ZIP layout check ( plainZip ): begins with  PK\\3\\4 ; one disk; no ZIP64; at most 10,000 entries and a 4 MiB central directory; the central directory ends exactly where the end record begins; every entry's local header lies before it and is a real local header; one entry is at offset 0 ( not_apk ).   Android can read the archive ( getPackageArchiveInfo ) ( not_apk ).   The package name is the app's own ( package ).   The archive's  versionCode  equals the offered one ( version_mismatch ) and is higher than the installed one ( not_newer ).   The archive's signing certificates are  exactly  the installed app's: the same set, both ways, never empty ( cert ).   All of these failures are permanent for that hash. The downloaded file is deleted.","url":"https://calevans.com/picture-frame/docs/dev/kiosk-update-protocol/index.html#verify","tags":"","category":""},{"id":327,"title":"Back-off","pageTitle":"Kiosk update protocol","text":"A transient failure ( download ,  not_owner , a missed deadline, a lack of space) retries after 5 minutes, doubling up to 6 hours, or the  Retry-After  the server sent if that is longer. The delay resets after a success.","url":"https://calevans.com/picture-frame/docs/dev/kiosk-update-protocol/index.html#back-off","tags":"","category":""},{"id":328,"title":"4. Install when nobody is looking","pageTitle":"Kiosk update protocol","text":"A verified build waits in  ready . Every 30 seconds the main thread asks the activity whether it is  quiet :   the network is up,   the admin panel is closed, and   the screen is  dimmed  (bedtime, or the  Dim  button), or nobody has touched it for 30 minutes.   When quiet,  install() :   Checks device owner and runs  verify()  again.   Adds one to the per-hash try counter ( attempts , at most 3 tries).   Opens a  PackageInstaller  session in  MODE_FULL_INSTALL , writes the APK, and calls  fsync .   Writes  files/update/state  atomically ( from ,  to ,  sha ,  attempt , and the kernel's boot id).   Creates an explicit-component  PendingIntent  for  UpdateResultReceiver  and  commits .   Android checks the new package's certificate against the installed one, replaces the app and kills it.  ReplacedReceiver  starts the activity again. The kiosk never shows the installer's confirmation screen: a session that wants the user is abandoned and counted as a failed install. If the installer reports a failure,  UpdateResultReceiver  writes the status to  files/update/result , and the updater records  install_<status> . It retries with back-off until the third try, then holds the failure permanently.","url":"https://calevans.com/picture-frame/docs/dev/kiosk-update-protocol/index.html#4-install-when-nobody-is-looking","tags":"","category":""},{"id":329,"title":"5. Proving health","pageTitle":"Kiosk update protocol","text":"After the restart the new version must show it works. It writes  files/update/healthy  (its own  versionCode ) and deletes  state  and  result  when  either :   the first self GET returned  200   and  the dashboard has loaded,  or   the activity has been resumed for five minutes without crashing, whatever the network does.   Health therefore does not depend on the server being reachable. On every later start,  startup()  looks at  state :         What it finds   What it concludes           state.to  is  not  above the running version   The update succeeded; health will clear it       state.to  is above the running version and  result  holds a status   The installer failed: record  install_<status>       state.to  is above the running version, no  result , and the boot id is the same or three tries are used up   The new build never ran well and the watchdog rolled back: record  unhealthy , permanent       state.to  is above the running version, otherwise   Power was cut mid-install: the offer comes again and counts as another try         Held  is a permanent failure for one hash and one  attempt  number. It lives in  files/update/failed , so it survives restarts. It ends when the admin clears the failure (the offer's  attempt  rises) or when the server offers a different hash.","url":"https://calevans.com/picture-frame/docs/dev/kiosk-update-protocol/index.html#5-proving-health","tags":"","category":""},{"id":330,"title":"6. The rollback watchdog","pageTitle":"Kiosk update protocol","text":"device/frame-fixmac.sh  runs as root from boot. Every 10 seconds, once  sys.boot_completed  is  1  and a  timeout  command exists, it runs the watchdog tick. It trusts nothing the app wrote.","url":"https://calevans.com/picture-frame/docs/dev/kiosk-update-protocol/index.html#6-the-rollback-watchdog","tags":"","category":""},{"id":331,"title":"Files","pageTitle":"Kiosk update protocol","text":"File   Owner   Written by   Holds           …/files/update/healthy   App   App   The version that proved itself       …/files/update/state   App   App   from ,  to ,  sha ,  attempt ,  boot       …/files/update/result ,  failed ,  attempts   App   App   Installer status; permanent failure; try count.  Root never reads these       /data/misc/frame/kiosk-good.apk   Root (0700 dir)   Root   A copy of the last build that ran well       /data/misc/frame/kiosk-good.vc   Root   Root   Its version code       Property  debug.frame.watchdog   Root   Root   1  while the watchdog can really work, else  0 . Apps cannot set  debug.*  properties       Property  debug.frame.rolledback   Root   Root   The version code that was rolled back; not persistent         Root reads  healthy  and the  to=  line of  state  only after checking each is a regular file, not a symlink, with no symlink in the path above it, owned by the app's user id, and read through  timeout dd  with a size cap so a named pipe cannot hang the script. Each number must be digits only, with no leading zero, at most 10 characters, and at most 2,100,000,000,  before  it is used in any comparison or arithmetic. Root writes nothing under the app's data directory.","url":"https://calevans.com/picture-frame/docs/dev/kiosk-update-protocol/index.html#files","tags":"","category":""},{"id":332,"title":"The tick","pageTitle":"Kiosk update protocol","text":"Read  H  (healthy),  T  (the  to  in  state ) and  G  (the saved good version).   Publish  debug.frame.watchdog  as  1  if  G  exists, else  0 .   No good build saved yet.  Save the installed one, at most 3 tries per boot, but only if it has proven itself ( H  equals the installed version) or no update is in progress at all (no  state  file). The copy is taken from  pm path , and only an expected path ( /data/app/<package>-*/base.apk ) is accepted.   Nothing to do  if  H  is empty or equals  G , and  T  is empty or equals  H . Reset the counter and clear the rollback marker.   If  H  equals the installed version and differs from  G , the new build earned its place: save it as  G .   Suspect  if  T  equals the installed version,  T  differs from  H , and  G  is lower than the installed version. Count one bad tick. Otherwise reset the counter.   At  90 bad ticks in a row (about 15 minutes) , and only if the rollback marker is not already  T : set the marker, copy  kiosk-good.apk  to a fresh unique name in  /data/local/tmp , run  pm install -r -d  on it, delete the copy, and start the kiosk.   The restored build is exactly the file root saved, and Android still checks its signature. The most a hostile server can cause is a return to the last build that ran well on that frame. Once the old build runs, its  startup()  sees an unfinished update with the same boot and records  unhealthy ; the failure is permanent, so the frame does not try that build again until an admin clears it.","url":"https://calevans.com/picture-frame/docs/dev/kiosk-update-protocol/index.html#the-tick","tags":"","category":""},{"id":333,"title":"7. What the frame reports","pageTitle":"Kiosk update protocol","text":"The kiosk's telemetry carries three update fields ( PUT /api/v1/frames/{id}/telemetry ):         Field   Source           version_code   The installed build's  versionCode       watchdog   true  only if  /system/bin/frame-fixmac.sh  contains the line  # frame-watchdog: 1   and  root has set  debug.frame.watchdog  to  1 . Checked every 10 minutes       kiosk_update_failure   {sha256, reason}  while a failure is held. Omitted when there is none         Telemetry is a replacement, so an omitted  kiosk_update_failure  clears the stored failure. The server keeps a failure only if its  sha256  is the frame's  current target ; anything else is dropped. It validates the reason against the list below ( install_  followed by an optionally negative number of up to three digits is also allowed).         Reason   Meaning   Retried by the kiosk           download   Network error, bad status, wrong content type, deadline, fewer bytes than offered, too little space, or a server address that is not https   Yes, with back-off       size   Offered size outside 1 byte to 32 MiB, a declared length that differs, more bytes than offered, or a stored file of the wrong length   No       hash   The downloaded SHA-256 does not match the offer   No       not_apk   The file is not a plain ZIP, or Android cannot read it   No       package   Not the kiosk's package   No       version_mismatch   The version inside the file is not the offered one   No       not_newer   The version is not above the installed one   No       cert   The signing certificates differ from the installed app's   No       release_sig   No release public key in the build, or the signature does not verify   No       unhealthy   The new build did not become healthy and was rolled back, or three tries ran out   No       not_owner   The app is not the device owner, so it may not install silently   Yes, with back-off       install_<status>   The installer's status code   Up to 3 tries         \"No\" means held until the admin clears the failure (or the server offers a different hash).","url":"https://calevans.com/picture-frame/docs/dev/kiosk-update-protocol/index.html#7-what-the-frame-reports","tags":"","category":""},{"id":334,"title":"The attempt counter","pageTitle":"Kiosk update protocol","text":"The admin clears a held failure with  DELETE /api/frames/{id}/kiosk-update-failure  (password re-entry required). The server deletes the stored failure and raises the frame's  kiosk_attempt  by one. The next offer carries the larger  attempt , and the kiosk, seeing a number above the one it recorded with the failure, clears the failure, resets its try counter and back-off, and tries once more.","url":"https://calevans.com/picture-frame/docs/dev/kiosk-update-protocol/index.html#the-attempt-counter","tags":"","category":""},{"id":335,"title":"What the admin sees","pageTitle":"Kiosk update protocol","text":"The frame's  kiosk.status  is derived, not stored:         Status   When           none   The frame has no target       failed   A failure for the target's hash is held, and the reported version is not already at or above the target's       unknown   It has a target, has not yet reported a version, and holds no failure       current   It reports a version at or above the target's       pending   Otherwise: waiting for the download or a quiet moment","url":"https://calevans.com/picture-frame/docs/dev/kiosk-update-protocol/index.html#what-the-admin-sees","tags":"","category":""},{"id":336,"title":"Rules for changing this","pageTitle":"Kiosk update protocol","text":"Never put a URL, a path or a credential in the offer, and never send the offer on the cookie path.   Never give the server a way to lower the version the device accepts.   Never add a restriction to the device-owner policy that blocks package installs ( DISALLOW_INSTALL_APPS ): updates and rollbacks both need them.   Keep the watchdog's rules: read only validated digits from the app's files, write nothing there, restore only the copy root made, and keep the rollback marker non-persistent.   Keep the receivers  exported=\"false\" .   A new kiosk-side failure reason needs three edits: the  REASON  pattern in  Updater.java ,  KIOSK_FAILURE_REASONS  in  kiosk_update.php , and the table above.   A release must not write preferences or files that the  previous  release cannot read, because a rollback keeps the app's data.   Every change here needs a security review. See  Security .","url":"https://calevans.com/picture-frame/docs/dev/kiosk-update-protocol/index.html#rules-for-changing-this","tags":"","category":""},{"id":337,"title":"Related","pageTitle":"Kiosk update protocol","text":"API reference : the release endpoints   Database :  kiosk_releases ,  kiosk_downloads  and the  frames.kiosk_*  columns   Build the kiosk and set up a frame : keys, the boot script and the first install   Developer start : the build","url":"https://calevans.com/picture-frame/docs/dev/kiosk-update-protocol/index.html#related","tags":"","category":""},{"id":338,"title":"Developer start","pageTitle":"Developer start","text":"This page is for people who want to change the code: fix a bug, add a widget, or understand how the pieces fit. When you finish it you will know where everything lives, how a request travels through the server, how to run the tests and a local server, how to build the kiosk, and which rules the project does not bend. If you want to  run  the system rather than change it, start with the  documentation home  instead.","url":"https://calevans.com/picture-frame/docs/dev/index.html","tags":"","category":""},{"id":339,"title":"Repository map","pageTitle":"Developer start","text":"text   . ├── docs/ Documentation │ └── dev/ These developer pages ├── web/ The server: PHP 8.5 and SQLite │ ├── public/ The two front controllers and their .htaccess files │ │ ├── index.php Frames, dashboard, public pages │ │ └── admin/index.php The admin │ ├── app/ Everything the front controllers load │ │ ├── bootstrap.php Config, constants, database helpers, errors │ │ ├── auth_admin.php Admin passwords, sessions, TOTP, re-authentication │ │ ├── auth_frame.php Frame key check │ │ ├── widgets.php Widget registry and hook runners │ │ ├── routes/ Core routes (frames, admin, admin login, kiosk releases) │ │ ├── widgets/<id>/ One folder per widget │ │ ├── fetch/ The cron fetcher: the only code that goes out to the internet │ │ ├── migrations/ 001 to 014 │ │ ├── bin/ migrate.php, user.php, kiosk-release.php │ │ ├── assets/ Core frame.js, admin.js and their CSS │ │ └── views/ Dashboard, admin shell, login page │ ├── tests/ Test runner and tests │ └── config.example.php Copy to config.php ├── kiosk/ The Android app: Java, no Gradle │ ├── src/com/calevans/frame/ │ ├── tools/ PinHash.java, ReleaseSign.java │ └── build.sh Builds the signed APK ├── device/frame-fixmac.sh Boot script that runs as root on the frame ├── scripts/ provision.sh, boot-ums.sh, leak-check.sh, pre-commit.sh, tests ├── tools/ Boot animation and unlock helpers ├── uboot/ Unlock patch and build files (GPL-2.0, kept apart) └── .env.example Settings for the kiosk build and provisioning    The Java package is  com.calevans.frame . It is the app's identity on every installed frame, so it does not change.","url":"https://calevans.com/picture-frame/docs/dev/index.html#repository-map","tags":"","category":""},{"id":340,"title":"How the pieces fit","pageTitle":"Developer start","text":"Three rules hold this shape together:   Two databases, two writers.   frames.db  holds your decisions and only the web user writes it; cron opens it read-only.  cache.db  holds fetched data and only cron writes it; the web user opens it read-only. Neither process can corrupt the other's file.   Only the fetcher goes out to the internet.  Web code never makes an outbound request. A test enforces this for every widget.   The kiosk is thin.  Layout and behavior decisions are made on the server. The kiosk polls, shows the page, and does the few things only native code can (install, dim, capture the screen, hold secrets).","url":"https://calevans.com/picture-frame/docs/dev/index.html#how-the-pieces-fit","tags":"","category":""},{"id":341,"title":"How a request is handled","pageTitle":"Developer start","text":"Frame front controller  ( public/index.php ):   bootstrap.php  reads  config.php , defines the constants and the database helpers.   The path must start with  /frames .   frames_dispatch()  builds the route list: the core routes first, then every widget's  routes.php .   Every route that matches the path is noted; the first one with the right method runs. If none matches the path, the answer is  404 ; if some match but not the method,  405  with  Allow .   Each handler authenticates itself ( frame_auth()  for frames, a link secret for public pages) and ends with  json_out() ,  empty_out()  or an asset response. These never return.   Admin front controller  ( public/admin/index.php ):   Bootstrap and the fixed security headers. Refuse to run unless  frames_url  is HTTPS.   Work out the path after  /admin . Only that part comes from the request.   /login  and  /assets/admin.css  need no sign-in. Everything else calls  admin_auth() : no valid session means  303  to the login page for  / , and  401 auth_required  for anything else.   /api/…  goes to  admin_api() , which checks the origin, matches the route (core routes first, then each widget's  admin_routes.php ), and requires the CSRF token for anything that is not a  GET .   /assets/admin.js , a widget's  admin/widget.js , and  /  (the shell) are the only other things served.   All errors travel as one exception type,  HttpError , and are turned into JSON  {\"error\", \"code\"}  (plus  fields  for validation) by one handler. See the  API reference .","url":"https://calevans.com/picture-frame/docs/dev/index.html#how-a-request-is-handled","tags":"","category":""},{"id":342,"title":"Principles","pageTitle":"Developer start","text":"These come from the owner's constraints and from past security reviews. A change that breaks one will be asked to change. Technology   PHP 8.5 and SQLite.  Plain functions and a few small classes. No framework, no ORM.   No Node and no Python  anywhere in project code, tests or tooling. JavaScript is plain scripts that run in the browser; there is no bundler and no JavaScript linter, so each frame widget is its own script (a syntax error in one loses that widget, not the page).   Composer packages are few and approved.  Today the only runtime dependency is  sabre/vobject  (calendar parsing), and it is loaded by the fetcher only. PHPStan is a development tool. Ask before adding a package.   The kiosk is Java built with Ubuntu's Android tools.  No Gradle, no downloaded SDK.   Security   Content Security Policy is strict:   default-src 'none' , scripts and styles only from  'self' . No  unsafe-inline , no  unsafe-eval .     No  innerHTML , no  document.write , no  eval , no inline  style= , no  on…=  handlers, no  <script>  in fragments.  Build DOM with  textContent  and  createElement . The admin has a small  h()  helper for this. Setting  element.style  from script is allowed.    Core code names no widget.  If you write a widget's id in  app/*.php ,  routes/ ,  fetch/ ,  views/ ,  bin/  or  public/ , a test fails (two documented exceptions).   Validate at the edge.  Every request body goes through  request_json()  (allowed fields, size limit,  415  for the wrong type,  422  for unknown fields). Check formats with a regular expression anchored with  ^…$ .   Secrets stay on the header path  and never appear in dashboard JSON or admin responses. Read  Security  before you touch authentication, updates or uploads.   Code style   Every PHP file starts with  declare(strict_types=1); . PHPStan runs at level 8 and must be clean.   Comments explain  why , not what. A comment that restates the next line goes.   Database:  STRICT  tables, Unix integer timestamps,  AUTOINCREMENT  only where ids must never be reused. See  Database .   State the current behavior in comments and docs, not the history.","url":"https://calevans.com/picture-frame/docs/dev/index.html#principles","tags":"","category":""},{"id":343,"title":"Set up a development copy","pageTitle":"Developer start","text":"You need PHP 8.5 on the command line with these extensions:  pdo_sqlite ,  mbstring ,  curl ,  gd  (with JPEG, PNG and WebP),  zlib ,  dom ,  xmlreader ,  xmlwriter , and  openssl  if you set  kiosk_release_pubkey . The Argon2id password hash needs PHP built with Argon2; without it the code uses bcrypt. You also need  Composer . From the repository root:   sh   cd  web composer install cp config.example.php config.php  # gitignored; the example values are fine for development     Keep the data outside the repository and point the app at it. The environment overrides work only for the command line and PHP's built-in server; a web server under Apache ignores them.   sh   mkdir -p /tmp/frame-dev/var /tmp/frame-dev/cache  export  FRAME_VAR_DIR=/tmp/frame-dev/var  export  FRAME_CACHE_DIR=/tmp/frame-dev/cache php app/bin/migrate.php  # creates frames.db, prints \"schema at 14\" (the number grows with each new migration)  php app/bin/user.php add dev  # asks for a password twice  php -r  'require \"app/bootstrap.php\"; require \"app/fetch/store.php\"; cache_rw();'   # creates an empty cache.db     The web side opens  cache.db  read-only, so it must exist before the first dashboard request. In production the cron fetcher creates it.","url":"https://calevans.com/picture-frame/docs/dev/index.html#set-up-a-development-copy","tags":"","category":""},{"id":344,"title":"Run the development server","pageTitle":"Developer start","text":"sh   php -S  127.0 . 0.1 : 8099  -t public tests/dev-router.php    tests/dev-router.php  does what the  .htaccess   FallbackResource  rules do under Apache: it strips a leading  /frames  for routing and sends everything to the right front controller. Then:         Address   What you get           http://127.0.0.1:8099/frames/admin/   The admin. Sign in as  dev .       http://127.0.0.1:8099/frames/api/v1/frames   The frame API ( POST  to enroll)         Under the built-in server the admin accepts plain  http , because that is the only case where it does not insist on HTTPS. Whether your browser keeps  Secure  cookies from a plain-http  localhost  address varies by browser; this has not been checked here. The tests sign in with curl and do not depend on a browser. To act as a frame without a device, enroll with  curl , claim the frame in the admin using the pairing code, then call the API with the key you generated. The  API reference  shows each request.","url":"https://calevans.com/picture-frame/docs/dev/index.html#run-the-development-server","tags":"","category":""},{"id":345,"title":"Change the server code","pageTitle":"Developer start","text":"There is no build step. Edit a file and reload. The dashboard page reloads itself when the code version changes (the newest modification time under  app/ , plus the frame's  config_version ).","url":"https://calevans.com/picture-frame/docs/dev/index.html#change-the-server-code","tags":"","category":""},{"id":346,"title":"Run the tests","pageTitle":"Developer start","text":"All commands run from  web/  unless noted.         What   Command   Notes           The whole PHP suite   php tests/run.php   Prints  N passed, M failed ; exit code is non-zero on any failure       Static analysis   vendor/bin/phpstan analyse   Level 8 over  app/  and  public/ ; widget  tests/  folders are excluded       Photo decoder with the sandbox   tests/run-in-docker.sh   Needs Docker. Builds an image with bubblewrap and  heif-dec , then runs the whole suite inside it       Boot-script validation   bash ../scripts/tests/watchdog_test.sh   Tests the version-number checks of the rollback watchdog under  bash  and  dash       .env  parsing   bash ../scripts/tests/env_test.sh         The photo-decoder wrapper   ../scripts/tests/frame-photo-run-test.sh   Runs a copy of the wrapper with its paths changed; needs neither root nor  sudo         Run the PHP suite and PHPStan after every change, and run  tests/run-in-docker.sh  after any change to  app/widgets/photos/ . The default host run uses  sandbox  mode  none  (set in  tests/config.test.php ), so the sandbox and HEIC paths are exercised only in the container.","url":"https://calevans.com/picture-frame/docs/dev/index.html#run-the-tests","tags":"","category":""},{"id":347,"title":"How tests are written","pageTitle":"Developer start","text":"There is no test framework.  tests/run.php  creates a temporary data folder, sets  FRAME_VAR_DIR ,  FRAME_CACHE_DIR  and  FRAME_CONFIG  ( tests/config.test.php ), loads  bootstrap.php , and then requires every  tests/*_test.php  and every  app/widgets/*/tests/*_test.php .   php   test ( 'a name that says what must be true' ,  function  () { $db =  fresh_db ();  // in-memory database with every migration applied   eq ( 200 ,  some_call ($db),  'what this checks' );  throws (HttpError:: class ,  fn () =>  some_call_that_fails ($db)); });            Helper   Use           test($name, $fn)   Registers and runs one test. A thrown exception is a failure.       eq($expected, $actual, $what)   Strict ( === ) comparison.       throws($class, $fn)   The callable must throw that class.       fresh_db()   An in-memory  frames.db  with all migrations.       seed_cache_db($dir)   An empty  cache.db  for servers under test.       t_core($ids)   Drops optional widget ids a test must not rely on.         HTTP tests start a real  php -S  on a free port with  tests/dev-router.php  and talk to it with curl.  tests/admin_login_helper.php  gives them  al_user_insert()  (a user with a known password),  al_login()  and  al_session()  (sign in and get the CSRF token). Where to put a test:   Core behavior:   web/tests/<area>_test.php .   A widget's behavior:   app/widgets/<id>/tests/<name>_test.php . It travels with the widget and disappears when you delete the folder, which keeps  widget isolation  true.   A security control:  always. Most controls in  Security  have a test that fails if the control is removed.","url":"https://calevans.com/picture-frame/docs/dev/index.html#how-tests-are-written","tags":"","category":""},{"id":348,"title":"Build the kiosk","pageTitle":"Developer start","text":"The kiosk builds with the included Lando setup, or natively if you have a JDK and the Android build tools.   Copy  .env.example  to  .env  in the repository root and fill it in.  FRAME_SERVER_URL  (HTTPS, ending in  / ),  KIOSK_PIN  and  KEYSTORE_PASS  are required.  RELEASE_KEY  and  RELEASE_PUBKEY  enable self-update; without  RELEASE_PUBKEY  the kiosk refuses every update.   From  kiosk/ , run  lando build-kiosk . To set a version, pass arguments after  -- :  lando build-kiosk -- --version-code 15 --version-name 15 .   Take the result from  kiosk/build/frame-kiosk.apk . With  RELEASE_KEY  set, the build also writes  frame-kiosk.apk.sig  and prints the hash and version code.   What the script does, in order: writes  BuildCfg.java  from  .env  (the server address, owner name, release public key, and a salted PBKDF2 hash of the PIN, never the PIN itself); creates the keystore on first use; packages with  aapt ; compiles with  javac --release 8 ; converts to a Dalvik executable with  dx ; aligns with  zipalign ; signs with  apksigner  using both v1 and v2 signatures; verifies both; and signs  <sha256>:<versionCode>  with the release key. --test-crash  builds an APK that crashes on start. It exists only to test the rollback watchdog; never upload one as a real release. Installing the APK, becoming device owner and the rest of the frame setup are in the  provisioning guide . The version rules for updates are in  Kiosk update protocol . Two numbers are easy to confuse.  versionCode  identifies a build and only ever increases.  Api.APP_VERSION  (in  Api.java ) is the  contract level  the kiosk reports as  app_version ; the server uses it to decide what the kiosk can do, for example screenshots need level 9 or higher.","url":"https://calevans.com/picture-frame/docs/dev/index.html#build-the-kiosk","tags":"","category":""},{"id":349,"title":"Install the pre-commit hook","pageTitle":"Developer start","text":"The repository is public and your working folder is not. A hook stops private files and private words from being committed.   sh   ln -sf ../../scripts/pre-commit.sh .git/hooks/pre-commit    On every commit the hook:   Removes  *:Zone.Identifier  files, which Windows adds next to anything copied in from Windows.   Runs  scripts/leak-check.sh  on the staged files. It refuses private file types ( .env , databases,  config.php ,  .htpasswd , APKs, keystores, keys and certificates, disk images, boot binaries) and any staged text that matches a pattern in  .leak-patterns .   Create two local files in the repository root. Both are gitignored, so the list of what is private is never published.         File   Contents           .leak-patterns   One extended regular expression per line ( grep -E -i ). Put your own name, host names, addresses and anything else that must not appear in a commit.       .leak-allow   Exact file paths that may contain a listed word, one per line. For example, a code-of-conduct file with a contact name.         Without  .leak-patterns  the check  refuses  the commit rather than pass silently.  scripts/leak-check.sh --all  checks every tracked file, which is useful before you push.  --types-only  checks file types only and says so. Never commit with  --no-verify . The project name  com.calevans.frame  and the project's own GitLab path are removed before matching, because they are public by design.","url":"https://calevans.com/picture-frame/docs/dev/index.html#install-the-pre-commit-hook","tags":"","category":""},{"id":350,"title":"Contribute","pageTitle":"Developer start","text":"Issues and merge requests are welcome on  GitLab . Before you open one:   Run the PHP tests and PHPStan, and  tests/run-in-docker.sh  if you touched photos.   Add or update tests for the behavior you changed.   Read the  security page  if you touched authentication, uploads, updates, headers or the boot script.   Report vulnerabilities in a  confidential  issue, not a public one.","url":"https://calevans.com/picture-frame/docs/dev/index.html#contribute","tags":"","category":""},{"id":351,"title":"Where to go next","pageTitle":"Developer start","text":"You want to…   Read           Add a feature to the frame   Writing a widget       Call or implement an endpoint   API reference       Change the self-update path   Kiosk update protocol       Change the schema   Database       Understand a security control   Security","url":"https://calevans.com/picture-frame/docs/dev/index.html#where-to-go-next","tags":"","category":""},{"id":352,"title":"API reference","pageTitle":"API reference","text":"This page lists every HTTP endpoint the server offers: what it is for, who may call it, what to send, what comes back, and the status codes and limits. It is for developers who write a client (the kiosk, a script, a test) or a widget route. All examples use  https://example.com/frames/  as the site address, which is the  frames_url  in  config.php . Everything on this page was taken from the routes in  web/app/routes/  and  web/app/widgets/*/routes.php  and  admin_routes.php . If you add a route, add it here.","url":"https://calevans.com/picture-frame/docs/dev/api/index.html","tags":"","category":""},{"id":353,"title":"Four kinds of caller","pageTitle":"API reference","text":"Caller   Base address   How it proves who it is           A frame  (the kiosk app or its WebView)   /frames/api/v1/…  and  /frames/…   Its frame key: the header  Authorization: Frame <id>.<key> , or the  fk  cookie       The admin   /frames/admin/api/…   A signed-in session cookie, plus a CSRF token on writes       A person with a link   /frames/drop/<token> ,  /frames/notes/<token> ,  /frames/api/v1/drops/<token>/… ,  /frames/api/v1/notes/<token>/…   A 43-character secret in the address       Anyone   POST /frames/api/v1/frames   Nothing (enrollment), but it is rate limited         The frame-facing API is versioned ( /api/v1/ ). Shipped kiosks cannot be updated in one step, so version 1 changes  only by adding : new endpoints, new response keys, new optional request fields. Clients must ignore keys they do not know. The admin API is unversioned and moves with the admin page.","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#four-kinds-of-caller","tags":"","category":""},{"id":354,"title":"Requests and responses","pageTitle":"API reference","text":"JSON bodies  need  Content-Type: application/json  (else  415 ) and must be a JSON  object . A field the endpoint does not list is a  422 . Each endpoint caps the body (listed below).   Raw bodies  (photos, APKs, screenshots) need an exact or  image/*  content type, as listed per endpoint.   Responses are  application/json; charset=utf-8  with  Cache-Control: no-store , except images, files and assets, which say otherwise.   A  201  carries a  Location  header. A  405  carries  Allow . A  HEAD  request is treated as a  GET .   Timestamps are Unix seconds. Frame ids are 32 lowercase hex characters; keys are 64.","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#requests-and-responses","tags":"","category":""},{"id":355,"title":"Errors","pageTitle":"API reference","text":"Every error has this shape. The client should branch on  code , never on the text.   json   {  \"error\" :  \"human readable message\" ,  \"code\" :  \"stable_code\"  }    A validation failure ( 422 ) adds the offending fields:   json   {  \"error\" :  \"validation failed\" ,  \"code\" :  \"invalid\" ,  \"fields\" : {  \"name\" :  \"name must be 1-64 characters\"  } }            Status   Default code   Meaning           400   bad_request   The body was not a JSON object       401   unauthorized   No or wrong credentials. Also  pairing_expired  (a frame's code ran out) and  auth_required  (admin: sign in)       403   forbidden   Authenticated but not allowed. See the specific codes below       404   not_found   No such route or record. Also used to hide records the caller may not know about       405   method_not_allowed   The path exists, the method does not.  Allow  lists the methods       409   conflict   The request conflicts with the current state. Always has a specific code where the caller can act on it       413   too_large   Body over the endpoint's limit       415   unsupported_media_type   Wrong  Content-Type , or a file that is not an accepted type       422   invalid   A field failed validation; see  fields       429   rate_limited   Too many requests.  Retry-After  says how many seconds to wait       500   internal   A server error. Details go to the log, never to the client.  not_configured  means  config.php  is missing or invalid       503   —   maintenance  (the database is at a different schema version than the code;  Retry-After: 30 ; run  bin/migrate.php ),  capacity ,  busy ,  unavailable         Specific  403  codes:  id_mismatch  (a frame asked for another frame's data),  not_active  (the frame is not claimed),  revoked  (body also has  \"status\": \"revoked\" ),  header_required ,  bad_origin ,  bad_csrf ,  reauth_required ,  expired .","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#errors","tags":"","category":""},{"id":356,"title":"Rate limits","pageTitle":"API reference","text":"Limit   Value   Applies to           Enrollment   3 per hour per address; 3 unclaimed frames per address; 50 unclaimed in total   POST /api/v1/frames       Kiosk release download   12 per hour per frame   GET …/kiosk-releases/{sha256}       Screenshot requests   One every 10 seconds per frame   Admin screenshot request       Pairing code guesses   5 failures per 10 minutes (all addresses together)   Admin claim and pairing lookup       Admin sign-in failures   10 per 15 minutes per address; 20 per hour per user   Login and re-authentication       Drop link, requests   120 per 10 minutes per link   Any drop call       Drop link, unknown link   20 per 10 minutes per address   Wrong links       Drop link, uploads   100 per hour and 300 per day per link; 500 per day per address; 10 rejected files per hour per link   Drop upload       Card link, requests   120 per 10 minutes per sender   Any card-link call       Card link, unknown link   20 per 10 minutes per address   Wrong links       Card link, sending   6 per hour and 20 per day per sender; 30 per day per address   POST …/cards         IPv6 addresses are counted by their  /64 . The drop and card limits can be changed for photos under  widgets.photos  in  config.php .","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#rate-limits","tags":"","category":""},{"id":357,"title":"Body size limits","pageTitle":"API reference","text":"Apache enforces the first number; the app enforces the exact value.         Endpoint   Limit           Default, frame site   64 KiB (enrollment: 1 KiB in the app; telemetry: 16 KiB)       Screenshot upload   600 KiB (614,400 bytes)       Drop photo upload   30 MiB       Default, admin   16 KiB       Photo upload (admin)   30 MiB       Card art upload (admin)   4 MiB       Kiosk release upload   16 MiB","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#body-size-limits","tags":"","category":""},{"id":358,"title":"Frame API","pageTitle":"API reference","text":"All paths are under  /frames/api/v1 . \"Frame auth\" means the  Authorization: Frame <id>.<key>  header or the  fk  cookie; a request for another frame's id gets  403 id_mismatch .         Method   Path   Auth   Purpose           POST   /frames   none   Enroll       GET   /frames/{id}   frame   Status and configuration (\"self GET\")       PUT   /frames/{id}/telemetry   frame, active   Report health       PUT   /frames/{id}/screenshot   frame, active   Upload a requested screenshot       GET   /frames/{id}/dashboard   frame, active   The dashboard JSON the page draws       GET   /frames/{id}/kiosk-releases/{sha256}   frame header, active   Download the kiosk update       GET   /frames/{id}/photos/{photoId}.jpg   frame, active   A photo from the local library       GET ,  PATCH   /frames/{id}/notes/{cardId}   frame, active   Read a card; mark it opened","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#frame-api","tags":"","category":""},{"id":359,"title":"POST /frames enroll","pageTitle":"API reference","text":"POST /frames Body (at most 1 KiB):         Field   Type   Rule           id   string   32 lowercase hex characters, chosen by the frame       key   string   64 lowercase hex characters, chosen by the frame       app_version   integer   0 to 2,147,483,647                 Status   Code   Body           201     {id, status: \"unclaimed\", pairing_code, pairing_expires_at, pairing_expires_in} , with  Location       200     The same body: a retry with the same id and key while still unclaimed       409   already_enrolled   Same key, but the frame is no longer unclaimed. Use the self GET       409   id_conflict   The id belongs to a different key. Make a new identity       422   invalid   fields  names  id ,  key  or  app_version       429   rate_limited   Retry-After       503   capacity   Too many unclaimed frames, or no free pairing code.  Retry-After         The pairing code is six digits and is valid for 24 hours.  pairing_expires_in  counts seconds from the response, so the kiosk needs no synchronized clock.","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#post-frames-enroll","tags":"","category":""},{"id":360,"title":"GET /frames/{id} self GET","pageTitle":"API reference","text":"GET /frames/{id} The answer depends on the frame's status.         Status   Condition   Body           200   Unclaimed   {status: \"unclaimed\", pairing_code, pairing_expires_at, pairing_expires_in}       401   pairing_expired   The code ran out. Enroll again with the same id and key       403   revoked   {error, code: \"revoked\", status: \"revoked\"}       200   Active   The object below         Active body:         Key   Type   Notes           status   string   \"active\"       name   string   The frame's name       config_version   integer   Rises whenever the admin changes something that affects this frame       display   object   {kind: \"dashboard\" or \"url\", url} . For  dashboard  the URL is the frame site       poll_seconds   integer   20       idle_home_seconds   integer   30 to 3600       allowed_origins   list of strings   The Home Assistant origin when that widget is on; otherwise  []       tz   string   IANA time zone       schedule   object or null   {bedtime: \"HH:MM\", wake_time: \"HH:MM\"}  in  tz , or  null  when always on       photos   list of strings, or absent   The frame's daily photo set (at most 50 URLs).  []  means \"clear your cache\". The key is  absent  when the server could not work it out, which means \"keep your cache\"       screenshot_request   string   32 hex characters, present only while a live-view request is pending and under 60 seconds old       pin   object or null   Header auth only.   {alg: \"pbkdf2-sha1\", iter, salt, hash} .  null  means \"use the build's default PIN\"       widget_secrets   object   Header auth only.   {<widget id>: {…}} , for example  {\"ha\": {\"token\": \"…\"}}       kiosk_update   object   Header auth only.  Present when the frame has a target release it does not yet run. See  the update protocol         The three header-only keys are  absent , not empty, on a cookie request, because page script must never see them.","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#get-framesid-self-get","tags":"","category":""},{"id":361,"title":"PUT /frames/{id}/telemetry","pageTitle":"API reference","text":"PUT /frames/{id}/telemetry A  PUT   replaces  the stored report: an omitted optional field is stored as empty. The one exception is  ha_dashboards . Body limit 16 KiB. Answer  204 .         Field   Type   Rule           app_version   integer   Required. 0 to 2,147,483,647       local_ip   string   A valid IP address       mac   string   aa:bb:cc:dd:ee:ff       ssid   string   At most 32 bytes, no control characters       rssi   integer   -127 to 0       uptime   integer   Seconds, 0 to 315,360,000       version_code   integer   The installed kiosk build, 1 to 2,100,000,000       watchdog   boolean   Whether the rollback watchdog can work on this frame       kiosk_update_failure   object   {sha256, reason} .  reason  is  download ,  size ,  hash ,  not_apk ,  package ,  not_newer ,  version_mismatch ,  cert ,  release_sig ,  unhealthy ,  not_owner , or  install_<status> . A well-formed report about a release that is not the frame's current target is dropped without an error; a malformed one is a  422       ha_dashboards   list    Up to 50  {path, title}  (see below).  Omitted or  null  leaves the stored list alone  ;  []  clears it          ha_dashboards  entries:  path  matches  ^[a-z0-9][a-z0-9_-]{0,63}$ ;  title  is at most 80 characters, and one with control or format characters is replaced by its  path . Any bad entry is a  422  with  fields.ha_dashboards .","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#put-framesidtelemetry","tags":"","category":""},{"id":362,"title":"PUT /frames/{id}/screenshot","pageTitle":"API reference","text":"PUT /frames/{id}/screenshot The admin asks for a live view; the self GET delivers a request id; the kiosk captures its screen and uploads it here. Query:  request_id  (32 hex),  dimmed  ( 0  or  1 ),  age_ms  (0 to 60,000). Body: the raw JPEG,  Content-Type: image/jpeg , at most 600 KiB, between 1 × 1 and 1920 × 1200 pixels.         Status   Code   Meaning           204     Stored, and the request id is spent. A repeat of an upload that already landed is also  204       409   no_request   The id does not match, was already fulfilled, or is older than 90 seconds       413   too_large   Over 600 KiB       415     Wrong content type       422   invalid   fields  names  request_id ,  dimmed ,  age_ms  or  body         The capture time is the server's time minus  age_ms ; the frame's clock is not trusted.","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#put-framesidscreenshot","tags":"","category":""},{"id":363,"title":"GET /frames/{id}/dashboard","pageTitle":"API reference","text":"GET /frames/{id}/dashboard Returns what the page draws.  Cache-Control: no-store .   json   {  \"now\" :  1790000000 ,  \"v\" :  \"1789999000:12\" ,  \"tz\" :  \"America/Chicago\" ,  \"idle_home_seconds\" :  120 ,  \"pages\" : [ {  \"widget\" :  \"weather\" ,  \"label\" :  \"Weather\" ,  \"icon\" :  \"weather\"  } ],  \"widgets\" : {  \"clock\" : {},  \"weather\" : {  \"place\" :  \"Home\" ,  \"now\" : {},  \"hourly\" : [],  \"daily\" : [] } } }      v  is  <newest app file modification time>:<config_version> . When it changes, the page reloads itself.   pages  lists the rail items the frame should show.   widgets  has an entry for each  enabled  widget and none for the others. A widget with nothing to say is  {} .   What each widget puts in its entry:         Widget   Entry           clock   {}       weather   {place, now: {temp, text, icon, wind, humidity, feels_like} or null, hourly: [{at, temp, icon, pop}], daily: [{date, hi, lo, icon, pop}]}  (24 hourly, 7 daily entries at most)       calendar   {today: [event] or null, week: {\"YYYY-MM-DD\": [event]}} .  today: null  means no calendar is connected yet. An event is  {title, all_day, start, end, location, calendar}       photos   {seconds, photos: [url]}       ha   {url}       notes   {keep_days, unopened_total, unopened: [card summary], mailbox: [card summary]} . A summary has  id ,  from ,  sent_at ,  stamp ,  cover  (and  opened_at  in the mailbox).  No message text","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#get-framesiddashboard","tags":"","category":""},{"id":364,"title":"GET /frames/{id}/kiosk-releases/{sha256}","pageTitle":"API reference","text":"GET /frames/{id}/kiosk-releases/{sha256} Downloads the kiosk update.  Header auth only : a cookie request gets  403 header_required . The  sha256  (64 lowercase hex) must be the hash of this frame's  current target  release, or the answer is  404 . The file is chosen from the database row, never from the URL.         Status   Meaning           200   The APK.  Content-Type: application/vnd.android.package-archive ,  Content-Length ,  Content-Disposition: attachment ,  X-Content-Type-Options: nosniff ,  Cache-Control: private, no-store ,  Accept-Ranges: none . Records that this frame fetched the release in full       403   header_required ,  not_active ,  revoked       404   Not this frame's target, or the file is missing       429   12 downloads per hour.  Retry-After","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#get-framesidkiosk-releasessha256","tags":"","category":""},{"id":365,"title":"GET /frames/{id}/photos/{photoId}.jpg","pageTitle":"API reference","text":"GET /frames/{id}/photos/{photoId}.jpg Returns one frame-sized photo from the local library.  photoId  is a positive integer. Auth is frame header or cookie.   200   image/jpeg  with a strong  ETag  (the content hash).  If-None-Match  gets  304 . Also  X-Content-Type-Options: nosniff ,  Content-Security-Policy: default-src 'none'; sandbox ,  Cross-Origin-Resource-Policy: same-origin ,  Cache-Control: private, max-age=86400 .   404  unless the photos widget is on for this frame, its source is the local library, and the photo is in the frame's current  approved  catalog.","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#get-framesidphotosphotoidjpg","tags":"","category":""},{"id":366,"title":"GET, PATCH /frames/{id}/notes/{cardId}","pageTitle":"API reference","text":"GET PATCH /frames/{id}/notes/{cardId} Part of the cards widget.   GET  returns the full card:  {id, from, sent_at, opened_at, cover, stamp, message, signature, signature_text} .  404  if the card is another frame's, cards are off, or it has aged out of the mailbox.   PATCH  with  {\"opened\": true}  (the only accepted body) marks it opened and returns  {opened_at} , the server's time.","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#get-patch-framesidnotescardid","tags":"","category":""},{"id":367,"title":"Page, assets and widget scripts","pageTitle":"API reference","text":"Frame auth, active frames only. These are what the WebView loads.         Method   Path   Returns           GET   /frames/   The dashboard HTML ( Cache-Control: no-store , strict CSP,  Referrer-Policy: no-referrer )       GET   /frames/assets/frame.js ,  /frames/assets/frame.css   The core script, and the CSS bundle (core plus every widget's  frame/*.css )       GET   /frames/assets/w/{widgetId}/widget.js   A widget's script. Only registered ids; the file name is fixed         Assets carry an  ETag  and answer  304 . With a valid  ?v=  query they are cached for a year ( private, max-age=31536000, immutable ); without one,  no-cache .","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#page-assets-and-widget-scripts","tags":"","category":""},{"id":368,"title":"Admin API","pageTitle":"API reference","text":"All paths are under  /frames/admin/api . Rules for every endpoint:   A signed-in session cookie is required, else  401 auth_required  with  WWW-Authenticate: Cookie realm=\"frame-admin\" .   The request must be same-origin ( Sec-Fetch-Site: same-origin , or a matching  https   Origin ), reads included, else  403 bad_origin .   Anything but a  GET  needs the header  X-CSRF-Token  (from  GET /api/session ), else  403 bad_csrf .   JSON bodies are at most 16 KiB.   The \"Re-auth\" column marks endpoints that need the password entered in the last five minutes. They fail with  403 reauth_required  until  POST /api/session/reauthentications  succeeds.   Every  Location  header is built from  frames_url .","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#admin-api","tags":"","category":""},{"id":369,"title":"Session","pageTitle":"API reference","text":"Method   Path   Re-auth   Purpose   Answer           GET   /session     Who am I; get the CSRF token   200 {authenticated: true, user, csrf}       DELETE   /session     Sign out   204       POST   /session/reauthentications     Prove the password again. Body  {password, code}  ( code  only if two-step sign-in is on)   204 ;  401 bad_credentials ;  429","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#session","tags":"","category":""},{"id":370,"title":"Frames","pageTitle":"API reference","text":"Method   Path   Re-auth   Purpose           GET   /frames     List every frame. With  ?pairing_code=123456 , list only the matching unclaimed frame       PATCH   /frames/{id}   Sometimes   Claim, revoke, restore or edit a frame       DELETE   /frames/{id}   Yes   Delete a frame. It gets  401  and may enroll again       DELETE   /frames?status=unclaimed   Yes   Delete every unclaimed frame       PUT   /frames/{id}/pin   Yes   Set the frame's PIN. Body  {pin} : 4 to 8 digits. Answer  204       DELETE   /frames/{id}/pin   Yes   Clear it. The kiosk falls back to its build default       POST   /frames/{id}/screenshot-requests     Ask for a live view       GET   /frames/{id}/screenshot     Status of the request and the latest capture       GET   /frames/{id}/screenshot/image?v=     The latest capture as  image/jpeg       DELETE   /frames/{id}/kiosk-update-failure   Yes   Let the frame try its current target again           PATCH /frames/{id}  accepts any of these fields (others are a  422 ):          Field   Rule           status   active  or  revoked . An unclaimed frame can only become  active , and then needs  pairing_code  and  name       pairing_code   Six digits. Checked again inside the transaction. Only used when claiming       name   1 to 64 characters, no control characters       display_kind   dashboard  or  url       display_url   An  https  address with no credentials, at most 2048 characters. Required when  display_kind  is  url       layout   dashboard       tz   An IANA time zone       idle_home_seconds   30 to 3600       pick_time   HH:MM  frame-local. Claiming assigns the first free minute from 05:00 spiralling out (05:00, 05:02, 04:58, 05:04, …) unless you send one       bedtime ,  wake_time   HH:MM  or  null . Both set or both  null , and different       kiosk_target_id   A release id, or  null  to clear         Re-auth is required when the request revokes or restores a frame, changes  display_kind  or  display_url , or includes  kiosk_target_id . The answer is  200  with the frame (below). Specific errors:  409 invalid_transition ,  409 pairing_failed  (wrong or expired code; counts toward the 5-per-10-minutes limit),  409 pick_time_unavailable  (another active frame picks at the exact same UTC minute),  409 not_active ,  409 release_not_proven ,  422 . A frame object:   json   {  \"id\" :  \"0123456789abcdef0123456789abcdef\" ,  \"short_id\" :  \"01234567\" ,  \"name\" :  \"Kitchen\" ,  \"status\" :  \"active\" ,  \"online\" :  true ,  \"last_seen\" :  1790000000 ,  \"last_ip\" :  \"192.0.2.10\" ,  \"app_version\" :  10 ,  \"local_ip\" :  \"192.0.2.20\" ,  \"mac\" :  \"02:00:00:00:00:01\" ,  \"ssid\" :  \"home\" ,  \"rssi\" : - 52 ,  \"uptime\" :  86400 ,  \"layout\" :  \"dashboard\" ,  \"location\" :  \"Home\" ,  \"display_kind\" :  \"dashboard\" ,  \"display_url\" :  null ,  \"tz\" :  \"America/Chicago\" ,  \"idle_home_seconds\" :  120 ,  \"pick_time\" :  \"05:00\" ,  \"bedtime\" :  null ,  \"wake_time\" :  null ,  \"config_version\" :  12 ,  \"enrolled_at\" :  1780000000 ,  \"enrolled_ip\" :  \"192.0.2.10\" ,  \"pin_set\" :  true ,  \"kiosk\" : {  \"version_code\" :  14 ,  \"watchdog\" :  true ,  \"target\" :  null ,  \"status\" :  \"none\" ,  \"failure\" :  null  },  \"screenshot\" :  null ,  \"screenshot_supported\" :  true  }    online  means seen in the last 120 seconds.  kiosk.status  is  none  (no target),  unknown  (no report yet),  current ,  pending  or  failed .  kiosk.target  is  {id, version_code, version_name} ;  kiosk.failure  is  {sha256, reason}  and only shows when it is about the current target.   POST /frames/{id}/screenshot-requests  answers  202 {request: {id, requested_at, expires_at}}  with a  Location  to the status. A second call while one is pending returns the same body. Errors:  404 ,  409 not_active ,  409 unsupported  (the kiosk's  app_version  is below 9),  429  with  Retry-After  (under 10 seconds since the last request). Poll  GET …/screenshot  until  latest.request_id  equals the id you got; if  request  becomes  null  without a match, it expired.    GET /frames/{id}/screenshot  answers  {request: {id, requested_at, expires_at} or null, latest: {request_id, captured_at, uploaded_at, dimmed, width, height, bytes, image_url} or null} .","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#frames","tags":"","category":""},{"id":371,"title":"Library: locations and calendars","pageTitle":"API reference","text":"Method   Path   Purpose           GET ,  POST   /locations   List, create. Body  {name, lat, lon, stations} .  name  1 to 64 characters,  lat  within ±90,  lon  within ±180,  stations  optional: comma-separated 4-character station ids, at most five       PATCH ,  DELETE   /locations/{id}   Edit (any fields), delete.  409 in_use  while a widget uses it       GET ,  POST   /calendars   List, create. Body  {label, url, color} .  url  is  https , on the  calendar_hosts  list, no credentials or port;  color  is  #rrggbb  or empty       PATCH ,  DELETE   /calendars/{id}   Edit, delete.  409 in_use  while a widget uses it         Creating returns  201  with a  Location . A duplicate name or address is  409 . A calendar is returned as  {id, label, host, color, created_at, state} ;  the full address is never returned .  state  is  {last_ok, last_err, fail_count, next_at}  or  null .","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#library-locations-and-calendars","tags":"","category":""},{"id":372,"title":"Widgets on a frame","pageTitle":"API reference","text":"Method   Path   Purpose           GET   /frames/{id}/widgets   Every registered widget with this frame's settings       PUT   /frames/{id}/widgets/{widget}   Set a widget's switch, position and settings         GET  returns a list. Enabled widgets come first, ordered by when they were switched on, then the rest in registry order. Each row:   json   {  \"widget\" :  \"weather\" ,  \"label\" :  \"Weather\" ,  \"icon\" :  \"w-weather\" ,  \"description\" :  \"Current conditions and forecast for a location.\" ,  \"settings\" : {  \"location_id\" :  \"location\"  },  \"enabled\" :  true ,  \"position\" :  1 ,  \"enabled_at\" :  1780000000 ,  \"config\" : {  \"location_id\" :  1  },  \"info\" : {} }    settings  maps each setting key to its type.  config  omits secret settings.  info  appears only for widgets that have an  info  hook. PUT  body:  {enabled: bool, position: 0 to 99, config: {…}} . Answer  204 .  404  for an unknown widget id;  422  with  fields.config  (a setting failed),  fields.enabled  or  fields.position . A secret setting you leave out is kept;  \"\"  clears it. Any real change bumps the frame's  config_version .","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#widgets-on-a-frame","tags":"","category":""},{"id":373,"title":"Kiosk releases","pageTitle":"API reference","text":"Method   Path   Re-auth   Purpose           GET   /kiosk-releases     List, newest first       POST   /kiosk-releases   Yes   Upload a release       PATCH   /kiosk-releases/{id}   Yes   Change the notes. Body  {notes} : at most 500 characters       DELETE   /kiosk-releases/{id}   Yes   Delete the record and the file       POST   /kiosk-releases/{id}/rollouts   Yes   Point every active frame below this version at it         A release object:  {id, version_code, version_name, sha256, size, notes, uploaded_at, uploaded_by, running_on, targeted, proven} .  running_on  counts frames reporting that version,  targeted  counts frames told to install it,  proven  is whether a frame has downloaded it and then reported running it.   POST /kiosk-releases  : the body is the raw APK. Headers:  Content-Type: application/vnd.android.package-archive  and  X-Release-Signature: <base64 DER ECDSA signature> . At most 16 MiB.          Status   Code   Meaning           201     The release object, with  Location       409   duplicate_sha256 ,  duplicate_version_code         413   too_large   Over 16 MiB       415   not_apk , or wrong content type   The file is not a plain APK (the same check the kiosk makes)       422   invalid   fields.signature  (malformed, or does not verify against  kiosk_release_pubkey  when that is set) or  fields.body  (empty, no readable manifest, package is not  com.calevans.frame , version code out of range, version name unusable)           POST /kiosk-releases/{id}/rollouts  answers  200 {targeted: [frame ids], already: [frame ids]} , or  409 release_not_proven  if no frame has proven the release.  DELETE  answers  204 , or  409 release_targeted  while a frame still targets it.  Setting a single frame's target is  PATCH /frames/{id}  with  kiosk_target_id . A frame without the rollback watchdog may only be given a proven release ( 409 release_not_proven ). The same upload exists on the command line:  php app/bin/kiosk-release.php add <apk> [<sig>] [--notes \"…\"] .","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#kiosk-releases","tags":"","category":""},{"id":374,"title":"Photo library (photos widget)","pageTitle":"API reference","text":"Where an endpoint takes or returns a photo, the item is:   json   {  \"id\" :  41 ,  \"album_id\" :  3 ,  \"status\" :  \"pending\" ,  \"width\" :  1280 ,  \"height\" :  720 ,  \"bytes\" :  183422 ,  \"added_at\" :  1790000000 ,  \"approved_at\" :  null ,  \"added_by\" :  \"Aunt Sue\" ,  \"drop_link_id\" :  7 ,  \"thumb_url\" :  \"/frames/admin/api/photos/41/thumb.jpg\" ,  \"image_url\" :  \"/frames/admin/api/photos/41/image.jpg\"  }    status  is  pending  or  approved ;  approved_at  is  null  while pending.  added_by  is  \"admin\"  for admin uploads and otherwise the link's name when the photo arrived.  width ,  height  and  bytes  describe the stored, resized JPEG.         Method   Path   Re-auth   Purpose           GET   /photo-library     Limits, usage and decoder status       GET ,  POST   /photo-albums     List ( {items} ), create ( {name} : 1 to 60 characters)       PATCH   /photo-albums/{a}     Rename       DELETE   /photo-albums/{a}   Yes   Delete with its photos and links.  409 in_use  while a frame is set to it       GET   /photo-albums/{a}/photos     List photos       POST   /photo-albums/{a}/photos     Upload one photo       POST   /photo-albums/{a}/photo-deletions   Yes   Delete up to 500 photos (also how a pending batch is rejected)       POST   /photo-albums/{a}/photo-approvals     Approve up to 500 pending photos       GET   /photos/{p}     One photo item       DELETE   /photos/{p}     Delete (or reject) one photo.  204       GET   /photos/{p}/thumb.jpg ,  /photos/{p}/image.jpg     The 320 × 200 and 1280 × 800 images. Work for pending photos       GET   /frames/{f}/photo-set     The frame's stored daily set, read-only           GET /photo-library     json   {  \"limits\" : {  \"library_bytes\" :  5368709120 ,  \"album_photos\" :  5000 ,  \"drop_per_day\" :  300 ,  \"upload_bytes\" :  31457280 ,  \"pending_album\" :  500 ,  \"pending_bytes\" :  536870912  },  \"usage\" : {  \"bytes\" :  123456789 ,  \"photos\" :  812 ,  \"pending\" :  3  },  \"imagick\" : {  \"ok\" :  true ,  \"code\" :  null ,  \"heic\" :  true  },  \"sandbox\" : {  \"mode\" :  \"bwrap\" ,  \"active\" :  true  } }    The key  imagick  kept its old name; it describes the image decoder.  code  is  decoder_unavailable  or  sandbox_unavailable  when  ok  is false (then uploads answer  503 ).  heic  is false when iPhone photos cannot be processed.  sandbox.mode  is  bwrap ,  user ,  auto  or  none . Albums:  an album is  {id, name, created_at, count (approved), pending, bytes (all), newest_at, used_by: [{frame_id, name}]} . Create answers  201  with  Location ;  409  for a duplicate name.   GET /photo-albums/{a}/photos  takes  status  ( pending  or  approved ; omit for both),  before  (a photo id) and  limit  (1 to 200, default 60). It answers  {items: [photo], next_before: id or null} , newest first.    POST /photo-albums/{a}/photos  : the body is one raw image,  Content-Type: image/*  or  application/octet-stream . The bytes decide the type: JPEG, PNG, WebP and HEIC are accepted.          Status   Code   Meaning           201     {duplicate: false, photo}  with  Location       200     {duplicate: true, photo} : these exact bytes are already in the album       404     No such album       409   album_full         413   too_large   Over  upload_bytes       415     Not an image type, or the bytes are not JPEG, PNG, WebP or HEIC       422   undecodable ,  unsupported_color  (CMYK),  unsupported_animation   Corrupt, too large in pixels (over 12,000 pixels a side or 64 megapixels), or refused by the decoder       503   decoder_unavailable ,  sandbox_unavailable ,  heic_unavailable ,  busy   busy  carries  Retry-After       507   storage_full ,  pending_full           Admin uploads are approved at once. Deletions and approvals  take  {ids: [1, 2, …]}  with 1 to 500 integers. Deletions answer  {deleted: [ids], missing: [ids]} ; approvals answer  {approved: [ids], missing: [ids]} .  missing  lists ids not in that album. Approving an already approved photo counts as approved. Images  are  image/jpeg ,  nosniff ,  Content-Security-Policy: default-src 'none'; sandbox ,  Cross-Origin-Resource-Policy: same-origin . Approved photos are cached ( private, max-age=86400 , with an  ETag ); pending ones are  no-store .   GET /frames/{f}/photo-set  answers  {enabled, album: {source, host, name, count} or null, tz, pick_time, picked_for, picked_at, next_pick_at, stale, photos: [{id, new, added_at, thumb}]} . It never picks a set or records anything.  Saving the source for a frame  uses the widget endpoint:  PUT /frames/{f}/widgets/photos  with  config: {source: \"nextcloud\" | \"local\", album_url, local_album, seconds} .  album_url  is a secret (omit to keep).  GET  returns  info: {source, sources: [{id, label}], album_set, host, name, count, pending, error} .","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#photo-library-photos-widget","tags":"","category":""},{"id":375,"title":"Drop links (photos widget)","pageTitle":"API reference","text":"A drop link belongs to one album. The page it opens is public and write-only.         Method   Path   Re-auth   Purpose           GET   /photo-albums/{a}/drop-links     List ( {items} )       POST   /photo-albums/{a}/drop-links     Create. Body  {name, expires_at}       PATCH   /photo-drop-links/{l}   If  revoked  is sent   Rename, revoke or restore, change expiry       POST   /photo-drop-links/{l}/link   Yes   Rotate: new secret, old link dead at once       DELETE   /photo-drop-links/{l}   Yes   Delete the link. Its photos stay       DELETE   /photo-drop-links/{l}/photos   Yes   Delete every photo that came through the link         A link object:  {id, album_id, name, created_at, last_used_at, expires_at, revoked_at, photos, pending} .  name  is 1 to 40 characters, unique within the album.  expires_at  must be in the future and at most 366 days away; the default is 90 days. Create answers  201 {drop_link, link} , where  link  is  https://example.com/frames/drop/<43 characters> . Rotating answers  200  with the same shape.  The link is shown once  and is never returned again. Responses that carry a link are  Cache-Control: no-store . Restoring a revoked link ( revoked: false ) makes the  old  link work again; rotate if it may have leaked.","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#drop-links-photos-widget","tags":"","category":""},{"id":376,"title":"Cards (notes widget)","pageTitle":"API reference","text":"Cards are greeting cards sent from family through personal links. The admin never reads their text.         Method   Path   Re-auth   Purpose           GET ,  PUT   /frames/{f}/notes     Cards settings:  {enabled, keep_days (1 to 30), recipient_label (at most 24 characters or null), categories (list of slugs or null for all)} . The  GET  also returns  all_categories  and  limits       GET ,  POST   /frames/{f}/note-senders     List senders, create one (body  {name} , 1 to 40 characters). Create answers  201 {sender, link}       PATCH   /note-senders/{id}   If  revoked  is sent   Rename, revoke or restore. Restoring rotates the link and returns it as  link       POST   /note-senders/{id}/link   Yes   Rotate the link:  200 {link}       DELETE   /note-senders/{id}   Yes   Delete.  409 in_use  if the sender has sent cards; revoke instead       DELETE   /note-senders/{id}/cards?status=unopened   Yes   Delete the sender's unopened cards:  200 {deleted}       GET ,  POST   /note-designs ,  /note-stamps     List; upload one image       PATCH ,  DELETE   /note-designs/{id} ,  /note-stamps/{id}     Enable or disable ( {enabled} ; designs also  {category} ); delete ( 409 in_use  once cards use it)       GET   /note-cards     Card metadata:  frame_id ,  status  ( opened  or  unopened ),  sender_id ,  before ,  limit  (1 to 100, default 25)       DELETE   /note-cards/{id}     Delete one unopened card.  409 opened  otherwise         Sender objects are  {id, frame_id, name, created_at, revoked_at, last_used_at, cards_sent} . The link is never part of a sender. Art uploads send the raw image with  Content-Type: image/jpeg ,  image/png  or  image/webp  (at most 4 MiB, minimum and maximum pixel sizes apply). Designs also need  ?category=<slug> . A new image answers  201 ; an image already in the library answers  200 , or  409 duplicate  if it is already filed under another category. An item is  {id, [category], url, width, height, bytes, enabled, created_at, uses} . /note-cards  items are  {id, frame_id, frame, sender_id, from, category, created_at, opened_at, cover_url, stamp_url}  with  next_before .","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#cards-notes-widget","tags":"","category":""},{"id":377,"title":"Public endpoints","pageTitle":"API reference","text":"These need no account. They need the secret from a link.","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#public-endpoints","tags":"","category":""},{"id":378,"title":"Drop page and upload","pageTitle":"API reference","text":"Method   Path   Purpose           GET   /frames/drop/<token>   The upload page (HTML)       GET   /frames/drop/assets/drop.js ,  drop.css   Its script and styles       GET   /frames/api/v1/drops/<token>   What the page needs to greet the sender       POST   /frames/api/v1/drops/<token>/photos   Upload one photo         <token>  is 43 characters from  A-Za-z0-9_- . Pages and API replies carry  Referrer-Policy: no-referrer  and  X-Robots-Tag: noindex, nofollow . A cross-site request ( Sec-Fetch-Site  other than  same-origin  or  none , or a foreign  Origin ) is  403 bad_origin .   GET /api/v1/drops/<token>  answers:    json   {  \"link\" :  \"Aunt Sue\" ,  \"album\" :  \"Family\" ,  \"limits\" : {  \"upload_bytes\" :  31457280  },  \"remaining\" : {  \"hour\" :  97 ,  \"day\" :  280  },  \"expires_at\" :  1797776000  }    remaining  is only this link's own quota.   POST /api/v1/drops/<token>/photos  : one raw image per request,  Content-Type: image/*  or  application/octet-stream . The photo lands  pending . Success is always the same answer, whether the photo is new, a duplicate of a pending one or a duplicate of an approved one:    json   {  \"received\" :  true  }    (status  202 ). Errors use the standard shape:         Status   Code   What the sender should do           401   unauthorized   Stop. The link is unknown       403   revoked ,  expired ,  bad_origin   Stop       413   too_large   Skip this file       415 ,  422   unsupported_media_type ,  undecodable ,  unsupported_color ,  unsupported_animation   Skip this file       429   rate_limited   Wait  Retry-After , then retry       503   busy  ( Retry-After: 5 ) or  unavailable  ( Retry-After: 60 )   Wait, then retry       507   unavailable   Stop and try again later. The album, the queue or the disk is full; the reason is not disclosed","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#drop-page-and-upload","tags":"","category":""},{"id":379,"title":"Card page and API","pageTitle":"API reference","text":"Method   Path   Purpose           GET   /frames/notes/<token>   The sender page (HTML)       GET   /frames/notes/assets/{notes.js,notes.css,card.css,stamp.css}   Its files       GET   /frames/notes/art/<sha256>.<ext>   A cover or stamp image ( <ext>  is  jpg ,  png  or  webp ). Only files the database lists; cached for a year       GET   /frames/api/v1/notes/<token>/me   Sender, recipient, allowed categories and limits       GET   /frames/api/v1/notes/<token>/designs?category=   Covers in a category, with  limit  (1 to 100, default 24) and  before       GET   /frames/api/v1/notes/<token>/cards   The sender's own cards, newest first, with  limit  and  before       GET   /frames/api/v1/notes/<token>/cards/{id}   One of the sender's cards, with its signature       POST   /frames/api/v1/notes/<token>/cards   Send a card           POST …/cards  body (at most 32 KiB):          Field   Rule           client_id   32 lowercase hex characters, chosen by the page. A repeat of an earlier send returns that card with  200  and counts against no limit       design_id   An enabled cover in an allowed category       message   1 to 400 characters, at most 8 lines       signature   A handwritten signature (strokes and points, with limits). Send exactly one of  signature  and  signature_text       signature_text   1 to 40 characters         Answers:  201  with the card and  Location: cards/<id> ;  409 not_enabled ,  409 sender_full  (10 unopened),  409 mailbox_full  (30 unopened on the frame);  422 ;  429 . A link that is revoked is  403 revoked ; a frame that is not active is  403 not_active .","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#card-page-and-api","tags":"","category":""},{"id":380,"title":"Related","pageTitle":"API reference","text":"Security : why the authentication and limits are what they are   Kiosk update protocol : the offer, download and telemetry in context   Writing a widget : adding routes   Database : the tables behind these endpoints","url":"https://calevans.com/picture-frame/docs/dev/api/index.html#related","tags":"","category":""},{"id":381,"title":"Start here","pageTitle":"Start here","text":"This is the documentation for the Picture Frame System. It is for people who own a PF1007L Wi-Fi photo frame, are comfortable in a terminal, and can run a small Linux server. When you finish the first-time path below, you have a frame on your wall that shows your photos, the weather and your calendar, talks only to your server, and updates itself. Nothing here needs a cloud account or a subscription. The server is PHP 8.5 and SQLite.","url":"https://calevans.com/picture-frame/docs/index.html","tags":"","category":""},{"id":382,"title":"What you are building","pageTitle":"Start here","text":"Three pieces, set up once:   The frame , unlocked so you can put your own software on it.   The server , a PHP and SQLite site on Apache that the frame talks to. You use its admin page to choose what each frame shows.   The kiosk app , built by you, that turns the frame into a locked-down dashboard.   Frames and phones only talk to your server. Your server only talks to the sources you list.","url":"https://calevans.com/picture-frame/docs/index.html#what-you-are-building","tags":"","category":""},{"id":383,"title":"The first-time path","pageTitle":"Start here","text":"Follow these pages in order. The server and the unlock are independent, so you can do them in either order. Provisioning needs both.         Step   Page   What you have when you finish           1   Hardware and requirements   You know whether your frame is supported and what to gather.       2   Unlock your PF1007L   A verified backup of the frame's storage, and root ADB.       3   Install the frame server  and  Use the admin   A running server, a database, an admin login, optional two-step sign-in, and a tour of the admin.       4   Build the kiosk and set up a frame   The kiosk installed, locked in, and showing a pairing code.       5   Claim the frame   The frame claimed in the admin.       6   Widgets  and  Photos   Clock, Weather, Calendar, Photos, Home Assistant and Cards on the screen.       7   Update the kiosk   A way to send new kiosk versions to a frame that is far away.         Plan on an afternoon for the unlock. Most of that is waiting for the backup to be written and checked, which takes about 90 minutes.","url":"https://calevans.com/picture-frame/docs/index.html#the-first-time-path","tags":"","category":""},{"id":384,"title":"Every page","pageTitle":"Start here","text":"Page   Kind   Read it when           Hardware and requirements   Reference   You are deciding whether to start.       Unlock your PF1007L   Guide   You are ready to back up and root the frame.       Install the frame server   Guide and reference   You are setting up or updating the server.       Use the admin   Guide and reference   You are creating logins or learning the admin screens.       Build the kiosk and set up a frame   Guide and reference   The frame is unlocked and the server is running.       Widgets   Reference   You are setting up what a frame shows.       Photos   Guide and explanation   You are choosing how photos reach a frame.       Update the kiosk   Guide and explanation   You are sending a new kiosk version to a frame.       Troubleshooting and recovery   Troubleshooting   Something is not working.       A guide for the family   Guide   You want something to print for the person with the frame, or for relatives who send cards and photos.       Security   Explanation   You want to know how the system protects your frames and data.       Developer documentation   Developer docs   You want to change the code or write a widget.","url":"https://calevans.com/picture-frame/docs/index.html#every-page","tags":"","category":""},{"id":385,"title":"Find a page by goal","pageTitle":"Start here","text":"I want to...   Go to           Check my frame is a PF1007L   Hardware and requirements       Back up the frame before touching it   Unlock: back up the storage       Install the server on Apache   Install the frame server       Create an admin login   Use the admin       Turn on two-step sign-in   Use the admin       Build the kiosk app   Provisioning: build the kiosk       Claim a frame   Provisioning: claim the frame       Show a Nextcloud album   Photos: method 1       Upload my own photos   Photos: method 2       Let relatives send photos   Photos: method 3       Let relatives send cards   Widgets: Cards       Show Home Assistant   Widgets: Home Assistant       Send a new kiosk version   Update the kiosk       Fix \"maintenance\" after a deploy   Troubleshooting       Recover a frame that will not boot   Troubleshooting       Give a frame to someone   Provisioning: give a frame away","url":"https://calevans.com/picture-frame/docs/index.html#find-a-page-by-goal","tags":"","category":""},{"id":386,"title":"How these pages write commands","pageTitle":"Start here","text":"WSL  means a shell in your Linux distribution on Windows.  PowerShell  means an Administrator PowerShell.  Server  means a shell on the machine that runs Apache.   <repo>  is the folder that holds this repository.   Examples use  https://frames.example.com/frames/  for your server,  192.0.2.x  for network addresses, and  /srv/frame  for the install folder. Replace them with your own.   Steps you must not skip are marked  Warning . Details that are easy to miss are marked  Note .","url":"https://calevans.com/picture-frame/docs/index.html#how-these-pages-write-commands","tags":"","category":""},{"id":387,"title":"Before you start","pageTitle":"Start here","text":"Only modify hardware you own. Rooting a frame can brick it. The unlock guide makes you back up the whole storage and check the backup before it writes anything, and it is worth reading all of  Unlock your PF1007L  before you start. The repository is MIT-licensed, except the  uboot/  folder, which is GPL-2.0-or-later. See  the license section of the project README . Next:  Hardware and requirements .","url":"https://calevans.com/picture-frame/docs/index.html#before-you-start","tags":"","category":""},{"id":388,"title":"A guide for the family","pageTitle":"A guide for the family","text":"This guide is for the people who use the frame. It has three short parts:   Part 1  is for the person who has the frame on the wall. It is made to be printed and kept beside the frame.   Part 2  is for family members who want to send a card from a phone.   Part 3  is for family members who want to send photos from a phone.   If you are looking for how to set up a frame, this is the wrong page. Ask the person who set up the frame, or see  Part 4 .","url":"https://calevans.com/picture-frame/for-family/index.html","tags":"","category":""},{"id":389,"title":"Part 1: If the frame is on your wall","pageTitle":"A guide for the family","text":"You do not need to do anything to keep the frame working. It shows your photos by itself. Family can send you cards, and the cards arrive by themselves too. This page tells you what you will see and what to do if something looks odd.","url":"https://calevans.com/picture-frame/for-family/index.html#part-1-if-the-frame-is-on-your-wall","tags":"","category":""},{"id":390,"title":"What you will see","pageTitle":"A guide for the family","text":"Most of the time, the frame shows photos. A new photo appears every half minute or so. If nobody touches the frame for a couple of minutes, it goes back to the photos. This is normal. It is not a problem.","url":"https://calevans.com/picture-frame/for-family/index.html#what-you-will-see","tags":"","category":""},{"id":391,"title":"Opening the menu","pageTitle":"A guide for the family","text":"To see the menu,  touch the screen once  while the photos are showing. A column of buttons slides in from the left side. It stays for a few seconds, then slides away again. You can touch any button. Nothing you touch can break the frame.","url":"https://calevans.com/picture-frame/for-family/index.html#opening-the-menu","tags":"","category":""},{"id":392,"title":"The buttons on the left","pageTitle":"A guide for the family","text":"You may see some or all of these. The person who set up your frame chooses which ones are there.         Button   What it shows           Home   The time, the weather, and today's plans on one screen.       Pictures   The photos, filling the whole screen.       Weather   The weather now, the next 24 hours, and the days ahead.       Week   Your plans for this week.       Home Assistant   The controls for your smart home, such as lights.       Mailbox   Your cards. This button only appears when you have cards.       Dim   Turns the screen dark. Touch the screen to wake it.         On some screens, a  Back  button appears in the top-left corner. Touch it to return to  Home .","url":"https://calevans.com/picture-frame/for-family/index.html#the-buttons-on-the-left","tags":"","category":""},{"id":393,"title":"Cards from family","pageTitle":"A guide for the family","text":"Family can send you a card from their phones. Here is how it works.   A small picture, like a postage stamp, appears at the edge of the photos.   Touch the stamp.   The front of the card opens in the middle of the screen.   Touch the card to open it.   The cover lifts and you can read the message.   Touch the card again to close it.   A few things to know:   A card waits for you as long as you like. It does not disappear if you do not open it right away.   Up to five stamps show at once. If there are more, one stamp says  +  and a number. Touch it to see them all in the  Mailbox .   While a card is open, the photos stop changing so you can read in peace.   If you walk away, the card closes by itself after a few minutes.","url":"https://calevans.com/picture-frame/for-family/index.html#cards-from-family","tags":"","category":""},{"id":394,"title":"Your Mailbox","pageTitle":"A guide for the family","text":"Once you have opened a card, it moves to your  Mailbox . Touch  Mailbox  in the menu to see your cards. Touch any card to read it again. Cards stay in the Mailbox for a while after you open them, up to 30 days. The bottom of the Mailbox page always tells you how long. After that, they are cleared away to make room for new ones. The  Mailbox  button shows a small number when you have cards you have not opened yet.","url":"https://calevans.com/picture-frame/for-family/index.html#your-mailbox","tags":"","category":""},{"id":395,"title":"Nighttime","pageTitle":"A guide for the family","text":"The frame goes dark by itself at bedtime. It lights up again by itself in the morning. You do not need to turn anything off or on. If you touch the screen during the night, it lights up so you can see it. That first touch only wakes the screen. It does not press anything. After you wake it at night, it stays lit. To put it back to sleep, touch the screen once to show the menu, then touch  Dim . Otherwise, it goes dark again at the next bedtime.","url":"https://calevans.com/picture-frame/for-family/index.html#nighttime","tags":"","category":""},{"id":396,"title":"If something looks wrong","pageTitle":"A guide for the family","text":"Stay calm. Almost every problem fixes itself. Go through these steps in order.   Wait one minute.  The frame often fixes itself.   Look at the screen.  If it says \"Connecting to Wi-Fi\", the frame is looking for your internet. This is normal for about half a minute after the frame is plugged in. It can take a little longer. Wait a few minutes.   Unplug the frame, count to ten, and plug it back in.  Then wait three minutes. It may show a few plain messages while it starts up. That is normal.   Still not right?  Call the person who set up your frame. Tell them what the screen says. Reading the words out loud is enough.","url":"https://calevans.com/picture-frame/for-family/index.html#if-something-looks-wrong","tags":"","category":""},{"id":397,"title":"A small \"offline\" message","pageTitle":"A guide for the family","text":"You may sometimes see a small message near a corner of the screen that says  offline since  and a time. It means the frame cannot reach the internet right now. The frame keeps trying by itself. New cards and photos will arrive once it is back. If it stays for more than an hour, check that your Wi-Fi is on, then call the person who set up your frame.","url":"https://calevans.com/picture-frame/for-family/index.html#a-small-offline-message","tags":"","category":""},{"id":398,"title":"A list of Wi-Fi networks","pageTitle":"A guide for the family","text":"If your router was changed, or the Wi-Fi password was changed, the frame may show a list of Wi-Fi networks on its own. You can leave this alone and call the person who set up your frame.","url":"https://calevans.com/picture-frame/for-family/index.html#a-list-of-wi-fi-networks","tags":"","category":""},{"id":399,"title":"A screen asking for a PIN","pageTitle":"A guide for the family","text":"Some screens may ask for a PIN.  Do not worry about this.  It is only for the person who set up your frame. You will never need it. If you see it, leave it alone and call them.","url":"https://calevans.com/picture-frame/for-family/index.html#a-screen-asking-for-a-pin","tags":"","category":""},{"id":400,"title":"Things you never need to do","pageTitle":"A guide for the family","text":"You never need to turn the frame off. It looks after itself.   You never need to update anything.   You never need to log in.   You never need to type anything on the frame.   You never need to delete photos or cards to make room.   If you are not sure about something, call the person who set up your frame. They would much rather hear from you than have you worry.","url":"https://calevans.com/picture-frame/for-family/index.html#things-you-never-need-to-do","tags":"","category":""},{"id":401,"title":"Part 2: Sending a card","pageTitle":"A guide for the family","text":"This part is for family members who want to send a card to the frame. When you finish, your card will show up on the frame as a small stamp. You need:   A phone with an internet connection.   Your personal card link. The person who set up the frame sends it to you.   Your link belongs to you. Please do not share it with others.","url":"https://calevans.com/picture-frame/for-family/index.html#part-2-sending-a-card","tags":"","category":""},{"id":402,"title":"Send a card","pageTitle":"A guide for the family","text":"Open your card link on your phone. The page says  Cards for  and the name of the person who gets the cards.   Check that the page says  Sending as  and your name.   Select  Send a card .","url":"https://calevans.com/picture-frame/for-family/index.html#send-a-card","tags":"","category":""},{"id":403,"title":"Step 1: Pick a card","pageTitle":"A guide for the family","text":"Choose a group, such as  Birthday ,  Thank you , or  Get well .   Select a cover you like. A check mark shows which one you picked.   If you do not see one you like, select  Show more .   Select  Next: Write your note .","url":"https://calevans.com/picture-frame/for-family/index.html#step-1-pick-a-card","tags":"","category":""},{"id":404,"title":"Step 2: Write your note","pageTitle":"A guide for the family","text":"In the box under  Your message , type your note.   Watch the preview above the box. It shows how your words will look on the card.   To see the front of the card, select  Front . To see the inside, select  Inside .   Select  Next: Sign your card .   Your note can have up to 400 characters and up to 8 lines. If it is too long, the page tells you and the button stays gray until you shorten it. Short notes appear in bigger letters, which are easier to read on the frame.","url":"https://calevans.com/picture-frame/for-family/index.html#step-2-write-your-note","tags":"","category":""},{"id":405,"title":"Step 3: Sign your card","pageTitle":"A guide for the family","text":"Use your finger to write your name in the box that says  Sign here .   If you make a mistake, select  Undo  to take back the last line. Select  Clear  to start your signature again.   If you would rather type, select  Type my name instead .   Select  Send to  and the name.   A box asks  Send this card to  the name. Check the cover, then select  Send card .   Turn your phone sideways to get more room to sign. Once you select  Send card , you cannot change the card or take it back. If you are not ready, select  Not yet .","url":"https://calevans.com/picture-frame/for-family/index.html#step-3-sign-your-card","tags":"","category":""},{"id":406,"title":"After you send","pageTitle":"A guide for the family","text":"You see  Sent . Your card shows up on the frame as a stamp within a minute or so. To send another, select  Send another card . If you leave the page before you finish, your card is saved on your phone. Open your link again to carry on where you stopped. To begin again, select  Start over .","url":"https://calevans.com/picture-frame/for-family/index.html#after-you-send","tags":"","category":""},{"id":407,"title":"What the other person sees","pageTitle":"A guide for the family","text":"A small stamp with your cover appears on the frame.   They touch the stamp and the front of your card opens.   They touch the card, and the cover lifts.   They read your message, and your signature draws itself, line by line.   After they close it, the card moves to their Mailbox for up to 30 days.","url":"https://calevans.com/picture-frame/for-family/index.html#what-the-other-person-sees","tags":"","category":""},{"id":408,"title":"See if your card was opened","pageTitle":"A guide for the family","text":"Open your card link.   Under  My cards , find your card.   Each card says either  Not opened yet  or  Opened , with the day and time. The time is the time where the frame is. To see the whole card again, select it. Select  Front  to see the cover. If the list looks out of date, select  Refresh .","url":"https://calevans.com/picture-frame/for-family/index.html#see-if-your-card-was-opened","tags":"","category":""},{"id":409,"title":"Your privacy","pageTitle":"A guide for the family","text":"Only you and the frame can read your message. The person who set up the frame can see that you sent a card, when you sent it, which cover you chose, and whether it was opened. They cannot read what you wrote or see your signature. If they look at the frame from far away, an open card is hidden from them.","url":"https://calevans.com/picture-frame/for-family/index.html#your-privacy","tags":"","category":""},{"id":410,"title":"If you cannot send a card","pageTitle":"A guide for the family","text":"What you see   What to do           This link isn't working anymore   Your link has been turned off or is wrong. Ask the person who set up the frame for a new one.       You've sent a lot of cards just now   You can send up to 6 cards an hour and 20 a day. Wait the number of minutes it says.       You already have 10 cards waiting   Ten of your cards have not been opened yet. Try again after some are opened.       The mailbox is full right now   There are a lot of unopened cards. Try again after some are opened.       Cards aren't being delivered right now   Cards are turned off for now. Ask the person who set up the frame.       Couldn't reach the server   Your phone may be offline. Your card is still there. Try again in a moment.       You're offline.   Check your phone's signal or Wi-Fi.       That design isn't available anymore   Pick a different cover.       Something went wrong sending your card   Your card is saved. Select  Send card  again.         If your link stops working, ask the person who set up the frame for a new one.","url":"https://calevans.com/picture-frame/for-family/index.html#if-you-cannot-send-a-card","tags":"","category":""},{"id":411,"title":"Part 3: Sending photos","pageTitle":"A guide for the family","text":"This part is for family members who want to add photos to the frame. When you finish, your photos are waiting to be approved. After that, they show on the frame. You need:   A phone with an internet connection.   Your photo link. The person who set up the frame sends it to you.","url":"https://calevans.com/picture-frame/for-family/index.html#part-3-sending-photos","tags":"","category":""},{"id":412,"title":"Send photos","pageTitle":"A guide for the family","text":"Open your photo link on your phone. The page says  Send photos to the frame .   Select  Choose photos .    Your phone shows your photos. Pick one or more.    iPhone:  pick  Photo Library , touch the photos you want, then select  Add  or  Done .   Android:  pick  Photos  or  Gallery , touch the photos you want, then confirm.       The photos start sending. Each one says  Sent  when it is done.   To add more, select  Choose more photos .   When all photos are sent, you see how many were sent and \"Thank you!\" Please keep the page open until it finishes. If you leave while photos are still sending, your phone warns you first.","url":"https://calevans.com/picture-frame/for-family/index.html#send-photos","tags":"","category":""},{"id":413,"title":"Why your photos do not show right away","pageTitle":"A guide for the family","text":"Photos you send are not shown straight away. Someone checks each one first. Your page says: \"Your photos will appear on the frame after they're approved.\" This is on purpose. It means only photos the family wants end up on the wall. Sending the same photo twice does no harm.","url":"https://calevans.com/picture-frame/for-family/index.html#why-your-photos-do-not-show-right-away","tags":"","category":""},{"id":414,"title":"How many photos you can send","pageTitle":"A guide for the family","text":"Each photo can be up to 30 MB. Almost every phone photo is much smaller.   Photos from iPhones and Androids both work. So do most other photo types.   The page tells you how many more photos you can send right now.   Usually you can send about 100 photos an hour and 300 a day.","url":"https://calevans.com/picture-frame/for-family/index.html#how-many-photos-you-can-send","tags":"","category":""},{"id":415,"title":"If a photo does not send","pageTitle":"A guide for the family","text":"What you see   What to do           That isn't a photo   The file is not a picture. Pick a photo instead.       Too big   The photo is larger than the limit. Pick a smaller one.       That file is empty   Pick a different photo.       Couldn't use this one. Try a different photo.   The frame cannot use that picture. Try another.       Something went wrong. Tap Try again.   Select  Try again .       No connection. Check your signal, then tap Try again.   Check your signal, then select  Try again . Photos already sent are safe.       You've sent a lot of photos   Wait a while, then select  Try again .       The frame can't take photos right now   The frame's storage is full or busy. Try again later, or tell the person who set up the frame.       This link isn't working anymore ,  has expired , or  has been turned off   Ask the person who set up the frame for a new link.","url":"https://calevans.com/picture-frame/for-family/index.html#if-a-photo-does-not-send","tags":"","category":""},{"id":416,"title":"Part 4: For the person who set it up","pageTitle":"A guide for the family","text":"Setting up and looking after the frame:  see the  documentation index .   To print Part 1 for the frame's owner:  open this page in your browser and choose Print. Part 1 is the first few pages. Print only those pages (the print preview shows where Part 2 starts). Choose a large paper size or a larger print scale so the text is easy to read.   Also write your own phone number at the top of the printed page. Part 1 says \"call the person who set up your frame\", so make sure they know who that is.","url":"https://calevans.com/picture-frame/for-family/index.html#part-4-for-the-person-who-set-it-up","tags":"","category":""},{"id":417,"title":"For agents","pageTitle":"For agents","text":"This site is static, so an agent can read it without a browser. Every route below is a plain file.","url":"https://calevans.com/picture-frame/for-agents/index.html","tags":"","category":""},{"id":418,"title":"AGENTS.md","pageTitle":"For agents","text":"AGENTS.md  is the context file for coding agents working in the repository. It is published here unchanged, as plain text. Read it before you change anything in the project, and keep to it.  The same file is in the repository at  gitlab.com/calevans/picture-frame-system , on the  master  branch.","url":"https://calevans.com/picture-frame/for-agents/index.html#agentsmd","tags":"","category":""},{"id":419,"title":"llms.txt","pageTitle":"For agents","text":"llms.txt  lists every documentation page with its title and a one-line summary. Each entry links to a plain Markdown copy of the page.","url":"https://calevans.com/picture-frame/for-agents/index.html#llmstxt","tags":"","category":""},{"id":420,"title":"Markdown copies","pageTitle":"For agents","text":"Every page has a Markdown copy next to it: replace  index.html  with  index.md  in the page's address. For example, the hardware page is at  /docs/hardware/  and its Markdown copy is at  /docs/hardware/index.md .","url":"https://calevans.com/picture-frame/for-agents/index.html#markdown-copies","tags":"","category":""},{"id":421,"title":"Sitemap and search","pageTitle":"For agents","text":"sitemap.xml  lists every public page.      search.json  is the index behind  site search , one record per section.","url":"https://calevans.com/picture-frame/for-agents/index.html#sitemap-and-search","tags":"","category":""},{"id":422,"title":"Ground rules from the project","pageTitle":"For agents","text":"PHP 8.5 and SQLite on the server. No frameworks, no Node, no Python in project code.   Never attach backups, firmware dumps or device credentials to an issue or merge request.   Every claim in the guides should be true on real hardware. Where a guide says it did not verify something, treat that step as unverified.   Everything here is MIT licensed, except the  uboot/  directory, which is GPL-2.0-or-later.","url":"https://calevans.com/picture-frame/for-agents/index.html#ground-rules-from-the-project","tags":"","category":""},{"id":423,"title":"Picture Frame System","pageTitle":"Picture Frame System","text":"Skip to content             A self-hosted photo frame","url":"https://calevans.com/picture-frame/index.html","tags":"","category":""},{"id":424,"title":"A photo frame that answers only to you.","pageTitle":"Picture Frame System","text":"Unlock a PF1007L Wi-Fi photo frame, replace its vendor software with a locked-down kiosk, and run the whole thing from a small PHP and SQLite server you own. No cloud account. No subscription. No framework.      Get started                       View on GitLab        MIT   PHP 8.5   SQLite   No cloud   Self-hosted                           10:42   Tuesday   /   72° and clear                     With love               FRAME   AL                   Why this exists","url":"https://calevans.com/picture-frame/index.html#hero-title","tags":"","category":""},{"id":425,"title":"Most photo frames phone home to a company that may not exist in three years.","pageTitle":"Picture Frame System","text":"Your pictures pass through their servers, the app on your phone is theirs, and when they lose interest, so does your frame. This project takes the same hardware and gives it to you.","url":"https://calevans.com/picture-frame/index.html#why-title","tags":"","category":""},{"id":426,"title":"No cloud","pageTitle":"Picture Frame System","text":"The frame talks to one server: yours. Phones and frames only ever talk to your server, and your server only talks to the sources you list.","url":"https://calevans.com/picture-frame/index.html","tags":"","category":""},{"id":427,"title":"No subscription","pageTitle":"Picture Frame System","text":"Nothing to renew. The code is MIT licensed, and the server is PHP and SQLite on a host you already know how to run.","url":"https://calevans.com/picture-frame/index.html","tags":"","category":""},{"id":428,"title":"No one else holds your photos","pageTitle":"Picture Frame System","text":"Photos come from your own album or photo library. Cards from relatives are readable on the frame, not in the admin. That includes you.                   What you get","url":"https://calevans.com/picture-frame/index.html","tags":"","category":""},{"id":429,"title":"A frame the family forgets about, in the best way.","pageTitle":"Picture Frame System","text":"The person with it on the wall never opens a settings screen. Photos change, the weather is right, and a card from a grandchild shows up.","url":"https://calevans.com/picture-frame/index.html#features-title","tags":"","category":""},{"id":430,"title":"A dashboard that fills the screen","pageTitle":"Picture Frame System","text":"Clock, weather, calendar, photos and more, laid out for a 10.1-inch panel. No status bar, no navigation bar, no on-screen exit.","url":"https://calevans.com/picture-frame/index.html","tags":"","category":""},{"id":431,"title":"Cards from the family","pageTitle":"Picture Frame System","text":"A relative opens a private link on a phone, picks a cover, writes a message and signs it with a finger. It arrives on the frame as a postage stamp over the photos. Nobody needs an account.","url":"https://calevans.com/picture-frame/index.html","tags":"","category":""},{"id":432,"title":"Photos that survive a bad day","pageTitle":"Picture Frame System","text":"The day's photos are stored on the frame, so they keep showing when the network or the photo host is down.","url":"https://calevans.com/picture-frame/index.html","tags":"","category":""},{"id":433,"title":"Bedtime and wake","pageTitle":"Picture Frame System","text":"A schedule per frame turns the backlight fully off, not just dim, and back on in the morning.","url":"https://calevans.com/picture-frame/index.html","tags":"","category":""},{"id":434,"title":"A desktop admin","pageTitle":"Picture Frame System","text":"Claim a frame with a six-digit pairing code, choose what it shows widget by widget, and see what it is showing right now. Two-step sign-in is optional.","url":"https://calevans.com/picture-frame/index.html","tags":"","category":""},{"id":435,"title":"Updates that undo themselves","pageTitle":"Picture Frame System","text":"Self-update with a staged rollout. If a new build is not healthy after about 15 minutes, the frame reinstalls the last one that was.          See every widget                        How it works","url":"https://calevans.com/picture-frame/index.html","tags":"","category":""},{"id":436,"title":"Three acts, done once per frame.","pageTitle":"Picture Frame System","text":"Acts one and two are independent, so start with whichever you like. Act three needs both.         1   Act one","url":"https://calevans.com/picture-frame/index.html#how-title","tags":"","category":""},{"id":437,"title":"Unlock","pageTitle":"Picture Frame System","text":"Build a patched U-Boot, boot the frame from a microSD card into USB recovery mode, back up the whole storage and verify the backup, then change one line to turn ADB on. One 512-byte sector changes. You never open the case and you never solder.    Unlock guide              2   Act two","url":"https://calevans.com/picture-frame/index.html","tags":"","category":""},{"id":438,"title":"Serve","pageTitle":"Picture Frame System","text":"Put the PHP app on an Apache server, create the SQLite database, create your admin login, and add one cron job. Two-step sign-in is optional.    Server guide              3   Act three","url":"https://calevans.com/picture-frame/index.html","tags":"","category":""},{"id":439,"title":"Provision","pageTitle":"Picture Frame System","text":"Build the kiosk app in a container, install it, set it as device owner, install the boot script, and claim the frame in the admin with the six-digit code it shows on screen.    Provisioning guide                            Yours, not ours","url":"https://calevans.com/picture-frame/index.html","tags":"","category":""},{"id":440,"title":"Your server. Your data. Your call.","pageTitle":"Picture Frame System","text":"Photos, cards and settings live in a SQLite database and a photo folder on a server you run. There is no project server, no account to create and no analytics. A frame reports its status to your server, and it loads only the photo and Home Assistant addresses you give it. The kiosk refuses plain HTTP.   Every frame has its own API key, secrets never reach page JavaScript, and ADB on the frame can be limited to your own network.      How the system defends itself                              Who it is for","url":"https://calevans.com/picture-frame/index.html#yours-title","tags":"","category":""},{"id":441,"title":"The curious","pageTitle":"Picture Frame System","text":"You want a photo frame that belongs to you and are willing to learn how it works.    Check the hardware","url":"https://calevans.com/picture-frame/index.html","tags":"","category":""},{"id":442,"title":"Builders","pageTitle":"Picture Frame System","text":"You run shell and PowerShell commands, host a PHP site on Apache, and can spend an afternoon on the unlock.    Start here","url":"https://calevans.com/picture-frame/index.html","tags":"","category":""},{"id":443,"title":"Family","pageTitle":"Picture Frame System","text":"Someone set a frame up for you. This is the page to print and keep beside it.    The family guide","url":"https://calevans.com/picture-frame/index.html","tags":"","category":""},{"id":444,"title":"Developers","pageTitle":"Picture Frame System","text":"PHP 8.5, SQLite, no framework, no Node. Widgets are modules you can add or remove.    Developer docs              Know before you start.  Weather is US-only (National Weather Service). This is for one frame model, the PF1007L, and the unlock was verified on one firmware build. The unlock scripts expect Windows with WSL2; the server runs on any Linux host.             Ready","url":"https://calevans.com/picture-frame/index.html","tags":"","category":""},{"id":445,"title":"Plan on an afternoon for the unlock.","pageTitle":"Picture Frame System","text":"Most of it is waiting for the backup to be written and checked, about 90 minutes.      Get started          What you need     You need:  a PF1007L photo frame ( where we bought ours ), Windows 10 or 11 with WSL2 for the unlock, and a Linux server with Apache 2.4, PHP 8.5 and a valid HTTPS certificate.   Released under the MIT License. The  uboot/  directory is GPL-2.0-or-later.  Read the license .","url":"https://calevans.com/picture-frame/index.html#cta-title","tags":"","category":""}]