Skip to content

Instantly share code, notes, and snippets.

@malesfth
Last active March 17, 2025 21:37
Show Gist options
  • Save malesfth/d5fb2eb36aab4e726727c14ba32f9f8b to your computer and use it in GitHub Desktop.
Save malesfth/d5fb2eb36aab4e726727c14ba32f9f8b to your computer and use it in GitHub Desktop.
iOS Scriptable Widget for Pi-hole
// Parameters:
// {"url":"https://pihole","token": "x"}
// Optional key in parameters: "theme": system|light|dark
let piholeURL = "" // set the URL here for debug
let apiToken = "" // set the api token here for debug
let wTheme = "" // set the theme for debug
if (config.runsInWidget) {
const widgetParams = (args.widgetParameter != null ? JSON.parse(args.widgetParameter) : null)
if (widgetParams==null) {
throw new Error("Please long press the widget and add the parameters.")
} else if (!widgetParams.hasOwnProperty("url") || !widgetParams.hasOwnProperty("token")) {
throw new Error("Wrong parameters.")
}
piholeURL = widgetParams.url
apiToken = widgetParams.token
if (widgetParams.hasOwnProperty("theme")) {
wTheme = widgetParams.theme
}
}
let wBackground = new LinearGradient()
let wColor = new Color("#ffffff")
setTheme()
let piholeStats = await getStats()
let wSize = config.widgetFamily || "large" //set size of widget for debug
let widget = await createWidget() || null
if (!config.runsInWidget) {
if (wSize=="large") { await widget.presentLarge() }
else if (wSize=="medium") { await widget.presentMedium() }
else { await widget.presentSmall() }
}
Script.setWidget(widget)
Script.complete()
async function createWidget() {
let w = new ListWidget()
w.backgroundGradient = wBackground
w.addSpacer()
w.setPadding(5, 15, 0, (wSize=="small" ? 0 : 10))
let state = (piholeStats!=null ? (piholeStats.status=="enabled" ? true : false) : null)
let icn = null
if (state==true) {
icn = SFSymbol.named((state ? "checkmark.shield.fill" : "xmark.shield.fill"))
} else {
icn = SFSymbol.named("xmark.circle.fill")
state = false
}
let topStack = w.addStack()
topStack.layoutHorizontally()
topStack.setPadding(5, 0, 0, 10)
let content = topStack.addImage(icn.image)
content.tintColor = (state ? Color.green() : Color.red())
content.imageSize = new Size(16,16)
topStack.addSpacer(5)
content = topStack.addText("Pi-hole")
content.font = Font.blackSystemFont(16)
content.textColor = wColor
if (state==true && wSize != "small") {
topStack.addSpacer()
topStack.addText(" ") // same line with distance to title
addUpdateItem(topStack, (piholeStats.core_current==piholeStats.core_latest ? true : false), "Pi" + ((wSize=="small") ? "" : "-hole"))
topStack.addText(" ")
addUpdateItem(topStack, (piholeStats.web_current==piholeStats.web_latest ? true : false), "WebUI")
topStack.addText(" ")
addUpdateItem(topStack, (piholeStats.FTL_current==piholeStats.FTL_latest ? true : false), "FTL")
}
w.addSpacer(8)
if (piholeStats==null) {
content = w.addText("No connection")
content.font = Font.thinSystemFont(14)
content.textColor = wColor
w.addSpacer()
return w
}
w.url = piholeURL + "/admin/"
addItem(w, "Total Queries", piholeStats.dns_queries_all_types)
layoutStack = w.addStack()
layoutStack.setPadding(5, 0, 0, 10)
layoutStack.centerAlignContent()
addItem(w, "Queries Blocked", piholeStats.ads_blocked_today)
layoutStack = w.addStack()
layoutStack.setPadding(5, 0, 0, 10)
layoutStack.centerAlignContent()
addItem(w, "Percent Blocked", Number.parseFloat(piholeStats.ads_percentage_today).toFixed(1).replace(".", ",") + "%")
layoutStack = w.addStack()
layoutStack.setPadding(5, 0, 0, 10)
layoutStack.centerAlignContent()
addItem(w, "Domains on Blocklist", piholeStats.domains_being_blocked)
if (wSize=="large") {
addItem(w, "Unique Domains", piholeStats.unique_domains)
layoutStack = w.addStack()
layoutStack.setPadding(5, 0, 0, 10)
addItem(w, "Cached Queries", piholeStats.queries_cached)
layoutStack = w.addStack()
layoutStack.setPadding(5, 0, 0, 10)
addItem(w, "Queries Forwarded", piholeStats.queries_forwarded)
layoutStack = w.addStack()
layoutStack.setPadding(5, 0, 0, 10)
addItem(w, "Clients Seen / Unique", piholeStats.clients_ever_seen + " / " + piholeStats.unique_clients)
}
w.addSpacer()
return w
}
function addItem(w, strHeadline, strData) {
let fontSizeHeadline = 12
let fontSizeString = 9
switch (wSize) {
case "large":
fontSizeHeadline = 18
fontSizeString = 14
break;
case "medium":
fontSizeHeadline = 14
fontSizeString = 11
break;
}
let layoutStack = w.addStack()
layoutStack.setPadding(3, 0, 0, 10)
layoutStack.centerAlignContent()
content = layoutStack.addText(strHeadline)
content.font = Font.mediumSystemFont(fontSizeHeadline)
content.textColor = wColor
layoutStack.addSpacer()
content = layoutStack.addText(strData.toString())
content.font = Font.mediumSystemFont(fontSizeString)
content.textColor = wColor
}
function addUpdateItem(stack, status, text) {
let icn = SFSymbol.named((status ? "checkmark.circle.fill" : "exclamationmark.triangle.fill"))
let content = stack.addImage(icn.image)
content.tintColor = (status ? Color.green() : Color.red())
content.imageSize = new Size(14,14)
content = stack.addText(((wSize!="small") ? " " : "" ) + text)
content.font = Font.semiboldMonospacedSystemFont(12)
content.textColor = wColor
}
function setTheme() {
if (wTheme=="system") {
if (Device.isUsingDarkAppearance()) {
wTheme = "dark"
} else {
wTheme = "light"
}
}
wBackground.locations = [0, 1]
if (wTheme=="dark") {
wBackground.colors = [
new Color("#384d54"),
new Color("#384d54")
]
wColor = new Color("#ffffff")
} else {
wBackground.colors = [
new Color("#ffffffe6"), //ffffffe6
new Color("#ffffffe6")
]
wColor = new Color("#000000")
}
}
async function getStats() {
try {
let req = new Request(piholeURL + "/admin/api.php?versions&summary&auth="+apiToken)
let json = await req.loadJSON()
if (json.length === 0) { return null }
return json
} catch(err) {
console.error(err)
return null
}
}
@malesfth
Copy link
Author

