diff --git a/src/app.ts b/src/app.ts index 88e5486..12dbf21 100644 --- a/src/app.ts +++ b/src/app.ts @@ -35,6 +35,7 @@ import GObject, { register } from "ags/gobject"; import GLib from "gi://GLib?version=2.0"; import Gio from "gi://Gio?version=2.0"; import Adw from "gi://Adw?version=1"; +import { NightLight } from "./modules/nightlight"; const runnerPlugins: Array = [ @@ -73,6 +74,7 @@ export class Shell extends Adw.Application { }); setConsoleLogDomain("colorshell"); + GLib.set_application_name("colorshell"); } public static getDefault(): Shell { @@ -274,6 +276,8 @@ you should use the socket in the XDG_RUNTIME_DIR/colorshell.sock for a faster re this.#connections.set(this, this.connect("shutdown", () => dispose())); this.#scope = getScope(); + NightLight.getDefault(); + initPlayer(); Clipboard.getDefault(); diff --git a/src/config.ts b/src/config.ts index 7a32f76..a2275fe 100644 --- a/src/config.ts +++ b/src/config.ts @@ -47,13 +47,22 @@ const generalConfigDefaults = { }; const userDataDefaults = { + /** last default adapter */ + bluetooth_default_adapter: undefined, + control_center: { /** last default backlight */ default_backlight: undefined }, - /** last default adapter */ - bluetooth_default_adapter: undefined + night_light: { + /** last blue light filter temperature */ + temperature: 6000, + /** last gamma filter value */ + gamma: 100, + /** wheter to enable identity filters("disables" the filters) */ + identity: true + } }; export const userData = new Config< diff --git a/src/modules/auth.ts b/src/modules/auth.ts index 057c67e..42307fd 100644 --- a/src/modules/auth.ts +++ b/src/modules/auth.ts @@ -1,7 +1,6 @@ -import { execAsync } from "ags/process"; +import { exec, execAsync } from "ags/process"; import { register } from "ags/gobject"; -import { EntryPopup, EntryPopupProps } from "../widget/EntryPopup"; -import { AskPopup, AskPopupProps } from "../widget/AskPopup"; +import { AuthPopup } from "../widget/AuthPopup"; import AstalAuth from "gi://AstalAuth"; import Polkit from "gi://Polkit"; @@ -14,39 +13,48 @@ import GLib from "gi://GLib?version=2.0"; export class Auth extends PolkitAgent.Listener { private static instance: Auth; #subject: Polkit.Subject; + #pam: AstalAuth.Pam; + #handle: any; constructor() { super(); - this.#subject = Polkit.UnixSession.new(GLib.get_user_name()); + this.#subject = Polkit.UnixSession.new(""); // TODO find how to get session id (for some reason, i can't find a session ID that works) + this.#pam = new AstalAuth.Pam(); - this.register(PolkitAgent.RegisterFlags.NONE, + this.#handle = this.register( + PolkitAgent.RegisterFlags.RUN_IN_THREAD, this.#subject, - "/io/github/retrozinndev/Colorshell/PolicyKit/AuthAgent", + "/io/github/retrozinndev/colorshell/PolicyKit/AuthAgent", null ); } vfunc_dispose() { - PolkitAgent.Listener.unregister(); + PolkitAgent.Listener.unregister(this.#handle); } - static initiate_authentication(action_id: string, message: string, icon_name: string, details: Polkit.Details, cookie: string, identities: Array, cancellable?: Gio.Cancellable, callback?: Gio.AsyncReadyCallback): void | Promise { - const authPopup = EntryPopup({ - title: "Authentication", - text: message, - isPassword: true, - onFinish: callback, - onCancel: () => cancellable?.cancel(), - closeOnAccept: false, - onAccept: (input: string) => { - if(this.validatePasswd(input)) { - authPopup.close(); - } - AskPopup({ + public static initiate_authentication(action_id: string, message: string, icon_name: string, details: Polkit.Details, cookie: string, identities: Array, cancellable: Gio.Cancellable|null, callback: Gio.AsyncReadyCallback|null): void { + const task = Gio.Task.new( + this.getDefault(), + cancellable, + callback as Gio.AsyncReadyCallback|null + ); - } as AskPopupProps) + AuthPopup({ + text: message, + iconName: icon_name, + onContinue: (data, reject, approve) => { + this.getDefault().validateAuth(data.passwd, data.user).then((success) => { + approve(); + task.return_boolean(success); + }).catch((error: GLib.Error) => { + // TODO implement a number of tries (usually it's 3) + reject(`Authentication failed: ${error.message}`); + task.return_error(error); + }); } - } as EntryPopupProps); + }); + } @@ -57,15 +65,39 @@ export class Auth extends PolkitAgent.Listener { return this.instance; } - private static validatePasswd(passwd: string): boolean { - return AstalAuth.Pam.authenticate(passwd, null); + // TODO: support fingerprint/facial auth + /** @returns true if data are correct, rejects promise otherwise */ + public validateAuth(passwd: string, user?: string): Promise { + if(user !== undefined) + this.#pam.username = user; + + return new Promise((resolve, reject) => { + const connections: Array = []; + connections.push( + this.#pam.connect("fail", () => { + reject( + `Auth: Authentication has failed for user ${this.#pam.username}` + ); + connections.forEach(id => this.#pam.disconnect(id)); + }), + this.#pam.connect("success", () => { + resolve(true); + connections.forEach(id => this.#pam.disconnect(id)); + }) + ); + + this.#pam.start_authenticate(); + this.#pam.supply_secret(passwd); + }); } - /** @returns if successful, true, or else, false */ + /** @returns true if successful */ public async polkitExecute(cmd: string | Array): Promise { let success: boolean = true; - await execAsync([ "pkexec", "--", ...(Array.isArray(cmd) ? - cmd as Array : [ cmd as string ]) ] + await execAsync([ + "pkexec", + "--", + ...(Array.isArray(cmd) ? cmd : [ cmd ]) ] ).catch((r) => { success = false; console.error(`Polkit: Couldn't authenticate. Stderr: ${r}`); @@ -73,4 +105,11 @@ export class Auth extends PolkitAgent.Listener { return success; } + + public static getDefault(): Auth { + if(!this.instance) + this.instance = new Auth(); + + return this.instance; + } } diff --git a/src/modules/nightlight.ts b/src/modules/nightlight.ts index 7edb1be..cd22c73 100644 --- a/src/modules/nightlight.ts +++ b/src/modules/nightlight.ts @@ -1,20 +1,22 @@ import { execAsync, exec } from "ags/process"; -import { interval } from "ags/time"; +import { userData } from "../config"; import GObject, { getter, register, setter } from "ags/gobject"; -import AstalIO from "gi://AstalIO"; import GLib from "gi://GLib?version=2.0"; -export { NightLight }; - @register({ GTypeName: "NightLight" }) -class NightLight extends GObject.Object { +export class NightLight extends GObject.Object { private static instance: NightLight; - #watchInterval: (AstalIO.Time|null) = null; - #temperature: number = 4500; - #gamma: number = 100; + public readonly maxTemperature = 20000; + public readonly minTemperature = 1000; + public readonly identityTemperature = 6000; + public readonly maxGamma = 100; + + #watchInterval: GLib.Source; + #temperature: number = this.identityTemperature; + #gamma: number = this.maxGamma; #identity: boolean = false; @getter(Number) @@ -25,11 +27,6 @@ class NightLight extends GObject.Object { public get gamma() { return this.#gamma; } public set gamma(newValue: number) { this.setGamma(newValue); } - public readonly maxTemperature = 20000; - public readonly minTemperature = 1000; - public readonly identityTemperature = 6000; - public readonly maxGamma = 100; - @getter(Boolean) public get identity() { return this.#identity; } @@ -43,7 +40,8 @@ class NightLight extends GObject.Object { constructor() { super(); - this.#watchInterval = interval(10000, () => { + this.loadData(); + this.#watchInterval = setInterval(() => { execAsync("hyprctl hyprsunset temperature").then(t => { if(t.trim() !== "" && t.trim().length <= 5) { const val = Number.parseInt(t.trim()); @@ -54,7 +52,8 @@ class NightLight extends GObject.Object { this.notify("temperature"); } } - }).catch((r) => console.error(r)); + }).catch((r: Error) => console.error(`Night Light: Couldn't sync temperature. Stderr: ${ + r.message}\n${r.stack}`)); execAsync("hyprctl hyprsunset gamma").then(g => { if(g.trim() !== "" && g.trim().length <= 5) { @@ -66,11 +65,13 @@ class NightLight extends GObject.Object { this.notify("gamma"); } } - }).catch((r) => console.error(r)); - }); + }).catch((r: Error) => console.error(`Night Light: Couldn't sync. Stderr: ${ + r.message}\n${r.stack}`)); + }, 10000); + } - this.vfunc_dispose = () => this.#watchInterval && - this.#watchInterval.cancel(); + vfunc_dispose(): void { + this.#watchInterval?.destroy(); } public static getDefault(): NightLight { @@ -84,7 +85,7 @@ class NightLight extends GObject.Object { if(value === this.temperature && !this.identity) return; if(value > this.maxTemperature || value < 1000) { - console.error(`Night Light(hyprsunset): provided temperatue ${value + console.error(`Night Light: provided temperatue ${value } is out of bounds (min: 1000; max: ${this.maxTemperature})`); return; } @@ -94,8 +95,8 @@ class NightLight extends GObject.Object { this.notify("temperature"); this.identity = false; - }).catch((r) => console.error( - `Night Light(hyprsunset): Couldn't set temperature. Stderr: ${r}` + }).catch((r: Error) => console.error( + `Night Light: Couldn't set temperature. Stderr: ${r.message}\n${r.stack}` )); } @@ -103,7 +104,7 @@ class NightLight extends GObject.Object { if(value === this.gamma && !this.identity) return; if(value > this.maxGamma || value < 0) { - console.error(`Night Light(hyprsunset): provided gamma ${value + console.error(`Night Light: provided gamma ${value } is out of bounds (min: 0; max: ${this.maxTemperature})`); return; } @@ -113,24 +114,33 @@ class NightLight extends GObject.Object { this.notify("gamma"); this.identity = false; - }).catch((r) => console.error( - `Night Light(hyprsunset): Couldn't set gamma. Stderr: ${r}` + }).catch((r: Error) => console.error( + `Night Light: Couldn't set gamma. Stderr: ${r.message}\n${r.stack}` )); } public applyIdentity(): void { this.dispatch("identity"); + if(!this.#identity) { this.#identity = true; this.notify("identity"); } } + private dispatch(call: "temperature", val: number): string; + private dispatch(call: "gamma", val: number): string; + private dispatch(call: "identity"): string; + private dispatch(call: "temperature"|"gamma"|"identity", val?: number): string { return exec(`hyprctl hyprsunset ${call}${val != null ? ` ${val}` : ""}`); } - private async dispatchAsync(...[call, val]: Parameters): Promise { + private async dispatchAsync(call: "temperature", val: number): Promise; + private async dispatchAsync(call: "gamma", val: number): Promise; + private async dispatchAsync(call: "identity"): Promise; + + private async dispatchAsync(call: "temperature"|"gamma"|"identity", val?: number): Promise { return await execAsync(`hyprctl hyprsunset ${call}${val != null ? ` ${val}` : ""}`); } @@ -145,10 +155,22 @@ class NightLight extends GObject.Object { } public saveData(): void { - exec(`sh ${GLib.get_user_config_dir()}/hypr/scripts/save-hyprsunset.sh`); + userData.setProperty("night_light.temperature", this.#temperature); + userData.setProperty("night_light.gamma", this.#gamma); + userData.setProperty("night_light.identity", this.#identity, true); } + /** load temperature, gamma and identity(off/on) properties from the user configuration */ public loadData(): void { - exec(`sh ${GLib.get_user_config_dir()}/hypr/scripts/load-hyprsunset.sh`); + const identity = userData.getProperty("night_light.identity", "boolean"); + const temperature = userData.getProperty("night_light.temperature", "number"); + const gamma = userData.getProperty("night_light.gamma", "number"); + + this.#temperature = temperature; + this.notify("temperature"); + this.#gamma = gamma; + this.notify("gamma"); + + this.identity = identity; } } diff --git a/src/widget/AuthPopup.tsx b/src/widget/AuthPopup.tsx new file mode 100644 index 0000000..0a325f9 --- /dev/null +++ b/src/widget/AuthPopup.tsx @@ -0,0 +1,73 @@ +import { Astal, Gtk } from "ags/gtk4"; +import { CustomDialog, getContainerCustomDialog } from "./CustomDialog"; + +import GLib from "gi://GLib?version=2.0"; + + +export type AuthPopupData = { + user: string; + hidePassword: boolean; + passwd: string; +}; + +export function AuthPopup(props: { + /** hide password on showup. @default true */ + hidePassword?: boolean; + /** icon name of the application that's requesting this popup */ + iconName?: string; + /** popup body */ + text: string; + /** selected user by default */ + user?: string; + /** approve data after the user clicks the "grant permission" button */ + onContinue: (data: AuthPopupData, reject: (message: string) => void, approve: () => void) => void; +}): Astal.Window { + const data = { + passwd: "", + user: props.user ?? GLib.get_user_name(), + hidePassword: props.hidePassword ?? true + } satisfies AuthPopupData; + const allowUserChange = props.user === undefined; + + const dialog = { + if(allowUserChange) + data.user = userEntry!.text; + + data.passwd = passwordEntry.text; + data.hidePassword = passwordEntry.showPeekIcon; + + props.onContinue(data, + // rejected by checker function + (m) => { + // show error to user + !messageLabel.is_visible && + messageLabel.set_visible(true); + messageLabel.set_label(m); + + // clear password entry + passwordEntry.set_text(""); + }, + // approved by the checker + dialog.close + ); + }, + closeOnClick: false + } + ]}> + + + + + + as Astal.Window; + const messageLabel = getContainerCustomDialog(dialog).get_last_child() as Gtk.Label; + const userEntry = allowUserChange ? getContainerCustomDialog(dialog).get_first_child() as Gtk.Entry : undefined; + const passwordEntry = getContainerCustomDialog(dialog).get_first_child()?.get_next_sibling() as Gtk.PasswordEntry; + + return dialog; +} diff --git a/src/widget/CustomDialog.tsx b/src/widget/CustomDialog.tsx index 85ea27c..2062d42 100644 --- a/src/widget/CustomDialog.tsx +++ b/src/widget/CustomDialog.tsx @@ -1,6 +1,6 @@ import { Astal, Gtk } from "ags/gtk4"; import { Windows } from "../windows"; -import { PopupWindow } from "./PopupWindow"; +import { getPopupWindowContainer, PopupWindow } from "./PopupWindow"; import { Separator } from "./Separator"; import { tr } from "../i18n/intl"; import { Accessor } from "ags"; @@ -74,3 +74,7 @@ export function CustomDialog({ options = [{ text: tr("accept") }], ...props}: Cu return popup; })(); } + +export function getContainerCustomDialog(dialog: Astal.Window): Gtk.Box { + return getPopupWindowContainer(dialog).get_first_child()?.get_last_child()?.get_prev_sibling() as Gtk.Box; +} diff --git a/src/window/control-center/widgets/pages/index.tsx b/src/window/control-center/widgets/pages/index.tsx index 1485366..867f86e 100644 --- a/src/window/control-center/widgets/pages/index.tsx +++ b/src/window/control-center/widgets/pages/index.tsx @@ -1,20 +1,18 @@ import { register } from "ags/gobject"; import { Gtk } from "ags/gtk4"; import { Page } from "../Page"; -import { timeout } from "ags/time"; -import AstalIO from "gi://AstalIO"; +import GLib from "gi://GLib?version=2.0"; -export { Pages }; export type PagesProps = { initialPage?: Page; transitionDuration?: number; }; @register({ GTypeName: "Pages" }) -class Pages extends Gtk.Box { - #timeouts: Array<[AstalIO.Time, (() => void)|undefined]> = []; +export class Pages extends Gtk.Box { + #timeouts: Array<[GLib.Source, (() => void)|undefined]> = []; #page: (Page|undefined); #transDuration: number; #transType: Gtk.RevealerTransitionType = Gtk.RevealerTransitionType.SLIDE_DOWN; @@ -40,7 +38,7 @@ class Pages extends Gtk.Box { const destroyId = this.connect("destroy", () => { this.disconnect(destroyId); this.#timeouts.forEach((tmout) => { - tmout[0].cancel(); + tmout[0].destroy(); (async () => tmout[1]?.())().catch((err: Error) => { console.error(`${err.message}\n${err.stack}`); }); @@ -89,10 +87,10 @@ class Pages extends Gtk.Box { page.set_reveal_child(false); this.#timeouts.push([ - timeout(page.transitionDuration, () => { + setTimeout(() => { this.remove(page); onClosed?.(); - }), + }, page.transitionDuration), onClosed ]); } diff --git a/src/window/control-center/widgets/tiles/Bluetooth.tsx b/src/window/control-center/widgets/tiles/Bluetooth.tsx index fcad5f6..df2ae1a 100644 --- a/src/window/control-center/widgets/tiles/Bluetooth.tsx +++ b/src/window/control-center/widgets/tiles/Bluetooth.tsx @@ -19,7 +19,7 @@ export const TileBluetooth = () => onEnabled={() => Bluetooth.getDefault().adapter?.set_powered(true)} onDisabled={() => Bluetooth.getDefault().adapter?.set_powered(false)} onClicked={() => TilesPages?.toggle(BluetoothPage)} - enableOnClicked hasArrow + hasArrow state={createBinding(AstalBluetooth.get_default(), "isPowered")} icon={createComputed([ createBinding(AstalBluetooth.get_default(), "isPowered"), diff --git a/src/window/control-center/widgets/tiles/NightLight.tsx b/src/window/control-center/widgets/tiles/NightLight.tsx index c41489c..c34e328 100644 --- a/src/window/control-center/widgets/tiles/NightLight.tsx +++ b/src/window/control-center/widgets/tiles/NightLight.tsx @@ -23,7 +23,6 @@ export const TileNightLight = () => hasArrow visible={isInstalled("hyprsunset")} onDisabled={() => NightLight.getDefault().identity = true} onEnabled={() => NightLight.getDefault().identity = false} - enableOnClicked onClicked={() => TilesPages?.toggle(PageNightLight)} state={createBinding(NightLight.getDefault(), "identity").as(identity => !identity)} /> diff --git a/src/window/control-center/widgets/tiles/Tile.tsx b/src/window/control-center/widgets/tiles/Tile.tsx index 86ba3e4..61b08e9 100644 --- a/src/window/control-center/widgets/tiles/Tile.tsx +++ b/src/window/control-center/widgets/tiles/Tile.tsx @@ -11,7 +11,10 @@ export class Tile extends Gtk.Box { @signal(Boolean) toggled(_state: boolean) {} @signal() enabled() {} @signal() disabled() {} - @signal() clicked() {} + @signal() clicked() { + if(this.enableOnClicked) + this.enable(); + } @property(String) public icon: string; @@ -20,7 +23,7 @@ export class Tile extends Gtk.Box { @property(String) public description: string = ""; @property(Boolean) - public enableOnClicked: boolean = true; + public enableOnClicked: boolean = false; @property(Boolean) public state: boolean = false; @property(Boolean) @@ -39,6 +42,7 @@ export class Tile extends Gtk.Box { this.state = true; !this.has_css_class("enabled") && this.add_css_class("enabled"); + this.emit("toggled", true); this.emit("enabled"); } @@ -69,25 +73,34 @@ export class Tile extends Gtk.Box { ])); this.add_css_class("tile"); + this.add_controller( + { + // gets the icon part of the tile + const { x, y, width, height } = this.get_first_child()!.get_allocation(); + + if((px < x || px > x+width) || (py < y || y > py+height)) + this.emit("clicked"); + }} /> as Gtk.GestureClick + ); this.icon = props.icon; this.title = props.title; this.hexpand = true; - if(props.hasArrow != null) + if(props.hasArrow !== undefined) this.hasArrow = props.hasArrow; - if(props.description != null) + if(props.description !== undefined) this.description = props.description; - if(props.state != null) + if(props.state !== undefined) this.state = props.state; - if(props.enableOnClicked != null) + if(props.enableOnClicked !== undefined) this.enableOnClicked = props.enableOnClicked; - if(this.state) - this.add_css_class("enabled"); // fix no highlight with state = true on construct + this.state && + this.add_css_class("enabled"); // fix no highlight when enabled on init this.prepend( @@ -110,28 +123,14 @@ export class Tile extends Gtk.Box { variableToBoolean(createBinding(this, "description")) } maxWidthChars={12} hexpand={false} /> - - { - this.emit("clicked"); - if(this.enableOnClicked && !this.state) - this.enable(); - - return true; - }} /> as Gtk.Box ); if(this.hasArrow) this.append( - - { - this.emit("clicked"); - if(this.enableOnClicked && !this.state) - this.enable(); - - return true; - }} /> - as Gtk.Image + as Gtk.Image ); }