I’ve updated the code now:

  • Removed apikey (not needed)
  • Approved code / applied updates from user @JoeGit42 (but changed getUpdateStats() to catch via WebView)
  • Bugfixes

Many thanks @JoeGit42 !

@Token2K
Copy link

Token2K commented Feb 15, 2022

New Error‘s after Pihole -up:
EFEC9FA5-C7E9-40DC-8367-2B1F3008FE17
This is a Problem with „%-Fix“ !!!

Thanks for viewing!

@marked80
Copy link

marked80 commented Feb 15, 2022

At Line 110 I placed the following

addItem(w, "Percent Blocked", piholeStats.ads_percentage_today + "%")

that solves the issue for me.

@marked80
Copy link

Or this if you want a comma as a decimal separator

addItem(w, "Percent Blocked", piholeStats.ads_percentage_today.replace(".", ",") + "%")

@Token2K
Copy link

Token2K commented Feb 15, 2022

@marked80
I’am use your second part - this help!
Big Thanks for the fastest Bugfix!

@marked80
Copy link

@Token2K

You can use the replace function for the other numbers too using this from line 100 till 117

addItem(w, "Total Queries", piholeStats.dns_queries_all_types.replace(",", "."))

layoutStack = w.addStack()
layoutStack.setPadding(5, 0, 0, 10)
layoutStack.centerAlignContent()
	
addItem(w, "Queries Blocked", piholeStats.ads_blocked_today.replace(",", "."))
	
layoutStack = w.addStack()
layoutStack.setPadding(5, 0, 0, 10)
layoutStack.centerAlignContent()

addItem(w, "Percent Blocked", piholeStats.ads_percentage_today.replace(".", ",") + "%")

layoutStack = w.addStack()
layoutStack.setPadding(5, 0, 0, 10)
layoutStack.centerAlignContent()
	
addItem(w, "Domains on Blocklist", piholeStats.domains_being_blocked.replaceAll(",", "."))

@marked80
Copy link

@Token2K

looks much better this way 😊
F4929083-602D-4DBA-A972-2A7949DCEE83

@Token2K
Copy link

Token2K commented Feb 20, 2022

8B93CED6-7D51-4B70-A30F-A1A9C65B2EFC

Line 98-134 … for large Display adapted!

Thanks for Idea. 👍

Please update to git+scriptdude!

@marked80
Copy link

@Token2K You are welcome! 👍🏼

@happyhelmi
Copy link

happyhelmi commented Dec 18, 2022

Hi all,

I have a problem to get a connection:

my code:
let piholeURL = "{“url“:“http://192.168.178.18“,“apikey“:“1234abcd“}" //set the URL here for debug http:// let wTheme = "system" // set the theme for debug

Parameters:
http://192.168.178.18“,“apikey“:“1234abcd“}

When I run it in scriptable it still gives me the following error:
D2F2244E-7515-4172-ACC7-9071BAB442F4

In the widget it shows me the following error:
A51376CD-22D8-4956-8539-A891EFAFE227

Can you help me? Any idea?

Thx a lot!

@btcperfect
Copy link

Latest Update broke my widget.

code in reference:

content = layoutStack.addText(strData.toString())

69F43C10-AEE5-46F0-95C5-E4C4AAF43515

@marked80
Copy link

marked80 commented Jan 22, 2023

Latest Update broke my widget.

code in reference:

content = layoutStack.addText(strData.toString())

69F43C10-AEE5-46F0-95C5-E4C4AAF43515

almost the same here…

2023-01-23 00:08:25: Error on line 99:63: TypeError: undefined is not an object (evaluating 'piholeStats.dns_queries_all_types.replace')

@GMTheDoctor
Copy link

Can someone please help me?
C6D87400-2CDB-4CEE-B8FF-A20A52D7DA52

584268B5-3ACB-42A5-9896-1B98AD6E8830

@marked80
Copy link

marked80 commented Feb 2, 2023

I also have not been able to fix it unfortunately.

It looks like the pihole stats values are not fetched anymore so that is why the error pops up.
It wants to convert the data to a string value but there is no data variable present at that point so it tries to convert null to string.

Does anyone know if the parameters have been changed for pi-hole?
I looked at the information of pi-hole but it still mentions the same parameters.

@malesfth
Copy link
Author

malesfth commented Feb 2, 2023

Hey there 👋

just updated the code. Because of changes of Pi-hole, you must use now the api token. You can create this one in the settings area.

Please update the parameters of your widget with the code inside:
{"url":"…","token":""}

@marked80
Copy link

marked80 commented Feb 2, 2023

Hey there 👋

just updated the code. Because of changes of Pi-hole, you must use now the api token. You can create this one in the settings area.

Please update the parameters of your widget with the code inside: {"url":"…","token":""}

Yes!! This works great. Just replaced the comma’s and dots again (in Europe that is the used decimal setting)

@happyhelmi
Copy link

Yeah, perfect! Thanks a lot. One question: does the update feature work? It always shows me green, even though there is a new update… any idea?

@malesfth
Copy link
Author

malesfth commented Feb 3, 2023

Yeah, perfect! Thanks a lot. One question: does the update feature work? It always shows me green, even though there is a new update… any idea?

Fixed!

@palasinio
Copy link

palasinio commented Feb 3, 2023

Just replaced the comma’s and dots again (in Europe that is the used decimal setting)
@marked80
How did you replaced them?

@dennerforen
Copy link

Use a text editor for the script and put the second pic with your data into the parameter field if you long press the widget.

pic to is without blanks, the linebreak is only optical.
81E0DC47-5EE2-4D9E-BB5B-56975D5530D5
8A7C9304-66EB-4F9B-9EE2-EC3FBCE3FAF1

@marked80
Copy link

marked80 commented Feb 3, 2023

Just replaced the comma’s and dots again (in Europe that is the used decimal setting)
@marked80
How did you replaced them?

With a .replace function.
Below is the modified script:

// Parameters:
// {"url":"https://pihole","token": "x"}
// Optional key in parameters: "theme": system|light|dark

let piholeURL = "http://192.xxx.x.x" // set the URL here for debug
let apiToken = "xxxxxxx" // set the api token here for debug
let wTheme = "" // set the theme for debug

if (config.runsInWidget) {
const widgetParams = (args.widgetParameter != null ? JSON.parse(args.widgetParameter) : null)
if (widgetParams==null) {
throw new Error("Please long press the widget and add the parameters.")
} else if (!widgetParams.hasOwnProperty("url")) {
throw new Error("Wrong parameters.")
}

piholeURL = widgetParams.url
apiToken = widgetParams.token
if (widgetParams.hasOwnProperty("theme")) {
	wTheme = widgetParams.theme
}

}

let wBackground = new LinearGradient()
let wColor = new Color("#ffffff")
setTheme()

let piholeStats = await getStats()
let adminPage = await getAdminPage()
let isLatestVersion = await getUpdateStats(adminPage)

let wSize = config.widgetFamily || "large" //set size of widget for debug
let widget = await createWidget() || null

if (!config.runsInWidget) {
if (wSize=="large") { await widget.presentLarge() }
else if (wSize=="medium") { await widget.presentMedium() }
else { await widget.presentSmall() }
}
Script.setWidget(widget)
Script.complete()

async function createWidget() {
let w = new ListWidget()
w.backgroundGradient = wBackground
w.addSpacer()
w.setPadding(5, 15, 0, (wSize=="small" ? 0 : 10))

let state = (piholeStats!=null ? (piholeStats.status=="enabled" ? true : false) : null)
let icn = null

if (state==true) {
	icn = SFSymbol.named((state ? "checkmark.shield.fill" : "xmark.shield.fill"))
} else {
	icn = SFSymbol.named("xmark.circle.fill")
	state = false
}

let topStack = w.addStack()
topStack.layoutHorizontally()
topStack.setPadding(5, 0, 0, 10)

let content = topStack.addImage(icn.image)
content.tintColor = (state ? Color.green() : Color.red())
content.imageSize = new Size(16,16)
topStack.addSpacer(5)

content = topStack.addText("Pi-hole")
content.font = Font.blackSystemFont(16)
content.textColor = wColor

if (state==true && wSize != "small") {
	topStack.addSpacer()
  	topStack.addText("    ") // same line with distance to title

addUpdateItem(topStack, isLatestVersion[0], "Pi" + ((wSize=="small") ? "" : "-hole"))
topStack.addText("  ")
addUpdateItem(topStack, isLatestVersion[2], "WebUI")
topStack.addText("  ")
addUpdateItem(topStack, isLatestVersion[1], "FTL")
	
}

w.addSpacer(8)

if (piholeStats==null) {
	content = w.addText("No connection")
    content.font = Font.thinSystemFont(14)
    content.textColor = wColor
	w.addSpacer()
	return w
}

w.url = piholeURL + "/admin/"

addItem(w, "Total Queries", piholeStats.dns_queries_all_types.replace(",", "."))
	
layoutStack = w.addStack()
layoutStack.setPadding(5, 0, 0, 10)
layoutStack.centerAlignContent()
	
addItem(w, "Queries Blocked", piholeStats.ads_blocked_today.replace(",", "."))
	
layoutStack = w.addStack()
layoutStack.setPadding(5, 0, 0, 10)
layoutStack.centerAlignContent()
	
addItem(w, "Percent Blocked", Number.parseFloat(piholeStats.ads_percentage_today).toFixed(1).replace(".", ",") + "%")
	
layoutStack = w.addStack()
layoutStack.setPadding(5, 0, 0, 10)
layoutStack.centerAlignContent()
	
addItem(w, "Domains on Blocklist", piholeStats.domains_being_blocked.replace(",", "."))

if (wSize=="large") {
	addItem(w, "Unique Domains", piholeStats.unique_domains)
	
	layoutStack = w.addStack()
	layoutStack.setPadding(5, 0, 0, 10)
	
	addItem(w, "Cached Queries", piholeStats.queries_cached.replace(",", "."))
	
	layoutStack = w.addStack()
	layoutStack.setPadding(5, 0, 0, 10)
	
	addItem(w, "Queries Forwarded", piholeStats.queries_forwarded.replace(",", "."))
	
	layoutStack = w.addStack()
	layoutStack.setPadding(5, 0, 0, 10)
	
	addItem(w, "Clients Seen / Unique", piholeStats.clients_ever_seen + " / " + piholeStats.unique_clients)
}

w.addSpacer()
return w

}

function addItem(w, strHeadline, strData) {
let fontSizeHeadline = 12
let fontSizeString = 9
switch (wSize) {
case "large":
fontSizeHeadline = 18
fontSizeString = 14
break;
case "medium":
fontSizeHeadline = 14
fontSizeString = 11
break;
}

let layoutStack = w.addStack()
layoutStack.setPadding(3, 0, 0, 10)
layoutStack.centerAlignContent()
	
content = layoutStack.addText(strHeadline)
content.font = Font.mediumSystemFont(fontSizeHeadline)
content.textColor = wColor
layoutStack.addSpacer()

content = layoutStack.addText(strData.toString())
content.font = Font.mediumSystemFont(fontSizeString)
content.textColor = wColor

}

function addUpdateItem(stack, status, text) {
let icn = SFSymbol.named((status ? "checkmark.circle.fill" : "exclamationmark.triangle.fill"))
let content = stack.addImage(icn.image)
content.tintColor = (status ? Color.green() : Color.red())
content.imageSize = new Size(14,14)
content = stack.addText(((wSize!="small") ? " " : "" ) + text)
content.font = Font.semiboldMonospacedSystemFont(12)
content.textColor = wColor
}

function setTheme() {
if (wTheme=="system") {
if (Device.isUsingDarkAppearance()) {
wTheme = "dark"
} else {
wTheme = "light"
}
}
wBackground.locations = [0, 1]
if (wTheme=="dark") {
wBackground.colors = [
new Color("#384d54"),
new Color("#384d54")
]
wColor = new Color("#ffffff")
} else {
wBackground.colors = [
new Color("#ffffffe6"), //ffffffe6
new Color("#ffffffe6")
]
wColor = new Color("#000000")
}
}

async function getUpdateStats (webPage) {
let latestVersions = [true, true, true]
try {
let view = new WebView()
await view.loadHTML(webPage);
let data = await view.evaluateJavaScript(`
let retoure = [true, true, true]
let a = Array.from(document.querySelectorAll('div.version-info div ul li'))

		for (let i=0; i<a.length; i++) {
			let el = a[i]

			if (el.getElementsByTagName('a').length == 2) {
				retoure[i] = false	
			}	
		}	
		completion(retoure)`, true)
	latestVersions = data
} catch(e) {
	console.log("Cannot get update stats: " + e)
}
return latestVersions

}

async function getStats() {
try {
let req = new Request(piholeURL + "/admin/api.php?summary&auth="+apiToken)
let json = await req.loadJSON()
if (json.length === 0) { json == null }
return json
} catch(err) {
console.error(err)
return null
}
}

async function getAdminPage() {
try {
let req = new Request(piholeURL + "/admin/")
let adminHTMLPage = await req.loadString()
return adminHTMLPage
} catch(err) {
console.log(err)
return null
}
}

@palasinio
Copy link

THX

@Token2K
Copy link

Token2K commented Feb 21, 2023

Thx @malesfth ... for this Update!
Thx @marked80 ... a very old "Bug/Error"! (for European's) ;-)

@Tristy13
Copy link

Hi

does this work with pi hole v6? Cant seem to get a connection when setting up the script.

@r8ders2k
Copy link

r8ders2k commented Feb 22, 2025

I'll second Tristy13's post. I was finally able to get the updated Pi-hole Remote app working, with Pi-hole V6, using the App Password. Will there be an update for the Pi-Hole widget?

@bjarne-dietrich
Copy link

bjarne-dietrich commented Feb 24, 2025

Pi-Hole v6 has introduced a new REST-API.
API-Tokens are not available anymore, Its required to generate a new app-password for api-access.
Also it's using authenticated sessions now.

This is working on my side:

// Parameters:
// {"url":"https://pihole","apikey":"123abc"}
// Optional key in parameters: "theme": system|light|dark, "apiPath": "/api"

let piholeURL = "http://localhost:80" //set the URL here for debug
let piholeAppPassword = "qP8JjyWxmhb7Tdij1geQJa05GHsMBYOaJtVAdk6Czjk=" // set the API-key here for debug
let wTheme = "system" // set the theme for debug

let piholeApiPath = "/api"

if (config.runsInWidget) {
	const widgetParams = (args.widgetParameter != null ? JSON.parse(args.widgetParameter) : null)
	if (widgetParams==null) {
		throw new Error("Please long press the widget and add the parameters.")
	} else if (!widgetParams.hasOwnProperty("url") && !widgetParams.hasOwnProperty("apikey")) {
		throw new Error("Wrong parameters.")
	}
	
	piholeURL = widgetParams.url
	piholeAppPassword = widgetParams.apikey

	if (widgetParams.hasOwnProperty("theme")) {
		wTheme = widgetParams.theme
	}

   if (widgetParams.hasOwnProperty("apiPath")) {
     piholeApiPath = widgetParams.apiPath
   }
  
}

let wBackground = new LinearGradient()
let wColor = new Color("#ffffff")
setTheme()

let sid = await authenticate();

let piholeStats = await sendGetRequest("/stats/summary", sid);

let dns = await sendGetRequest("/dns/blocking", sid);



let wSize = config.widgetFamily || "small" //set size of widget for debug
let widget = await createWidget() || null

if (!config.runsInWidget) {
	if (wSize=="large") { await widget.presentLarge() }
	else if (wSize=="medium") { await widget.presentMedium() }
	else { await widget.presentSmall() }
}

await invalidateSession(sid);

Script.setWidget(widget)
Script.complete()

async function createWidget() {
	let w = new ListWidget()
	w.backgroundGradient = wBackground
	w.addSpacer()
	w.setPadding(5, 15, 0, (wSize=="small" ? 0 : 10))
	
	let state = (piholeStats!=null ? (dns.blocking=="enabled" ? true : false) : null)
	let icn = null
	
	if (state==true) {
		icn = SFSymbol.named((state ? "checkmark.shield.fill" : "xmark.shield.fill"))
	} else {
		icn = SFSymbol.named("xmark.circle.fill")
		state = false
	}
	
	let topStack = w.addStack()
	let content = topStack.addImage(icn.image)
	content.tintColor = (state ? Color.green() : Color.red())
	content.imageSize = new Size(16,16)
	topStack.addSpacer(5)
	
	content = topStack.addText("Pi-hole")
	content.font = Font.blackSystemFont(16)
	content.textColor = wColor
	
	w.addSpacer(8)
	
	if (piholeStats==null) {
		content = w.addText("No connection")
	    content.font = Font.thinSystemFont(14)
	    content.textColor = wColor
		w.addSpacer()
		return w
	}
	
	w.url = piholeURL
	
	addItem(w, "Total Queries", `${piholeStats.queries.total}` )
		
	layoutStack = w.addStack()
	layoutStack.setPadding(5, 0, 0, 10)
	layoutStack.centerAlignContent()
		
	addItem(w, "Queries Blocked", `${piholeStats.queries.blocked}`)
		
	layoutStack = w.addStack()
	layoutStack.setPadding(5, 0, 0, 10)
	layoutStack.centerAlignContent()
		

	addItem(w, "Percent Blocked", piholeStats.queries.percent_blocked.toFixed(1) + "%")
		
	layoutStack = w.addStack()
	layoutStack.setPadding(5, 0, 0, 10)
	layoutStack.centerAlignContent()
		
	addItem(w, "Domains on Blocklist", `${piholeStats.gravity.domains_being_blocked}`)

	if (wSize=="large") {
		addItem(w, "Unique Domains", `${piholeStats.queries.unique_domains}`)
		
		layoutStack = w.addStack()
		layoutStack.setPadding(5, 0, 0, 10)
		
		addItem(w, "Cached Queries", `${piholeStats.queries.cached}`)
		
		layoutStack = w.addStack()
		layoutStack.setPadding(5, 0, 0, 10)
		
		addItem(w, "Queries Forwarded", `${piholeStats.queries.cached}`)
	}
  
	w.addSpacer()
	return w
}

function addItem(w, strHeadline, strData) {
	let fontSizeHeadline = 12
	let fontSizeString = 9
	switch (wSize) {
		case "large":
			fontSizeHeadline = 18
			fontSizeString = 14
		break;
		case "medium":
			fontSizeHeadline = 14
			fontSizeString = 11
		break;
	}
	
	let layoutStack = w.addStack()
	layoutStack.setPadding(3, 0, 0, 10)
	layoutStack.centerAlignContent()
		
	content = layoutStack.addText(strHeadline)
	content.font = Font.mediumSystemFont(fontSizeHeadline)
	content.textColor = wColor
	layoutStack.addSpacer()
		
	content = layoutStack.addText(strData)
	content.font = Font.mediumSystemFont(fontSizeString)
	content.textColor = wColor
}

function setTheme() {
	if (wTheme=="system") {
		if (Device.isUsingDarkAppearance()) {
			wTheme = "dark"
		} else {
		    wTheme = "light"
		}
	}
	wBackground.locations = [0, 1]
	if (wTheme=="dark") {
		wBackground.colors = [
			new Color("#384d54"),
			new Color("#384d54")
		]
		wColor = new Color("#ffffff")
	} else {
		wBackground.colors = [
			new Color("#ffffffe6"),
			new Color("#ffffffe6")
		]
		wColor = new Color("#000000")
	}
}

async function sendGetRequest(endpoint, sid) {
	try {
		let req = new Request(piholeURL + piholeApiPath + endpoint)
		req.headers = { 'accept': 'application/json', "sid": sid};

		let json = await req.loadJSON();

		return json
	} catch {
		return null
	}
}  
            

async function authenticate() {
	try {
		let req = new Request(piholeURL + piholeApiPath + "/auth")
      req.method = 'POST'
		req.body = JSON.stringify({ password: piholeAppPassword });

		let json = await req.loadJSON()

		if (!json.session?.valid) {
			throw new Error("Authentication failed");
		}
		return json.session?.sid
	} catch {
		return null
	}
}

async function invalidateSession(sid){
  	try {
		let req = new Request(piholeURL + piholeApiPath+ "/auth")
      req.method = 'DELETE'
		req.headers = { 'accept': 'application/json', "sid": sid};

		let json = await req.load()

	} catch { }
}

gist

@happyhelmi
Copy link

Perfect! Great job! Thank you very much!! Unfortunately, I can't see the status. Do you have an idea to solve the problem?

@fhoo
Copy link

fhoo commented Mar 2, 2025

I have added the version info to @bjarne-dietrich‘s version:

// Parameters:
// {"url":"https://pihole","apikey":"123abc"}
// Optional key in parameters: "theme": system|light|dark, "apiPath": "/api"

let piholeURL = "" //set the URL here for debug
let piholeAppPassword = "" // set the API-key here for debug
let wTheme = "" // set the theme for debug

let piholeApiPath = "/api"

if (config.runsInWidget) {
const widgetParams = (args.widgetParameter != null ? JSON.parse(args.widgetParameter) : null)
if (widgetParams==null) {
throw new Error("Please long press the widget and add the parameters.")
} else if (!widgetParams.hasOwnProperty("url") && !widgetParams.hasOwnProperty("apikey")) {
throw new Error("Wrong parameters.")
}

piholeURL = widgetParams.url
piholeAppPassword = widgetParams.apikey

if (widgetParams.hasOwnProperty("theme")) {
	wTheme = widgetParams.theme
}

if (widgetParams.hasOwnProperty("apiPath")) {
piholeApiPath = widgetParams.apiPath
}

}

let wBackground = new LinearGradient()
let wColor = new Color("#ffffff")
setTheme()

let sid = await authenticate();

let piholeStats = await sendGetRequest("/stats/summary", sid);

let dns = await sendGetRequest("/dns/blocking", sid);

let version = await sendGetRequest("/info/version", sid);

let wSize = config.widgetFamily || "small" //set size of widget for debug
let widget = await createWidget() || null

if (!config.runsInWidget) {
if (wSize=="large") { await widget.presentLarge() }
else if (wSize=="medium") { await widget.presentMedium() }
else { await widget.presentSmall() }
}

await invalidateSession(sid);

Script.setWidget(widget)
Script.complete()

async function createWidget() {
let w = new ListWidget()
w.backgroundGradient = wBackground
w.addSpacer()
w.setPadding(5, 15, 0, (wSize=="small" ? 0 : 10))

let state = (piholeStats!=null ? (dns.blocking=="enabled" ? true : false) : null)
let icn = null

if (state==true) {
	icn = SFSymbol.named((state ? "checkmark.shield.fill" : "xmark.shield.fill"))
} else {
	icn = SFSymbol.named("xmark.circle.fill")
	state = false
}

let topStack = w.addStack()
let content = topStack.addImage(icn.image)
content.tintColor = (state ? Color.green() : Color.red())
content.imageSize = new Size(16,16)
topStack.addSpacer(5)

content = topStack.addText("Pi-hole")
content.font = Font.blackSystemFont(16)
content.textColor = wColor


if (state==true && wSize != "small") {
	topStack.addSpacer()
  	topStack.addText("    ") // same line with distance to title

addUpdateItem(topStack, (version.version.core.local.version==version.version.core.remote.version ? true : false), "Pi" + ((wSize=="small") ? "" : "-hole"))
topStack.addText("  ")
addUpdateItem(topStack, (version.version.web.local.version==version.version.web.remote.version ? true : false), "WebUI")
topStack.addText("  ")
addUpdateItem(topStack, (version.version.ftl.local.version==version.version.ftl.remote.version ? true : false), "FTL")
	
}


w.addSpacer(8)

if (piholeStats==null) {
	content = w.addText("No connection")
    content.font = Font.thinSystemFont(14)
    content.textColor = wColor
	w.addSpacer()
	return w
}

w.url = piholeURL

addItem(w, "Total Queries", `${piholeStats.queries.total}` )
	
layoutStack = w.addStack()
layoutStack.setPadding(5, 0, 0, 10)
layoutStack.centerAlignContent()
	
addItem(w, "Queries Blocked", `${piholeStats.queries.blocked}`)
	
layoutStack = w.addStack()
layoutStack.setPadding(5, 0, 0, 10)
layoutStack.centerAlignContent()
	

addItem(w, "Percent Blocked", piholeStats.queries.percent_blocked.toFixed(1) + "%")
	
layoutStack = w.addStack()
layoutStack.setPadding(5, 0, 0, 10)
layoutStack.centerAlignContent()
	
addItem(w, "Domains on Blocklist", `${piholeStats.gravity.domains_being_blocked}`)

if (wSize=="large") {
	addItem(w, "Unique Domains", `${piholeStats.queries.unique_domains}`)
	
	layoutStack = w.addStack()
	layoutStack.setPadding(5, 0, 0, 10)
	
	addItem(w, "Cached Queries", `${piholeStats.queries.cached}`)
	
	layoutStack = w.addStack()
	layoutStack.setPadding(5, 0, 0, 10)
	
	addItem(w, "Queries Forwarded", `${piholeStats.queries.cached}`)
}

w.addSpacer()
return w

}

function addItem(w, strHeadline, strData) {
let fontSizeHeadline = 12
let fontSizeString = 9
switch (wSize) {
case "large":
fontSizeHeadline = 18
fontSizeString = 14
break;
case "medium":
fontSizeHeadline = 14
fontSizeString = 11
break;
}

let layoutStack = w.addStack()
layoutStack.setPadding(3, 0, 0, 10)
layoutStack.centerAlignContent()
	
content = layoutStack.addText(strHeadline)
content.font = Font.mediumSystemFont(fontSizeHeadline)
content.textColor = wColor
layoutStack.addSpacer()
	
content = layoutStack.addText(strData)
content.font = Font.mediumSystemFont(fontSizeString)
content.textColor = wColor

}

function setTheme() {
if (wTheme=="system") {
if (Device.isUsingDarkAppearance()) {
wTheme = "dark"
} else {
wTheme = "light"
}
}
wBackground.locations = [0, 1]
if (wTheme=="dark") {
wBackground.colors = [
new Color("#384d54"),
new Color("#384d54")
]
wColor = new Color("#ffffff")
} else {
wBackground.colors = [
new Color("#ffffffe6"),
new Color("#ffffffe6")
]
wColor = new Color("#000000")
}
}

async function sendGetRequest(endpoint, sid) {
try {
let req = new Request(piholeURL + piholeApiPath + endpoint)
req.headers = { 'accept': 'application/json', "sid": sid};

	let json = await req.loadJSON();

	return json
} catch {
	return null
}

}

function addUpdateItem(stack, status, text) {
let icn = SFSymbol.named((status ? "checkmark.circle.fill" : "exclamationmark.triangle.fill"))
let content = stack.addImage(icn.image)
content.tintColor = (status ? Color.green() : Color.red())
content.imageSize = new Size(14,14)
content = stack.addText(((wSize!="small") ? " " : "" ) + text)
content.font = Font.semiboldMonospacedSystemFont(12)
content.textColor = wColor
}

async function authenticate() {
try {
let req = new Request(piholeURL + piholeApiPath + "/auth")
req.method = 'POST'
req.body = JSON.stringify({ password: piholeAppPassword });

	let json = await req.loadJSON()

	if (!json.session?.valid) {
		throw new Error("Authentication failed");
	}
	return json.session?.sid
} catch {
	return null
}

}

async function invalidateSession(sid){
try {
let req = new Request(piholeURL + piholeApiPath+ "/auth")
req.method = 'DELETE'
req.headers = { 'accept': 'application/json', "sid": sid};

	let json = await req.load()

} catch { }

}

@fhoo
Copy link

fhoo commented Mar 6, 2025 via email

@marked80
Copy link

In line 107 I would add + "/admin/" If you then press the widget it forwards nicely to the pihole login screen instead of to the root IP that will give you an error message on the page.

w.url = piholeURL + "/admin/"

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment