publish: the public projection begins here
This is a projection, not a development branch. The tree above was constructed from the internal source named below under a manifest that decides which paths may leave, then scanned as a whole tree rather than as a series of patches, and only then published. Public history starts here because the history before it was not admissible, and neither was the tree. What used to stand in this repository included a rescue copy of another machine, a directory of phone handoffs, deployment wired to one house, and a submodule pointing at a forge no stranger can reach. None of that was ever the product. It stays in the private forge, which is allowed to hold the whole working organism, and this is what was deliberately sent out instead. Three mechanisms produced this tree, in decreasing order of trust. A top-level path the manifest does not name never arrives at all, which is the one that catches directories nobody has thought of yet. Named internal files inside admitted roots are dropped. A short, reviewed table replaces deployment defaults that a public build must not carry -- an endpoint aimed at one LAN, a VPN profile belonging to one phone, packaging built from one checkout path. Everything after this commit is an ordinary publication with the same three trailers, so a force push stops being routine and starts meaning that something deliberate happened. The trailers bind the projection to its source without pretending the public SHA is the private one: same lineage, different tree, and the record says so. Source-Sha: 8f27b1e76a8fef560a336aba18e6990713ff1047 Policy-Sha: 6b261d2f3e6e1fb19874846ba4bb1dfe15565d25b8618c1c1afba0419c101d27 Tree-Digest: 18ec3563c5e5ef9a414993a9f6734b251ff9ed3cd56eebdd6cac01e45c6e3067
This commit is contained in:
commit
8f42fc953d
1476 changed files with 238455 additions and 0 deletions
937
surfaces/quickshell/ii-base/services/Ai.qml
Normal file
937
surfaces/quickshell/ii-base/services/Ai.qml
Normal file
|
|
@ -0,0 +1,937 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs.modules.common.functions as CF
|
||||
import qs.modules.common
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import QtQuick
|
||||
import qs.services.ai
|
||||
|
||||
/**
|
||||
* Basic service to handle LLM chats. Supports Google's and OpenAI's API formats.
|
||||
* Supports Gemini and OpenAI models.
|
||||
* Limitations:
|
||||
* - For now functions only work with Gemini API format
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property Component aiMessageComponent: AiMessageData {}
|
||||
property Component aiModelComponent: AiModel {}
|
||||
property Component geminiApiStrategy: GeminiApiStrategy {}
|
||||
property Component openaiApiStrategy: OpenAiApiStrategy {}
|
||||
property Component openaiApiResponsesStrategy: OpenAiResponsesApiStrategy {}
|
||||
property Component mistralApiStrategy: MistralApiStrategy {}
|
||||
readonly property string interfaceRole: "interface"
|
||||
readonly property string apiKeyEnvVarName: "API_KEY"
|
||||
|
||||
signal responseFinished()
|
||||
|
||||
property string systemPrompt: {
|
||||
let prompt = Config.options?.ai?.systemPrompt ?? "";
|
||||
for (let key in root.promptSubstitutions) {
|
||||
// prompt = prompt.replaceAll(key, root.promptSubstitutions[key]);
|
||||
// QML/JS doesn't support replaceAll, so use split/join
|
||||
prompt = prompt.split(key).join(root.promptSubstitutions[key]);
|
||||
}
|
||||
return prompt;
|
||||
}
|
||||
// property var messages: []
|
||||
property var messageIDs: []
|
||||
property var messageByID: ({})
|
||||
readonly property var apiKeys: KeyringStorage.keyringData?.apiKeys ?? {}
|
||||
readonly property var apiKeysLoaded: KeyringStorage.loaded
|
||||
readonly property bool currentModelHasApiKey: {
|
||||
const model = models[currentModelId];
|
||||
if (!model || !model.requires_key) return true;
|
||||
if (!apiKeysLoaded) return false;
|
||||
const key = apiKeys[model.key_id];
|
||||
return (key?.length > 0);
|
||||
}
|
||||
property var postResponseHook
|
||||
property real temperature: Persistent.states?.ai?.temperature ?? 0.5
|
||||
property QtObject tokenCount: QtObject {
|
||||
property int input: -1
|
||||
property int output: -1
|
||||
property int total: -1
|
||||
}
|
||||
|
||||
function idForMessage(message) {
|
||||
// Generate a unique ID using timestamp and random value
|
||||
return Date.now().toString(36) + Math.random().toString(36).substr(2, 8);
|
||||
}
|
||||
|
||||
function safeModelName(modelName) {
|
||||
return modelName.replace(/:/g, "_").replace(/ /g, "-").replace(/\//g, "-")
|
||||
}
|
||||
|
||||
property list<var> defaultPrompts: []
|
||||
property list<var> userPrompts: []
|
||||
property list<var> promptFiles: [...defaultPrompts, ...userPrompts]
|
||||
property list<var> savedChats: []
|
||||
|
||||
property var promptSubstitutions: {
|
||||
"{DISTRO}": SystemInfo.distroName,
|
||||
"{DATETIME}": `${DateTime.time}, ${DateTime.collapsedCalendarFormat}`,
|
||||
"{WINDOWCLASS}": ToplevelManager.activeToplevel?.appId ?? "Unknown",
|
||||
"{DE}": `${SystemInfo.desktopEnvironment} (${SystemInfo.windowingSystem})`
|
||||
}
|
||||
|
||||
// Gemini: https://ai.google.dev/gemini-api/docs/function-calling
|
||||
// OpenAI: https://platform.openai.com/docs/guides/function-calling
|
||||
property string currentTool: Config?.options.ai.tool ?? "search"
|
||||
property var tools: {
|
||||
"gemini": {
|
||||
"functions": [{"functionDeclarations": [
|
||||
{
|
||||
"name": "switch_to_search_mode",
|
||||
"description": "Search the web",
|
||||
},
|
||||
{
|
||||
"name": "get_shell_config",
|
||||
"description": "Get the desktop shell config file contents",
|
||||
},
|
||||
{
|
||||
"name": "set_shell_config",
|
||||
"description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.",
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "The value to set, e.g. `true`"
|
||||
}
|
||||
},
|
||||
"required": ["key", "value"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "run_shell_command",
|
||||
"description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The bash command to run",
|
||||
},
|
||||
},
|
||||
"required": ["command"]
|
||||
}
|
||||
},
|
||||
]}],
|
||||
"search": [{
|
||||
"google_search": {}
|
||||
}],
|
||||
"none": []
|
||||
},
|
||||
"openai": {
|
||||
"functions": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_shell_config",
|
||||
"description": "Get the desktop shell config file contents",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "set_shell_config",
|
||||
"description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.",
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "The value to set, e.g. `true`"
|
||||
}
|
||||
},
|
||||
"required": ["key", "value"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_shell_command",
|
||||
"description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The bash command to run",
|
||||
},
|
||||
},
|
||||
"required": ["command"]
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
"search": [],
|
||||
"none": [],
|
||||
},
|
||||
"responses": {
|
||||
"functions": [
|
||||
|
||||
]
|
||||
},
|
||||
"mistral": {
|
||||
"functions": [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "get_shell_config",
|
||||
"description": "Get the desktop shell config file contents",
|
||||
"parameters": {"type": "object", "properties": {}}
|
||||
},
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "set_shell_config",
|
||||
"description": "Set a field in the desktop graphical shell config file. Must only be used after `get_shell_config`.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"key": {
|
||||
"type": "string",
|
||||
"description": "The key to set, e.g. `bar.borderless`. MUST NOT BE GUESSED, use `get_shell_config` to see what keys are available before setting.",
|
||||
},
|
||||
"value": {
|
||||
"type": "string",
|
||||
"description": "The value to set, e.g. `true`"
|
||||
}
|
||||
},
|
||||
"required": ["key", "value"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "run_shell_command",
|
||||
"description": "Run a shell command in bash and get its output. Use this only for quick commands that don't require user interaction. For commands that require interaction, ask the user to run manually instead.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"command": {
|
||||
"type": "string",
|
||||
"description": "The bash command to run",
|
||||
},
|
||||
},
|
||||
"required": ["command"]
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
"search": [],
|
||||
"none": [],
|
||||
}
|
||||
}
|
||||
property list<var> availableTools: Object.keys(root.tools[models[currentModelId]?.api_format])
|
||||
property var toolDescriptions: {
|
||||
"functions": Translation.tr("Commands, edit configs, search.\nTakes an extra turn to switch to search mode if that's needed"),
|
||||
"search": Translation.tr("Gives the model search capabilities (immediately)"),
|
||||
"none": Translation.tr("Disable tools")
|
||||
}
|
||||
|
||||
// Model properties:
|
||||
// - name: Name of the model
|
||||
// - icon: Icon name of the model
|
||||
// - description: Description of the model
|
||||
// - endpoint: Endpoint of the model
|
||||
// - model: Model name of the model
|
||||
// - requires_key: Whether the model requires an API key
|
||||
// - key_id: The identifier of the API key. Use the same identifier for models that can be accessed with the same key.
|
||||
// - key_get_link: Link to get an API key
|
||||
// - key_get_description: Description of pricing and how to get an API key
|
||||
// - api_format: The API format of the model. Can be "openai" or "gemini". Default is "openai".
|
||||
// - extraParams: Extra parameters to be passed to the model. This is a JSON object.
|
||||
property var models: Config.options.policies.ai === 2 ? {} : {
|
||||
"gemini-2.5-flash": aiModelComponent.createObject(this, {
|
||||
"name": "Gemini 2.5 Flash",
|
||||
"icon": "google-gemini-symbolic",
|
||||
"description": Translation.tr("Online | Google's model\nNewer model that's slower than its predecessor but should deliver higher quality answers"),
|
||||
"homepage": "https://aistudio.google.com",
|
||||
"endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-flash:streamGenerateContent",
|
||||
"model": "gemini-2.5-flash",
|
||||
"requires_key": true,
|
||||
"key_id": "gemini",
|
||||
"key_get_link": "https://aistudio.google.com/app/apikey",
|
||||
"key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"),
|
||||
"api_format": "gemini",
|
||||
}),
|
||||
"gemini-3-flash": aiModelComponent.createObject(this, {
|
||||
"name": "Gemini 3 Flash",
|
||||
"icon": "google-gemini-symbolic",
|
||||
"description": Translation.tr("Online | Google's model\nPro-level intelligence at the speed and pricing of Flash."),
|
||||
"homepage": "https://aistudio.google.com",
|
||||
"endpoint": "https://generativelanguage.googleapis.com/v1beta/models/gemini-3-flash-preview:streamGenerateContent",
|
||||
"model": "gemini-3-flash-preview",
|
||||
"requires_key": true,
|
||||
"key_id": "gemini",
|
||||
"key_get_link": "https://aistudio.google.com/app/apikey",
|
||||
"key_get_description": Translation.tr("**Pricing**: free. Data used for training.\n\n**Instructions**: Log into Google account, allow AI Studio to create Google Cloud project or whatever it asks, go back and click Get API key"),
|
||||
"api_format": "gemini",
|
||||
}),
|
||||
"mistral-medium-3": aiModelComponent.createObject(this, {
|
||||
"name": "Mistral Medium 3",
|
||||
"icon": "mistral-symbolic",
|
||||
"description": Translation.tr("Online | %1's model | Delivers fast, responsive and well-formatted answers. Disadvantages: not very eager to do stuff; might make up unknown function calls").arg("Mistral"),
|
||||
"homepage": "https://mistral.ai/news/mistral-medium-3",
|
||||
"endpoint": "https://api.mistral.ai/v1/chat/completions",
|
||||
"model": "mistral-medium-2505",
|
||||
"requires_key": true,
|
||||
"key_id": "mistral",
|
||||
"key_get_link": "https://console.mistral.ai/api-keys",
|
||||
"key_get_description": Translation.tr("**Instructions**: Log into Mistral account, go to Keys on the sidebar, click Create new key"),
|
||||
"api_format": "mistral",
|
||||
}),
|
||||
}
|
||||
property var modelList: Object.keys(root.models)
|
||||
property var currentModelId: Persistent.states?.ai?.model || modelList[0]
|
||||
property var currentModel: models[currentModelId] || models[modelList[0]]
|
||||
|
||||
property var apiStrategies: {
|
||||
"openai": openaiApiStrategy.createObject(this),
|
||||
"responses": openaiApiResponsesStrategy.createObject(this),
|
||||
"gemini": geminiApiStrategy.createObject(this),
|
||||
"mistral": mistralApiStrategy.createObject(this),
|
||||
}
|
||||
property ApiStrategy currentApiStrategy: apiStrategies[models[currentModelId]?.api_format || "openai"]
|
||||
|
||||
function addUserModels() {
|
||||
(Config?.options.ai?.extraModels ?? []).forEach(model => {
|
||||
const safeModelName = root.safeModelName(model["model"]);
|
||||
root.addModel(safeModelName, model)
|
||||
});
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Config
|
||||
function onReadyChanged() {
|
||||
if (!Config.ready) return;
|
||||
root.addUserModels()
|
||||
}
|
||||
}
|
||||
|
||||
property string requestScriptFilePath: "/tmp/quickshell/ai/request.sh"
|
||||
property string pendingFilePath: ""
|
||||
|
||||
Component.onCompleted: {
|
||||
setModel(currentModelId, false, false); // Do necessary setup for model
|
||||
root.addUserModels() // Config onReadyChanged above might not fire if config is loaded before this service
|
||||
}
|
||||
|
||||
function guessModelLogo(model) {
|
||||
if (model.includes("llama")) return "ollama-symbolic";
|
||||
if (model.includes("gemma")) return "google-gemini-symbolic";
|
||||
if (model.includes("deepseek")) return "deepseek-symbolic";
|
||||
if (/^phi\d*:/i.test(model)) return "microsoft-symbolic";
|
||||
return "ollama-symbolic";
|
||||
}
|
||||
|
||||
function guessModelName(model) {
|
||||
const replaced = model.replace(/-/g, ' ').replace(/:/g, ' ');
|
||||
let words = replaced.split(' ');
|
||||
words[words.length - 1] = words[words.length - 1].replace(/(\d+)b$/, (_, num) => `${num}B`)
|
||||
words = words.map((word) => {
|
||||
return (word.charAt(0).toUpperCase() + word.slice(1))
|
||||
});
|
||||
if (words[words.length - 1] === "Latest") words.pop();
|
||||
else words[words.length - 1] = `(${words[words.length - 1]})`; // Surround the last word with square brackets
|
||||
const result = words.join(' ');
|
||||
return result;
|
||||
}
|
||||
|
||||
function addModel(modelName, data) {
|
||||
root.models = Object.assign({}, root.models, {
|
||||
[modelName]: aiModelComponent.createObject(this, data)
|
||||
});
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getOllamaModels
|
||||
running: true
|
||||
command: ["bash", "-c", `${Directories.scriptPath}/ai/show-installed-ollama-models.sh`.replace(/file:\/\//, "")]
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
try {
|
||||
if (data.length === 0) return;
|
||||
const dataJson = JSON.parse(data);
|
||||
root.modelList = [...root.modelList, ...dataJson];
|
||||
dataJson.forEach(model => {
|
||||
const safeModelName = root.safeModelName(model);
|
||||
root.addModel(safeModelName, {
|
||||
"name": guessModelName(model),
|
||||
"icon": guessModelLogo(model),
|
||||
"description": Translation.tr("Local Ollama model | %1").arg(model),
|
||||
"homepage": `https://ollama.com/library/${model}`,
|
||||
"endpoint": "http://localhost:11434/v1/chat/completions",
|
||||
"model": model,
|
||||
"requires_key": false,
|
||||
})
|
||||
});
|
||||
|
||||
root.modelList = Object.keys(root.models);
|
||||
|
||||
if (root.modelList.includes(root.currentModelId)) {
|
||||
root.setModel(root.currentModelId, false, false);
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.log("Could not fetch Ollama models:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getDefaultPrompts
|
||||
running: true
|
||||
command: ["ls", "-1", Directories.defaultAiPrompts]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
if (text.length === 0) return;
|
||||
root.defaultPrompts = text.split("\n")
|
||||
.filter(fileName => fileName.endsWith(".md") || fileName.endsWith(".txt"))
|
||||
.map(fileName => `${Directories.defaultAiPrompts}/${fileName}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getUserPrompts
|
||||
running: true
|
||||
command: ["ls", "-1", Directories.userAiPrompts]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
if (text.length === 0) return;
|
||||
root.userPrompts = text.split("\n")
|
||||
.filter(fileName => fileName.endsWith(".md") || fileName.endsWith(".txt"))
|
||||
.map(fileName => `${Directories.userAiPrompts}/${fileName}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getSavedChats
|
||||
running: true
|
||||
command: ["ls", "-1", Directories.aiChats]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
if (text.length === 0) return;
|
||||
root.savedChats = text.split("\n")
|
||||
.filter(fileName => fileName.endsWith(".json"))
|
||||
.map(fileName => `${Directories.aiChats}/${fileName}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: promptLoader
|
||||
watchChanges: false;
|
||||
onLoadedChanged: {
|
||||
if (!promptLoader.loaded) return;
|
||||
Config.options.ai.systemPrompt = promptLoader.text();
|
||||
root.addMessage(Translation.tr("Loaded the following system prompt\n\n---\n\n%1").arg(Config.options.ai.systemPrompt), root.interfaceRole);
|
||||
}
|
||||
}
|
||||
|
||||
function printPrompt() {
|
||||
root.addMessage(Translation.tr("The current system prompt is\n\n---\n\n%1").arg(Config.options.ai.systemPrompt), root.interfaceRole);
|
||||
}
|
||||
|
||||
function loadPrompt(filePath) {
|
||||
promptLoader.path = "" // Unload
|
||||
promptLoader.path = filePath; // Load
|
||||
promptLoader.reload();
|
||||
}
|
||||
|
||||
function addMessage(message, role) {
|
||||
if (message.length === 0) return;
|
||||
const aiMessage = aiMessageComponent.createObject(root, {
|
||||
"role": role,
|
||||
"content": message,
|
||||
"rawContent": message,
|
||||
"thinking": false,
|
||||
"done": true,
|
||||
});
|
||||
const id = idForMessage(aiMessage);
|
||||
root.messageIDs = [...root.messageIDs, id];
|
||||
root.messageByID[id] = aiMessage;
|
||||
}
|
||||
|
||||
function removeMessage(index) {
|
||||
if (index < 0 || index >= messageIDs.length) return;
|
||||
const id = root.messageIDs[index];
|
||||
root.messageIDs.splice(index, 1);
|
||||
root.messageIDs = [...root.messageIDs];
|
||||
delete root.messageByID[id];
|
||||
}
|
||||
|
||||
function addApiKeyAdvice(model) {
|
||||
root.addMessage(
|
||||
Translation.tr('To set an API key, pass it with the %4 command\n\nTo view the key, pass "get" with the command<br/>\n\n### For %1:\n\n**Link**: %2\n\n%3')
|
||||
.arg(model.name).arg(model.key_get_link).arg(model.key_get_description ?? Translation.tr("<i>No further instruction provided</i>")).arg("/key"),
|
||||
Ai.interfaceRole
|
||||
);
|
||||
}
|
||||
|
||||
function getModel() {
|
||||
return models[currentModelId];
|
||||
}
|
||||
|
||||
function setModel(modelId, feedback = true, setPersistentState = true) {
|
||||
if (!modelId) modelId = ""
|
||||
modelId = modelId.toLowerCase()
|
||||
if (modelList.indexOf(modelId) !== -1) {
|
||||
const model = models[modelId]
|
||||
// See if policy prevents online models
|
||||
if (Config.options.policies.ai === 2 && !model.endpoint.includes("localhost")) {
|
||||
root.addMessage(
|
||||
Translation.tr("Online models disallowed\n\nControlled by `policies.ai` config option"),
|
||||
root.interfaceRole
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (setPersistentState) Persistent.states.ai.model = modelId;
|
||||
if (feedback) root.addMessage(Translation.tr("Model set to %1").arg(model.name), root.interfaceRole);
|
||||
root.currentModel = model
|
||||
if (model.requires_key) {
|
||||
// If key not there show advice
|
||||
if (root.apiKeysLoaded && (!root.apiKeys[model.key_id] || root.apiKeys[model.key_id].length === 0)) {
|
||||
root.addApiKeyAdvice(model)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (feedback) root.addMessage(Translation.tr("Invalid model. Supported: \n```\n") + modelList.join("\n```\n```\n"), Ai.interfaceRole) + "\n```"
|
||||
}
|
||||
}
|
||||
|
||||
function setTool(tool) {
|
||||
if (!root.tools[models[currentModelId]?.api_format] || !(tool in root.tools[models[currentModelId]?.api_format])) {
|
||||
root.addMessage(Translation.tr("Invalid tool. Supported tools:\n- %1").arg(root.availableTools.join("\n- ")), root.interfaceRole);
|
||||
return false;
|
||||
}
|
||||
Config.options.ai.tool = tool;
|
||||
return true;
|
||||
}
|
||||
|
||||
function getTemperature() {
|
||||
return root.temperature;
|
||||
}
|
||||
|
||||
function setTemperature(value) {
|
||||
if (value == NaN || value < 0 || value > 2) {
|
||||
root.addMessage(Translation.tr("Temperature must be between 0 and 2"), Ai.interfaceRole);
|
||||
return;
|
||||
}
|
||||
Persistent.states.ai.temperature = value;
|
||||
root.temperature = value;
|
||||
root.addMessage(Translation.tr("Temperature set to %1").arg(value), Ai.interfaceRole);
|
||||
}
|
||||
|
||||
function setApiKey(key) {
|
||||
const model = models[currentModelId];
|
||||
if (!model.requires_key) {
|
||||
root.addMessage(Translation.tr("%1 does not require an API key").arg(model.name), Ai.interfaceRole);
|
||||
return;
|
||||
}
|
||||
if (!key || key.length === 0) {
|
||||
const model = models[currentModelId];
|
||||
root.addApiKeyAdvice(model)
|
||||
return;
|
||||
}
|
||||
KeyringStorage.setNestedField(["apiKeys", model.key_id], key.trim());
|
||||
root.addMessage(Translation.tr("API key set for %1").arg(model.name), Ai.interfaceRole);
|
||||
}
|
||||
|
||||
function printApiKey() {
|
||||
const model = models[currentModelId];
|
||||
if (model.requires_key) {
|
||||
const key = root.apiKeys[model.key_id];
|
||||
if (key) {
|
||||
root.addMessage(Translation.tr("API key:\n\n```txt\n%1\n```").arg(key), Ai.interfaceRole);
|
||||
} else {
|
||||
root.addMessage(Translation.tr("No API key set for %1").arg(model.name), Ai.interfaceRole);
|
||||
}
|
||||
} else {
|
||||
root.addMessage(Translation.tr("%1 does not require an API key").arg(model.name), Ai.interfaceRole);
|
||||
}
|
||||
}
|
||||
|
||||
function printTemperature() {
|
||||
root.addMessage(Translation.tr("Temperature: %1").arg(root.temperature), Ai.interfaceRole);
|
||||
}
|
||||
|
||||
function clearMessages() {
|
||||
root.messageIDs = [];
|
||||
root.messageByID = ({});
|
||||
root.tokenCount.input = -1;
|
||||
root.tokenCount.output = -1;
|
||||
root.tokenCount.total = -1;
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: requesterScriptFile
|
||||
}
|
||||
|
||||
Process {
|
||||
id: requester
|
||||
property list<string> baseCommand: ["bash"]
|
||||
property AiMessageData message
|
||||
property ApiStrategy currentStrategy
|
||||
|
||||
function markDone() {
|
||||
requester.message.done = true;
|
||||
if (root.postResponseHook) {
|
||||
root.postResponseHook();
|
||||
root.postResponseHook = null; // Reset hook after use
|
||||
}
|
||||
root.saveChat("lastSession")
|
||||
root.responseFinished()
|
||||
}
|
||||
|
||||
function makeRequest() {
|
||||
const model = models[currentModelId];
|
||||
|
||||
// Fetch API keys if needed
|
||||
if (model?.requires_key && !KeyringStorage.loaded) KeyringStorage.fetchKeyringData();
|
||||
|
||||
requester.currentStrategy = root.currentApiStrategy;
|
||||
requester.currentStrategy.reset(); // Reset strategy state
|
||||
|
||||
/* Put API key in environment variable */
|
||||
if (model.requires_key) requester.environment[`${root.apiKeyEnvVarName}`] = root.apiKeys ? (root.apiKeys[model.key_id] ?? "") : ""
|
||||
|
||||
/* Build endpoint, request data */
|
||||
const endpoint = root.currentApiStrategy.buildEndpoint(model);
|
||||
const messageArray = root.messageIDs.map(id => root.messageByID[id]);
|
||||
const filteredMessageArray = messageArray.filter(message => message.role !== Ai.interfaceRole);
|
||||
const data = root.currentApiStrategy.buildRequestData(model, filteredMessageArray, root.systemPrompt, root.temperature, root.tools[model.api_format][root.currentTool], root.pendingFilePath);
|
||||
// console.log("[Ai] Request data: ", JSON.stringify(data, null, 2));
|
||||
|
||||
let requestHeaders = {
|
||||
"Content-Type": "application/json",
|
||||
}
|
||||
|
||||
/* Create local message object */
|
||||
requester.message = root.aiMessageComponent.createObject(root, {
|
||||
"role": "assistant",
|
||||
"model": currentModelId,
|
||||
"content": "",
|
||||
"rawContent": "",
|
||||
"thinking": true,
|
||||
"done": false,
|
||||
});
|
||||
const id = idForMessage(requester.message);
|
||||
root.messageIDs = [...root.messageIDs, id];
|
||||
root.messageByID[id] = requester.message;
|
||||
|
||||
/* Build header string for curl */
|
||||
let headerString = Object.entries(requestHeaders)
|
||||
.filter(([k, v]) => v && v.length > 0)
|
||||
.map(([k, v]) => `-H '${k}: ${v}'`)
|
||||
.join(' ');
|
||||
|
||||
// console.log("Request headers: ", JSON.stringify(requestHeaders));
|
||||
// console.log("Header string: ", headerString);
|
||||
|
||||
/* Get authorization header from strategy */
|
||||
const authHeader = requester.currentStrategy.buildAuthorizationHeader(root.apiKeyEnvVarName);
|
||||
|
||||
/* Script shebang */
|
||||
const scriptShebang = "#!/usr/bin/env bash\n";
|
||||
|
||||
/* Create extra setup when there's an attached file */
|
||||
let scriptFileSetupContent = ""
|
||||
if (root.pendingFilePath && root.pendingFilePath.length > 0) {
|
||||
requester.message.localFilePath = root.pendingFilePath;
|
||||
scriptFileSetupContent = requester.currentStrategy.buildScriptFileSetup(root.pendingFilePath);
|
||||
root.pendingFilePath = ""
|
||||
}
|
||||
|
||||
/* Create command string */
|
||||
let scriptRequestContent = ""
|
||||
scriptRequestContent += `curl --no-buffer "${endpoint}"`
|
||||
+ ` ${headerString}`
|
||||
+ (authHeader ? ` ${authHeader}` : "")
|
||||
+ ` --data '${CF.StringUtils.shellSingleQuoteEscape(JSON.stringify(data))}'`
|
||||
+ "\n"
|
||||
|
||||
/* Send the request */
|
||||
const scriptContent = requester.currentStrategy.finalizeScriptContent(scriptShebang + scriptFileSetupContent + scriptRequestContent)
|
||||
const shellScriptPath = CF.FileUtils.trimFileProtocol(root.requestScriptFilePath)
|
||||
requesterScriptFile.path = Qt.resolvedUrl(shellScriptPath)
|
||||
requesterScriptFile.setText(scriptContent)
|
||||
requester.command = baseCommand.concat([shellScriptPath]);
|
||||
requester.running = true
|
||||
}
|
||||
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
if (data.length === 0) return;
|
||||
if (requester.message.thinking) requester.message.thinking = false;
|
||||
// console.log("[Ai] Raw response line: ", data);
|
||||
|
||||
// Handle response line
|
||||
try {
|
||||
const result = requester.currentStrategy.parseResponseLine(data, requester.message);
|
||||
// console.log("[Ai] Parsed response result: ", JSON.stringify(result, null, 2));
|
||||
|
||||
if (result.functionCall) {
|
||||
requester.message.functionCall = result.functionCall;
|
||||
root.handleFunctionCall(result.functionCall.name, result.functionCall.args, requester.message);
|
||||
}
|
||||
if (result.tokenUsage) {
|
||||
root.tokenCount.input = result.tokenUsage.input;
|
||||
root.tokenCount.output = result.tokenUsage.output;
|
||||
root.tokenCount.total = result.tokenUsage.total;
|
||||
}
|
||||
if (result.finished) {
|
||||
requester.markDone();
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.log("[AI] Could not parse response: ", e);
|
||||
requester.message.rawContent += data;
|
||||
requester.message.content += data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
const result = requester.currentStrategy.onRequestFinished(requester.message);
|
||||
|
||||
if (result.finished) {
|
||||
requester.markDone();
|
||||
} else if (!requester.message.done) {
|
||||
requester.markDone();
|
||||
}
|
||||
|
||||
// Handle error responses
|
||||
if (requester.message.content.includes("API key not valid")) {
|
||||
root.addApiKeyAdvice(models[requester.message.model]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sendUserMessage(message) {
|
||||
if (message.length === 0) return;
|
||||
root.addMessage(message, "user");
|
||||
requester.makeRequest();
|
||||
}
|
||||
|
||||
function attachFile(filePath: string) {
|
||||
root.pendingFilePath = CF.FileUtils.trimFileProtocol(filePath);
|
||||
}
|
||||
|
||||
function regenerate(messageIndex) {
|
||||
if (messageIndex < 0 || messageIndex >= messageIDs.length) return;
|
||||
const id = root.messageIDs[messageIndex];
|
||||
const message = root.messageByID[id];
|
||||
if (message.role !== "assistant") return;
|
||||
// Remove all messages after this one
|
||||
for (let i = root.messageIDs.length - 1; i >= messageIndex; i--) {
|
||||
root.removeMessage(i);
|
||||
}
|
||||
requester.makeRequest();
|
||||
}
|
||||
|
||||
function createFunctionOutputMessage(name, output, includeOutputInChat = true) {
|
||||
return aiMessageComponent.createObject(root, {
|
||||
"role": "user",
|
||||
"content": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n<think>\n" + output + "\n</think>") : ""}`,
|
||||
"rawContent": `[[ Output of ${name} ]]${includeOutputInChat ? ("\n\n<think>\n" + output + "\n</think>") : ""}`,
|
||||
"functionName": name,
|
||||
"functionResponse": output,
|
||||
"thinking": false,
|
||||
"done": true,
|
||||
// "visibleToUser": false,
|
||||
});
|
||||
}
|
||||
|
||||
function addFunctionOutputMessage(name, output) {
|
||||
const aiMessage = createFunctionOutputMessage(name, output);
|
||||
const id = idForMessage(aiMessage);
|
||||
root.messageIDs = [...root.messageIDs, id];
|
||||
root.messageByID[id] = aiMessage;
|
||||
}
|
||||
|
||||
function rejectCommand(message: AiMessageData) {
|
||||
if (!message.functionPending) return;
|
||||
message.functionPending = false; // User decided, no more "thinking"
|
||||
addFunctionOutputMessage(message.functionName, Translation.tr("Command rejected by user"))
|
||||
}
|
||||
|
||||
function approveCommand(message: AiMessageData) {
|
||||
if (!message.functionPending) return;
|
||||
message.functionPending = false; // User decided, no more "thinking"
|
||||
|
||||
const responseMessage = createFunctionOutputMessage(message.functionName, "", false);
|
||||
const id = idForMessage(responseMessage);
|
||||
root.messageIDs = [...root.messageIDs, id];
|
||||
root.messageByID[id] = responseMessage;
|
||||
|
||||
commandExecutionProc.message = responseMessage;
|
||||
commandExecutionProc.baseMessageContent = responseMessage.content;
|
||||
commandExecutionProc.shellCommand = message.functionCall.args.command;
|
||||
commandExecutionProc.running = true; // Start the command execution
|
||||
}
|
||||
|
||||
Process {
|
||||
id: commandExecutionProc
|
||||
property string shellCommand: ""
|
||||
property AiMessageData message
|
||||
property string baseMessageContent: ""
|
||||
command: ["bash", "-c", shellCommand]
|
||||
stdout: SplitParser {
|
||||
onRead: (output) => {
|
||||
commandExecutionProc.message.functionResponse += output + "\n\n";
|
||||
const updatedContent = commandExecutionProc.baseMessageContent + `\n\n<think>\n<tt>${commandExecutionProc.message.functionResponse}</tt>\n</think>`;
|
||||
commandExecutionProc.message.rawContent = updatedContent;
|
||||
commandExecutionProc.message.content = updatedContent;
|
||||
}
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
commandExecutionProc.message.functionResponse += `[[ Command exited with code ${exitCode} (${exitStatus}) ]]\n`;
|
||||
requester.makeRequest(); // Continue
|
||||
}
|
||||
}
|
||||
|
||||
function handleFunctionCall(name, args: var, message: AiMessageData) {
|
||||
if (name === "switch_to_search_mode") {
|
||||
const modelId = root.currentModelId;
|
||||
root.currentTool = "search"
|
||||
root.postResponseHook = () => { root.currentTool = "functions" }
|
||||
addFunctionOutputMessage(name, Translation.tr("Switched to search mode. Continue with the user's request."))
|
||||
requester.makeRequest();
|
||||
} else if (name === "get_shell_config") {
|
||||
const configJson = CF.ObjectUtils.toPlainObject(Config.options)
|
||||
addFunctionOutputMessage(name, JSON.stringify(configJson));
|
||||
requester.makeRequest();
|
||||
} else if (name === "set_shell_config") {
|
||||
if (!args.key || !args.value) {
|
||||
addFunctionOutputMessage(name, Translation.tr("Invalid arguments. Must provide `key` and `value`."));
|
||||
return;
|
||||
}
|
||||
const key = args.key;
|
||||
const value = args.value;
|
||||
Config.setNestedValue(key, value);
|
||||
} else if (name === "run_shell_command") {
|
||||
if (!args.command || args.command.length === 0) {
|
||||
addFunctionOutputMessage(name, Translation.tr("Invalid arguments. Must provide `command`."));
|
||||
return;
|
||||
}
|
||||
const contentToAppend = `\n\n**Command execution request**\n\n\`\`\`command\n${args.command}\n\`\`\``;
|
||||
message.rawContent += contentToAppend;
|
||||
message.content += contentToAppend;
|
||||
message.functionPending = true; // Use thinking to indicate the command is waiting for approval
|
||||
}
|
||||
else root.addMessage(Translation.tr("Unknown function call: %1").arg(name), "assistant");
|
||||
}
|
||||
|
||||
function chatToJson() {
|
||||
return root.messageIDs.map(id => {
|
||||
const message = root.messageByID[id]
|
||||
return ({
|
||||
"role": message.role,
|
||||
"rawContent": message.rawContent,
|
||||
"fileMimeType": message.fileMimeType,
|
||||
"fileUri": message.fileUri,
|
||||
"localFilePath": message.localFilePath,
|
||||
"model": message.model,
|
||||
"thinking": false,
|
||||
"done": true,
|
||||
"annotations": message.annotations,
|
||||
"annotationSources": message.annotationSources,
|
||||
"functionName": message.functionName,
|
||||
"functionCall": message.functionCall,
|
||||
"thoughtSignature": message.thoughtSignature,
|
||||
"functionResponse": message.functionResponse,
|
||||
"visibleToUser": message.visibleToUser,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: chatSaveFile
|
||||
property string chatName: ""
|
||||
path: chatName.length > 0 ? `${Directories.aiChats}/${chatName}.json` : ""
|
||||
blockLoading: true // Prevent race conditions
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: chatWriter
|
||||
}
|
||||
|
||||
/**
|
||||
* Saves chat to a JSON list of message objects.
|
||||
* @param chatName name of the chat
|
||||
*/
|
||||
function saveChat(chatName) {
|
||||
const filePath = `${Directories.aiChats}/${chatName.trim()}.json`
|
||||
chatWriter.path = filePath
|
||||
const saveContent = JSON.stringify(root.chatToJson())
|
||||
chatWriter.setText(saveContent)
|
||||
getSavedChats.running = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads chat from a JSON list of message objects.
|
||||
* @param chatName name of the chat
|
||||
*/
|
||||
function loadChat(chatName) {
|
||||
try {
|
||||
chatSaveFile.chatName = chatName.trim()
|
||||
chatSaveFile.reload()
|
||||
const saveContent = chatSaveFile.text()
|
||||
// console.log(saveContent)
|
||||
const saveData = JSON.parse(saveContent)
|
||||
root.clearMessages()
|
||||
root.messageIDs = saveData.map((_, i) => {
|
||||
return i
|
||||
})
|
||||
// console.log(JSON.stringify(messageIDs))
|
||||
for (let i = 0; i < saveData.length; i++) {
|
||||
const message = saveData[i];
|
||||
root.messageByID[i] = root.aiMessageComponent.createObject(root, {
|
||||
"role": message.role,
|
||||
"rawContent": message.rawContent,
|
||||
"content": message.rawContent,
|
||||
"fileMimeType": message.fileMimeType,
|
||||
"fileUri": message.fileUri,
|
||||
"localFilePath": message.localFilePath,
|
||||
"model": message.model,
|
||||
"thinking": message.thinking,
|
||||
"done": message.done,
|
||||
"annotations": message.annotations,
|
||||
"annotationSources": message.annotationSources,
|
||||
"functionName": message.functionName,
|
||||
"functionCall": message.functionCall,
|
||||
"thoughtSignature": message.thoughtSignature,
|
||||
"functionResponse": message.functionResponse,
|
||||
"visibleToUser": message.visibleToUser,
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[AI] Could not load chat: ", e);
|
||||
} finally {
|
||||
getSavedChats.running = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
205
surfaces/quickshell/ii-base/services/AppSearch.qml
Normal file
205
surfaces/quickshell/ii-base/services/AppSearch.qml
Normal file
|
|
@ -0,0 +1,205 @@
|
|||
pragma Singleton
|
||||
|
||||
import qs.modules.common
|
||||
import qs.modules.common.functions
|
||||
import Quickshell
|
||||
|
||||
/**
|
||||
* - Eases fuzzy searching for applications by name
|
||||
* - Guesses icon name for window class name
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
property bool sloppySearch: Config.options?.search.sloppy ?? false
|
||||
property real scoreThreshold: 0.2
|
||||
property var substitutions: ({
|
||||
"code-url-handler": "visual-studio-code",
|
||||
"Code": "visual-studio-code",
|
||||
"gnome-tweaks": "org.gnome.tweaks",
|
||||
"pavucontrol-qt": "pavucontrol",
|
||||
"wps": "wps-office2019-kprometheus",
|
||||
"wpsoffice": "wps-office2019-kprometheus",
|
||||
"footclient": "foot",
|
||||
})
|
||||
property var regexSubstitutions: [
|
||||
{
|
||||
"regex": /^steam_app_(\d+)$/,
|
||||
"replace": "steam_icon_$1"
|
||||
},
|
||||
{
|
||||
"regex": /Minecraft.*/,
|
||||
"replace": "minecraft"
|
||||
},
|
||||
{
|
||||
"regex": /.*polkit.*/,
|
||||
"replace": "system-lock-screen"
|
||||
},
|
||||
{
|
||||
"regex": /gcr.prompter/,
|
||||
"replace": "system-lock-screen"
|
||||
}
|
||||
]
|
||||
|
||||
// Deduped list to fix double icons
|
||||
readonly property list<DesktopEntry> list: Array.from(DesktopEntries.applications.values)
|
||||
.filter((app, index, self) =>
|
||||
index === self.findIndex((t) => (
|
||||
t.id === app.id
|
||||
))
|
||||
)
|
||||
|
||||
readonly property var preppedNames: list.map(a => ({
|
||||
name: Fuzzy.prepare(`${a.name} `),
|
||||
entry: a
|
||||
}))
|
||||
|
||||
readonly property var preppedIcons: list.map(a => ({
|
||||
name: Fuzzy.prepare(`${a.icon} `),
|
||||
entry: a
|
||||
}))
|
||||
|
||||
function fuzzyQuery(search: string): var { // Idk why list<DesktopEntry> doesn't work
|
||||
if (root.sloppySearch) {
|
||||
return root.levenshteinQuery(search);
|
||||
}
|
||||
|
||||
return Fuzzy.go(search, preppedNames, {
|
||||
all: true,
|
||||
key: "name"
|
||||
}).map(r => {
|
||||
return r.obj.entry
|
||||
});
|
||||
}
|
||||
|
||||
function levenshteinQuery(search: string): var {
|
||||
const prepared = BitwiseFuzzy.prepare(search);
|
||||
return BitwiseFuzzy.search(prepared, list, { key: "name", threshold: root.scoreThreshold });
|
||||
}
|
||||
|
||||
function iconExists(iconName) {
|
||||
if (!iconName || iconName.length == 0) return false;
|
||||
return (Quickshell.iconPath(iconName, true).length > 0)
|
||||
&& !iconName.includes("image-missing");
|
||||
}
|
||||
|
||||
function getReverseDomainNameAppName(str) {
|
||||
return str.split('.').slice(-1)[0]
|
||||
}
|
||||
|
||||
function getKebabNormalizedAppName(str) {
|
||||
return str.toLowerCase().replace(/\s+/g, "-");
|
||||
}
|
||||
|
||||
function getUndescoreToKebabAppName(str) {
|
||||
return str.toLowerCase().replace(/_/g, "-");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a window app_id / pinned appId to its DesktopEntry.
|
||||
*
|
||||
* DesktopEntries.heuristicLookup() matches the entry id and
|
||||
* StartupWMClass, which covers most apps but NOT the ones that append an
|
||||
* instance suffix to their Wayland app_id. Firefox is the reference case:
|
||||
* it derives its remoting name from the profile, so the window reports
|
||||
* "firefox-default" while the entry is "firefox" with
|
||||
* StartupWMClass=firefox. heuristicLookup returns null, and a dock tap on
|
||||
* a pinned-but-not-running app then calls execute() on null — a silent
|
||||
* dead tap that reports no error anywhere.
|
||||
*
|
||||
* So: fall back to trimming trailing "-segment" pieces, longest match
|
||||
* first, then to a StartupWMClass prefix scan. Returns null only when
|
||||
* nothing in the entry set plausibly owns the id.
|
||||
*/
|
||||
function resolveEntry(appId) {
|
||||
if (!appId || appId.length == 0) return null;
|
||||
|
||||
const direct = DesktopEntries.heuristicLookup(appId);
|
||||
if (direct) return direct;
|
||||
|
||||
// "firefox-default" -> "firefox"; "signal-desktop-beta" -> "signal-desktop"
|
||||
let candidate = appId;
|
||||
while (candidate.includes("-")) {
|
||||
candidate = candidate.slice(0, candidate.lastIndexOf("-"));
|
||||
const trimmed = DesktopEntries.heuristicLookup(candidate);
|
||||
if (trimmed) return trimmed;
|
||||
}
|
||||
|
||||
// Last resort: an entry whose StartupWMClass prefixes the app_id.
|
||||
const lowered = appId.toLowerCase();
|
||||
for (const entry of root.list) {
|
||||
const wmClass = entry.startupClass;
|
||||
if (!wmClass || wmClass.length == 0) continue;
|
||||
if (lowered.startsWith(wmClass.toLowerCase())) return entry;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function guessIcon(str) {
|
||||
if (!str || str.length == 0) return "image-missing";
|
||||
|
||||
// Quickshell's desktop entry lookup
|
||||
const entry = DesktopEntries.byId(str);
|
||||
if (entry) return entry.icon;
|
||||
|
||||
// Normal substitutions
|
||||
if (substitutions[str]) return substitutions[str];
|
||||
if (substitutions[str.toLowerCase()]) return substitutions[str.toLowerCase()];
|
||||
|
||||
// Regex substitutions
|
||||
for (let i = 0; i < regexSubstitutions.length; i++) {
|
||||
const substitution = regexSubstitutions[i];
|
||||
const replacedName = str.replace(
|
||||
substitution.regex,
|
||||
substitution.replace,
|
||||
);
|
||||
if (replacedName != str) return replacedName;
|
||||
}
|
||||
|
||||
// Icon exists -> return as is
|
||||
if (iconExists(str)) return str;
|
||||
|
||||
|
||||
// Simple guesses
|
||||
const lowercased = str.toLowerCase();
|
||||
if (iconExists(lowercased)) return lowercased;
|
||||
|
||||
const reverseDomainNameAppName = getReverseDomainNameAppName(str);
|
||||
if (iconExists(reverseDomainNameAppName)) return reverseDomainNameAppName;
|
||||
|
||||
const lowercasedDomainNameAppName = reverseDomainNameAppName.toLowerCase();
|
||||
if (iconExists(lowercasedDomainNameAppName)) return lowercasedDomainNameAppName;
|
||||
|
||||
const kebabNormalizedGuess = getKebabNormalizedAppName(str);
|
||||
if (iconExists(kebabNormalizedGuess)) return kebabNormalizedGuess;
|
||||
|
||||
const undescoreToKebabGuess = getUndescoreToKebabAppName(str);
|
||||
if (iconExists(undescoreToKebabGuess)) return undescoreToKebabGuess;
|
||||
|
||||
// Search in desktop entries
|
||||
const iconSearchResults = Fuzzy.go(str, preppedIcons, {
|
||||
all: true,
|
||||
key: "name"
|
||||
}).map(r => {
|
||||
return r.obj.entry
|
||||
});
|
||||
if (iconSearchResults.length > 0) {
|
||||
const guess = iconSearchResults[0].icon
|
||||
if (iconExists(guess)) return guess;
|
||||
}
|
||||
|
||||
const nameSearchResults = root.fuzzyQuery(str);
|
||||
if (nameSearchResults.length > 0) {
|
||||
const guess = nameSearchResults[0].icon
|
||||
if (iconExists(guess)) return guess;
|
||||
}
|
||||
|
||||
// Desktop entry lookup, suffix-tolerant so "firefox-default" and
|
||||
// friends land on their real entry's icon instead of falling through.
|
||||
const heuristicEntry = root.resolveEntry(str);
|
||||
if (heuristicEntry) return heuristicEntry.icon;
|
||||
|
||||
// Give up
|
||||
return "application-x-executable";
|
||||
}
|
||||
}
|
||||
149
surfaces/quickshell/ii-base/services/Audio.qml
Normal file
149
surfaces/quickshell/ii-base/services/Audio.qml
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
import qs.modules.common
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.Pipewire
|
||||
|
||||
/**
|
||||
* A nice wrapper for default Pipewire audio sink and source.
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
// Misc props
|
||||
property bool ready: Pipewire.defaultAudioSink?.ready ?? false
|
||||
property PwNode sink: Pipewire.defaultAudioSink
|
||||
property PwNode source: Pipewire.defaultAudioSource
|
||||
readonly property real hardMaxValue: 2.00 // People keep joking about setting volume to 5172% so...
|
||||
property string audioTheme: Config.options.sounds.theme
|
||||
property real value: sink?.audio.volume ?? 0
|
||||
property bool autoMuted: false
|
||||
|
||||
function friendlyDeviceName(node) {
|
||||
return (node.nickname || node.description || Translation.tr("Unknown"));
|
||||
}
|
||||
function appNodeDisplayName(node) {
|
||||
return (node.properties["application.name"] || node.description || node.name)
|
||||
}
|
||||
|
||||
// Lists
|
||||
function correctType(node, isSink) {
|
||||
return (node.isSink === isSink) && node.audio
|
||||
}
|
||||
function appNodes(isSink) {
|
||||
return Pipewire.nodes.values.filter((node) => { // Should be list<PwNode> but it breaks ScriptModel
|
||||
return root.correctType(node, isSink) && node.isStream
|
||||
})
|
||||
}
|
||||
function devices(isSink) {
|
||||
return Pipewire.nodes.values.filter(node => {
|
||||
return root.correctType(node, isSink) && !node.isStream
|
||||
})
|
||||
}
|
||||
readonly property list<var> outputAppNodes: root.appNodes(true)
|
||||
readonly property list<var> inputAppNodes: root.appNodes(false)
|
||||
readonly property list<var> outputDevices: root.devices(true)
|
||||
readonly property list<var> inputDevices: root.devices(false)
|
||||
|
||||
// Signals
|
||||
signal sinkProtectionTriggered(string reason);
|
||||
|
||||
// Controls
|
||||
function toggleMute() {
|
||||
root.autoMuted = false
|
||||
Audio.sink.audio.muted = !Audio.sink.audio.muted
|
||||
}
|
||||
|
||||
function toggleMicMute() {
|
||||
Audio.source.audio.muted = !Audio.source.audio.muted
|
||||
}
|
||||
|
||||
function incrementVolume() {
|
||||
const currentVolume = Audio.value;
|
||||
const step = currentVolume < 0.1 ? 0.01 : 0.02 || 0.2;
|
||||
Audio.sink.audio.volume = Math.min(1, Audio.sink.audio.volume + step);
|
||||
}
|
||||
|
||||
function decrementVolume() {
|
||||
const currentVolume = Audio.value;
|
||||
const step = currentVolume < 0.1 ? 0.01 : 0.02 || 0.2;
|
||||
Audio.sink.audio.volume -= step;
|
||||
}
|
||||
|
||||
function setDefaultSink(node) {
|
||||
Pipewire.preferredDefaultAudioSink = node;
|
||||
}
|
||||
|
||||
function setDefaultSource(node) {
|
||||
Pipewire.preferredDefaultAudioSource = node;
|
||||
}
|
||||
|
||||
// Internals
|
||||
PwObjectTracker {
|
||||
objects: [sink, source]
|
||||
}
|
||||
|
||||
Connections { // Protection against sudden volume changes
|
||||
target: sink?.audio ?? null
|
||||
property bool lastReady: false
|
||||
property real lastVolume: 0
|
||||
function onVolumeChanged() {
|
||||
if (!Config.options.audio.protection.enable) return;
|
||||
const newVolume = sink.audio.volume;
|
||||
// when resuming from suspend, we should not write volume to avoid pipewire volume reset issues
|
||||
if (isNaN(newVolume) || newVolume === undefined || newVolume === null) {
|
||||
lastReady = false;
|
||||
lastVolume = 0;
|
||||
return;
|
||||
}
|
||||
if (!lastReady) {
|
||||
lastVolume = newVolume;
|
||||
lastReady = true;
|
||||
return;
|
||||
}
|
||||
const maxAllowedIncrease = Config.options.audio.protection.maxAllowedIncrease / 100;
|
||||
const maxAllowed = Config.options.audio.protection.maxAllowed / 100;
|
||||
|
||||
if (newVolume - lastVolume > maxAllowedIncrease) {
|
||||
sink.audio.volume = lastVolume;
|
||||
root.sinkProtectionTriggered(Translation.tr("Illegal increment"));
|
||||
} else if (newVolume > maxAllowed || newVolume > root.hardMaxValue) {
|
||||
root.sinkProtectionTriggered(Translation.tr("Exceeded max allowed"));
|
||||
sink.audio.volume = Math.min(lastVolume, maxAllowed);
|
||||
}
|
||||
lastVolume = sink.audio.volume;
|
||||
|
||||
if (lastVolume === 0 && !sink.audio.muted) {
|
||||
sink.audio.muted = true;
|
||||
root.autoMuted = true;
|
||||
} else if (lastVolume > 0 && root.autoMuted) {
|
||||
sink.audio.muted = false;
|
||||
root.autoMuted = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function playSystemSound(soundName) {
|
||||
const ogaPath = `/usr/share/sounds/${root.audioTheme}/stereo/${soundName}.oga`;
|
||||
const oggPath = `/usr/share/sounds/${root.audioTheme}/stereo/${soundName}.ogg`;
|
||||
|
||||
// Try playing .oga first
|
||||
let command = [
|
||||
"ffplay",
|
||||
"-nodisp",
|
||||
"-autoexit",
|
||||
ogaPath
|
||||
];
|
||||
Quickshell.execDetached(command);
|
||||
|
||||
// Also try playing .ogg (ffplay will just fail silently if file doesn't exist)
|
||||
command = [
|
||||
"ffplay",
|
||||
"-nodisp",
|
||||
"-autoexit",
|
||||
oggPath
|
||||
];
|
||||
Quickshell.execDetached(command);
|
||||
}
|
||||
}
|
||||
112
surfaces/quickshell/ii-base/services/Battery.qml
Normal file
112
surfaces/quickshell/ii-base/services/Battery.qml
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
pragma Singleton
|
||||
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import Quickshell
|
||||
import Quickshell.Services.UPower
|
||||
import Quickshell.Services.Mpris
|
||||
import QtQuick
|
||||
import Quickshell.Io
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
property bool available: UPower.displayDevice.isLaptopBattery
|
||||
property var chargeState: UPower.displayDevice.state
|
||||
property bool isCharging: chargeState == UPowerDeviceState.Charging
|
||||
property bool isPluggedIn: isCharging || chargeState == UPowerDeviceState.PendingCharge
|
||||
property real percentage: UPower.displayDevice?.percentage ?? 1
|
||||
readonly property bool allowAutomaticSuspend: Config.options.battery.automaticSuspend
|
||||
readonly property bool soundEnabled: Config.options.sounds.battery
|
||||
|
||||
property bool isLow: available && (percentage <= Config.options.battery.low / 100)
|
||||
property bool isCritical: available && (percentage <= Config.options.battery.critical / 100)
|
||||
property bool isSuspending: available && (percentage <= Config.options.battery.suspend / 100)
|
||||
property bool isFull: available && (percentage >= Config.options.battery.full / 100)
|
||||
|
||||
property bool isLowAndNotCharging: isLow && !isCharging
|
||||
property bool isCriticalAndNotCharging: isCritical && !isCharging
|
||||
property bool isSuspendingAndNotCharging: allowAutomaticSuspend && isSuspending && !isCharging
|
||||
property bool isFullAndCharging: isFull && isCharging
|
||||
|
||||
property real energyRate: UPower.displayDevice.changeRate
|
||||
property real timeToEmpty: UPower.displayDevice.timeToEmpty
|
||||
property real timeToFull: UPower.displayDevice.timeToFull
|
||||
|
||||
property real health: (function() {
|
||||
const devList = UPower.devices.values;
|
||||
for (let i = 0; i < devList.length; ++i) {
|
||||
const dev = devList[i];
|
||||
if (dev.isLaptopBattery && dev.healthSupported) {
|
||||
const health = dev.healthPercentage;
|
||||
if (health === 0) {
|
||||
return 0.01;
|
||||
} else if (health < 1) {
|
||||
return health * 100;
|
||||
} else {
|
||||
return health;
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0;
|
||||
})()
|
||||
|
||||
|
||||
onIsLowAndNotChargingChanged: {
|
||||
if (!root.available || !isLowAndNotCharging) return;
|
||||
Quickshell.execDetached([
|
||||
"notify-send",
|
||||
Translation.tr("Low battery"),
|
||||
Translation.tr("Consider plugging in your device"),
|
||||
"-u", "critical",
|
||||
"-a", "Shell",
|
||||
"--hint=int:transient:1",
|
||||
])
|
||||
|
||||
if (root.soundEnabled) Audio.playSystemSound("dialog-warning");
|
||||
}
|
||||
|
||||
onIsCriticalAndNotChargingChanged: {
|
||||
if (!root.available || !isCriticalAndNotCharging) return;
|
||||
Quickshell.execDetached([
|
||||
"notify-send",
|
||||
Translation.tr("Critically low battery"),
|
||||
Translation.tr("Please charge!\nAutomatic suspend triggers at %1%").arg(Config.options.battery.suspend),
|
||||
"-u", "critical",
|
||||
"-a", "Shell",
|
||||
"--hint=int:transient:1",
|
||||
]);
|
||||
|
||||
if (root.soundEnabled) Audio.playSystemSound("suspend-error");
|
||||
}
|
||||
|
||||
onIsSuspendingAndNotChargingChanged: {
|
||||
if (root.available && isSuspendingAndNotCharging) {
|
||||
for (const player of Mpris.players.values) {
|
||||
if (player.canPause) player.pause();
|
||||
}
|
||||
Quickshell.execDetached(["bash", "-c", `systemctl suspend || loginctl suspend`]);
|
||||
}
|
||||
}
|
||||
|
||||
onIsFullAndChargingChanged: {
|
||||
if (!root.available || !isFullAndCharging) return;
|
||||
Quickshell.execDetached([
|
||||
"notify-send",
|
||||
Translation.tr("Battery full"),
|
||||
Translation.tr("Please unplug the charger"),
|
||||
"-a", "Shell",
|
||||
"--hint=int:transient:1",
|
||||
]);
|
||||
|
||||
if (root.soundEnabled) Audio.playSystemSound("complete");
|
||||
}
|
||||
|
||||
onIsPluggedInChanged: {
|
||||
if (!root.available || !root.soundEnabled) return;
|
||||
if (isPluggedIn) {
|
||||
Audio.playSystemSound("power-plug")
|
||||
} else {
|
||||
Audio.playSystemSound("power-unplug")
|
||||
}
|
||||
}
|
||||
}
|
||||
37
surfaces/quickshell/ii-base/services/BluetoothStatus.qml
Normal file
37
surfaces/quickshell/ii-base/services/BluetoothStatus.qml
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Bluetooth
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property bool available: Bluetooth.adapters.values.length > 0
|
||||
readonly property bool enabled: Bluetooth.defaultAdapter?.enabled ?? false
|
||||
readonly property BluetoothDevice firstActiveDevice: Bluetooth.defaultAdapter?.devices.values.find(device => device.connected) ?? null
|
||||
readonly property int activeDeviceCount: Bluetooth.defaultAdapter?.devices.values.filter(device => device.connected).length ?? 0
|
||||
readonly property bool connected: Bluetooth.devices.values.some(d => d.connected)
|
||||
|
||||
function sortFunction(a, b) {
|
||||
// Ones with meaningful names before MAC addresses
|
||||
const macRegex = /^([0-9A-Fa-f]{2}-){5}[0-9A-Fa-f]{2}$/;
|
||||
const aIsMac = macRegex.test(a.name);
|
||||
const bIsMac = macRegex.test(b.name);
|
||||
if (aIsMac !== bIsMac)
|
||||
return aIsMac ? 1 : -1;
|
||||
|
||||
// Alphabetical by name
|
||||
return a.name.localeCompare(b.name);
|
||||
}
|
||||
property list<var> connectedDevices: Bluetooth.devices.values.filter(d => d.connected).sort(sortFunction)
|
||||
property list<var> pairedButNotConnectedDevices: Bluetooth.devices.values.filter(d => d.paired && !d.connected).sort(sortFunction)
|
||||
property list<var> unpairedDevices: Bluetooth.devices.values.filter(d => !d.paired && !d.connected).sort(sortFunction)
|
||||
property list<var> friendlyDeviceList: [
|
||||
...connectedDevices,
|
||||
...pairedButNotConnectedDevices,
|
||||
...unpairedDevices
|
||||
]
|
||||
}
|
||||
470
surfaces/quickshell/ii-base/services/Booru.qml
Normal file
470
surfaces/quickshell/ii-base/services/Booru.qml
Normal file
|
|
@ -0,0 +1,470 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs.modules.common
|
||||
import qs.services
|
||||
import Quickshell;
|
||||
import QtQuick;
|
||||
|
||||
/**
|
||||
* A service for interacting with various booru APIs.
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
property Component booruResponseDataComponent: BooruResponseData {}
|
||||
|
||||
signal tagSuggestion(string query, var suggestions)
|
||||
signal responseFinished()
|
||||
|
||||
property string failMessage: Translation.tr("That didn't work. Tips:\n- Check your tags and NSFW settings\n- If you don't have a tag in mind, type a page number")
|
||||
property var responses: []
|
||||
property int runningRequests: 0
|
||||
property var defaultUserAgent: Config.options?.networking?.userAgent || "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
|
||||
property var providerList: Object.keys(providers).filter(provider => provider !== "system" && providers[provider].api)
|
||||
property var providers: {
|
||||
"system": { "name": Translation.tr("System") },
|
||||
"yandere": {
|
||||
"name": "yande.re",
|
||||
"url": "https://yande.re",
|
||||
"api": "https://yande.re/post.json",
|
||||
"description": Translation.tr("All-rounder | Good quality, decent quantity"),
|
||||
"mapFunc": (response) => {
|
||||
return response.map(item => {
|
||||
return {
|
||||
"id": item.id,
|
||||
"width": item.width,
|
||||
"height": item.height,
|
||||
"aspect_ratio": item.width / item.height,
|
||||
"tags": item.tags,
|
||||
"rating": item.rating,
|
||||
"is_nsfw": (item.rating != 's'),
|
||||
"md5": item.md5,
|
||||
"preview_url": item.preview_url,
|
||||
"sample_url": item.sample_url ?? item.file_url,
|
||||
"file_url": item.file_url,
|
||||
"file_ext": item.file_ext,
|
||||
"source": getWorkingImageSource(item.source) ?? item.file_url,
|
||||
}
|
||||
})
|
||||
},
|
||||
"tagSearchTemplate": "https://yande.re/tag.json?order=count&limit=10&name={{query}}*",
|
||||
"tagMapFunc": (response) => {
|
||||
return response.map(item => {
|
||||
return {
|
||||
"name": item.name,
|
||||
"count": item.count
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
"konachan": {
|
||||
"name": "Konachan",
|
||||
"url": "https://konachan.net",
|
||||
"api": "https://konachan.net/post.json",
|
||||
"description": Translation.tr("For desktop wallpapers | Good quality"),
|
||||
"mapFunc": (response) => {
|
||||
return response.map(item => {
|
||||
return {
|
||||
"id": item.id,
|
||||
"width": item.width,
|
||||
"height": item.height,
|
||||
"aspect_ratio": item.width / item.height,
|
||||
"tags": item.tags,
|
||||
"rating": item.rating,
|
||||
"is_nsfw": (item.rating != 's'),
|
||||
"md5": item.md5,
|
||||
"preview_url": item.preview_url,
|
||||
"sample_url": item.sample_url ?? item.file_url,
|
||||
"file_url": item.file_url,
|
||||
"file_ext": item.file_ext,
|
||||
"source": getWorkingImageSource(item.source) ?? item.file_url,
|
||||
}
|
||||
})
|
||||
},
|
||||
"tagSearchTemplate": "https://konachan.net/tag.json?order=count&limit=10&name={{query}}*",
|
||||
"tagMapFunc": (response) => {
|
||||
return response.map(item => {
|
||||
return {
|
||||
"name": item.name,
|
||||
"count": item.count
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
"zerochan": {
|
||||
"name": "Zerochan",
|
||||
"url": "https://www.zerochan.net",
|
||||
"api": "https://www.zerochan.net/?json",
|
||||
"description": Translation.tr("Clean stuff | Excellent quality, no NSFW"),
|
||||
"mapFunc": (response) => {
|
||||
response = response.items
|
||||
return response.map(item => {
|
||||
return {
|
||||
"id": item.id,
|
||||
"width": item.width,
|
||||
"height": item.height,
|
||||
"aspect_ratio": item.width / item.height,
|
||||
"tags": item.tags.join(" "),
|
||||
"rating": "safe", // Zerochan doesn't have nsfw
|
||||
"is_nsfw": false,
|
||||
"md5": item.md5,
|
||||
"preview_url": item.thumbnail,
|
||||
"sample_url": item.thumbnail,
|
||||
"file_url": item.thumbnail,
|
||||
"file_ext": "avif",
|
||||
"source": getWorkingImageSource(item.source) ?? item.thumbnail,
|
||||
"character": item.tag
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
"danbooru": {
|
||||
"name": "Danbooru",
|
||||
"url": "https://danbooru.donmai.us",
|
||||
"api": "https://danbooru.donmai.us/posts.json",
|
||||
"description": Translation.tr("The popular one | Best quantity, but quality can vary wildly"),
|
||||
"mapFunc": (response) => {
|
||||
return response.map(item => {
|
||||
return {
|
||||
"id": item.id,
|
||||
"width": item.image_width,
|
||||
"height": item.image_height,
|
||||
"aspect_ratio": item.image_width / item.image_height,
|
||||
"tags": item.tag_string,
|
||||
"rating": item.rating,
|
||||
"is_nsfw": (item.rating != 's'),
|
||||
"md5": item.md5,
|
||||
"preview_url": item.preview_file_url,
|
||||
"sample_url": item.file_url ?? item.large_file_url,
|
||||
"file_url": item.large_file_url,
|
||||
"file_ext": item.file_ext,
|
||||
"source": getWorkingImageSource(item.source) ?? item.file_url,
|
||||
}
|
||||
})
|
||||
},
|
||||
"tagSearchTemplate": "https://danbooru.donmai.us/tags.json?limit=10&search[name_matches]={{query}}*",
|
||||
"tagMapFunc": (response) => {
|
||||
return response.map(item => {
|
||||
return {
|
||||
"name": item.name,
|
||||
"count": item.post_count
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
"gelbooru": {
|
||||
"name": "Gelbooru",
|
||||
"url": "https://gelbooru.com",
|
||||
"api": "https://gelbooru.com/index.php?page=dapi&s=post&q=index&json=1",
|
||||
"description": Translation.tr("The hentai one | Great quantity, a lot of NSFW, quality varies wildly"),
|
||||
"mapFunc": (response) => {
|
||||
response = response.post
|
||||
return response.map(item => {
|
||||
return {
|
||||
"id": item.id,
|
||||
"width": item.width,
|
||||
"height": item.height,
|
||||
"aspect_ratio": item.width / item.height,
|
||||
"tags": item.tags,
|
||||
"rating": item.rating.replace('general', 's').charAt(0),
|
||||
"is_nsfw": (item.rating != 's'),
|
||||
"md5": item.md5,
|
||||
"preview_url": item.preview_url,
|
||||
"sample_url": item.sample_url ?? item.file_url,
|
||||
"file_url": item.file_url,
|
||||
"file_ext": item.file_url.split('.').pop(),
|
||||
"source": getWorkingImageSource(item.source) ?? item.file_url,
|
||||
}
|
||||
})
|
||||
},
|
||||
"tagSearchTemplate": "https://gelbooru.com/index.php?page=dapi&s=tag&q=index&json=1&orderby=count&limit=10&name_pattern={{query}}%",
|
||||
"tagMapFunc": (response) => {
|
||||
return response.tag.map(item => {
|
||||
return {
|
||||
"name": item.name,
|
||||
"count": item.count
|
||||
}
|
||||
})
|
||||
}
|
||||
},
|
||||
"waifu.im": {
|
||||
"name": "waifu.im",
|
||||
"url": "https://waifu.im",
|
||||
"api": "https://api.waifu.im/images",
|
||||
"description": Translation.tr("Waifus only | Excellent quality, limited quantity"),
|
||||
"mapFunc": (response) => {
|
||||
response = response.items
|
||||
return response.map(item => {
|
||||
return {
|
||||
"id": item.id,
|
||||
"width": item.width,
|
||||
"height": item.height,
|
||||
"aspect_ratio": item.width / item.height,
|
||||
"tags": item.tags.map(tag => {return tag.name}).join(" "),
|
||||
"rating": item.isNsfw ? "e" : "s",
|
||||
"is_nsfw": item.isNsfw,
|
||||
"md5": item.md5,
|
||||
"preview_url": item.sample_url ?? item.url, // preview_url just says access denied (maybe i fucked up and sent too many requests idk)
|
||||
"sample_url": item.url,
|
||||
"file_url": item.url,
|
||||
"file_ext": item.extension,
|
||||
"source": getWorkingImageSource(item.source) ?? item.url,
|
||||
}
|
||||
})
|
||||
},
|
||||
"tagSearchTemplate": "https://api.waifu.im/tags?Name={{query}}",
|
||||
"tagMapFunc": (response) => {
|
||||
return response.items.map(item => {return {"name": item.name}})
|
||||
}
|
||||
},
|
||||
"t.alcy.cc": {
|
||||
"name": "Alcy",
|
||||
"url": "https://t.alcy.cc",
|
||||
"api": "https://t.alcy.cc/",
|
||||
"description": Translation.tr("Large images | God tier quality, no NSFW."),
|
||||
"fixedTags": [
|
||||
{
|
||||
"name": "ycy",
|
||||
"count": "General"
|
||||
},
|
||||
{
|
||||
"name": "moez",
|
||||
"count": "Moe"
|
||||
},
|
||||
{
|
||||
"name": "ysz",
|
||||
"count": "Genshin Impact"
|
||||
},
|
||||
{
|
||||
"name": "fj",
|
||||
"count": "Landscape"
|
||||
},
|
||||
{
|
||||
"name": "bd",
|
||||
"count": "Girl on white background"
|
||||
},
|
||||
{
|
||||
"name": "xhl",
|
||||
"count": "Shiggy"
|
||||
},
|
||||
],
|
||||
"manualParseFunc": (responseText) => {
|
||||
// Alcy just returns image links, each on a new line
|
||||
const lines = responseText.trim().split('\n');
|
||||
return lines.map(line => {
|
||||
return {
|
||||
"id": Qt.md5(line),
|
||||
// Alcy doesn't provide dimensions and images are often of god resolution
|
||||
"width": 1000,
|
||||
"height": 1000,
|
||||
"aspect_ratio": 1,
|
||||
"tags": "[no tags]",
|
||||
"rating": "s",
|
||||
"is_nsfw": false,
|
||||
"md5": Qt.md5(line),
|
||||
"preview_url": line,
|
||||
"sample_url": line,
|
||||
"file_url": line,
|
||||
"file_ext": line.split('.').pop(),
|
||||
"source": "",
|
||||
}
|
||||
});
|
||||
},
|
||||
}
|
||||
}
|
||||
property var currentProvider: Persistent.states.booru.provider
|
||||
|
||||
function getWorkingImageSource(url) {
|
||||
if (url?.includes('pximg.net')) {
|
||||
return `https://www.pixiv.net/en/artworks/${url.substring(url.lastIndexOf('/') + 1).replace(/_p\d+\.(png|jpg|jpeg|gif)$/, '')}`;
|
||||
}
|
||||
return url;
|
||||
}
|
||||
|
||||
function setProvider(provider) {
|
||||
provider = provider.toLowerCase()
|
||||
if (providerList.indexOf(provider) !== -1) {
|
||||
Persistent.states.booru.provider = provider
|
||||
root.addSystemMessage(Translation.tr("Provider set to ") + providers[provider].name
|
||||
+ (provider == "zerochan" ? Translation.tr(". Notes for Zerochan:\n- You must enter a color\n- Set your zerochan username in `sidebar.booru.zerochan.username` config option. You [might be banned for not doing so](https://www.zerochan.net/api#:~:text=The%20request%20may%20still%20be%20completed%20successfully%20without%20this%20custom%20header%2C%20but%20your%20project%20may%20be%20banned%20for%20being%20anonymous.)!") : ""))
|
||||
} else {
|
||||
root.addSystemMessage(Translation.tr("Invalid API provider. Supported: \n- ") + providerList.join("\n- "))
|
||||
}
|
||||
}
|
||||
|
||||
function clearResponses() {
|
||||
responses = []
|
||||
}
|
||||
|
||||
function addSystemMessage(message) {
|
||||
responses = [...responses, root.booruResponseDataComponent.createObject(null, {
|
||||
"provider": "system",
|
||||
"tags": [],
|
||||
"page": -1,
|
||||
"images": [],
|
||||
"message": `${message}`
|
||||
})]
|
||||
}
|
||||
|
||||
function constructRequestUrl(tags, nsfw=true, limit=20, page=1) {
|
||||
var provider = providers[currentProvider]
|
||||
var baseUrl = provider.api
|
||||
var url = baseUrl
|
||||
var tagString = tags.join(" ")
|
||||
if (!nsfw && !(["zerochan", "waifu.im", "t.alcy.cc"].includes(currentProvider))) {
|
||||
if (currentProvider == "gelbooru")
|
||||
tagString += " rating:general";
|
||||
else
|
||||
tagString += " rating:safe";
|
||||
}
|
||||
var params = []
|
||||
// Tags & limit
|
||||
if (currentProvider === "zerochan") {
|
||||
params.push("c=" + tagString) // zerochan doesn't have search in api, so we use color
|
||||
params.push("l=" + limit)
|
||||
params.push("s=" + "fav")
|
||||
params.push("t=" + 1)
|
||||
params.push("p=" + page)
|
||||
}
|
||||
else if (currentProvider === "waifu.im") {
|
||||
var tagsArray = tagString.split(" ");
|
||||
tagsArray.forEach(tag => {
|
||||
params.push("IncludedTags=" + encodeURIComponent(tag.toLowerCase()));
|
||||
});
|
||||
params.push("PageSize=" + Math.min(limit, 30)) // Only admin can do > 30
|
||||
params.push("IsNsfw=" + (nsfw ? "All" : "False")) // null is random
|
||||
}
|
||||
else if (currentProvider === "t.alcy.cc") {
|
||||
url += tagString
|
||||
params.push("json")
|
||||
params.push("quantity=" + limit)
|
||||
}
|
||||
else {
|
||||
params.push("tags=" + encodeURIComponent(tagString))
|
||||
params.push("limit=" + limit)
|
||||
if (currentProvider == "gelbooru") {
|
||||
params.push("pid=" + page)
|
||||
}
|
||||
else {
|
||||
params.push("page=" + page)
|
||||
}
|
||||
}
|
||||
if (baseUrl.indexOf("?") === -1) {
|
||||
url += "?" + params.join("&")
|
||||
} else {
|
||||
url += "&" + params.join("&")
|
||||
}
|
||||
return url
|
||||
}
|
||||
|
||||
function makeRequest(tags, nsfw=false, limit=20, page=1) {
|
||||
var url = constructRequestUrl(tags, nsfw, limit, page)
|
||||
console.log("[Booru] Making request to " + url)
|
||||
|
||||
const newResponse = root.booruResponseDataComponent.createObject(null, {
|
||||
"provider": currentProvider,
|
||||
"tags": tags,
|
||||
"page": page,
|
||||
"images": [],
|
||||
"message": ""
|
||||
})
|
||||
|
||||
var xhr = new XMLHttpRequest()
|
||||
xhr.open("GET", url)
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
|
||||
try {
|
||||
// console.log("[Booru] Raw response: " + xhr.responseText)
|
||||
const provider = providers[currentProvider]
|
||||
let response;
|
||||
if (provider.manualParseFunc) {
|
||||
response = provider.manualParseFunc(xhr.responseText)
|
||||
} else {
|
||||
response = JSON.parse(xhr.responseText)
|
||||
response = provider.mapFunc(response)
|
||||
}
|
||||
// console.log("[Booru] Mapped response: " + JSON.stringify(response))
|
||||
newResponse.images = response
|
||||
newResponse.message = response.length > 0 ? "" : root.failMessage
|
||||
|
||||
} catch (e) {
|
||||
console.log("[Booru] Failed to parse response: " + e)
|
||||
newResponse.message = root.failMessage
|
||||
} finally {
|
||||
root.runningRequests--;
|
||||
root.responses = [...root.responses, newResponse]
|
||||
}
|
||||
}
|
||||
else if (xhr.readyState === XMLHttpRequest.DONE) {
|
||||
console.log("[Booru] Request failed with status: " + xhr.status)
|
||||
newResponse.message = root.failMessage
|
||||
root.runningRequests--;
|
||||
root.responses = [...root.responses, newResponse]
|
||||
}
|
||||
root.responseFinished()
|
||||
}
|
||||
|
||||
try {
|
||||
// Required for danbooru and konachan
|
||||
if (["danbooru", "konachan"].includes(currentProvider)) {
|
||||
xhr.setRequestHeader("User-Agent", defaultUserAgent)
|
||||
}
|
||||
else if (currentProvider == "zerochan") {
|
||||
const userAgent = Config.options?.sidebar?.booru?.zerochan?.username ? `Desktop sidebar booru viewer - username: ${Config.options.sidebar.booru.zerochan.username}` : defaultUserAgent
|
||||
xhr.setRequestHeader("User-Agent", userAgent)
|
||||
}
|
||||
root.runningRequests++;
|
||||
xhr.send()
|
||||
} catch (error) {
|
||||
console.log("Could not set User-Agent:", error)
|
||||
}
|
||||
}
|
||||
|
||||
property var currentTagRequest: null
|
||||
function triggerTagSearch(query) {
|
||||
if (currentTagRequest) {
|
||||
currentTagRequest.abort();
|
||||
}
|
||||
|
||||
var provider = providers[currentProvider]
|
||||
if (provider.fixedTags) {
|
||||
root.tagSuggestion(query, provider.fixedTags)
|
||||
return provider.fixedTags;
|
||||
} else if (!provider.tagSearchTemplate) {
|
||||
return
|
||||
}
|
||||
var url = provider.tagSearchTemplate.replace("{{query}}", encodeURIComponent(query))
|
||||
|
||||
var xhr = new XMLHttpRequest()
|
||||
currentTagRequest = xhr
|
||||
xhr.open("GET", url)
|
||||
xhr.onreadystatechange = function() {
|
||||
if (xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
|
||||
currentTagRequest = null
|
||||
try {
|
||||
// console.log("[Booru] Raw response: " + xhr.responseText)
|
||||
var response = JSON.parse(xhr.responseText)
|
||||
response = provider.tagMapFunc(response)
|
||||
// console.log("[Booru] Mapped response: " + JSON.stringify(response))
|
||||
root.tagSuggestion(query, response)
|
||||
} catch (e) {
|
||||
console.log("[Booru] Failed to parse response: " + e)
|
||||
}
|
||||
}
|
||||
else if (xhr.readyState === XMLHttpRequest.DONE) {
|
||||
console.log("[Booru] Request failed with status: " + xhr.status)
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
// Required for danbooru and konachan
|
||||
if (["danbooru", "konachan"].includes(currentProvider)) {
|
||||
xhr.setRequestHeader("User-Agent", defaultUserAgent)
|
||||
}
|
||||
xhr.send()
|
||||
} catch (error) {
|
||||
console.log("Could not set User-Agent:", error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
13
surfaces/quickshell/ii-base/services/BooruResponseData.qml
Normal file
13
surfaces/quickshell/ii-base/services/BooruResponseData.qml
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import qs.modules.common
|
||||
import QtQuick;
|
||||
|
||||
/**
|
||||
* A booru response.
|
||||
*/
|
||||
QtObject {
|
||||
property string provider
|
||||
property var tags
|
||||
property var page
|
||||
property var images
|
||||
property string message
|
||||
}
|
||||
271
surfaces/quickshell/ii-base/services/Brightness.qml
Normal file
271
surfaces/quickshell/ii-base/services/Brightness.qml
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
// From https://github.com/caelestia-dots/shell with modifications.
|
||||
// License: GPLv3
|
||||
|
||||
import qs.modules.common
|
||||
import qs.modules.common.functions
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Hyprland
|
||||
import QtQuick
|
||||
|
||||
/**
|
||||
* For managing brightness of monitors. Supports both brightnessctl and ddcutil.
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
signal brightnessChanged()
|
||||
|
||||
property var ddcMonitors: []
|
||||
readonly property list<BrightnessMonitor> monitors: Quickshell.screens.map(screen => monitorComp.createObject(root, {
|
||||
screen
|
||||
}))
|
||||
|
||||
function getMonitorForScreen(screen: ShellScreen): var {
|
||||
return monitors.find(m => m.screen === screen);
|
||||
}
|
||||
|
||||
function increaseBrightness(): void {
|
||||
// if gamma is not yet 100, first increase gamma
|
||||
if (Hyprsunset.gamma !== 100) {
|
||||
Hyprsunset.setGamma(Hyprsunset.gamma + 5);
|
||||
return;
|
||||
}
|
||||
|
||||
const focusedName = Hyprland.focusedMonitor.name;
|
||||
const monitor = monitors.find(m => focusedName === m.screen.name);
|
||||
if (monitor)
|
||||
monitor.setBrightness(monitor.brightness + 0.05);
|
||||
}
|
||||
|
||||
function decreaseBrightness(): void {
|
||||
const focusedName = Hyprland.focusedMonitor.name;
|
||||
const monitor = monitors.find(m => focusedName === m.screen.name);
|
||||
if (monitor && monitor.brightness > 0)
|
||||
monitor.setBrightness(monitor.brightness - 0.05);
|
||||
// if brightness is 0, then decrease gamma
|
||||
else {
|
||||
Hyprsunset.setGamma(Hyprsunset.gamma - 5);
|
||||
}
|
||||
}
|
||||
|
||||
reloadableId: "brightness"
|
||||
|
||||
onMonitorsChanged: {
|
||||
ddcMonitors = [];
|
||||
ddcProc.running = true;
|
||||
}
|
||||
|
||||
function initializeMonitor(i: int): void {
|
||||
if (i >= monitors.length)
|
||||
return;
|
||||
monitors[i].initialize();
|
||||
}
|
||||
|
||||
function ddcDetectFinished(): void {
|
||||
initializeMonitor(0);
|
||||
}
|
||||
|
||||
Process {
|
||||
id: ddcProc
|
||||
|
||||
command: ["ddcutil", "detect", "--brief"]
|
||||
stdout: SplitParser {
|
||||
splitMarker: "\n\n"
|
||||
onRead: data => {
|
||||
if (data.startsWith("Display ")) {
|
||||
const lines = data.split("\n").map(l => l.trim());
|
||||
root.ddcMonitors.push({
|
||||
name: lines.find(l => l.startsWith("DRM connector:")).split("-").slice(1).join('-'),
|
||||
busNum: lines.find(l => l.startsWith("I2C bus:")).split("/dev/i2c-")[1]
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
onExited: root.ddcDetectFinished()
|
||||
}
|
||||
|
||||
Process {
|
||||
id: setProc
|
||||
}
|
||||
|
||||
component BrightnessMonitor: QtObject {
|
||||
id: monitor
|
||||
|
||||
required property ShellScreen screen
|
||||
property bool isDdc
|
||||
property string busNum
|
||||
property int rawMaxBrightness: 100
|
||||
property real brightness
|
||||
property real brightnessMultiplier: 1.0
|
||||
property real multipliedBrightness: Math.max(0, Math.min(1, brightness * (Config.options.light.antiFlashbang.enable ? brightnessMultiplier : 1)))
|
||||
property bool ready: false
|
||||
property bool animateChanges: !monitor.isDdc
|
||||
|
||||
onBrightnessChanged: {
|
||||
if (!monitor.ready) return;
|
||||
root.brightnessChanged();
|
||||
}
|
||||
|
||||
Behavior on multipliedBrightness {
|
||||
enabled: monitor.animateChanges
|
||||
NumberAnimation {
|
||||
duration: 200
|
||||
easing.type: Easing.BezierSpline
|
||||
easing.bezierCurve: Appearance.animationCurves.expressiveEffects
|
||||
}
|
||||
}
|
||||
onMultipliedBrightnessChanged: {
|
||||
if (monitor.animationEnabled) syncBrightness();
|
||||
else setTimer.restart();
|
||||
}
|
||||
|
||||
function initialize() {
|
||||
monitor.ready = false;
|
||||
const match = root.ddcMonitors.find(m => m.name === screen.name && !root.monitors.slice(0, root.monitors.indexOf(this)).some(mon => mon.busNum === m.busNum));
|
||||
isDdc = !!match;
|
||||
busNum = match?.busNum ?? "";
|
||||
initProc.command = isDdc ? ["ddcutil", "-b", busNum, "getvcp", "10", "--brief"] : ["sh", "-c", `echo "a b c $(brightnessctl g) $(brightnessctl m)"`];
|
||||
initProc.running = true;
|
||||
}
|
||||
|
||||
readonly property Process initProc: Process {
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
const [, , , current, max] = data.split(" ");
|
||||
monitor.rawMaxBrightness = parseInt(max);
|
||||
monitor.brightness = parseInt(current) / monitor.rawMaxBrightness;
|
||||
monitor.ready = true;
|
||||
}
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
initializeMonitor(root.monitors.indexOf(monitor) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
// We need a delay for DDC monitors because they can be quite slow and might act weird with rapid changes
|
||||
property var setTimer: Timer {
|
||||
id: setTimer
|
||||
interval: monitor.isDdc ? 300 : 0
|
||||
onTriggered: {
|
||||
syncBrightness();
|
||||
}
|
||||
}
|
||||
|
||||
function syncBrightness() {
|
||||
const brightnessValue = Math.max(monitor.multipliedBrightness, 0);
|
||||
if (isDdc) {
|
||||
const rawValueRounded = Math.max(Math.floor(brightnessValue * monitor.rawMaxBrightness), 1);
|
||||
setProc.exec(["ddcutil", "-b", busNum, "setvcp", "10", rawValueRounded]);
|
||||
} else {
|
||||
const valuePercentNumber = Math.floor(brightnessValue * 100);
|
||||
let valuePercent = `${valuePercentNumber}%`;
|
||||
if (valuePercentNumber == 0) valuePercent = "1"; // Prevent fully black
|
||||
setProc.exec(["brightnessctl", "--class", "backlight", "s", valuePercent, "--quiet"])
|
||||
}
|
||||
}
|
||||
|
||||
function setBrightness(value: real): void {
|
||||
value = Math.max(0, Math.min(1, value));
|
||||
monitor.brightness = value;
|
||||
}
|
||||
|
||||
function setBrightnessMultiplier(value: real): void {
|
||||
monitor.brightnessMultiplier = value;
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: monitorComp
|
||||
|
||||
BrightnessMonitor {}
|
||||
}
|
||||
|
||||
// Anti-flashbang
|
||||
property int workspaceAnimationDelay: 500
|
||||
property int contentSwitchDelay: 30
|
||||
property string screenshotDir: "/tmp/quickshell/brightness/antiflashbang"
|
||||
function brightnessMultiplierForLightness(x: real): real {
|
||||
// I hand picked some values and fitted an exponential curve for this
|
||||
// 6.600135 + 216.360356 * e^(-0.0811129189x)
|
||||
// Division by 100 is to normalize to [0, 1]
|
||||
return (6.600135 + 216.360356 * Math.pow(Math.E, -0.0811129189 * x)) / 100.0;
|
||||
}
|
||||
Variants {
|
||||
model: Quickshell.screens
|
||||
Scope {
|
||||
id: screenScope
|
||||
required property var modelData
|
||||
property string screenName: modelData.name
|
||||
property string screenshotPath: `${root.screenshotDir}/screenshot-${screenName}.png`
|
||||
Connections {
|
||||
enabled: Config.options.light.antiFlashbang.enable && Appearance.m3colors.darkmode
|
||||
target: Hyprland
|
||||
function onRawEvent(event) {
|
||||
if (["activewindowv2", "windowtitlev2"].includes(event.name)) {
|
||||
screenshotTimer.interval = root.contentSwitchDelay;
|
||||
screenshotTimer.restart();
|
||||
} else if (["workspacev2"].includes(event.name)) {
|
||||
screenshotTimer.interval = root.workspaceAnimationDelay;
|
||||
screenshotTimer.restart();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: screenshotTimer
|
||||
interval: 700 // This is what I have for a Hyprland ws anim
|
||||
onTriggered: {
|
||||
screenshotProc.running = false;
|
||||
screenshotProc.running = true;
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: screenshotProc
|
||||
command: ["bash", "-c",
|
||||
`mkdir -p '${StringUtils.shellSingleQuoteEscape(root.screenshotDir)}'`
|
||||
+ ` && grim -o '${StringUtils.shellSingleQuoteEscape(screenScope.screenName)}' -`
|
||||
+ ` | magick png:- -colorspace Gray -format "%[fx:mean*100]" info:`
|
||||
]
|
||||
stdout: StdioCollector {
|
||||
id: lightnessCollector
|
||||
onStreamFinished: {
|
||||
Quickshell.execDetached(["rm", screenScope.screenshotPath]); // Cleanup
|
||||
const lightness = lightnessCollector.text
|
||||
const newMultiplier = root.brightnessMultiplierForLightness(parseFloat(lightness))
|
||||
Brightness.getMonitorForScreen(screenScope.modelData).setBrightnessMultiplier(newMultiplier)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// External trigger points
|
||||
|
||||
IpcHandler {
|
||||
target: "brightness"
|
||||
|
||||
function increment() {
|
||||
onPressed: root.increaseBrightness()
|
||||
}
|
||||
|
||||
function decrement() {
|
||||
onPressed: root.decreaseBrightness()
|
||||
}
|
||||
}
|
||||
|
||||
GlobalShortcut {
|
||||
name: "brightnessIncrease"
|
||||
description: "Increase brightness"
|
||||
onPressed: root.increaseBrightness()
|
||||
}
|
||||
|
||||
GlobalShortcut {
|
||||
name: "brightnessDecrease"
|
||||
description: "Decrease brightness"
|
||||
onPressed: root.decreaseBrightness()
|
||||
}
|
||||
}
|
||||
152
surfaces/quickshell/ii-base/services/ClaudeUsage.qml
Normal file
152
surfaces/quickshell/ii-base/services/ClaudeUsage.qml
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
/**
|
||||
* Claude subscription usage (Pro / Max).
|
||||
*
|
||||
* Reads the OAuth access token from ~/.claude/.credentials.json (kept fresh by
|
||||
* Claude Code) and polls https://api.anthropic.com/api/oauth/usage — the same
|
||||
* data Claude Code's /usage command shows. Only active when
|
||||
* Config.options.bar.claudeUsage.enable is true.
|
||||
*
|
||||
* Requires `jq` and `curl` (already used elsewhere in the shell).
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property bool enabled: Config.options.bar.claudeUsage.enable
|
||||
readonly property int fetchInterval: Config.options.bar.claudeUsage.fetchInterval * 60 * 1000
|
||||
|
||||
property bool available: false
|
||||
property string lastError: ""
|
||||
property string subscriptionType: "" // "pro" | "max" | ...
|
||||
|
||||
// Utilization percentages (0-100); -1 means "not reported by the API"
|
||||
property real fiveHour: 0
|
||||
property real sevenDay: 0
|
||||
property real sevenDayOpus: -1
|
||||
property real sevenDaySonnet: -1
|
||||
|
||||
// Reset timestamps (epoch ms; 0 if unknown)
|
||||
property double fiveHourReset: 0
|
||||
property double sevenDayReset: 0
|
||||
|
||||
// Pay-as-you-go extra credits
|
||||
property bool extraEnabled: false
|
||||
property real extraUsedCredits: 0
|
||||
property real extraMonthlyLimit: 0
|
||||
property string extraCurrency: ""
|
||||
|
||||
function _pct(v) {
|
||||
return (v === null || v === undefined) ? -1 : v;
|
||||
}
|
||||
|
||||
function _parseIso(s) {
|
||||
if (!s)
|
||||
return 0;
|
||||
const t = Date.parse(s);
|
||||
return isNaN(t) ? 0 : t;
|
||||
}
|
||||
|
||||
// Human "2h 5m" until the given epoch-ms. References DateTime.time so it
|
||||
// recomputes on the clock tick.
|
||||
function timeUntil(epochMs) {
|
||||
DateTime.time; // reactivity dependency
|
||||
if (!epochMs)
|
||||
return "—";
|
||||
let diff = Math.floor((epochMs - Date.now()) / 1000);
|
||||
if (diff <= 0)
|
||||
return Translation.tr("now");
|
||||
const d = Math.floor(diff / 86400);
|
||||
diff %= 86400;
|
||||
const h = Math.floor(diff / 3600);
|
||||
diff %= 3600;
|
||||
const m = Math.floor(diff / 60);
|
||||
let out = "";
|
||||
if (d > 0)
|
||||
out += `${d}d `;
|
||||
if (h > 0)
|
||||
out += `${h}h `;
|
||||
out += `${m}m`;
|
||||
return out.trim();
|
||||
}
|
||||
|
||||
function refine(data) {
|
||||
root.subscriptionType = data.subscriptionType ?? "";
|
||||
root.fiveHour = data.five_hour?.utilization ?? 0;
|
||||
root.sevenDay = data.seven_day?.utilization ?? 0;
|
||||
root.sevenDayOpus = root._pct(data.seven_day_opus?.utilization);
|
||||
root.sevenDaySonnet = root._pct(data.seven_day_sonnet?.utilization);
|
||||
root.fiveHourReset = root._parseIso(data.five_hour?.resets_at);
|
||||
root.sevenDayReset = root._parseIso(data.seven_day?.resets_at);
|
||||
const ex = data.extra_usage;
|
||||
root.extraEnabled = ex?.is_enabled ?? false;
|
||||
root.extraUsedCredits = ex?.used_credits ?? 0;
|
||||
root.extraMonthlyLimit = ex?.monthly_limit ?? 0;
|
||||
root.extraCurrency = ex?.currency ?? "";
|
||||
root.available = true;
|
||||
root.lastError = "";
|
||||
}
|
||||
|
||||
function getData() {
|
||||
if (!root.enabled)
|
||||
return;
|
||||
fetcher.running = false;
|
||||
fetcher.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: fetcher
|
||||
command: ["bash", "-c", "creds=\"$HOME/.claude/.credentials.json\"; " + "tok=$(jq -r '.claudeAiOauth.accessToken' \"$creds\" 2>/dev/null); " + "sub=$(jq -r '.claudeAiOauth.subscriptionType' \"$creds\" 2>/dev/null); " + "if [ -z \"$tok\" ] || [ \"$tok\" = null ]; then echo '{\"error\":\"no Claude token\"}'; exit 0; fi; " + "curl -s --max-time 10 " + "-H \"Authorization: Bearer $tok\" " + "-H \"anthropic-beta: oauth-2025-04-20\" " + "-H \"anthropic-version: 2023-06-01\" " + "https://api.anthropic.com/api/oauth/usage " + "| jq -c --arg sub \"$sub\" '. + {subscriptionType:$sub}'"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
if (text.trim().length === 0) {
|
||||
root.available = false;
|
||||
root.lastError = "empty response";
|
||||
retryTimer.restart();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const d = JSON.parse(text);
|
||||
if (d.error) {
|
||||
root.available = false;
|
||||
root.lastError = String(d.error);
|
||||
retryTimer.restart();
|
||||
return;
|
||||
}
|
||||
root.refine(d);
|
||||
} catch (e) {
|
||||
root.available = false;
|
||||
root.lastError = e.message;
|
||||
retryTimer.restart();
|
||||
console.error(`[ClaudeUsage] ${e.message}: ${text}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
running: root.enabled
|
||||
repeat: true
|
||||
interval: root.fetchInterval
|
||||
triggeredOnStart: true
|
||||
onTriggered: root.getData()
|
||||
}
|
||||
|
||||
// Quick retry while we don't have data yet (cold start / token mid-refresh /
|
||||
// transient network), so a failed first fetch doesn't leave "Unavailable"
|
||||
// showing until the next full interval.
|
||||
Timer {
|
||||
id: retryTimer
|
||||
interval: 15000
|
||||
repeat: false
|
||||
onTriggered: if (root.enabled && !root.available) root.getData()
|
||||
}
|
||||
}
|
||||
157
surfaces/quickshell/ii-base/services/Cliphist.qml
Normal file
157
surfaces/quickshell/ii-base/services/Cliphist.qml
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs.modules.common
|
||||
import qs.modules.common.functions
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
// property string cliphistBinary: FileUtils.trimFileProtocol(`${Directories.home}/.cargo/bin/stash`)
|
||||
property string cliphistBinary: "cliphist"
|
||||
property real pasteDelay: 0.05
|
||||
property string pressPasteCommand: "ydotool key -d 1 29:1 47:1 47:0 29:0"
|
||||
property bool sloppySearch: Config.options?.search.sloppy ?? false
|
||||
property real scoreThreshold: 0.2
|
||||
property list<string> entries: []
|
||||
readonly property var preparedEntries: entries.map(a => ({
|
||||
name: Fuzzy.prepare(`${a.replace(/^\s*\S+\s+/, "")}`),
|
||||
entry: a
|
||||
}))
|
||||
function fuzzyQuery(search: string): var {
|
||||
if (search.trim() === "") {
|
||||
return entries;
|
||||
}
|
||||
if (root.sloppySearch) {
|
||||
const results = entries.slice(0, 100).map(str => ({
|
||||
entry: str,
|
||||
score: Levendist.computeTextMatchScore(str.toLowerCase(), search.toLowerCase())
|
||||
})).filter(item => item.score > root.scoreThreshold)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
return results
|
||||
.map(item => item.entry)
|
||||
}
|
||||
|
||||
return Fuzzy.go(search, preparedEntries, {
|
||||
all: true,
|
||||
key: "name"
|
||||
}).map(r => {
|
||||
return r.obj.entry
|
||||
});
|
||||
}
|
||||
|
||||
function entryIsImage(entry) {
|
||||
return !!(/^\d+\t\[\[.*binary data.*\d+x\d+.*\]\]$/.test(entry))
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
readProc.buffer = []
|
||||
readProc.running = true
|
||||
}
|
||||
|
||||
function copy(entry) {
|
||||
if (root.cliphistBinary.includes("cliphist")) // Classic cliphist
|
||||
Quickshell.execDetached(["bash", "-c", `printf '${StringUtils.shellSingleQuoteEscape(entry)}' | ${root.cliphistBinary} decode | wl-copy`]);
|
||||
else { // Stash
|
||||
const entryNumber = entry.split("\t")[0];
|
||||
Quickshell.execDetached(["bash", "-c", `${root.cliphistBinary} decode ${entryNumber} | wl-copy`]);
|
||||
}
|
||||
}
|
||||
|
||||
function paste(entry) {
|
||||
if (root.cliphistBinary.includes("cliphist")) // Classic cliphist
|
||||
Quickshell.execDetached(["bash", "-c", `printf '${StringUtils.shellSingleQuoteEscape(entry)}' | ${root.cliphistBinary} decode | wl-copy && wl-paste`]);
|
||||
else { // Stash
|
||||
const entryNumber = entry.split("\t")[0];
|
||||
Quickshell.execDetached(["bash", "-c", `${root.cliphistBinary} decode ${entryNumber} | wl-copy; ${root.pressPasteCommand}`]);
|
||||
}
|
||||
}
|
||||
|
||||
function superpaste(count, isImage = false) {
|
||||
// Find entries
|
||||
const targetEntries = entries.filter(entry => {
|
||||
if (!isImage) return true;
|
||||
return entryIsImage(entry);
|
||||
}).slice(0, count)
|
||||
const pasteCommands = [...targetEntries].reverse().map(entry => `printf '${StringUtils.shellSingleQuoteEscape(entry)}' | ${root.cliphistBinary} decode | wl-copy && sleep ${root.pasteDelay} && ${root.pressPasteCommand}`)
|
||||
// Act
|
||||
Quickshell.execDetached(["bash", "-c", pasteCommands.join(` && sleep ${root.pasteDelay} && `)]);
|
||||
}
|
||||
|
||||
Process {
|
||||
id: deleteProc
|
||||
property string entry: ""
|
||||
command: ["bash", "-c", `echo '${StringUtils.shellSingleQuoteEscape(deleteProc.entry)}' | ${root.cliphistBinary} delete`]
|
||||
function deleteEntry(entry) {
|
||||
deleteProc.entry = entry;
|
||||
deleteProc.running = true;
|
||||
deleteProc.entry = "";
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function deleteEntry(entry) {
|
||||
deleteProc.deleteEntry(entry);
|
||||
}
|
||||
|
||||
Process {
|
||||
id: wipeProc
|
||||
command: [root.cliphistBinary, "wipe"]
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
function wipe() {
|
||||
wipeProc.running = true;
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Quickshell
|
||||
function onClipboardTextChanged() {
|
||||
delayedUpdateTimer.restart()
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: delayedUpdateTimer
|
||||
interval: Config.options.hacks.arbitraryRaceConditionDelay
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
root.refresh()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: readProc
|
||||
property list<string> buffer: []
|
||||
|
||||
command: [root.cliphistBinary, "list"]
|
||||
|
||||
stdout: SplitParser {
|
||||
onRead: (line) => {
|
||||
readProc.buffer.push(line)
|
||||
}
|
||||
}
|
||||
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode === 0) {
|
||||
root.entries = readProc.buffer
|
||||
} else {
|
||||
console.error("[Cliphist] Failed to refresh with code", exitCode, "and status", exitStatus)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "cliphistService"
|
||||
|
||||
function update(): void {
|
||||
root.refresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
48
surfaces/quickshell/ii-base/services/ConflictKiller.qml
Normal file
48
surfaces/quickshell/ii-base/services/ConflictKiller.qml
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
pragma Singleton
|
||||
|
||||
import qs.modules.common
|
||||
import qs.modules.common.functions
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property string killDialogQmlPath: FileUtils.trimFileProtocol(Quickshell.shellPath("killDialog.qml"))
|
||||
|
||||
function load() {
|
||||
// dummy to force init
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Config
|
||||
function onReadyChanged() {
|
||||
if (Config.ready) checkConflictsProc.running = true
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: checkConflictsProc
|
||||
command: ["bash", "-c", `echo "$(pidof kded6);$(pidof mako dunst)"`]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const output = this.text;
|
||||
const conflictingTrays = output.split(";")[0].trim().length > 0;
|
||||
const conflictingNotifications = output.split(";")[1].trim().length > 0;
|
||||
var openDialog = false;
|
||||
if (conflictingTrays) {
|
||||
if (!Config.options.conflictKiller.autoKillTrays) openDialog = true;
|
||||
else Quickshell.execDetached(["killall", "kded6"])
|
||||
}
|
||||
if (conflictingNotifications) {
|
||||
if (!Config.options.conflictKiller.autoKillNotificationDaemons) openDialog = true;
|
||||
else Quickshell.execDetached(["killall", "mako", "dunst"])
|
||||
}
|
||||
if (openDialog) {
|
||||
Quickshell.execDetached(["qs", "-p", root.killDialogQmlPath])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
64
surfaces/quickshell/ii-base/services/DateTime.qml
Normal file
64
surfaces/quickshell/ii-base/services/DateTime.qml
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
import qs
|
||||
import qs.modules.common
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
/**
|
||||
* A nice wrapper for date and time strings.
|
||||
*/
|
||||
Singleton {
|
||||
property var clock: SystemClock {
|
||||
id: clock
|
||||
precision: {
|
||||
if (Config.options.time.secondPrecision || GlobalStates.screenLocked)
|
||||
return SystemClock.Seconds;
|
||||
return SystemClock.Minutes;
|
||||
}
|
||||
}
|
||||
property string time: Qt.locale().toString(clock.date, Config.options?.time.format ?? "hh:mm")
|
||||
property string shortDate: Qt.locale().toString(clock.date, Config.options?.time.shortDateFormat ?? "dd/MM")
|
||||
property string date: Qt.locale().toString(clock.date, Config.options?.time.dateWithYearFormat ?? "dd/MM/yyyy")
|
||||
property string longDate: Qt.locale().toString(clock.date, Config.options?.time.dateFormat ?? "dddd, dd/MM")
|
||||
property string collapsedCalendarFormat: Qt.locale().toString(clock.date, "dddd, MMMM dd")
|
||||
property string uptime: "0h, 0m"
|
||||
|
||||
Timer {
|
||||
// Uptime advances one minute per minute; reload /proc/uptime once a
|
||||
// minute, not 100x/second. (Was interval: 10 — a 10ms busy-reload of a
|
||||
// disk file, an always-on drain independent of the SystemClock above.)
|
||||
interval: 60000
|
||||
running: true
|
||||
repeat: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: {
|
||||
fileUptime.reload();
|
||||
const textUptime = fileUptime.text();
|
||||
const uptimeSeconds = Number(textUptime.split(" ")[0] ?? 0);
|
||||
|
||||
// Convert seconds to days, hours, and minutes
|
||||
const days = Math.floor(uptimeSeconds / 86400);
|
||||
const hours = Math.floor((uptimeSeconds % 86400) / 3600);
|
||||
const minutes = Math.floor((uptimeSeconds % 3600) / 60);
|
||||
|
||||
// Build the formatted uptime string
|
||||
let formatted = "";
|
||||
if (days > 0)
|
||||
formatted += `${days}d`;
|
||||
if (hours > 0)
|
||||
formatted += `${formatted ? ", " : ""}${hours}h`;
|
||||
if (minutes > 0 || !formatted)
|
||||
formatted += `${formatted ? ", " : ""}${minutes}m`;
|
||||
uptime = formatted;
|
||||
interval = Config.options?.resources?.updateInterval ?? 3000;
|
||||
}
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: fileUptime
|
||||
|
||||
path: "/proc/uptime"
|
||||
}
|
||||
}
|
||||
61
surfaces/quickshell/ii-base/services/EasyEffects.qml
Normal file
61
surfaces/quickshell/ii-base/services/EasyEffects.qml
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
import qs.modules.common
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Pipewire
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
/**
|
||||
* Handles EasyEffects active state and presets.
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property bool available: false
|
||||
property bool active: false
|
||||
|
||||
function fetchAvailability() {
|
||||
fetchAvailabilityProc.running = true
|
||||
}
|
||||
|
||||
function fetchActiveState() {
|
||||
fetchActiveStateProc.running = true
|
||||
}
|
||||
|
||||
function disable() {
|
||||
root.active = false
|
||||
Quickshell.execDetached(["bash", "-c", "pkill easyeffects || flatpak pkill com.github.wwmm.easyeffects"])
|
||||
}
|
||||
|
||||
function enable() {
|
||||
root.active = true
|
||||
Quickshell.execDetached(["bash", "-c", "easyeffects --hide-window --service-mode || flatpak run com.github.wwmm.easyeffects --hide-window --service-mode"])
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (root.active) {
|
||||
root.disable()
|
||||
} else {
|
||||
root.enable()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: fetchAvailabilityProc
|
||||
running: true
|
||||
command: ["bash", "-c", "command -v easyeffects || flatpak info com.github.wwmm.easyeffects > /dev/null 2>&1"]
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.available = exitCode === 0
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: fetchActiveStateProc
|
||||
running: true
|
||||
command: ["bash", "-c", "pidof easyeffects || flatpak ps | grep com.github.wwmm.easyeffects > /dev/null 2>&1"]
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.active = exitCode === 0
|
||||
}
|
||||
}
|
||||
}
|
||||
64
surfaces/quickshell/ii-base/services/Emojis.qml
Normal file
64
surfaces/quickshell/ii-base/services/Emojis.qml
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs.modules.common
|
||||
import qs.modules.common.functions
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
/**
|
||||
* Emojis.
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
property string emojiScriptPath: `${Directories.config}/hypr/hyprland/scripts/fuzzel-emoji.sh`
|
||||
property string lineBeforeData: "### DATA ###"
|
||||
property list<var> list
|
||||
readonly property var preparedEntries: list.map(a => ({
|
||||
name: Fuzzy.prepare(`${a}`),
|
||||
entry: a
|
||||
}))
|
||||
function fuzzyQuery(search: string): var {
|
||||
if (root.sloppySearch) {
|
||||
const results = entries.slice(0, 100).map(str => ({
|
||||
entry: str,
|
||||
score: Levendist.computeTextMatchScore(str.toLowerCase(), search.toLowerCase())
|
||||
})).filter(item => item.score > root.scoreThreshold)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
return results
|
||||
.map(item => item.entry)
|
||||
}
|
||||
|
||||
return Fuzzy.go(search, preparedEntries, {
|
||||
all: true,
|
||||
key: "name"
|
||||
}).map(r => {
|
||||
return r.obj.entry
|
||||
});
|
||||
}
|
||||
|
||||
function load() {
|
||||
emojiFileView.reload()
|
||||
}
|
||||
|
||||
function updateEmojis(fileContent) {
|
||||
const lines = fileContent.split("\n")
|
||||
const dataIndex = lines.indexOf(root.lineBeforeData)
|
||||
if (dataIndex === -1) {
|
||||
console.warn("No data section found in emoji script file.")
|
||||
return
|
||||
}
|
||||
const emojis = lines.slice(dataIndex + 1).filter(line => line.trim() !== "")
|
||||
root.list = emojis.map(line => line.trim())
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: emojiFileView
|
||||
path: Qt.resolvedUrl(root.emojiScriptPath)
|
||||
onLoadedChanged: {
|
||||
const fileContent = emojiFileView.text()
|
||||
root.updateEmojis(fileContent)
|
||||
}
|
||||
}
|
||||
}
|
||||
158
surfaces/quickshell/ii-base/services/FileSearch.qml
Normal file
158
surfaces/quickshell/ii-base/services/FileSearch.qml
Normal file
|
|
@ -0,0 +1,158 @@
|
|||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.modules.common
|
||||
import qs.modules.common.functions
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property var results: []
|
||||
property string pendingQuery: ""
|
||||
property string activeQuery: ""
|
||||
property int searchId: 0
|
||||
property bool running: false
|
||||
|
||||
function normalizePath(path) {
|
||||
if (!path) return "";
|
||||
let normalized = FileUtils.trimFileProtocol(String(path)).trim();
|
||||
if (normalized.startsWith("~/")) {
|
||||
normalized = FileUtils.trimFileProtocol(Directories.home) + normalized.slice(1);
|
||||
}
|
||||
if (normalized.endsWith("/")) {
|
||||
normalized = normalized.slice(0, -1);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function isExcluded(path) {
|
||||
if (Config.options.search.fileSearch.excludeHiddenDirs) {
|
||||
// Match any hidden dir segment: "/.name/" or ending "/.name"
|
||||
if (path.match(/\/\.[^/]+(\/|$)/)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
const exclude = Config.options.search.fileSearch.exclude;
|
||||
if (!exclude || exclude.length === 0) return false;
|
||||
for (let i = 0; i < exclude.length; i++) {
|
||||
const needle = String(exclude[i]).trim();
|
||||
if (needle.length === 0) continue;
|
||||
if (path.includes(`/${needle}/`) || path.endsWith(`/${needle}`)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isAllowedByPath(path) {
|
||||
const paths = Config.options.search.fileSearch.paths;
|
||||
if (!paths || paths.length === 0) return true;
|
||||
for (let i = 0; i < paths.length; i++) {
|
||||
const base = normalizePath(paths[i]);
|
||||
if (!base) continue;
|
||||
if (path === base || path.startsWith(base + "/")) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function shouldSearch(query) {
|
||||
if (!Config.options.search.fileSearch.enable) return false;
|
||||
if (!query || query.trim().length < 2) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function reset() {
|
||||
results = [];
|
||||
activeQuery = "";
|
||||
if (plocateProc.running) {
|
||||
plocateProc.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
function search(query) {
|
||||
pendingQuery = String(query || "").trim();
|
||||
if (!shouldSearch(pendingQuery)) {
|
||||
reset();
|
||||
return;
|
||||
}
|
||||
debounceTimer.restart();
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: debounceTimer
|
||||
interval: 200
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
root.runSearch(pendingQuery);
|
||||
}
|
||||
}
|
||||
|
||||
function runSearch(query) {
|
||||
const trimmed = String(query || "").trim();
|
||||
if (!shouldSearch(trimmed)) { reset(); return; }
|
||||
if (plocateProc.running) plocateProc.running = false;
|
||||
|
||||
searchId += 1;
|
||||
activeQuery = trimmed;
|
||||
results = [];
|
||||
|
||||
const maxResults = Config.options.search.fileSearch.maxResults;
|
||||
|
||||
// Tag each line with d: or f: prefix
|
||||
const command = [
|
||||
"bash", "-c",
|
||||
`plocate -i --basename --limit ${maxResults} '${trimmed}' | while IFS= read -r p; do [ -d "$p" ] && printf 'd:%s\n' "$p" || printf 'f:%s\n' "$p"; done`
|
||||
];
|
||||
|
||||
plocateProc.runId = searchId;
|
||||
plocateProc.accepted = 0;
|
||||
plocateProc.command = command;
|
||||
plocateProc.running = true;
|
||||
root.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: plocateProc
|
||||
property int runId: 0
|
||||
property int accepted: 0
|
||||
|
||||
stdout: SplitParser {
|
||||
onRead: line => {
|
||||
if (plocateProc.runId !== root.searchId) return;
|
||||
const raw = String(line || "").trim();
|
||||
if (!raw || raw.length < 3) return;
|
||||
|
||||
const isDir = raw.startsWith("d:");
|
||||
const rawPath = raw.slice(2);
|
||||
|
||||
const normalized = root.normalizePath(rawPath);
|
||||
if (!normalized) return;
|
||||
if (!root.isAllowedByPath(normalized)) return;
|
||||
if (root.isExcluded(normalized)) return;
|
||||
if (plocateProc.accepted >= Config.options.search.fileSearch.maxResults) {
|
||||
plocateProc.running = false;
|
||||
return;
|
||||
}
|
||||
|
||||
plocateProc.accepted += 1;
|
||||
root.results = root.results.concat([{ path: normalized, isDir: isDir }]);
|
||||
}
|
||||
}
|
||||
|
||||
stderr: SplitParser {
|
||||
onRead: line => {
|
||||
if (plocateProc.runId !== root.searchId) return;
|
||||
}
|
||||
}
|
||||
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (plocateProc.runId !== root.searchId) return;
|
||||
root.running = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
43
surfaces/quickshell/ii-base/services/FirstRunExperience.qml
Normal file
43
surfaces/quickshell/ii-base/services/FirstRunExperience.qml
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
pragma Singleton
|
||||
|
||||
import qs.modules.common
|
||||
import qs.modules.common.functions
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
property string firstRunFilePath: `${Directories.state}/user/first_run.txt`
|
||||
property string firstRunFileContent: "This file is just here to confirm you've been greeted :>"
|
||||
property string firstRunNotifSummary: "Welcome!"
|
||||
property string firstRunNotifBody: "Hit Super+/ for a list of keybinds"
|
||||
property string defaultWallpaperPath: FileUtils.trimFileProtocol(`${Directories.assetsPath}/images/default_wallpaper.png`)
|
||||
property string welcomeQmlPath: FileUtils.trimFileProtocol(Quickshell.shellPath("welcome.qml"))
|
||||
|
||||
function load() {
|
||||
firstRunFileView.reload()
|
||||
}
|
||||
|
||||
function enableNextTime() {
|
||||
Quickshell.execDetached(["rm", "-f", root.firstRunFilePath])
|
||||
}
|
||||
function disableNextTime() {
|
||||
Quickshell.execDetached(["bash", "-c", `echo '${root.firstRunFileContent}' > '${root.firstRunFilePath}'`])
|
||||
}
|
||||
|
||||
function handleFirstRun() {
|
||||
Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, root.defaultWallpaperPath])
|
||||
Quickshell.execDetached(["bash", "-c", `qs -p '${root.welcomeQmlPath}'`])
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: firstRunFileView
|
||||
path: Qt.resolvedUrl(firstRunFilePath)
|
||||
onLoadFailed: (error) => {
|
||||
if (error == FileViewError.FileNotFound) {
|
||||
firstRunFileView.setText(root.firstRunFileContent)
|
||||
root.handleFirstRun()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
72
surfaces/quickshell/ii-base/services/GlobalFocusGrab.qml
Normal file
72
surfaces/quickshell/ii-base/services/GlobalFocusGrab.qml
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
|
||||
/**
|
||||
* Manages a HyprlandFocusGrab that's to be shared by all windows.
|
||||
* "Persistent" is for windows that should always be included but not closed on dismiss, like bar and onscreen keyboard.
|
||||
* "Dismissable" is for stuff like sidebars
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
signal dismissed()
|
||||
|
||||
property list<var> persistent: []
|
||||
property list<var> dismissable: []
|
||||
|
||||
function dismiss() {
|
||||
root.dismissable = [];
|
||||
root.dismissed();
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
console.log("[GlobalFocusGrab] Initialized");
|
||||
}
|
||||
|
||||
function addPersistent(window) {
|
||||
if (root.persistent.indexOf(window) === -1) {
|
||||
root.persistent.push(window);
|
||||
}
|
||||
}
|
||||
|
||||
function removePersistent(window) {
|
||||
var index = root.persistent.indexOf(window);
|
||||
if (index !== -1) {
|
||||
root.persistent.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function addDismissable(window) {
|
||||
if (root.dismissable.indexOf(window) === -1) {
|
||||
root.dismissable.push(window);
|
||||
}
|
||||
}
|
||||
|
||||
function removeDismissable(window) {
|
||||
var index = root.dismissable.indexOf(window);
|
||||
if (index !== -1) {
|
||||
root.dismissable.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
function hasActive(element) {
|
||||
return element?.activeFocus || Array.from(
|
||||
element?.children
|
||||
).some(
|
||||
(child) => hasActive(child)
|
||||
);
|
||||
}
|
||||
|
||||
HyprlandFocusGrab {
|
||||
id: grab
|
||||
windows: root.dismissable.every(w => !w?.focusable) || root.dismissable.some(w => hasActive(w?.contentItem)) ? [...root.dismissable, ...root.persistent] : [...root.dismissable]
|
||||
active: root.dismissable.length > 0
|
||||
onCleared: () => {
|
||||
root.dismiss();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
115
surfaces/quickshell/ii-base/services/GoogleCloud.qml
Normal file
115
surfaces/quickshell/ii-base/services/GoogleCloud.qml
Normal file
|
|
@ -0,0 +1,115 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import qs.modules.common.utils
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property var keyContent: ({})
|
||||
property string keyProjectId: keyContent?.project_id
|
||||
property bool keyError: false
|
||||
property bool keyReady: false
|
||||
property string token: ""
|
||||
property date tokenExpiry
|
||||
property bool tokenError: false
|
||||
property bool tokenReady: false
|
||||
readonly property string projectId: keyProjectId
|
||||
|
||||
readonly property bool loaded: keyReady && tokenReady
|
||||
|
||||
readonly property string tokenForKeyScriptPath: Quickshell.shellPath("services/gCloud/token-from-key-venv.sh")
|
||||
|
||||
function load() {
|
||||
// Init load will be handled by Component.onCompleted
|
||||
if (!tokenReady) return;
|
||||
// We just reload if key expired
|
||||
if (new Date() >= root.tokenExpiry) {
|
||||
root.tokenReady = false;
|
||||
root.keyReady = false;
|
||||
loadKeyIfPossible();
|
||||
}
|
||||
}
|
||||
|
||||
function unready() {
|
||||
root.keyReady = false;
|
||||
root.tokenReady = false;
|
||||
root.keyError = false;
|
||||
root.tokenError = false;
|
||||
}
|
||||
|
||||
function setKeyJson(str: string): bool {
|
||||
try {
|
||||
var keyData = JSON.parse(str)
|
||||
root.unready();
|
||||
KeyringStorage.setNestedField(["googleCloud", "serviceAccountKey"], keyData);
|
||||
return true;
|
||||
} catch(e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getToken() {
|
||||
if (root.keyError) {
|
||||
root.tokenError = true;
|
||||
root.tokenReady = true;
|
||||
return;
|
||||
}
|
||||
tokenProc.runSequence([(() => { // prep token fetcher
|
||||
tokenProc.environment.SERVICE_KEY_CONTENT = JSON.stringify(root.keyContent);
|
||||
tokenProc.command = [ //
|
||||
"bash", "-c" //
|
||||
, `${tokenForKeyScriptPath} "$SERVICE_KEY_CONTENT"`];
|
||||
}), //
|
||||
[], // run token fetcher
|
||||
((out) => {
|
||||
try {
|
||||
const data = JSON.parse(out)
|
||||
root.token = data.token
|
||||
// Js wants millis instead of seconds
|
||||
root.tokenExpiry = new Date(data.expiry * 1000)
|
||||
root.tokenError = false;
|
||||
} catch(e) {
|
||||
root.tokenError = true;
|
||||
print("[GoogleCloud] Failed to parse token response: " + e + "\n" + out)
|
||||
}
|
||||
root.tokenReady = true;
|
||||
}
|
||||
)]);
|
||||
}
|
||||
|
||||
function loadKeyIfPossible() {
|
||||
if (KeyringStorage.loaded) {
|
||||
root.keyContent = KeyringStorage.keyringData?.googleCloud?.serviceAccountKey;
|
||||
if (!root.keyContent?.project_id) {
|
||||
root.keyError = true;
|
||||
} else {
|
||||
root.keyError = false;
|
||||
root.keyProjectId = root.keyContent.project_id;
|
||||
}
|
||||
root.keyReady = true;
|
||||
root.getToken();
|
||||
} else {
|
||||
KeyringStorage.fetchKeyringData();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
loadKeyIfPossible();
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: KeyringStorage
|
||||
function onLoadedChanged() {
|
||||
root.loadKeyIfPossible();
|
||||
}
|
||||
function onDataChanged() {
|
||||
root.loadKeyIfPossible();
|
||||
}
|
||||
}
|
||||
|
||||
MultiTurnProcess {
|
||||
id: tokenProc
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,57 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
|
||||
import qs.modules.common.models.hyprland
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property string shaderPath: Quickshell.shellPath("services/hyprlandAntiFlashbangShader/anti-flashbang.glsl")
|
||||
readonly property string weakShaderPath: Quickshell.shellPath("services/hyprlandAntiFlashbangShader/anti-flashbang-weak.glsl")
|
||||
property bool enabled: confOpt.value == shaderPath || weak
|
||||
property bool weak: confOpt.value == weakShaderPath
|
||||
|
||||
function enable() {
|
||||
HyprlandConfig.setMany({
|
||||
"decoration:screen_shader": root.shaderPath,
|
||||
"debug:damage_tracking": 1, // Turn off dmg tracking to prevent weird flashes. 1 = monitor only
|
||||
});
|
||||
}
|
||||
|
||||
function enableWeak() {
|
||||
HyprlandConfig.setMany({
|
||||
"decoration:screen_shader": root.weakShaderPath,
|
||||
"debug:damage_tracking": 1,
|
||||
});
|
||||
}
|
||||
|
||||
function disable() {
|
||||
HyprlandConfig.resetMany([
|
||||
"decoration:screen_shader",
|
||||
"debug:damage_tracking"
|
||||
]);
|
||||
}
|
||||
|
||||
function toggle() {
|
||||
if (root.enabled) disable()
|
||||
else enable()
|
||||
}
|
||||
|
||||
function cycle() {
|
||||
if (!enabled) {
|
||||
enableWeak();
|
||||
} else if (weak) {
|
||||
enable();
|
||||
} else {
|
||||
disable();
|
||||
}
|
||||
}
|
||||
|
||||
HyprlandConfigOption {
|
||||
id: confOpt
|
||||
key: "decoration:screen_shader"
|
||||
}
|
||||
}
|
||||
63
surfaces/quickshell/ii-base/services/HyprlandConfig.qml
Normal file
63
surfaces/quickshell/ii-base/services/HyprlandConfig.qml
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Hyprland
|
||||
|
||||
import qs.modules.common
|
||||
import qs.modules.common.functions
|
||||
|
||||
/**
|
||||
* Configs Hyprland
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
signal reloaded()
|
||||
|
||||
readonly property string configuratorScriptPath: Quickshell.shellPath("scripts/hyprland/hyprconfigurator.py")
|
||||
readonly property string shellOverridesPath: FileUtils.trimFileProtocol(`${Directories.config}/hypr/hyprland/shellOverrides/main.lua`)
|
||||
|
||||
function set(key: string, value: var) {
|
||||
Quickshell.execDetached(["bash", "-c", //
|
||||
`${root.configuratorScriptPath} --file ${root.shellOverridesPath} --set "${key}" "${value}"` //
|
||||
])
|
||||
}
|
||||
|
||||
function setMany(entries: var) {
|
||||
let args = ""
|
||||
for (let key in entries) {
|
||||
args += `--set "${key}" "${entries[key]}" `
|
||||
}
|
||||
Quickshell.execDetached(["bash", "-c", //
|
||||
`${root.configuratorScriptPath} --file ${root.shellOverridesPath} ${args}` //
|
||||
])
|
||||
}
|
||||
|
||||
function reset(key: string) {
|
||||
Quickshell.execDetached(["bash", "-c", //
|
||||
`${root.configuratorScriptPath} --file ${root.shellOverridesPath} --reset "${key}"` //
|
||||
])
|
||||
}
|
||||
|
||||
function resetMany(keys: list<string>) {
|
||||
let args = ""
|
||||
for (let i = 0; i < keys.length; i++) {
|
||||
args += `--reset "${keys[i]}" `
|
||||
}
|
||||
Quickshell.execDetached(["bash", "-c", //
|
||||
`${root.configuratorScriptPath} --file ${root.shellOverridesPath} ${args}` //
|
||||
])
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Hyprland
|
||||
|
||||
function onRawEvent(event) {
|
||||
if (event.name == "configreloaded") {
|
||||
root.reloaded()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
226
surfaces/quickshell/ii-base/services/HyprlandData.qml
Normal file
226
surfaces/quickshell/ii-base/services/HyprlandData.qml
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Wayland
|
||||
import Quickshell.Hyprland
|
||||
|
||||
/**
|
||||
* Provides access to some Hyprland data not available in Quickshell.Hyprland.
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
property var windowList: []
|
||||
property var addresses: []
|
||||
property var windowByAddress: ({})
|
||||
property var workspaces: []
|
||||
property var workspaceIds: []
|
||||
property var workspaceById: ({})
|
||||
property var activeWorkspace: null
|
||||
property var activeWindow: null
|
||||
property var monitors: []
|
||||
|
||||
// Parse hyprctl's JSON, or keep what we had.
|
||||
//
|
||||
// Every collector below did a bare `JSON.parse()` on the process output.
|
||||
// That is fine while Hyprland is the compositor and a hard error the
|
||||
// moment it is not: under viewtop there is no `hyprctl`, the output is
|
||||
// empty, and each collector threw a SyntaxError on every refresh — six
|
||||
// exceptions per pass, forever, drowning the log the shell is diagnosed
|
||||
// from.
|
||||
//
|
||||
// Absence is a state, not a failure. `HYPRLAND_INSTANCE_SIGNATURE` unset
|
||||
// means "another compositor", and the honest answer is to keep the last
|
||||
// known value and say so once — the same distinction
|
||||
// DEVICE-STATE-MACHINE.md §10 draws between "no evidence" and "evidence
|
||||
// says nothing".
|
||||
property bool available: true
|
||||
function parseOrKeep(text, fallback, what) {
|
||||
if (!text || text.trim().length === 0) {
|
||||
if (root.available) {
|
||||
root.available = false;
|
||||
console.log("[HyprlandData] no hyprctl output for", what,
|
||||
"— assuming another compositor; monitor geometry comes from `screen`");
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (!root.available) {
|
||||
root.available = true;
|
||||
console.log("[HyprlandData] hyprctl is answering again");
|
||||
}
|
||||
return parsed;
|
||||
} catch (e) {
|
||||
// Latched like the empty case: `hyprctl` missing does not always
|
||||
// mean *empty* output — a shell that prints an error to stdout
|
||||
// lands here instead, and it lands here on every refresh. One line
|
||||
// per edge, not four per pass. Same rule §10 applies to a sensor
|
||||
// that has gone quiet: say it when it changes, not when it repeats.
|
||||
if (root.available) {
|
||||
root.available = false;
|
||||
console.log("[HyprlandData]", what, "is unparseable —",
|
||||
"assuming another compositor; monitor geometry comes from `screen`:", e);
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
property var layers: ({})
|
||||
|
||||
// Convenient stuff
|
||||
|
||||
function toplevelsForWorkspace(workspace) {
|
||||
return ToplevelManager.toplevels.values.filter(toplevel => {
|
||||
const address = `0x${toplevel.HyprlandToplevel?.address}`;
|
||||
var win = HyprlandData.windowByAddress[address];
|
||||
return win?.workspace?.id === workspace;
|
||||
})
|
||||
}
|
||||
|
||||
function hyprlandClientsForWorkspace(workspace) {
|
||||
return root.windowList.filter(win => win.workspace.id === workspace);
|
||||
}
|
||||
|
||||
function clientForToplevel(toplevel) {
|
||||
if (!toplevel || !toplevel.HyprlandToplevel) {
|
||||
return null;
|
||||
}
|
||||
const address = `0x${toplevel?.HyprlandToplevel?.address}`;
|
||||
return root.windowByAddress[address];
|
||||
}
|
||||
|
||||
// Internals
|
||||
|
||||
function updateWindows() {
|
||||
getClients.running = true;
|
||||
getActiveWindow.running = true;
|
||||
}
|
||||
|
||||
function updateLayers() {
|
||||
getLayers.running = true;
|
||||
}
|
||||
|
||||
function updateMonitors() {
|
||||
getMonitors.running = true;
|
||||
}
|
||||
|
||||
function updateWorkspaces() {
|
||||
getWorkspaces.running = true;
|
||||
getActiveWorkspace.running = true;
|
||||
}
|
||||
|
||||
function updateAll() {
|
||||
updateWindows();
|
||||
updateMonitors();
|
||||
updateLayers();
|
||||
updateWorkspaces();
|
||||
}
|
||||
|
||||
function biggestWindowForWorkspace(workspaceId) {
|
||||
const windowsInThisWorkspace = HyprlandData.windowList.filter(w => w.workspace.id == workspaceId);
|
||||
return windowsInThisWorkspace.reduce((maxWin, win) => {
|
||||
const maxArea = (maxWin?.size?.[0] ?? 0) * (maxWin?.size?.[1] ?? 0);
|
||||
const winArea = (win?.size?.[0] ?? 0) * (win?.size?.[1] ?? 0);
|
||||
return winArea > maxArea ? win : maxWin;
|
||||
}, null);
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
updateAll();
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: Hyprland
|
||||
|
||||
function onRawEvent(event) {
|
||||
// console.log("Hyprland raw event:", event.name);
|
||||
if (["openlayer", "closelayer", "screencast"].includes(event.name)) return;
|
||||
updateAll()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getClients
|
||||
command: ["hyprctl", "clients", "-j"]
|
||||
stdout: StdioCollector {
|
||||
id: clientsCollector
|
||||
onStreamFinished: {
|
||||
root.windowList = root.parseOrKeep(clientsCollector.text, [], "data")
|
||||
let tempWinByAddress = {};
|
||||
for (var i = 0; i < root.windowList.length; ++i) {
|
||||
var win = root.windowList[i];
|
||||
tempWinByAddress[win.address] = win;
|
||||
}
|
||||
root.windowByAddress = tempWinByAddress;
|
||||
root.addresses = root.windowList.map(win => win.address);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getActiveWindow
|
||||
command: ["hyprctl", "activewindow", "-j"]
|
||||
stdout: StdioCollector {
|
||||
id: activeWindowCollector
|
||||
onStreamFinished: {
|
||||
root.activeWindow = root.parseOrKeep(activeWindowCollector.text, root.activeWindow, "activewindow")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getMonitors
|
||||
command: ["hyprctl", "monitors", "-j"]
|
||||
stdout: StdioCollector {
|
||||
id: monitorsCollector
|
||||
onStreamFinished: {
|
||||
root.monitors = root.parseOrKeep(monitorsCollector.text, root.monitors, "monitors");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getLayers
|
||||
command: ["hyprctl", "layers", "-j"]
|
||||
stdout: StdioCollector {
|
||||
id: layersCollector
|
||||
onStreamFinished: {
|
||||
root.layers = root.parseOrKeep(layersCollector.text, root.layers, "layers");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getWorkspaces
|
||||
command: ["hyprctl", "workspaces", "-j"]
|
||||
stdout: StdioCollector {
|
||||
id: workspacesCollector
|
||||
onStreamFinished: {
|
||||
var rawWorkspaces = root.parseOrKeep(workspacesCollector.text, root.workspaces, "workspaces");
|
||||
// Filter out invalid workspace ids (e.g. lock-screen temp workspace 2147483647 - N)
|
||||
root.workspaces = rawWorkspaces.filter(ws => ws.id >= 1 && ws.id <= 100);
|
||||
let tempWorkspaceById = {};
|
||||
for (var i = 0; i < root.workspaces.length; ++i) {
|
||||
var ws = root.workspaces[i];
|
||||
tempWorkspaceById[ws.id] = ws;
|
||||
}
|
||||
root.workspaceById = tempWorkspaceById;
|
||||
root.workspaceIds = root.workspaces.map(ws => ws.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getActiveWorkspace
|
||||
command: ["hyprctl", "activeworkspace", "-j"]
|
||||
stdout: StdioCollector {
|
||||
id: activeWorkspaceCollector
|
||||
onStreamFinished: {
|
||||
root.activeWorkspace = root.parseOrKeep(activeWorkspaceCollector.text, root.activeWorkspace, "activeworkspace");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
55
surfaces/quickshell/ii-base/services/HyprlandKeybinds.qml
Normal file
55
surfaces/quickshell/ii-base/services/HyprlandKeybinds.qml
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs.modules.common
|
||||
import qs.modules.common.functions
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Hyprland
|
||||
|
||||
/**
|
||||
* A service that provides access to Hyprland keybinds.
|
||||
* Uses the `get_keybinds.py` script to parse comments in config files in a certain format and convert to JSON.
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
property var keybinds: []
|
||||
property var keybindCategories: []
|
||||
|
||||
Connections {
|
||||
target: Hyprland
|
||||
|
||||
function onRawEvent(event) {
|
||||
if (event.name == "configreloaded") {
|
||||
getKeybinds.running = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getKeybinds
|
||||
running: true
|
||||
command: ["hyprctl", "binds", "-j"]
|
||||
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
try {
|
||||
root.keybinds = JSON.parse(text)
|
||||
var groups = []
|
||||
for (var i = 0; i < root.keybinds.length; i++) {
|
||||
var bind = root.keybinds[i].description
|
||||
var group = bind.substring(0, bind.indexOf(":"))
|
||||
if (!groups.includes(group) && group.length > 0) {
|
||||
groups.push(group)
|
||||
}
|
||||
}
|
||||
root.keybindCategories = groups
|
||||
} catch (e) {
|
||||
console.error("[CheatsheetKeybinds] Error parsing keybinds:", e)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
119
surfaces/quickshell/ii-base/services/HyprlandXkb.qml
Normal file
119
surfaces/quickshell/ii-base/services/HyprlandXkb.qml
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Hyprland
|
||||
import qs.modules.common
|
||||
|
||||
/**
|
||||
* Exposes the active Hyprland Xkb keyboard layout name and code for indicators.
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
// You can read these
|
||||
property list<string> layoutCodes: []
|
||||
property var cachedLayoutCodes: ({})
|
||||
property string currentLayoutName: ""
|
||||
property string currentLayoutCode: ""
|
||||
// For the service
|
||||
property var baseLayoutFilePath: "/usr/share/X11/xkb/rules/base.lst"
|
||||
property bool needsLayoutRefresh: false
|
||||
|
||||
// Update the layout code according to the layout name (Hyprland gives the name not the code)
|
||||
onCurrentLayoutNameChanged: root.updateLayoutCode()
|
||||
function updateLayoutCode() {
|
||||
if (cachedLayoutCodes.hasOwnProperty(currentLayoutName)) {
|
||||
root.currentLayoutCode = cachedLayoutCodes[currentLayoutName];
|
||||
} else {
|
||||
getLayoutProc.running = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the layout code from the base.lst file by grabbing the line with the current layout name
|
||||
Process {
|
||||
id: getLayoutProc
|
||||
command: ["cat", root.baseLayoutFilePath]
|
||||
|
||||
stdout: StdioCollector {
|
||||
id: layoutCollector
|
||||
|
||||
onStreamFinished: {
|
||||
const lines = layoutCollector.text.split("\n");
|
||||
const targetDescription = root.currentLayoutName;
|
||||
const foundLine = lines.find(line => {
|
||||
// Skip comment lines and empty lines
|
||||
if (!line.trim() || line.trim().startsWith('!'))
|
||||
return false;
|
||||
|
||||
// Match layout: (whitespace + ) key + whitespace + description
|
||||
const matchLayout = line.match(/^\s*(\S+)\s+(.+)$/);
|
||||
if (matchLayout && matchLayout[2] === targetDescription) {
|
||||
root.cachedLayoutCodes[matchLayout[2]] = matchLayout[1];
|
||||
root.currentLayoutCode = matchLayout[1];
|
||||
return true;
|
||||
}
|
||||
|
||||
// Match variant: (whitespace + ) variant + whitespace + key + whitespace + description
|
||||
const matchVariant = line.match(/^\s*(\S+)\s+(\S+)\s+(.+)$/);
|
||||
if (matchVariant && matchVariant[3] === targetDescription) {
|
||||
const complexLayout = matchVariant[2] + matchVariant[1];
|
||||
root.cachedLayoutCodes[matchVariant[3]] = complexLayout;
|
||||
root.currentLayoutCode = complexLayout;
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
});
|
||||
// console.log("[HyprlandXkb] Found line:", foundLine);
|
||||
// console.log("[HyprlandXkb] Layout:", root.currentLayoutName, "| Code:", root.currentLayoutCode);
|
||||
// console.log("[HyprlandXkb] Cached layout codes:", JSON.stringify(root.cachedLayoutCodes, null, 2));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Find out available layouts and current active layout. Should only be necessary on init
|
||||
Process {
|
||||
id: fetchLayoutsProc
|
||||
running: true
|
||||
command: ["hyprctl", "-j", "devices"]
|
||||
|
||||
stdout: StdioCollector {
|
||||
id: devicesCollector
|
||||
onStreamFinished: {
|
||||
const parsedOutput = JSON.parse(devicesCollector.text);
|
||||
const hyprlandKeyboard = parsedOutput["keyboards"].find(kb => kb.main === true);
|
||||
root.layoutCodes = hyprlandKeyboard["layout"].split(",");
|
||||
root.currentLayoutName = hyprlandKeyboard["active_keymap"];
|
||||
// console.log("[HyprlandXkb] Fetched | Layouts (multiple: " + (root.layoutCodes.length > 1) + "): "
|
||||
// + root.layoutCodes.join(", ") + " | Active: " + root.currentLayoutName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update the layout name when it changes
|
||||
Connections {
|
||||
target: Hyprland
|
||||
function onRawEvent(event) {
|
||||
if (event.name === "activelayout") {
|
||||
if (root.needsLayoutRefresh) {
|
||||
root.needsLayoutRefresh = false;
|
||||
fetchLayoutsProc.running = true;
|
||||
}
|
||||
|
||||
// If there's only one layout, the updated layout is always the same
|
||||
if (root.layoutCodes.length <= 1) return;
|
||||
|
||||
// Update when layout might have changed
|
||||
const dataString = event.data;
|
||||
root.currentLayoutName = dataString.substring(dataString.indexOf(",") + 1);
|
||||
|
||||
// Update layout for on-screen keyboard (osk)
|
||||
Config.options.osk.layout = root.currentLayoutName;
|
||||
} else if (event.name == "configreloaded") {
|
||||
// Mark layout code list to be updated when config is reloaded
|
||||
root.needsLayoutRefresh = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
172
surfaces/quickshell/ii-base/services/Hyprsunset.qml
Normal file
172
surfaces/quickshell/ii-base/services/Hyprsunset.qml
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import qs.modules.common
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Hyprland
|
||||
|
||||
/**
|
||||
* Simple hyprsunset service with automatic mode.
|
||||
* In theory we don't need this because hyprsunset has a config file, but it somehow doesn't work.
|
||||
* It should also be possible to control it via hyprctl, but it doesn't work consistently either so we're just killing and launching.
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
signal gammaChangeAttempt()
|
||||
|
||||
readonly property real gammaLowerLimit: 25
|
||||
|
||||
property string from: Config.options?.light?.night?.from ?? "19:00"
|
||||
property string to: Config.options?.light?.night?.to ?? "06:30"
|
||||
property bool automatic: Config.options?.light?.night?.automatic && (Config?.ready ?? true)
|
||||
property int colorTemperature: Config.options?.light?.night?.colorTemperature ?? 5000
|
||||
property int defaultColorTemperature: 6000
|
||||
property int gamma: 100
|
||||
property bool shouldBeOn
|
||||
property bool firstEvaluation: true
|
||||
property bool temperatureActive: false
|
||||
|
||||
property int fromHour: Number(from.split(":")[0])
|
||||
property int fromMinute: Number(from.split(":")[1])
|
||||
property int toHour: Number(to.split(":")[0])
|
||||
property int toMinute: Number(to.split(":")[1])
|
||||
|
||||
property int clockHour: DateTime.clock.hours
|
||||
property int clockMinute: DateTime.clock.minutes
|
||||
|
||||
property var manualActive
|
||||
property int manualActiveHour
|
||||
property int manualActiveMinute
|
||||
|
||||
onClockMinuteChanged: reEvaluate()
|
||||
onAutomaticChanged: {
|
||||
root.manualActive = undefined;
|
||||
root.firstEvaluation = true;
|
||||
reEvaluate();
|
||||
}
|
||||
|
||||
function inBetween(t, from, to) {
|
||||
if (from < to) {
|
||||
return (t >= from && t <= to);
|
||||
} else {
|
||||
// Wrapped around midnight
|
||||
return (t >= from || t <= to);
|
||||
}
|
||||
}
|
||||
|
||||
function reEvaluate() {
|
||||
const t = clockHour * 60 + clockMinute;
|
||||
const from = fromHour * 60 + fromMinute;
|
||||
const to = toHour * 60 + toMinute;
|
||||
const manualActive = manualActiveHour * 60 + manualActiveMinute;
|
||||
|
||||
if (root.manualActive !== undefined && (inBetween(from, manualActive, t) || inBetween(to, manualActive, t))) {
|
||||
root.manualActive = undefined;
|
||||
}
|
||||
root.shouldBeOn = inBetween(t, from, to);
|
||||
if (firstEvaluation) {
|
||||
firstEvaluation = false;
|
||||
root.ensureState();
|
||||
}
|
||||
}
|
||||
|
||||
onShouldBeOnChanged: ensureState()
|
||||
function ensureState() {
|
||||
// console.log("[Hyprsunset] Ensuring state:", root.shouldBeOn, "Automatic mode:", root.automatic);
|
||||
if (!root.automatic || root.manualActive !== undefined)
|
||||
return;
|
||||
if (root.shouldBeOn) {
|
||||
root.enableTemperature();
|
||||
} else {
|
||||
root.disableTemperature();
|
||||
}
|
||||
}
|
||||
|
||||
function startHyprsunset() {
|
||||
Quickshell.execDetached(["bash", "-c", `pidof hyprsunset || hyprsunset`]);
|
||||
}
|
||||
|
||||
function load() {
|
||||
root.startHyprsunset();
|
||||
root.ensureState();
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: updateHyprsunset
|
||||
interval: 100
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
root.ensureState();
|
||||
root.setGamma(root.gamma);
|
||||
}
|
||||
}
|
||||
|
||||
function enableTemperature() {
|
||||
root.temperatureActive = true;
|
||||
|
||||
// console.log("[Hyprsunset] Enabling");
|
||||
root.startHyprsunset();
|
||||
Quickshell.execDetached(["bash", "-c", `hyprctl hyprsunset temperature ${root.colorTemperature}`]);
|
||||
}
|
||||
|
||||
function disableTemperature() {
|
||||
root.temperatureActive = false;
|
||||
// console.log("[Hyprsunset] Disabling");
|
||||
Quickshell.execDetached(["bash", "-c", `hyprctl hyprsunset temperature ${root.defaultColorTemperature}`]);
|
||||
}
|
||||
|
||||
function setGamma(gamma) {
|
||||
root.gamma = Math.max(root.gammaLowerLimit, Math.min(100, gamma));
|
||||
|
||||
root.gammaChangeAttempt();
|
||||
|
||||
root.startHyprsunset();
|
||||
Quickshell.execDetached(["bash", "-c", `hyprctl hyprsunset gamma ${root.gamma}`]);
|
||||
}
|
||||
|
||||
function fetchState() {
|
||||
fetchProc.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: fetchProc
|
||||
running: true
|
||||
command: ["bash", "-c", "hyprctl hyprsunset temperature"]
|
||||
stdout: StdioCollector {
|
||||
id: stateCollector
|
||||
onStreamFinished: {
|
||||
const output = stateCollector.text.trim();
|
||||
if (output.length == 0 || output.startsWith("Couldn't"))
|
||||
root.temperatureActive = false;
|
||||
else
|
||||
root.temperatureActive = (output != root.defaultColorTemperature); // 6000 is the default when off
|
||||
// console.log("[Hyprsunset] Fetched state:", output, "->", root.temperatureActive);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTemperature(active = undefined) {
|
||||
if (root.manualActive === undefined) {
|
||||
root.manualActive = root.temperatureActive;
|
||||
root.manualActiveHour = root.clockHour;
|
||||
root.manualActiveMinute = root.clockMinute;
|
||||
}
|
||||
|
||||
root.manualActive = active !== undefined ? active : !root.manualActive;
|
||||
if (root.manualActive) {
|
||||
root.enableTemperature();
|
||||
} else {
|
||||
root.disableTemperature();
|
||||
}
|
||||
}
|
||||
|
||||
// Change temp
|
||||
Connections {
|
||||
target: Config.options.light.night
|
||||
function onColorTemperatureChanged() {
|
||||
if (!root.temperatureActive) return;
|
||||
Quickshell.execDetached(["hyprctl", "hyprsunset", "temperature", `${Config.options.light.night.colorTemperature}`]);
|
||||
}
|
||||
}
|
||||
}
|
||||
60
surfaces/quickshell/ii-base/services/Idle.qml
Normal file
60
surfaces/quickshell/ii-base/services/Idle.qml
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
pragma Singleton
|
||||
import qs.modules.common
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
|
||||
/**
|
||||
* A nice wrapper for date and time strings.
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property alias inhibit: idleInhibitor.enabled
|
||||
property bool autoIdleInhibit: Config.options.autoIdleInhibit
|
||||
inhibit: autoIdleInhibit
|
||||
|
||||
|
||||
Connections {
|
||||
target: Persistent
|
||||
function onReadyChanged() {
|
||||
if (Persistent.isNewHyprlandInstance) {
|
||||
Persistent.states.idle.inhibit = Config.options.autoIdleInhibit;
|
||||
}
|
||||
root.inhibit = Persistent.states.idle.inhibit;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleInhibit(active = null) {
|
||||
if (active !== null) {
|
||||
root.inhibit = active;
|
||||
} else {
|
||||
root.inhibit = !root.inhibit;
|
||||
}
|
||||
Persistent.states.idle.inhibit = root.inhibit;
|
||||
}
|
||||
|
||||
function toggleAutoInhibit(active = null) {
|
||||
Config.options.autoIdleInhibit = !Config.options.autoIdleInhibit
|
||||
}
|
||||
|
||||
IdleInhibitor {
|
||||
id: idleInhibitor
|
||||
window: PanelWindow {
|
||||
// Inhibitor requires a "visible" surface
|
||||
// Actually not lol
|
||||
implicitWidth: 0
|
||||
implicitHeight: 0
|
||||
color: "transparent"
|
||||
// Just in case...
|
||||
anchors {
|
||||
right: true
|
||||
bottom: true
|
||||
}
|
||||
// Make it not interactable
|
||||
mask: Region {
|
||||
item: null
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
126
surfaces/quickshell/ii-base/services/KeyringStorage.qml
Normal file
126
surfaces/quickshell/ii-base/services/KeyringStorage.qml
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs
|
||||
import qs.modules.common
|
||||
import qs.modules.common.functions
|
||||
import Quickshell;
|
||||
import Quickshell.Io;
|
||||
import QtQuick;
|
||||
|
||||
/**
|
||||
* For storing sensitive data in the keyring.
|
||||
* Use this for small data only, since it stores a JSON of the contents directly and doesn't use a database.
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
signal dataChanged()
|
||||
|
||||
property bool loaded: false
|
||||
property var keyringData: ({})
|
||||
|
||||
property var properties: {
|
||||
"application": "illogical-impulse",
|
||||
"explanation": Translation.tr("For storing API keys and other sensitive information"),
|
||||
}
|
||||
property var propertiesAsArgs: Object.keys(root.properties).reduce(
|
||||
function(arr, key) {
|
||||
return arr.concat([key, root.properties[key]]);
|
||||
}, []
|
||||
)
|
||||
property string keyringLabel: Translation.tr("%1 Safe Storage").arg("illogical-impulse")
|
||||
|
||||
function setNestedField(path, value) {
|
||||
if (!root.keyringData) root.keyringData = {};
|
||||
let keys = path;
|
||||
let obj = root.keyringData;
|
||||
let parents = [obj];
|
||||
|
||||
// Traverse and collect parent objects
|
||||
for (let i = 0; i < keys.length - 1; ++i) {
|
||||
if (!obj[keys[i]] || typeof obj[keys[i]] !== "object") {
|
||||
obj[keys[i]] = {};
|
||||
}
|
||||
obj = obj[keys[i]];
|
||||
parents.push(obj);
|
||||
}
|
||||
|
||||
// Set the value at the innermost key
|
||||
obj[keys[keys.length - 1]] = value;
|
||||
|
||||
// Reassign each parent object from the bottom up to trigger change notifications
|
||||
for (let i = keys.length - 2; i >= 0; --i) {
|
||||
let parent = parents[i];
|
||||
let key = keys[i];
|
||||
// Shallow clone to change object identity (spread replaced with Object.assign)
|
||||
parent[key] = Object.assign({}, parent[key]);
|
||||
}
|
||||
|
||||
// Finally, reassign root.keyringData to trigger top-level change
|
||||
root.keyringData = Object.assign({}, root.keyringData);
|
||||
|
||||
saveKeyringData();
|
||||
}
|
||||
|
||||
function fetchKeyringData() {
|
||||
// console.log("[KeyringStorage] Fetching keyring data...");
|
||||
// console.log("[KeyringStorage] getData command:'" + getData.command.join("' '") + "'");
|
||||
getData.running = true;
|
||||
}
|
||||
|
||||
function saveKeyringData() {
|
||||
saveData.stdinEnabled = true;
|
||||
saveData.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: saveData
|
||||
command: [
|
||||
"secret-tool", "store", "--label=" + keyringLabel,
|
||||
...propertiesAsArgs,
|
||||
]
|
||||
onRunningChanged: {
|
||||
if (saveData.running) {
|
||||
// console.log("[KeyringStorage] Saving with command: '" + saveData.command.join("' '") + "'");
|
||||
saveData.write(JSON.stringify(root.keyringData));
|
||||
root.dataChanged()
|
||||
stdinEnabled = false // End input stream
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getData
|
||||
command: [ // We need to use echo for a newline so splitparser does parse
|
||||
"bash", "-c", `${Directories.scriptPath}/keyring/try_lookup.sh 2> /dev/null`,
|
||||
]
|
||||
stdout: StdioCollector {
|
||||
id: keyringDataOutputCollector
|
||||
onStreamFinished: {
|
||||
const data = keyringDataOutputCollector.text;
|
||||
if (data.length === 0 || !data.startsWith("{")) return;
|
||||
try {
|
||||
root.keyringData = JSON.parse(data);
|
||||
// console.log("[KeyringStorage] Keyring data fetched:", JSON.stringify(root.keyringData));
|
||||
} catch (e) {
|
||||
console.error("[KeyringStorage] Failed to get keyring data, reinitializing.");
|
||||
root.keyringData = {};
|
||||
saveKeyringData()
|
||||
}
|
||||
}
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
// console.log("[KeyringStorage] Keyring data fetch process exited with code:", exitCode);
|
||||
if (exitCode === 1) {
|
||||
console.error("[KeyringStorage] Entry not found, initializing.");
|
||||
root.keyringData = {};
|
||||
saveKeyringData()
|
||||
}
|
||||
if (exitCode !== 2) {
|
||||
root.loaded = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
83
surfaces/quickshell/ii-base/services/LatexRenderer.qml
Normal file
83
surfaces/quickshell/ii-base/services/LatexRenderer.qml
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs.modules.common.functions
|
||||
import qs.modules.common
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
|
||||
/**
|
||||
* Renders LaTeX snippets with MicroTeX.
|
||||
* For every request:
|
||||
* 1. Hash it
|
||||
* 2. Check if the hash is already processed
|
||||
* 3. If not, render it with MicroTeX and mark as processed
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var renderPadding: 4 // This is to prevent cutoff in the rendered images
|
||||
|
||||
property list<string> processedHashes: []
|
||||
property var processedExpressions: ({})
|
||||
property var renderedImagePaths: ({})
|
||||
property string microtexBinaryDir: "/opt/MicroTeX"
|
||||
property string microtexBinaryName: "LaTeX"
|
||||
property string latexOutputPath: Directories.latexOutput
|
||||
|
||||
signal renderFinished(string hash, string imagePath)
|
||||
|
||||
/**
|
||||
* Requests rendering of a LaTeX expression.
|
||||
* Returns the [hash, isNew]
|
||||
*/
|
||||
function requestRender(expression) {
|
||||
// 1. Hash it and initialize necessary variables
|
||||
const hash = Qt.md5(expression)
|
||||
const imagePath = `${latexOutputPath}/${hash}.svg`
|
||||
|
||||
// 2. Check if the hash is already processed
|
||||
if (processedHashes.includes(hash)) {
|
||||
// console.log("Already processed: " + hash)
|
||||
renderFinished(hash, imagePath)
|
||||
return [hash, false]
|
||||
} else {
|
||||
root.processedHashes.push(hash)
|
||||
root.processedExpressions[hash] = expression
|
||||
// console.log("Rendering expression: " + expression)
|
||||
}
|
||||
|
||||
// 3. If not, render it with MicroTeX and mark as processed
|
||||
// console.log(`[LatexRenderer] Rendering expression: ${expression} with hash: ${hash}`)
|
||||
// console.log(` to file: ${imagePath}`)
|
||||
// console.log(` with command: cd ${microtexBinaryDir} && ./${microtexBinaryName} -headless -input=${StringUtils.shellSingleQuoteEscape(expression)} -output=${imagePath} -textsize=${Appearance.font.pixelSize.normal} -padding=${renderPadding} -background=${Appearance.m3colors.m3tertiary} -foreground=${Appearance.m3colors.m3onTertiary} -maxwidth=0.85`)
|
||||
const processQml = `
|
||||
import Quickshell.Io
|
||||
Process {
|
||||
id: microtexProcess${hash}
|
||||
running: true
|
||||
command: [ "bash", "-c",
|
||||
"cd ${root.microtexBinaryDir} && ./${root.microtexBinaryName} -headless '-input=${StringUtils.shellSingleQuoteEscape(StringUtils.escapeBackslashes(expression))}' "
|
||||
+ "'-output=${imagePath}' "
|
||||
+ "'-textsize=${Appearance.font.pixelSize.normal}' "
|
||||
+ "'-padding=${renderPadding}' "
|
||||
// + "'-background=${Appearance.m3colors.m3tertiary}' "
|
||||
+ "'-foreground=${Appearance.colors.colOnLayer1}' "
|
||||
+ "-maxwidth=0.85 "
|
||||
]
|
||||
// stdout: SplitParser {
|
||||
// onRead: data => { console.log("MicroTeX: " + data) }
|
||||
// }
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
// console.log("[LatexRenderer] MicroTeX process exited with code: " + exitCode + ", status: " + exitStatus)
|
||||
renderedImagePaths["${hash}"] = "${imagePath}"
|
||||
root.renderFinished("${hash}", "${imagePath}")
|
||||
microtexProcess${hash}.destroy()
|
||||
}
|
||||
}
|
||||
`
|
||||
// console.log("MicroTeX: " + processQml)
|
||||
Qt.createQmlObject(processQml, root, `MicroTeXProcess_${hash}`)
|
||||
return [hash, true]
|
||||
}
|
||||
}
|
||||
41
surfaces/quickshell/ii-base/services/LauncherApps.qml
Normal file
41
surfaces/quickshell/ii-base/services/LauncherApps.qml
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
pragma Singleton
|
||||
|
||||
import qs.modules.common
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
function isPinned(appId) {
|
||||
return Config.options.launcher.pinnedApps.indexOf(appId) !== -1;
|
||||
}
|
||||
|
||||
function togglePin(appId) {
|
||||
if (root.isPinned(appId)) {
|
||||
Config.options.launcher.pinnedApps = Config.options.launcher.pinnedApps.filter(id => id !== appId)
|
||||
} else {
|
||||
Config.options.launcher.pinnedApps = Config.options.launcher.pinnedApps.concat([appId])
|
||||
}
|
||||
}
|
||||
|
||||
function moveToFront(appId) {
|
||||
if (!root.isPinned(appId)) return;
|
||||
const pinnedApps = Config.options.launcher.pinnedApps;
|
||||
Config.options.launcher.pinnedApps = [appId].concat(pinnedApps.filter(id => id !== appId));
|
||||
}
|
||||
|
||||
function moveLeft(appId) {
|
||||
const pinnedApps = Config.options.launcher.pinnedApps;
|
||||
const index = pinnedApps.indexOf(appId);
|
||||
if (index === -1 || index === 0) return;
|
||||
Config.options.launcher.pinnedApps = pinnedApps.slice(0, index - 1).concat([appId]).concat(pinnedApps[index - 1]).concat(pinnedApps.slice(index + 1));
|
||||
}
|
||||
|
||||
function moveRight(appId) {
|
||||
const pinnedApps = Config.options.launcher.pinnedApps;
|
||||
const index = pinnedApps.indexOf(appId);
|
||||
if (index === -1 || index === pinnedApps.length - 1) return;
|
||||
Config.options.launcher.pinnedApps = pinnedApps.slice(0, index).concat(pinnedApps[index + 1]).concat([appId]).concat(pinnedApps.slice(index + 2));
|
||||
}
|
||||
}
|
||||
445
surfaces/quickshell/ii-base/services/LauncherSearch.qml
Normal file
445
surfaces/quickshell/ii-base/services/LauncherSearch.qml
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
pragma Singleton
|
||||
|
||||
import qs.modules.common
|
||||
import qs.modules.common.models
|
||||
import qs.modules.common.functions
|
||||
import QtQuick
|
||||
import Qt.labs.folderlistmodel
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Hyprland
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property string query: ""
|
||||
|
||||
function ensurePrefix(prefix) {
|
||||
if ([Config.options.search.prefix.action, Config.options.search.prefix.app, Config.options.search.prefix.clipboard, Config.options.search.prefix.emojis, Config.options.search.prefix.math, Config.options.search.prefix.shellCommand, Config.options.search.prefix.webSearch,].some(i => root.query.startsWith(i))) {
|
||||
root.query = prefix + root.query.slice(1);
|
||||
} else {
|
||||
root.query = prefix + root.query;
|
||||
}
|
||||
}
|
||||
onQueryChanged: {
|
||||
FileSearch.search(StringUtils.cleanPrefix(root.query, Config.options.search.prefix.app));
|
||||
}
|
||||
|
||||
// https://specifications.freedesktop.org/menu/latest/category-registry.html
|
||||
property list<string> mainRegisteredCategories: ["AudioVideo", "Development", "Education", "Game", "Graphics", "Network", "Office", "Science", "Settings", "System", "Utility"]
|
||||
property list<string> appCategories: DesktopEntries.applications.values.reduce((acc, entry) => {
|
||||
for (const category of entry.categories) {
|
||||
if (!acc.includes(category) && mainRegisteredCategories.includes(category)) {
|
||||
acc.push(category);
|
||||
}
|
||||
}
|
||||
return acc;
|
||||
}, []).sort()
|
||||
|
||||
// Load user action scripts from ~/.config/illogical-impulse/actions/
|
||||
// Uses FolderListModel to auto-reload when scripts are added/removed
|
||||
property var userActionScripts: {
|
||||
const actions = [];
|
||||
for (let i = 0; i < userActionsFolder.count; i++) {
|
||||
const fileName = userActionsFolder.get(i, "fileName");
|
||||
const filePath = userActionsFolder.get(i, "filePath");
|
||||
if (fileName && filePath) {
|
||||
const actionName = fileName.replace(/\.[^/.]+$/, ""); // strip extension
|
||||
actions.push({
|
||||
action: actionName,
|
||||
execute: ((path) => (args) => {
|
||||
Quickshell.execDetached([path, ...(args ? args.split(" ") : [])]);
|
||||
})(FileUtils.trimFileProtocol(filePath.toString()))
|
||||
});
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
}
|
||||
|
||||
FolderListModel {
|
||||
id: userActionsFolder
|
||||
folder: Qt.resolvedUrl(Directories.userActions)
|
||||
showDirs: false
|
||||
showHidden: false
|
||||
sortField: FolderListModel.Name
|
||||
}
|
||||
|
||||
property var searchActions: [
|
||||
{
|
||||
action: "accentcolor",
|
||||
execute: args => {
|
||||
Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--noswitch", "--color", ...(args != '' ? [`${args}`] : [])]);
|
||||
}
|
||||
},
|
||||
{
|
||||
action: "dark",
|
||||
execute: () => {
|
||||
Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--mode", "dark", "--noswitch"]);
|
||||
}
|
||||
},
|
||||
{
|
||||
action: "konachanwallpaper",
|
||||
execute: () => {
|
||||
Quickshell.execDetached([Quickshell.shellPath("scripts/colors/random/random_konachan_wall.sh")]);
|
||||
}
|
||||
},
|
||||
{
|
||||
action: "light",
|
||||
execute: () => {
|
||||
Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--mode", "light", "--noswitch"]);
|
||||
}
|
||||
},
|
||||
{
|
||||
action: "superpaste",
|
||||
execute: args => {
|
||||
if (!/^(\d+)/.test(args.trim())) {
|
||||
// Invalid if doesn't start with numbers
|
||||
Quickshell.execDetached(["notify-send", Translation.tr("Superpaste"), Translation.tr("Usage: <tt>%1superpaste NUM_OF_ENTRIES[i]</tt>\nSupply <tt>i</tt> when you want images\nExamples:\n<tt>%1superpaste 4i</tt> for the last 4 images\n<tt>%1superpaste 7</tt> for the last 7 entries").arg(Config.options.search.prefix.action), "-a", "Shell"]);
|
||||
return;
|
||||
}
|
||||
const syntaxMatch = /^(?:(\d+)(i)?)/.exec(args.trim());
|
||||
const count = syntaxMatch[1] ? parseInt(syntaxMatch[1]) : 1;
|
||||
const isImage = !!syntaxMatch[2];
|
||||
Cliphist.superpaste(count, isImage);
|
||||
}
|
||||
},
|
||||
{
|
||||
action: "todo",
|
||||
execute: args => {
|
||||
Todo.addTask(args);
|
||||
}
|
||||
},
|
||||
{
|
||||
action: "wallpaper",
|
||||
execute: () => {
|
||||
Hyprland.dispatch(`hl.dsp.global("quickshell:wallpaperSelectorToggle")`)
|
||||
}
|
||||
},
|
||||
{
|
||||
action: "wipeclipboard",
|
||||
execute: () => {
|
||||
Cliphist.wipe();
|
||||
}
|
||||
},
|
||||
]
|
||||
|
||||
// Combined built-in and user actions
|
||||
property var allActions: searchActions.concat(userActionScripts)
|
||||
|
||||
property string mathResult: ""
|
||||
property bool clipboardWorkSafetyActive: {
|
||||
const enabled = Config.options.workSafety.enable.clipboard;
|
||||
const sensitiveNetwork = (StringUtils.stringListContainsSubstring(Network.networkName.toLowerCase(), Config.options.workSafety.triggerCondition.networkNameKeywords));
|
||||
return enabled && sensitiveNetwork;
|
||||
}
|
||||
|
||||
function containsUnsafeLink(entry) {
|
||||
if (entry == undefined)
|
||||
return false;
|
||||
const unsafeKeywords = Config.options.workSafety.triggerCondition.linkKeywords;
|
||||
return StringUtils.stringListContainsSubstring(entry.toLowerCase(), unsafeKeywords);
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: nonAppResultsTimer
|
||||
interval: Config.options.search.nonAppResultDelay
|
||||
onTriggered: {
|
||||
let expr = root.query;
|
||||
if (expr.startsWith(Config.options.search.prefix.math)) {
|
||||
expr = expr.slice(Config.options.search.prefix.math.length);
|
||||
}
|
||||
mathProc.calculateExpression(expr);
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: mathProc
|
||||
property list<string> baseCommand: ["qalc", "-t"]
|
||||
function calculateExpression(expression) {
|
||||
mathProc.running = false;
|
||||
mathProc.command = baseCommand.concat(expression);
|
||||
mathProc.running = true;
|
||||
}
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
root.mathResult = data;
|
||||
}
|
||||
}
|
||||
}
|
||||
property list<var> fileResults: {
|
||||
if (!Config.options.search.fileSearch.enable) return [];
|
||||
if (!FileSearch.results || FileSearch.results.length === 0) return [];
|
||||
|
||||
return FileSearch.results.map(path => {
|
||||
const trimmed = FileUtils.trimFileProtocol(path.path);
|
||||
const isDir = path.isDir;
|
||||
const displayName = isDir ? FileUtils.folderNameForPath(trimmed) : FileUtils.fileNameForPath(trimmed);
|
||||
const parentDir = FileUtils.parentDirectory(trimmed);
|
||||
return resultComp.createObject(null, {
|
||||
rawValue: trimmed,
|
||||
name: displayName || trimmed,
|
||||
verb: Translation.tr("Open"),
|
||||
type: isDir ? Translation.tr("Folder") : Translation.tr("File"),
|
||||
iconName: isDir ? "folder" : "description",
|
||||
iconType: LauncherSearchResult.IconType.Material,
|
||||
execute: () => {
|
||||
Qt.openUrlExternally(`file://${trimmed}`);
|
||||
},
|
||||
actions: [resultComp.createObject(null, {
|
||||
name: Translation.tr("Open Parent folder"),
|
||||
iconName: "folder_open",
|
||||
iconType: LauncherSearchResult.IconType.Material,
|
||||
execute: () => {
|
||||
if (parentDir) {
|
||||
Qt.openUrlExternally(`file://${parentDir}`);
|
||||
}
|
||||
}
|
||||
})]
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
property list<var> results: {
|
||||
// Search results are handled here
|
||||
////////////////// Skip? //////////////////
|
||||
if (root.query == "")
|
||||
return [];
|
||||
|
||||
///////////// Special cases ///////////////
|
||||
if (root.query.startsWith(Config.options.search.prefix.clipboard)) {
|
||||
// Clipboard
|
||||
const searchString = StringUtils.cleanPrefix(root.query, Config.options.search.prefix.clipboard);
|
||||
const allClipStrings = [searchString, ...KeymapTranslation.translateAll(searchString)];
|
||||
const seenClipEntries = new Set();
|
||||
const clipEntries = allClipStrings.reduce((acc, q) => {
|
||||
return acc.concat(Cliphist.fuzzyQuery(q).filter(e => {
|
||||
if (seenClipEntries.has(e)) return false;
|
||||
seenClipEntries.add(e);
|
||||
return true;
|
||||
}));
|
||||
}, []);
|
||||
return clipEntries.map((entry, index, array) => {
|
||||
const mightBlurImage = Cliphist.entryIsImage(entry) && root.clipboardWorkSafetyActive;
|
||||
let shouldBlurImage = mightBlurImage;
|
||||
if (mightBlurImage) {
|
||||
shouldBlurImage = shouldBlurImage && (root.containsUnsafeLink(array[index - 1]) || root.containsUnsafeLink(array[index + 1]));
|
||||
}
|
||||
const type = `#${entry.match(/^\s*(\S+)/)?.[1] || ""}`;
|
||||
return resultComp.createObject(null, {
|
||||
rawValue: entry,
|
||||
name: StringUtils.cleanCliphistEntry(entry),
|
||||
verb: "",
|
||||
type: type,
|
||||
execute: () => {
|
||||
Cliphist.copy(entry);
|
||||
},
|
||||
actions: [resultComp.createObject(null, {
|
||||
name: Translation.tr("Copy"),
|
||||
iconName: "content_copy",
|
||||
iconType: LauncherSearchResult.IconType.Material,
|
||||
execute: () => {
|
||||
Cliphist.copy(entry);
|
||||
}
|
||||
}), resultComp.createObject(null, {
|
||||
name: Translation.tr("Delete"),
|
||||
iconName: "delete",
|
||||
iconType: LauncherSearchResult.IconType.Material,
|
||||
execute: () => {
|
||||
Cliphist.deleteEntry(entry);
|
||||
}
|
||||
})],
|
||||
blurImage: shouldBlurImage
|
||||
});
|
||||
}).filter(Boolean);
|
||||
} else if (root.query.startsWith(Config.options.search.prefix.emojis)) {
|
||||
const searchString = StringUtils.cleanPrefix(root.query, Config.options.search.prefix.emojis);
|
||||
const allEmojiStrings = [searchString, ...KeymapTranslation.translateAll(searchString)];
|
||||
const seenEmojis = new Set();
|
||||
const emojiEntries = allEmojiStrings.reduce((acc, q) => {
|
||||
return acc.concat(Emojis.fuzzyQuery(q).filter(entry => {
|
||||
const key = entry.match(/^\s*(\S+)/)?.[1] || entry;
|
||||
if (seenEmojis.has(key)) return false;
|
||||
seenEmojis.add(key);
|
||||
return true;
|
||||
}));
|
||||
}, []);
|
||||
return emojiEntries.map(entry => {
|
||||
const emoji = entry.match(/^\s*(\S+)/)?.[1] || "";
|
||||
return resultComp.createObject(null, {
|
||||
rawValue: entry,
|
||||
name: entry.replace(/^\s*\S+\s+/, ""),
|
||||
iconName: emoji,
|
||||
iconType: LauncherSearchResult.IconType.Text,
|
||||
verb: Translation.tr("Copy"),
|
||||
type: Translation.tr("Emoji"),
|
||||
execute: () => {
|
||||
Quickshell.clipboardText = entry.match(/^\s*(\S+)/)?.[1];
|
||||
}
|
||||
});
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
////////////////// Init ///////////////////
|
||||
nonAppResultsTimer.restart();
|
||||
const mathResultObject = resultComp.createObject(null, {
|
||||
name: root.mathResult,
|
||||
verb: Translation.tr("Copy"),
|
||||
type: Translation.tr("Math result"),
|
||||
fontType: LauncherSearchResult.FontType.Monospace,
|
||||
iconName: 'calculate',
|
||||
iconType: LauncherSearchResult.IconType.Material,
|
||||
execute: () => {
|
||||
Quickshell.clipboardText = root.mathResult;
|
||||
}
|
||||
});
|
||||
|
||||
const _appQuery = StringUtils.cleanPrefix(root.query, Config.options.search.prefix.app);
|
||||
const _primaryEntries = AppSearch.fuzzyQuery(_appQuery);
|
||||
const _seenIds = new Set(_primaryEntries.map(e => e.id));
|
||||
const _layoutEntries = KeymapTranslation.translateAll(_appQuery).reduce((acc, tq) => {
|
||||
return acc.concat(AppSearch.fuzzyQuery(tq).filter(e => {
|
||||
if (_seenIds.has(e.id)) return false;
|
||||
_seenIds.add(e.id);
|
||||
return true;
|
||||
}));
|
||||
}, []);
|
||||
const _translitQuery = KeymapTranslation.transliterate(_appQuery);
|
||||
const _translitEntries = (_translitQuery && _translitQuery !== _appQuery)
|
||||
? AppSearch.levenshteinQuery(_translitQuery).filter(e => {
|
||||
if (_seenIds.has(e.id)) return false;
|
||||
_seenIds.add(e.id);
|
||||
return true;
|
||||
})
|
||||
: [];
|
||||
const _typoEntries = (_primaryEntries.length === 0 && _layoutEntries.length === 0 && _translitEntries.length === 0)
|
||||
? AppSearch.levenshteinQuery(_appQuery).filter(e => {
|
||||
if (_seenIds.has(e.id)) return false;
|
||||
_seenIds.add(e.id);
|
||||
return true;
|
||||
})
|
||||
: [];
|
||||
const appResultObjects = [..._primaryEntries, ..._layoutEntries, ..._translitEntries, ..._typoEntries].map(entry => {
|
||||
return resultComp.createObject(null, {
|
||||
type: Translation.tr("App"),
|
||||
id: entry.id,
|
||||
name: entry.name,
|
||||
iconName: entry.icon,
|
||||
iconType: LauncherSearchResult.IconType.System,
|
||||
verb: Translation.tr("Open"),
|
||||
execute: () => {
|
||||
if (!entry.runInTerminal)
|
||||
entry.execute();
|
||||
else {
|
||||
// Fixed: Escape each argument individually and pass as separate tokens to -e
|
||||
Quickshell.execDetached(["bash", '-c', `${Config.options.apps.terminal} -e ${entry.command.map(arg => "'" + StringUtils.shellSingleQuoteEscape(arg) + "'").join(' ')}`]);
|
||||
}
|
||||
},
|
||||
comment: entry.comment,
|
||||
runInTerminal: entry.runInTerminal,
|
||||
genericName: entry.genericName,
|
||||
keywords: entry.keywords,
|
||||
actions: entry.actions.map(action => {
|
||||
return resultComp.createObject(null, {
|
||||
name: action.name,
|
||||
iconName: action.icon,
|
||||
iconType: LauncherSearchResult.IconType.System,
|
||||
execute: () => {
|
||||
if (!action.runInTerminal)
|
||||
action.execute();
|
||||
else {
|
||||
Quickshell.execDetached(["bash", '-c', `${Config.options.apps.terminal} -e ${action.command.map(arg => "'" + StringUtils.shellSingleQuoteEscape(arg) + "'").join(' ')}`]);
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
});
|
||||
});
|
||||
|
||||
const commandResultObject = resultComp.createObject(null, {
|
||||
name: StringUtils.cleanPrefix(root.query, Config.options.search.prefix.shellCommand).replace("file://", ""),
|
||||
verb: Translation.tr("Run"),
|
||||
type: Translation.tr("Command"),
|
||||
fontType: LauncherSearchResult.FontType.Monospace,
|
||||
iconName: 'terminal',
|
||||
iconType: LauncherSearchResult.IconType.Material,
|
||||
execute: () => {
|
||||
let cleanedCommand = root.query.replace("file://", "");
|
||||
cleanedCommand = StringUtils.cleanPrefix(cleanedCommand, Config.options.search.prefix.shellCommand);
|
||||
if (cleanedCommand.startsWith(Config.options.search.prefix.shellCommand)) {
|
||||
cleanedCommand = cleanedCommand.slice(Config.options.search.prefix.shellCommand.length);
|
||||
}
|
||||
Quickshell.execDetached(["bash", "-c", root.query.startsWith('sudo') ? `${Config.options.apps.terminal} fish -C '${cleanedCommand}'` : cleanedCommand]);
|
||||
}
|
||||
});
|
||||
const webSearchResultObject = resultComp.createObject(null, {
|
||||
name: StringUtils.cleanPrefix(root.query, Config.options.search.prefix.webSearch),
|
||||
verb: Translation.tr("Search"),
|
||||
type: Translation.tr("Web search"),
|
||||
iconName: 'travel_explore',
|
||||
iconType: LauncherSearchResult.IconType.Material,
|
||||
execute: () => {
|
||||
let query = StringUtils.cleanPrefix(root.query, Config.options.search.prefix.webSearch);
|
||||
let url = Config.options.search.engineBaseUrl + query;
|
||||
for (let site of Config.options.search.excludedSites) {
|
||||
url += ` -site:${site}`;
|
||||
}
|
||||
Qt.openUrlExternally(url);
|
||||
}
|
||||
});
|
||||
const launcherActionObjects = root.allActions.map(action => {
|
||||
const actionString = `${Config.options.search.prefix.action}${action.action}`;
|
||||
if (actionString.startsWith(root.query) || root.query.startsWith(actionString)) {
|
||||
return resultComp.createObject(null, {
|
||||
name: root.query.startsWith(actionString) ? root.query : actionString,
|
||||
verb: Translation.tr("Run"),
|
||||
type: Translation.tr("Action"),
|
||||
iconName: 'settings_suggest',
|
||||
iconType: LauncherSearchResult.IconType.Material,
|
||||
execute: () => {
|
||||
action.execute(root.query.split(" ").slice(1).join(" "));
|
||||
}
|
||||
});
|
||||
}
|
||||
return null;
|
||||
}).filter(Boolean);
|
||||
|
||||
//////// Prioritized by prefix /////////
|
||||
let result = [];
|
||||
const startsWithNumber = /^\d/.test(root.query);
|
||||
const startsWithMathPrefix = root.query.startsWith(Config.options.search.prefix.math);
|
||||
const startsWithShellCommandPrefix = root.query.startsWith(Config.options.search.prefix.shellCommand);
|
||||
const startsWithWebSearchPrefix = root.query.startsWith(Config.options.search.prefix.webSearch);
|
||||
if (startsWithNumber || startsWithMathPrefix) {
|
||||
result.push(mathResultObject);
|
||||
} else if (startsWithShellCommandPrefix) {
|
||||
result.push(commandResultObject);
|
||||
} else if (startsWithWebSearchPrefix) {
|
||||
result.push(webSearchResultObject);
|
||||
}
|
||||
|
||||
//////////////// Apps //////////////////
|
||||
result = result.concat(appResultObjects);
|
||||
result = result.concat(fileResults);
|
||||
|
||||
|
||||
////////// Launcher actions ////////////
|
||||
result = result.concat(launcherActionObjects);
|
||||
|
||||
/// Math result, command, web search ///
|
||||
if (Config.options.search.prefix.showDefaultActionsWithoutPrefix) {
|
||||
if (!startsWithShellCommandPrefix)
|
||||
result.push(commandResultObject);
|
||||
if (!startsWithNumber && !startsWithMathPrefix)
|
||||
result.push(mathResultObject);
|
||||
if (!startsWithWebSearchPrefix)
|
||||
result.push(webSearchResultObject);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
Component {
|
||||
id: resultComp
|
||||
LauncherSearchResult {}
|
||||
}
|
||||
}
|
||||
97
surfaces/quickshell/ii-base/services/MaterialThemeLoader.qml
Normal file
97
surfaces/quickshell/ii-base/services/MaterialThemeLoader.qml
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs.modules.common
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Hyprland
|
||||
|
||||
/**
|
||||
* Automatically reloads generated material colors.
|
||||
* It is necessary to run reapplyTheme() on startup because Singletons are lazily loaded.
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
property string filePath: Directories.generatedMaterialThemePath
|
||||
|
||||
function reapplyTheme() {
|
||||
themeFileView.reload()
|
||||
}
|
||||
|
||||
function applyColors(fileContent) {
|
||||
const json = JSON.parse(fileContent)
|
||||
for (const key in json) {
|
||||
if (json.hasOwnProperty(key)) {
|
||||
// Convert snake_case to CamelCase
|
||||
const camelCaseKey = key.replace(/_([a-z])/g, (g) => g[1].toUpperCase())
|
||||
const m3Key = `m3${camelCaseKey}`
|
||||
Appearance.m3colors[m3Key] = json[key]
|
||||
}
|
||||
}
|
||||
|
||||
Appearance.m3colors.darkmode = (Appearance.m3colors.m3background.hslLightness < 0.5)
|
||||
}
|
||||
|
||||
function resetFilePathNextTime() {
|
||||
resetFilePathNextWallpaperChange.enabled = true
|
||||
}
|
||||
|
||||
Connections {
|
||||
id: resetFilePathNextWallpaperChange
|
||||
enabled: false
|
||||
target: Config.options.background
|
||||
function onWallpaperPathChanged() {
|
||||
root.filePath = ""
|
||||
root.filePath = Directories.generatedMaterialThemePath
|
||||
resetFilePathNextWallpaperChange.enabled = false
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: delayedFileRead
|
||||
interval: Config.options?.hacks?.arbitraryRaceConditionDelay ?? 100
|
||||
repeat: false
|
||||
running: false
|
||||
onTriggered: {
|
||||
root.applyColors(themeFileView.text())
|
||||
}
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: themeFileView
|
||||
path: Qt.resolvedUrl(root.filePath)
|
||||
watchChanges: true
|
||||
onFileChanged: {
|
||||
this.reload()
|
||||
delayedFileRead.start()
|
||||
}
|
||||
onLoadedChanged: {
|
||||
const fileContent = themeFileView.text()
|
||||
root.applyColors(fileContent)
|
||||
}
|
||||
onLoadFailed: root.resetFilePathNextTime();
|
||||
}
|
||||
|
||||
function toggleLightDark() {
|
||||
const currentlyDark = Appearance.m3colors.darkmode;
|
||||
Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--mode", currentlyDark ? "light" : "dark", "--noswitch"]);
|
||||
}
|
||||
|
||||
GlobalShortcut {
|
||||
name: "toggleLightDark"
|
||||
description: "Toggles between dark theme and light theme"
|
||||
|
||||
onPressed: {
|
||||
root.toggleLightDark();
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "theme"
|
||||
|
||||
function toggleLightDark(): void {
|
||||
root.toggleLightDark();
|
||||
}
|
||||
}
|
||||
}
|
||||
282
surfaces/quickshell/ii-base/services/MprisController.qml
Normal file
282
surfaces/quickshell/ii-base/services/MprisController.qml
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
// From https://git.outfoxxed.me/outfoxxed/nixnew
|
||||
// It does not have a license, but the author is okay with redistribution.
|
||||
|
||||
import QtQml.Models
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Mpris
|
||||
import qs.modules.common
|
||||
|
||||
/**
|
||||
* A service that provides easy access to the active Mpris player.
|
||||
*/
|
||||
Singleton {
|
||||
id: root;
|
||||
property list<MprisPlayer> players: Mpris.players.values.filter(player => isRealPlayer(player));
|
||||
property MprisPlayer trackedPlayer: null;
|
||||
property MprisPlayer activePlayer: trackedPlayer ?? Mpris.players.values[0] ?? null;
|
||||
signal trackChanged(reverse: bool);
|
||||
|
||||
property bool __reverse: false;
|
||||
|
||||
property var activeTrack;
|
||||
|
||||
readonly property bool hasActivePlasmaIntegration: Mpris.players.values.some(
|
||||
p => p.dbusName?.startsWith('org.mpris.MediaPlayer2.plasma-browser-integration')
|
||||
)
|
||||
function isRealPlayer(player) {
|
||||
if (!Config.options.media.filterDuplicatePlayers) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
// Remove native browser buses only if plasma-browser-integration is actually active on D-Bus
|
||||
!(hasActivePlasmaIntegration && player.dbusName.startsWith('org.mpris.MediaPlayer2.firefox')) && !(hasActivePlasmaIntegration && player.dbusName.startsWith('org.mpris.MediaPlayer2.chromium')) &&
|
||||
// playerctld just copies other buses and we don't need duplicates
|
||||
!player.dbusName?.startsWith('org.mpris.MediaPlayer2.playerctld') &&
|
||||
// Non-instance mpd bus
|
||||
!(player.dbusName?.endsWith('.mpd') && !player.dbusName.endsWith('MediaPlayer2.mpd')));
|
||||
}
|
||||
|
||||
// Last non-empty trackArtUrl seen per player (keyed by dbusName). Some
|
||||
// MPRIS players (Firefox via firefox-mpris, Spotify) emit transient empty
|
||||
// trackArtUrl between/during tracks. Consumers like PlayerControl that get
|
||||
// recreated on panel open/close need a stable fallback so the cover
|
||||
// doesn't vanish whenever they happen to mount during an empty window.
|
||||
// dbusName is the right key here: MprisPlayer.uniqueId in Quickshell is a
|
||||
// per-track identifier (it increments when the track changes), not a
|
||||
// per-player one, so keying on it cross-contaminates between players that
|
||||
// happen to share a uniqueId value.
|
||||
property var stableArtUrlByPlayer: ({})
|
||||
|
||||
function _captureArtUrl(player) {
|
||||
if (player?.trackArtUrl?.length > 0 && !!player?.dbusName) {
|
||||
const map = Object.assign({}, root.stableArtUrlByPlayer);
|
||||
map[player.dbusName] = player.trackArtUrl;
|
||||
root.stableArtUrlByPlayer = map;
|
||||
}
|
||||
}
|
||||
|
||||
// Best (largest) downloaded cover file seen per player for the current
|
||||
// track, keyed by dbusName. Persists across PlayerControl lifecycles so
|
||||
// reopening the panel doesn't downgrade to a thumbnail when Firefox
|
||||
// happens to be sitting on its low-res variant. Entry shape:
|
||||
// { trackKey: string, artFilePath: string, artBytes: number }
|
||||
property var bestArtByPlayer: ({})
|
||||
|
||||
function rememberBestArt(player, trackKey, artFilePath, artBytes) {
|
||||
const id = player?.dbusName;
|
||||
if (!id || artBytes <= 0 || !artFilePath) return;
|
||||
const existing = root.bestArtByPlayer[id];
|
||||
// Same track and existing is already >= new size: nothing to do.
|
||||
if (existing && existing.trackKey === trackKey && existing.artBytes >= artBytes) return;
|
||||
const map = Object.assign({}, root.bestArtByPlayer);
|
||||
map[id] = { trackKey: trackKey, artFilePath: artFilePath, artBytes: artBytes };
|
||||
root.bestArtByPlayer = map;
|
||||
}
|
||||
|
||||
function getBestArt(player, trackKey) {
|
||||
const id = player?.dbusName;
|
||||
if (!id) return null;
|
||||
const entry = root.bestArtByPlayer[id];
|
||||
if (!entry || entry.trackKey !== trackKey) return null;
|
||||
return entry;
|
||||
}
|
||||
|
||||
function _trackKeyOf(player) {
|
||||
// title|artist (album omitted on purpose). Some players (Firefox via
|
||||
// firefox-mpris) emit metadata progressively: first trackArtUrl +
|
||||
// title + artist with album="", then a moment later update album.
|
||||
// If album were part of the key, the high-res variant emitted with
|
||||
// the partial metadata and the low-res variant emitted with the
|
||||
// completed metadata would look like different tracks to the
|
||||
// "never-downgrade" guard.
|
||||
return `${player?.trackTitle ?? ""}|${player?.trackArtist ?? ""}`;
|
||||
}
|
||||
|
||||
// Per-player worker. Holds a Process that downloads each new trackArtUrl
|
||||
// the player emits, regardless of whether the media controls panel is
|
||||
// open. This is what makes the cover stay sharp across panel
|
||||
// close/reopen and across auto-advance while the panel is closed —
|
||||
// PlayerControl is no longer the only thing watching for art emissions.
|
||||
component PlayerWorker: QtObject {
|
||||
id: worker
|
||||
required property MprisPlayer player
|
||||
|
||||
function _fetchArt() {
|
||||
const url = worker.player?.trackArtUrl;
|
||||
if (!url || url.length === 0) return;
|
||||
artDownloader.trackKey = root._trackKeyOf(worker.player);
|
||||
artDownloader.targetFile = url;
|
||||
artDownloader.artFilePath = `${Directories.coverArt}/${Qt.md5(url)}`;
|
||||
artDownloader.running = true;
|
||||
}
|
||||
|
||||
property Process artDownloader: Process {
|
||||
property string trackKey
|
||||
property string targetFile
|
||||
property string artFilePath
|
||||
property int sizeBytes: 0
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
const n = parseInt(data.trim());
|
||||
if (!isNaN(n)) artDownloader.sizeBytes = n;
|
||||
}
|
||||
}
|
||||
command: ["bash", "-c", `[ -f ${artFilePath} ] || curl -4 -sSL '${targetFile}' -o '${artFilePath}'; stat -c %s '${artFilePath}' 2>/dev/null`]
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0 || sizeBytes <= 0 || artFilePath.length === 0) return;
|
||||
root.rememberBestArt(worker.player, trackKey, artFilePath, sizeBytes);
|
||||
}
|
||||
}
|
||||
|
||||
property Connections _conn: Connections {
|
||||
target: worker.player
|
||||
function onPlaybackStateChanged() {
|
||||
if (root.trackedPlayer !== worker.player) root.trackedPlayer = worker.player;
|
||||
}
|
||||
function onTrackArtUrlChanged() {
|
||||
root._captureArtUrl(worker.player);
|
||||
worker._fetchArt();
|
||||
}
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (root.trackedPlayer == null || worker.player.isPlaying) {
|
||||
root.trackedPlayer = worker.player;
|
||||
}
|
||||
root._captureArtUrl(worker.player);
|
||||
worker._fetchArt();
|
||||
}
|
||||
|
||||
Component.onDestruction: {
|
||||
if (root.trackedPlayer == null || !root.trackedPlayer.isPlaying) {
|
||||
for (const p of Mpris.players.values) {
|
||||
if (p.playbackState.isPlaying) {
|
||||
root.trackedPlayer = p;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (root.trackedPlayer == null && Mpris.players.values.length != 0) {
|
||||
root.trackedPlayer = Mpris.players.values[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Instantiator {
|
||||
model: Mpris.players
|
||||
delegate: PlayerWorker {
|
||||
required property MprisPlayer modelData
|
||||
player: modelData
|
||||
}
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: activePlayer
|
||||
|
||||
function onPostTrackChanged() {
|
||||
root.updateTrack();
|
||||
}
|
||||
|
||||
function onTrackArtUrlChanged() {
|
||||
// console.log("arturl:", activePlayer.trackArtUrl)
|
||||
// root.updateTrack();
|
||||
if (root.activePlayer.uniqueId == root.activeTrack.uniqueId && root.activePlayer.trackArtUrl != root.activeTrack.artUrl) {
|
||||
// cantata likes to send cover updates *BEFORE* updating the track info.
|
||||
// as such, art url changes shouldn't be able to break the reverse animation
|
||||
const r = root.__reverse;
|
||||
root.updateTrack();
|
||||
root.__reverse = r;
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onActivePlayerChanged: this.updateTrack();
|
||||
|
||||
function updateTrack() {
|
||||
//console.log(`update: ${this.activePlayer?.trackTitle ?? ""} : ${this.activePlayer?.trackArtists}`)
|
||||
this.activeTrack = {
|
||||
uniqueId: this.activePlayer?.uniqueId ?? 0,
|
||||
artUrl: this.activePlayer?.trackArtUrl ?? "",
|
||||
title: this.activePlayer?.trackTitle || Translation.tr("Unknown Title"),
|
||||
artist: this.activePlayer?.trackArtist || Translation.tr("Unknown Artist"),
|
||||
album: this.activePlayer?.trackAlbum || Translation.tr("Unknown Album"),
|
||||
};
|
||||
|
||||
this.trackChanged(__reverse);
|
||||
this.__reverse = false;
|
||||
}
|
||||
|
||||
property bool isPlaying: this.activePlayer && this.activePlayer.isPlaying;
|
||||
property bool canTogglePlaying: this.activePlayer?.canTogglePlaying ?? false;
|
||||
function togglePlaying() {
|
||||
if (this.canTogglePlaying) this.activePlayer.togglePlaying();
|
||||
}
|
||||
|
||||
property bool canGoPrevious: this.activePlayer?.canGoPrevious ?? false;
|
||||
function previous() {
|
||||
if (this.canGoPrevious) {
|
||||
this.__reverse = true;
|
||||
this.activePlayer.previous();
|
||||
}
|
||||
}
|
||||
|
||||
property bool canGoNext: this.activePlayer?.canGoNext ?? false;
|
||||
function next() {
|
||||
if (this.canGoNext) {
|
||||
this.__reverse = false;
|
||||
this.activePlayer.next();
|
||||
}
|
||||
}
|
||||
|
||||
property bool canChangeVolume: this.activePlayer && this.activePlayer.volumeSupported && this.activePlayer.canControl;
|
||||
|
||||
property bool loopSupported: this.activePlayer && this.activePlayer.loopSupported && this.activePlayer.canControl;
|
||||
property var loopState: this.activePlayer?.loopState ?? MprisLoopState.None;
|
||||
function setLoopState(loopState: var) {
|
||||
if (this.loopSupported) {
|
||||
this.activePlayer.loopState = loopState;
|
||||
}
|
||||
}
|
||||
|
||||
property bool shuffleSupported: this.activePlayer && this.activePlayer.shuffleSupported && this.activePlayer.canControl;
|
||||
property bool hasShuffle: this.activePlayer?.shuffle ?? false;
|
||||
function setShuffle(shuffle: bool) {
|
||||
if (this.shuffleSupported) {
|
||||
this.activePlayer.shuffle = shuffle;
|
||||
}
|
||||
}
|
||||
|
||||
function setActivePlayer(player: MprisPlayer) {
|
||||
const targetPlayer = player ?? Mpris.players[0];
|
||||
console.log(`[Mpris] Active player ${targetPlayer} << ${activePlayer}`)
|
||||
|
||||
if (targetPlayer && this.activePlayer) {
|
||||
this.__reverse = Mpris.players.indexOf(targetPlayer) < Mpris.players.indexOf(this.activePlayer);
|
||||
} else {
|
||||
// always animate forward if going to null
|
||||
this.__reverse = false;
|
||||
}
|
||||
|
||||
this.trackedPlayer = targetPlayer;
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "mpris"
|
||||
|
||||
function pauseAll(): void {
|
||||
for (const player of Mpris.players.values) {
|
||||
if (player.canPause) player.pause();
|
||||
}
|
||||
}
|
||||
|
||||
function playPause(): void { root.togglePlaying(); }
|
||||
function previous(): void { root.previous(); }
|
||||
function next(): void { root.next(); }
|
||||
}
|
||||
}
|
||||
332
surfaces/quickshell/ii-base/services/Network.qml
Normal file
332
surfaces/quickshell/ii-base/services/Network.qml
Normal file
|
|
@ -0,0 +1,332 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
// Took many bits from https://github.com/caelestia-dots/shell (GPLv3)
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import qs.services.network
|
||||
|
||||
/**
|
||||
* Network service with nmcli.
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property bool wifi: true
|
||||
property bool ethernet: false
|
||||
|
||||
property bool wifiEnabled: false
|
||||
property bool wifiScanning: false
|
||||
property bool wifiConnecting: connectProc.running
|
||||
property WifiAccessPoint wifiConnectTarget
|
||||
readonly property list<WifiAccessPoint> wifiNetworks: []
|
||||
readonly property WifiAccessPoint active: wifiNetworks.find(n => n.active) ?? null
|
||||
readonly property list<var> friendlyWifiNetworks: [...wifiNetworks].sort((a, b) => {
|
||||
if (a.active && !b.active)
|
||||
return -1;
|
||||
if (!a.active && b.active)
|
||||
return 1;
|
||||
return b.strength - a.strength;
|
||||
})
|
||||
property string wifiStatus: "disconnected"
|
||||
|
||||
property string networkName: ""
|
||||
property int networkStrength
|
||||
property string materialSymbol: root.ethernet
|
||||
? "lan"
|
||||
: (root.wifiEnabled && root.wifiStatus === "connected")
|
||||
? (
|
||||
(root.active?.strength ?? 0) > 83 ? "signal_wifi_4_bar" :
|
||||
(root.active?.strength ?? 0) > 67 ? "network_wifi" :
|
||||
(root.active?.strength ?? 0) > 50 ? "network_wifi_3_bar" :
|
||||
(root.active?.strength ?? 0) > 33 ? "network_wifi_2_bar" :
|
||||
(root.active?.strength ?? 0) > 17 ? "network_wifi_1_bar" :
|
||||
"signal_wifi_0_bar"
|
||||
)
|
||||
: (root.wifiStatus === "connecting")
|
||||
? "signal_wifi_statusbar_not_connected"
|
||||
: (root.wifiStatus === "disconnected")
|
||||
? "wifi_find"
|
||||
: (root.wifiStatus === "disabled")
|
||||
? "signal_wifi_off"
|
||||
: "signal_wifi_bad"
|
||||
|
||||
// Control
|
||||
function enableWifi(enabled = true): void {
|
||||
const cmd = enabled ? "on" : "off";
|
||||
enableWifiProc.exec(["nmcli", "radio", "wifi", cmd]);
|
||||
}
|
||||
|
||||
function toggleWifi(): void {
|
||||
enableWifi(!wifiEnabled);
|
||||
}
|
||||
|
||||
function rescanWifi(): void {
|
||||
wifiScanning = true;
|
||||
rescanProcess.running = true;
|
||||
}
|
||||
|
||||
function connectToWifiNetwork(accessPoint: WifiAccessPoint): void {
|
||||
accessPoint.askingPassword = false;
|
||||
root.wifiConnectTarget = accessPoint;
|
||||
// We use this instead of `nmcli connection up SSID` because this also creates a connection profile
|
||||
connectProc.exec(["nmcli", "dev", "wifi", "connect", accessPoint.ssid])
|
||||
|
||||
}
|
||||
|
||||
function disconnectWifiNetwork(): void {
|
||||
if (active) disconnectProc.exec(["nmcli", "connection", "down", active.ssid]);
|
||||
}
|
||||
|
||||
function openPublicWifiPortal() {
|
||||
Quickshell.execDetached(["xdg-open", "https://nmcheck.gnome.org/"]) // From some StackExchange thread, seems to work
|
||||
}
|
||||
|
||||
function changePassword(network: WifiAccessPoint, password: string, username = ""): void {
|
||||
// TODO: enterprise wifi with username
|
||||
network.askingPassword = false;
|
||||
changePasswordProc.exec({
|
||||
"environment": {
|
||||
"PASSWORD": password,
|
||||
"SSID": network.ssid
|
||||
},
|
||||
"command": ["bash", "-c", 'nmcli connection modify "$SSID" wifi-sec.psk "$PASSWORD"']
|
||||
})
|
||||
}
|
||||
|
||||
Process {
|
||||
id: enableWifiProc
|
||||
}
|
||||
|
||||
Process {
|
||||
id: connectProc
|
||||
environment: ({
|
||||
LANG: "C",
|
||||
LC_ALL: "C"
|
||||
})
|
||||
stdout: SplitParser {
|
||||
onRead: line => {
|
||||
// print(line)
|
||||
getNetworks.running = true
|
||||
}
|
||||
}
|
||||
stderr: SplitParser {
|
||||
onRead: line => {
|
||||
// print("err:", line)
|
||||
if (line.includes("Secrets were required")) {
|
||||
root.wifiConnectTarget.askingPassword = true
|
||||
}
|
||||
}
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.wifiConnectTarget.askingPassword = (exitCode !== 0)
|
||||
root.wifiConnectTarget = null
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: disconnectProc
|
||||
stdout: SplitParser {
|
||||
onRead: getNetworks.running = true
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: changePasswordProc
|
||||
onExited: { // Re-attempt connection after changing password
|
||||
connectProc.running = false
|
||||
connectProc.running = true
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: rescanProcess
|
||||
command: ["nmcli", "dev", "wifi", "list", "--rescan", "yes"]
|
||||
stdout: SplitParser {
|
||||
onRead: {
|
||||
wifiScanning = false;
|
||||
getNetworks.running = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Status update
|
||||
function update() {
|
||||
updateConnectionType.startCheck();
|
||||
wifiStatusProcess.running = true
|
||||
updateNetworkName.running = true;
|
||||
updateNetworkStrength.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: subscriber
|
||||
running: true
|
||||
command: ["nmcli", "monitor"]
|
||||
stdout: SplitParser {
|
||||
onRead: root.update()
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: updateConnectionType
|
||||
property string buffer
|
||||
command: ["sh", "-c", "nmcli -t -f TYPE,STATE d status && nmcli -t -f CONNECTIVITY g"]
|
||||
running: true
|
||||
function startCheck() {
|
||||
buffer = "";
|
||||
updateConnectionType.running = true;
|
||||
}
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
updateConnectionType.buffer += data + "\n";
|
||||
}
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
const lines = updateConnectionType.buffer.trim().split('\n');
|
||||
const connectivity = lines.pop() // none, limited, full
|
||||
let hasEthernet = false;
|
||||
let hasWifi = false;
|
||||
let wifiStatus = "disconnected";
|
||||
lines.forEach(line => {
|
||||
if (line.includes("ethernet") && line.includes("connected"))
|
||||
hasEthernet = true;
|
||||
else if (line.includes("wifi:")) {
|
||||
if (line.includes("disconnected")) {
|
||||
wifiStatus = "disconnected"
|
||||
}
|
||||
else if (line.includes("connected")) {
|
||||
hasWifi = true;
|
||||
wifiStatus = "connected"
|
||||
|
||||
if (connectivity === "limited") {
|
||||
hasWifi = false;
|
||||
wifiStatus = "limited"
|
||||
}
|
||||
}
|
||||
else if (line.includes("connecting")) {
|
||||
wifiStatus = "connecting"
|
||||
}
|
||||
else if (line.includes("unavailable")) {
|
||||
wifiStatus = "disabled"
|
||||
}
|
||||
}
|
||||
});
|
||||
root.wifiStatus = wifiStatus;
|
||||
root.ethernet = hasEthernet;
|
||||
root.wifi = hasWifi;
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: updateNetworkName
|
||||
command: ["sh", "-c", "nmcli -t -f NAME c show --active | head -1"]
|
||||
running: true
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
root.networkName = data;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: updateNetworkStrength
|
||||
running: true
|
||||
command: ["sh", "-c", "nmcli -f IN-USE,SIGNAL,SSID device wifi | awk '/^\\*/{if (NR!=1) {print $2}}'"]
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
root.networkStrength = parseInt(data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: wifiStatusProcess
|
||||
command: ["nmcli", "radio", "wifi"]
|
||||
Component.onCompleted: running = true
|
||||
environment: ({
|
||||
LANG: "C",
|
||||
LC_ALL: "C"
|
||||
})
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
root.wifiEnabled = text.trim() === "enabled";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getNetworks
|
||||
running: true
|
||||
command: ["nmcli", "-g", "ACTIVE,SIGNAL,FREQ,SSID,BSSID,SECURITY", "d", "w"]
|
||||
environment: ({
|
||||
LANG: "C",
|
||||
LC_ALL: "C"
|
||||
})
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
const PLACEHOLDER = "STRINGWHICHHOPEFULLYWONTBEUSED";
|
||||
const rep = new RegExp("\\\\:", "g");
|
||||
const rep2 = new RegExp(PLACEHOLDER, "g");
|
||||
|
||||
const allNetworks = text.trim().split("\n").map(n => {
|
||||
const net = n.replace(rep, PLACEHOLDER).split(":");
|
||||
return {
|
||||
active: net[0] === "yes",
|
||||
strength: parseInt(net[1]),
|
||||
frequency: parseInt(net[2]),
|
||||
ssid: net[3],
|
||||
bssid: net[4]?.replace(rep2, ":") ?? "",
|
||||
security: net[5] || ""
|
||||
};
|
||||
}).filter(n => n.ssid && n.ssid.length > 0);
|
||||
|
||||
// Group networks by SSID and prioritize connected ones
|
||||
const networkMap = new Map();
|
||||
for (const network of allNetworks) {
|
||||
const existing = networkMap.get(network.ssid);
|
||||
if (!existing) {
|
||||
networkMap.set(network.ssid, network);
|
||||
} else {
|
||||
// Prioritize active/connected networks
|
||||
if (network.active && !existing.active) {
|
||||
networkMap.set(network.ssid, network);
|
||||
} else if (!network.active && !existing.active) {
|
||||
// If both are inactive, keep the one with better signal
|
||||
if (network.strength > existing.strength) {
|
||||
networkMap.set(network.ssid, network);
|
||||
}
|
||||
}
|
||||
// If existing is active and new is not, keep existing
|
||||
}
|
||||
}
|
||||
|
||||
const wifiNetworks = Array.from(networkMap.values());
|
||||
|
||||
const rNetworks = root.wifiNetworks;
|
||||
|
||||
const destroyed = rNetworks.filter(rn => !wifiNetworks.find(n => n.frequency === rn.frequency && n.ssid === rn.ssid && n.bssid === rn.bssid));
|
||||
for (const network of destroyed)
|
||||
rNetworks.splice(rNetworks.indexOf(network), 1).forEach(n => n.destroy());
|
||||
|
||||
for (const network of wifiNetworks) {
|
||||
const match = rNetworks.find(n => n.frequency === network.frequency && n.ssid === network.ssid && n.bssid === network.bssid);
|
||||
if (match) {
|
||||
match.lastIpcObject = network;
|
||||
} else {
|
||||
rNetworks.push(apComp.createObject(root, {
|
||||
lastIpcObject: network
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Component {
|
||||
id: apComp
|
||||
|
||||
WifiAccessPoint {}
|
||||
}
|
||||
}
|
||||
163
surfaces/quickshell/ii-base/services/NetworkTraffic.qml
Normal file
163
surfaces/quickshell/ii-base/services/NetworkTraffic.qml
Normal file
|
|
@ -0,0 +1,163 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property string interfaceName: ""
|
||||
property real rxBytesPerSecond: 0
|
||||
property real txBytesPerSecond: 0
|
||||
property string downloadSpeedText: formatSpeed(rxBytesPerSecond)
|
||||
property string uploadSpeedText: formatSpeed(txBytesPerSecond)
|
||||
property string downloadSpeedCompactText: formatCompactSpeed(rxBytesPerSecond)
|
||||
property string uploadSpeedCompactText: formatCompactSpeed(txBytesPerSecond)
|
||||
readonly property bool available: interfaceName.length > 0
|
||||
|
||||
property real previousRxBytes: -1
|
||||
property real previousTxBytes: -1
|
||||
property double previousTimestampMs: 0
|
||||
|
||||
function formatSpeed(bytesPerSecond) {
|
||||
const units = ["B/s", "K/s", "M/s", "G/s"];
|
||||
let value = Math.max(0, bytesPerSecond);
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
const precision = value >= 100 ? 0 : value >= 10 ? 1 : 2;
|
||||
return `${value.toFixed(precision)}${units[unitIndex]}`;
|
||||
}
|
||||
|
||||
function formatCompactSpeed(bytesPerSecond) {
|
||||
const units = ["B", "K", "M", "G"];
|
||||
let value = Math.max(0, bytesPerSecond);
|
||||
let unitIndex = 0;
|
||||
|
||||
while (value >= 1024 && unitIndex < units.length - 1) {
|
||||
value /= 1024;
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
let textValue = "0";
|
||||
if (value >= 100)
|
||||
textValue = `${Math.round(value)}`;
|
||||
else if (value >= 10)
|
||||
textValue = `${Math.round(value)}`;
|
||||
else
|
||||
textValue = `${value.toFixed(1)}`.replace(/\.0$/, "");
|
||||
|
||||
return `${textValue}${units[unitIndex]}/s`;
|
||||
}
|
||||
|
||||
function pickInterfaceName(routeText, devText) {
|
||||
const routeLines = routeText.trim().split("\n").slice(1);
|
||||
for (const line of routeLines) {
|
||||
const columns = line.trim().split(/\s+/);
|
||||
if (columns.length < 2)
|
||||
continue;
|
||||
|
||||
const iface = columns[0];
|
||||
const destination = columns[1];
|
||||
if (destination === "00000000" && iface !== "lo")
|
||||
return iface;
|
||||
}
|
||||
|
||||
const devLines = devText.trim().split("\n").slice(2);
|
||||
for (const line of devLines) {
|
||||
const iface = line.split(":")[0]?.trim() ?? "";
|
||||
if (iface.length > 0 && iface !== "lo")
|
||||
return iface;
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseCounters(devText, ifaceName) {
|
||||
if (!ifaceName)
|
||||
return null;
|
||||
|
||||
const escapedName = ifaceName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const lineMatch = devText.match(new RegExp(`^\\s*${escapedName}\\s*:\\s*(.+)$`, "m"));
|
||||
if (!lineMatch)
|
||||
return null;
|
||||
|
||||
const fields = lineMatch[1].trim().split(/\s+/);
|
||||
if (fields.length < 9)
|
||||
return null;
|
||||
|
||||
return {
|
||||
rx: Number(fields[0]),
|
||||
tx: Number(fields[8])
|
||||
};
|
||||
}
|
||||
|
||||
function update() {
|
||||
routeFile.reload();
|
||||
devFile.reload();
|
||||
|
||||
const routeText = routeFile.text();
|
||||
const devText = devFile.text();
|
||||
const detectedInterface = pickInterfaceName(routeText, devText);
|
||||
|
||||
if (detectedInterface !== interfaceName) {
|
||||
interfaceName = detectedInterface;
|
||||
previousRxBytes = -1;
|
||||
previousTxBytes = -1;
|
||||
previousTimestampMs = 0;
|
||||
}
|
||||
|
||||
if (!interfaceName) {
|
||||
rxBytesPerSecond = 0;
|
||||
txBytesPerSecond = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const counters = parseCounters(devText, interfaceName);
|
||||
if (!counters) {
|
||||
rxBytesPerSecond = 0;
|
||||
txBytesPerSecond = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const now = Date.now();
|
||||
|
||||
if (previousRxBytes < 0 || previousTxBytes < 0 || previousTimestampMs <= 0) {
|
||||
previousRxBytes = counters.rx;
|
||||
previousTxBytes = counters.tx;
|
||||
previousTimestampMs = now;
|
||||
rxBytesPerSecond = 0;
|
||||
txBytesPerSecond = 0;
|
||||
return;
|
||||
}
|
||||
|
||||
const elapsedMs = Math.max(1, now - previousTimestampMs);
|
||||
rxBytesPerSecond = Math.max(0, (counters.rx - previousRxBytes) * 1000 / elapsedMs);
|
||||
txBytesPerSecond = Math.max(0, (counters.tx - previousTxBytes) * 1000 / elapsedMs);
|
||||
|
||||
previousRxBytes = counters.rx;
|
||||
previousTxBytes = counters.tx;
|
||||
previousTimestampMs = now;
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 1000
|
||||
// Souveraine: pause while the display is inactive (idle-power task 4).
|
||||
// The rate math is elapsed-time based, so the first tick after resume
|
||||
// yields one correctly averaged sample rather than a spike.
|
||||
running: GlobalStates.displayActive
|
||||
repeat: true
|
||||
triggeredOnStart: true
|
||||
onTriggered: root.update()
|
||||
}
|
||||
|
||||
FileView { id: routeFile; path: "/proc/net/route" }
|
||||
FileView { id: devFile; path: "/proc/net/dev" }
|
||||
}
|
||||
305
surfaces/quickshell/ii-base/services/Notifications.qml
Normal file
305
surfaces/quickshell/ii-base/services/Notifications.qml
Normal file
|
|
@ -0,0 +1,305 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs.modules.common
|
||||
import qs
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import Quickshell.Services.Notifications
|
||||
|
||||
/**
|
||||
* Provides extra features not in Quickshell.Services.Notifications:
|
||||
* - Persistent storage
|
||||
* - Popup notifications, with timeout
|
||||
* - Notification groups by app
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
component Notif: QtObject {
|
||||
id: wrapper
|
||||
required property int notificationId // Could just be `id` but it conflicts with the default prop in QtObject
|
||||
property Notification notification
|
||||
property list<var> actions: notification?.actions.map((action) => ({
|
||||
"identifier": action.identifier,
|
||||
"text": action.text,
|
||||
})) ?? []
|
||||
property bool popup: false
|
||||
property bool isTransient: notification?.hints.transient ?? false
|
||||
property string appIcon: notification?.appIcon ?? ""
|
||||
property string appName: notification?.appName ?? ""
|
||||
property string body: notification?.body ?? ""
|
||||
property string image: notification?.image ?? ""
|
||||
property string summary: notification?.summary ?? ""
|
||||
property double time
|
||||
property string urgency: notification?.urgency.toString() ?? "normal"
|
||||
property Timer timer
|
||||
|
||||
onNotificationChanged: {
|
||||
if (notification === null) {
|
||||
root.discardNotification(notificationId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function notifToJSON(notif) {
|
||||
return {
|
||||
"notificationId": notif.notificationId,
|
||||
"actions": notif.actions,
|
||||
"appIcon": notif.appIcon,
|
||||
"appName": notif.appName,
|
||||
"body": notif.body,
|
||||
"image": notif.image,
|
||||
"summary": notif.summary,
|
||||
"time": notif.time,
|
||||
"urgency": notif.urgency,
|
||||
}
|
||||
}
|
||||
function notifToString(notif) {
|
||||
return JSON.stringify(notifToJSON(notif), null, 2);
|
||||
}
|
||||
|
||||
component NotifTimer: Timer {
|
||||
required property int notificationId
|
||||
interval: 7000
|
||||
running: true
|
||||
onTriggered: () => {
|
||||
const index = root.list.findIndex((notif) => notif.notificationId === notificationId);
|
||||
const notifObject = root.list[index];
|
||||
print("[Notifications] Notification timer triggered for ID: " + notificationId + ", transient: " + notifObject?.isTransient);
|
||||
if (notifObject.isTransient) root.discardNotification(notificationId);
|
||||
else root.timeoutNotification(notificationId);
|
||||
destroy()
|
||||
}
|
||||
}
|
||||
|
||||
property bool silent: false
|
||||
property int unread: 0
|
||||
property var filePath: Directories.notificationsPath
|
||||
property list<Notif> list: []
|
||||
property var popupList: list.filter((notif) => notif.popup);
|
||||
property bool popupInhibited: (GlobalStates?.sidebarRightOpen ?? false) || silent
|
||||
property var latestTimeForApp: ({})
|
||||
Component {
|
||||
id: notifComponent
|
||||
Notif {}
|
||||
}
|
||||
Component {
|
||||
id: notifTimerComponent
|
||||
NotifTimer {}
|
||||
}
|
||||
|
||||
function stringifyList(list) {
|
||||
return JSON.stringify(list.map((notif) => notifToJSON(notif)), null, 2);
|
||||
}
|
||||
|
||||
onListChanged: {
|
||||
// Update latest time for each app
|
||||
root.list.forEach((notif) => {
|
||||
if (!root.latestTimeForApp[notif.appName] || notif.time > root.latestTimeForApp[notif.appName]) {
|
||||
root.latestTimeForApp[notif.appName] = Math.max(root.latestTimeForApp[notif.appName] || 0, notif.time);
|
||||
}
|
||||
});
|
||||
// Remove apps that no longer have notifications
|
||||
Object.keys(root.latestTimeForApp).forEach((appName) => {
|
||||
if (!root.list.some((notif) => notif.appName === appName)) {
|
||||
delete root.latestTimeForApp[appName];
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function appNameListForGroups(groups) {
|
||||
return Object.keys(groups).sort((a, b) => {
|
||||
// Sort by time, descending
|
||||
return groups[b].time - groups[a].time;
|
||||
});
|
||||
}
|
||||
|
||||
function groupsForList(list) {
|
||||
const groups = {};
|
||||
list.forEach((notif) => {
|
||||
if (!groups[notif.appName]) {
|
||||
groups[notif.appName] = {
|
||||
appName: notif.appName,
|
||||
appIcon: notif.appIcon,
|
||||
notifications: [],
|
||||
time: 0
|
||||
};
|
||||
}
|
||||
groups[notif.appName].notifications.push(notif);
|
||||
// Always set to the latest time in the group
|
||||
groups[notif.appName].time = latestTimeForApp[notif.appName] || notif.time;
|
||||
});
|
||||
return groups;
|
||||
}
|
||||
|
||||
property var groupsByAppName: groupsForList(root.list)
|
||||
property var popupGroupsByAppName: groupsForList(root.popupList)
|
||||
property list<string> appNameList: appNameListForGroups(root.groupsByAppName)
|
||||
property list<string> popupAppNameList: appNameListForGroups(root.popupGroupsByAppName)
|
||||
|
||||
// Quickshell's notification IDs starts at 1 on each run, while saved notifications
|
||||
// can already contain higher IDs. This is for avoiding id collisions
|
||||
property int idOffset
|
||||
signal initDone();
|
||||
signal notify(notification: var);
|
||||
signal discard(id: int);
|
||||
signal discardAll();
|
||||
signal timeout(id: var);
|
||||
|
||||
NotificationServer {
|
||||
id: notifServer
|
||||
// actionIconsSupported: true
|
||||
actionsSupported: true
|
||||
bodyHyperlinksSupported: true
|
||||
bodyImagesSupported: true
|
||||
bodyMarkupSupported: true
|
||||
bodySupported: true
|
||||
imageSupported: true
|
||||
keepOnReload: false
|
||||
persistenceSupported: true
|
||||
|
||||
onNotification: (notification) => {
|
||||
notification.tracked = true
|
||||
const newNotifObject = notifComponent.createObject(root, {
|
||||
"notificationId": notification.id + root.idOffset,
|
||||
"notification": notification,
|
||||
"time": Date.now(),
|
||||
});
|
||||
root.list = [...root.list, newNotifObject];
|
||||
|
||||
// Popup
|
||||
if (!root.popupInhibited) {
|
||||
newNotifObject.popup = true;
|
||||
if (notification.expireTimeout != 0) {
|
||||
newNotifObject.timer = notifTimerComponent.createObject(root, {
|
||||
"notificationId": newNotifObject.notificationId,
|
||||
"interval": Config?.options.notifications.timeout ?? 7000,
|
||||
});
|
||||
}
|
||||
root.unread++;
|
||||
}
|
||||
root.notify(newNotifObject);
|
||||
// console.log(notifToString(newNotifObject));
|
||||
notifFileView.setText(stringifyList(root.list));
|
||||
}
|
||||
}
|
||||
|
||||
function markAllRead() {
|
||||
root.unread = 0;
|
||||
}
|
||||
|
||||
function discardNotification(id) {
|
||||
console.log("[Notifications] Discarding notification with ID: " + id);
|
||||
const index = root.list.findIndex((notif) => notif.notificationId === id);
|
||||
const notifServerIndex = notifServer.trackedNotifications.values.findIndex((notif) => notif.id + root.idOffset === id);
|
||||
if (index !== -1) {
|
||||
root.list.splice(index, 1);
|
||||
notifFileView.setText(stringifyList(root.list));
|
||||
triggerListChange()
|
||||
}
|
||||
if (notifServerIndex !== -1) {
|
||||
notifServer.trackedNotifications.values[notifServerIndex].dismiss()
|
||||
}
|
||||
root.discard(id); // Emit signal
|
||||
}
|
||||
|
||||
function discardAllNotifications() {
|
||||
root.list = []
|
||||
triggerListChange()
|
||||
notifFileView.setText(stringifyList(root.list));
|
||||
notifServer.trackedNotifications.values.forEach((notif) => {
|
||||
notif.dismiss()
|
||||
})
|
||||
root.discardAll();
|
||||
}
|
||||
|
||||
function cancelTimeout(id) {
|
||||
const index = root.list.findIndex((notif) => notif.notificationId === id);
|
||||
if (root.list[index] != null)
|
||||
root.list[index].timer.stop();
|
||||
}
|
||||
|
||||
function timeoutNotification(id) {
|
||||
const index = root.list.findIndex((notif) => notif.notificationId === id);
|
||||
if (root.list[index] != null)
|
||||
root.list[index].popup = false;
|
||||
root.timeout(id);
|
||||
}
|
||||
|
||||
function timeoutAll() {
|
||||
root.popupList.forEach((notif) => {
|
||||
root.timeout(notif.notificationId);
|
||||
})
|
||||
root.popupList.forEach((notif) => {
|
||||
notif.popup = false;
|
||||
});
|
||||
}
|
||||
|
||||
function attemptInvokeAction(id, notifIdentifier) {
|
||||
console.log("[Notifications] Attempting to invoke action with identifier: " + notifIdentifier + " for notification ID: " + id);
|
||||
const notifServerIndex = notifServer.trackedNotifications.values.findIndex((notif) => notif.id + root.idOffset === id);
|
||||
console.log("Notification server index: " + notifServerIndex);
|
||||
if (notifServerIndex !== -1) {
|
||||
const notifServerNotif = notifServer.trackedNotifications.values[notifServerIndex];
|
||||
const action = notifServerNotif.actions.find((action) => action.identifier === notifIdentifier);
|
||||
// console.log("Action found: " + JSON.stringify(action));
|
||||
action.invoke()
|
||||
}
|
||||
else {
|
||||
console.log("Notification not found in server: " + id)
|
||||
}
|
||||
root.discardNotification(id);
|
||||
}
|
||||
|
||||
function triggerListChange() {
|
||||
root.list = root.list.slice(0)
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
notifFileView.reload()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
refresh()
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: notifFileView
|
||||
path: Qt.resolvedUrl(filePath)
|
||||
onLoaded: {
|
||||
const fileContents = notifFileView.text()
|
||||
root.list = JSON.parse(fileContents).map((notif) => {
|
||||
return notifComponent.createObject(root, {
|
||||
"notificationId": notif.notificationId,
|
||||
"actions": [], // Notification actions are meaningless if they're not tracked by the server or the sender is dead
|
||||
"appIcon": notif.appIcon,
|
||||
"appName": notif.appName,
|
||||
"body": notif.body,
|
||||
"image": notif.image,
|
||||
"summary": notif.summary,
|
||||
"time": notif.time,
|
||||
"urgency": notif.urgency,
|
||||
});
|
||||
});
|
||||
// Find largest notificationId
|
||||
let maxId = 0
|
||||
root.list.forEach((notif) => {
|
||||
maxId = Math.max(maxId, notif.notificationId)
|
||||
})
|
||||
|
||||
console.log("[Notifications] File loaded")
|
||||
root.idOffset = maxId
|
||||
root.initDone()
|
||||
}
|
||||
onLoadFailed: (error) => {
|
||||
if(error == FileViewError.FileNotFound) {
|
||||
console.log("[Notifications] File not found, creating new file.")
|
||||
root.list = []
|
||||
notifFileView.setText(stringifyList(root.list));
|
||||
} else {
|
||||
console.log("[Notifications] Error loading file: " + error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
57
surfaces/quickshell/ii-base/services/PhysicalKeyboard.qml
Normal file
57
surfaces/quickshell/ii-base/services/PhysicalKeyboard.qml
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
pragma Singleton
|
||||
|
||||
import qs.modules.common
|
||||
import qs.modules.common.functions
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
signal keyPressed(int keycode)
|
||||
signal keyReleased(int keycode)
|
||||
|
||||
property bool active: false
|
||||
|
||||
onActiveChanged: {
|
||||
if (active) {
|
||||
monitor.running = true;
|
||||
} else {
|
||||
monitor.running = false;
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: monitor
|
||||
command: ["python3", `${Directories.scriptPath}/keyboard_monitor.py`]
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
try {
|
||||
const event = JSON.parse(data);
|
||||
if (event.error) {
|
||||
console.warn("[PhysicalKeyboard]", event.error);
|
||||
return;
|
||||
}
|
||||
if (event.status === "ready") {
|
||||
return;
|
||||
}
|
||||
if (event.keycode !== undefined) {
|
||||
if (event.pressed) {
|
||||
root.keyPressed(event.keycode);
|
||||
} else {
|
||||
root.keyReleased(event.keycode);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn("[PhysicalKeyboard] Parse error:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (root.active) {
|
||||
console.warn("[PhysicalKeyboard] Monitor exited unexpectedly with code", exitCode, "- restarting");
|
||||
monitor.running = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
49
surfaces/quickshell/ii-base/services/PolkitService.qml
Normal file
49
surfaces/quickshell/ii-base/services/PolkitService.qml
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.Polkit
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
property alias agent: polkitAgent
|
||||
property alias active: polkitAgent.isActive
|
||||
property alias flow: polkitAgent.flow
|
||||
property bool interactionAvailable: false
|
||||
property string cleanMessage: {
|
||||
if (!root.flow) return "";
|
||||
return root.flow.message.endsWith(".")
|
||||
? root.flow.message.slice(0, -1)
|
||||
: root.flow.message
|
||||
}
|
||||
property string cleanPrompt: {
|
||||
const inputPrompt = PolkitService.flow?.inputPrompt.trim() ?? "";
|
||||
const cleanedInputPrompt = inputPrompt.endsWith(":") ? inputPrompt.slice(0, -1) : inputPrompt;
|
||||
const usePasswordChars = !PolkitService.flow?.responseVisible ?? true
|
||||
return cleanedInputPrompt || (usePasswordChars ? Translation.tr("Password") : Translation.tr("Input"))
|
||||
}
|
||||
|
||||
function cancel() {
|
||||
root.flow.cancelAuthenticationRequest()
|
||||
}
|
||||
|
||||
function submit(string) {
|
||||
root.flow.submit(string)
|
||||
root.interactionAvailable = false
|
||||
}
|
||||
|
||||
Connections {
|
||||
target: root.flow
|
||||
function onAuthenticationFailed() {
|
||||
root.interactionAvailable = true;
|
||||
}
|
||||
}
|
||||
|
||||
PolkitAgent {
|
||||
id: polkitAgent
|
||||
onAuthenticationRequestStarted: {
|
||||
root.interactionAvailable = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
surfaces/quickshell/ii-base/services/Privacy.qml
Normal file
16
surfaces/quickshell/ii-base/services/Privacy.qml
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
import qs.modules.common
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.Pipewire
|
||||
|
||||
/**
|
||||
* Screensharing and mic activity.
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property bool screenSharing: Pipewire.linkGroups.values.filter(pwlg => pwlg.source.type === PwNodeType.VideoSource).map(pwlg => pwlg.target)
|
||||
property bool micActive: Pipewire.linkGroups.values.filter(pwlg => pwlg.source.type === PwNodeType.AudioSource && pwlg.target.type === PwNodeType.AudioInStream).map(pwlg => pwlg.target)
|
||||
}
|
||||
121
surfaces/quickshell/ii-base/services/ResourceUsage.qml
Normal file
121
surfaces/quickshell/ii-base/services/ResourceUsage.qml
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs
|
||||
import qs.modules.common
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
/**
|
||||
* Simple polled resource usage service with RAM, Swap, and CPU usage.
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
property real memoryTotal: 1
|
||||
property real memoryFree: 0
|
||||
property real memoryUsed: memoryTotal - memoryFree
|
||||
property real memoryUsedPercentage: memoryUsed / memoryTotal
|
||||
property real swapTotal: 1
|
||||
property real swapFree: 0
|
||||
property real swapUsed: swapTotal - swapFree
|
||||
property real swapUsedPercentage: swapTotal > 0 ? (swapUsed / swapTotal) : 0
|
||||
property real cpuUsage: 0
|
||||
property var previousCpuStats
|
||||
|
||||
property string maxAvailableMemoryString: kbToGbString(ResourceUsage.memoryTotal)
|
||||
property string maxAvailableSwapString: kbToGbString(ResourceUsage.swapTotal)
|
||||
property string maxAvailableCpuString: "--"
|
||||
|
||||
readonly property int historyLength: Config?.options.resources.historyLength ?? 60
|
||||
property list<real> cpuUsageHistory: []
|
||||
property list<real> memoryUsageHistory: []
|
||||
property list<real> swapUsageHistory: []
|
||||
|
||||
function kbToGbString(kb) {
|
||||
return (kb / (1024 * 1024)).toFixed(1) + " GB";
|
||||
}
|
||||
|
||||
function updateMemoryUsageHistory() {
|
||||
memoryUsageHistory = [...memoryUsageHistory, memoryUsedPercentage]
|
||||
if (memoryUsageHistory.length > historyLength) {
|
||||
memoryUsageHistory.shift()
|
||||
}
|
||||
}
|
||||
function updateSwapUsageHistory() {
|
||||
swapUsageHistory = [...swapUsageHistory, swapUsedPercentage]
|
||||
if (swapUsageHistory.length > historyLength) {
|
||||
swapUsageHistory.shift()
|
||||
}
|
||||
}
|
||||
function updateCpuUsageHistory() {
|
||||
cpuUsageHistory = [...cpuUsageHistory, cpuUsage]
|
||||
if (cpuUsageHistory.length > historyLength) {
|
||||
cpuUsageHistory.shift()
|
||||
}
|
||||
}
|
||||
function updateHistories() {
|
||||
updateMemoryUsageHistory()
|
||||
updateSwapUsageHistory()
|
||||
updateCpuUsageHistory()
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: 1
|
||||
// Souveraine: pause while the display is dimmed/locked/asleep —
|
||||
// stats history nobody can see is pure CPU burn (idle-power task 4).
|
||||
running: GlobalStates.displayActive
|
||||
repeat: true
|
||||
onTriggered: {
|
||||
// Reload files
|
||||
fileMeminfo.reload()
|
||||
fileStat.reload()
|
||||
|
||||
// Parse memory and swap usage
|
||||
const textMeminfo = fileMeminfo.text()
|
||||
memoryTotal = Number(textMeminfo.match(/MemTotal: *(\d+)/)?.[1] ?? 1)
|
||||
memoryFree = Number(textMeminfo.match(/MemAvailable: *(\d+)/)?.[1] ?? 0)
|
||||
swapTotal = Number(textMeminfo.match(/SwapTotal: *(\d+)/)?.[1] ?? 1)
|
||||
swapFree = Number(textMeminfo.match(/SwapFree: *(\d+)/)?.[1] ?? 0)
|
||||
|
||||
// Parse CPU usage
|
||||
const textStat = fileStat.text()
|
||||
const cpuLine = textStat.match(/^cpu\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)\s+(\d+)/)
|
||||
if (cpuLine) {
|
||||
const stats = cpuLine.slice(1).map(Number)
|
||||
const total = stats.reduce((a, b) => a + b, 0)
|
||||
const idle = stats[3]
|
||||
|
||||
if (previousCpuStats) {
|
||||
const totalDiff = total - previousCpuStats.total
|
||||
const idleDiff = idle - previousCpuStats.idle
|
||||
cpuUsage = totalDiff > 0 ? (1 - idleDiff / totalDiff) : 0
|
||||
}
|
||||
|
||||
previousCpuStats = { total, idle }
|
||||
}
|
||||
|
||||
root.updateHistories()
|
||||
interval = Config.options?.resources?.updateInterval ?? 3000
|
||||
}
|
||||
}
|
||||
|
||||
FileView { id: fileMeminfo; path: "/proc/meminfo" }
|
||||
FileView { id: fileStat; path: "/proc/stat" }
|
||||
|
||||
Process {
|
||||
id: findCpuMaxFreqProc
|
||||
environment: ({
|
||||
LANG: "C",
|
||||
LC_ALL: "C"
|
||||
})
|
||||
command: ["bash", "-c", "lscpu | grep 'CPU max MHz' | awk '{print $4}'"]
|
||||
running: true
|
||||
stdout: StdioCollector {
|
||||
id: outputCollector
|
||||
onStreamFinished: {
|
||||
root.maxAvailableCpuString = (parseFloat(outputCollector.text) / 1000).toFixed(0) + " GHz"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
39
surfaces/quickshell/ii-base/services/SessionWarnings.qml
Normal file
39
surfaces/quickshell/ii-base/services/SessionWarnings.qml
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
pragma Singleton
|
||||
|
||||
import qs.modules.common
|
||||
import qs.modules.common.functions
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property bool packageManagerRunning: false
|
||||
property bool downloadRunning: false
|
||||
|
||||
function refresh() {
|
||||
packageManagerRunning = false;
|
||||
downloadRunning = false;
|
||||
detectPackageManagerProc.running = false;
|
||||
detectPackageManagerProc.running = true;
|
||||
detectDownloadProc.running = false;
|
||||
detectDownloadProc.running = true;
|
||||
}
|
||||
|
||||
Process {
|
||||
id: detectPackageManagerProc
|
||||
command: ["bash", "-c", "pidof yay paru dnf zypper apt apx xbps snap apk yum epsi pikman || ls /var/lib/pacman/db.lck"]
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.packageManagerRunning = (exitCode === 0);
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: detectDownloadProc
|
||||
command: ["bash", "-c", "pidof curl wget aria2c yt-dlp || ls ~/Downloads | grep -E '\.crdownload$|\.part$'"]
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.downloadRunning = (exitCode === 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
103
surfaces/quickshell/ii-base/services/SongRec.qml
Normal file
103
surfaces/quickshell/ii-base/services/SongRec.qml
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs.modules.common
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
enum MonitorSource { Monitor, Input }
|
||||
|
||||
property var monitorSource: SongRec.MonitorSource.Monitor
|
||||
property int timeoutInterval: Config.options.musicRecognition.interval
|
||||
property int timeoutDuration: Config.options.musicRecognition.timeout
|
||||
readonly property bool running: recognizeMusicProc.running
|
||||
|
||||
function toggleRunning(running) {
|
||||
if (recognizeMusicProc.running && !running === true) root.manuallyStopped = true;
|
||||
if (running != undefined) {
|
||||
recognizeMusicProc.running = running
|
||||
} else {
|
||||
recognizeMusicProc.running = !root.running
|
||||
}
|
||||
musicReconizedProc.running = false
|
||||
}
|
||||
|
||||
function toggleMonitorSource(source) {
|
||||
if (source !== undefined) {
|
||||
root.monitorSource = source
|
||||
return
|
||||
}
|
||||
root.monitorSource = (root.monitorSource === SongRec.MonitorSource.Monitor) ? SongRec.MonitorSource.Input : SongRec.MonitorSource.Monitor
|
||||
}
|
||||
function monitorSourceToString(source) {
|
||||
if (source === SongRec.MonitorSource.Monitor) {
|
||||
return "monitor"
|
||||
} else {
|
||||
return "input"
|
||||
}
|
||||
}
|
||||
readonly property string monitorSourceString: monitorSourceToString(monitorSource)
|
||||
property var recognizedTrack: ({ title:"", subtitle:"", url:""})
|
||||
property bool manuallyStopped: false
|
||||
|
||||
function handleRecognition(jsonText) {
|
||||
try {
|
||||
var obj = JSON.parse(jsonText)
|
||||
root.recognizedTrack = {
|
||||
title: obj.track.title,
|
||||
subtitle: obj.track.subtitle,
|
||||
url: obj.track.url
|
||||
}
|
||||
musicReconizedProc.running = true
|
||||
} catch(e) {
|
||||
Quickshell.execDetached(["notify-send", Translation.tr("Couldn't recognize music"), Translation.tr("Perhaps what you're listening to is too niche"), "-a", "Shell"])
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: recognizeMusicProc
|
||||
running: false
|
||||
command: [`${Directories.scriptPath}/musicRecognition/recognize-music.sh`, "-i", root.timeoutInterval, "-t", root.timeoutDuration, "-s", root.monitorSourceString]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
if (root.manuallyStopped) {
|
||||
root.manuallyStopped = false
|
||||
return
|
||||
}
|
||||
handleRecognition(this.text)
|
||||
}
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode === 1) {
|
||||
Quickshell.execDetached(["notify-send", Translation.tr("Couldn't recognize music"), Translation.tr("Make sure you have songrec installed"), "-a", "Shell"])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: musicReconizedProc
|
||||
running: false
|
||||
command: [
|
||||
"notify-send",
|
||||
Translation.tr("Music Recognized"),
|
||||
root.recognizedTrack.title + " - " + root.recognizedTrack.subtitle,
|
||||
"-A", "Shazam",
|
||||
"-A", "YouTube",
|
||||
"-a", "Shell"
|
||||
]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
if (this.text === "") return
|
||||
if (this.text == 0) {
|
||||
Qt.openUrlExternally(root.recognizedTrack.url);
|
||||
} else {
|
||||
Qt.openUrlExternally("https://www.youtube.com/results?search_query=" + root.recognizedTrack.title + " - " + root.recognizedTrack.subtitle);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
117
surfaces/quickshell/ii-base/services/SystemInfo.qml
Normal file
117
surfaces/quickshell/ii-base/services/SystemInfo.qml
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
/**
|
||||
* Provides some system info: distro, username.
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
property string distroName: "Unknown"
|
||||
property string distroId: "unknown"
|
||||
property string distroIcon: "linux-symbolic"
|
||||
property string username: "user"
|
||||
property string homeUrl: ""
|
||||
property string documentationUrl: ""
|
||||
property string supportUrl: ""
|
||||
property string bugReportUrl: ""
|
||||
property string privacyPolicyUrl: ""
|
||||
property string logo: ""
|
||||
property string desktopEnvironment: ""
|
||||
property string windowingSystem: ""
|
||||
|
||||
Timer {
|
||||
triggeredOnStart: true
|
||||
interval: 1
|
||||
running: true
|
||||
repeat: false
|
||||
onTriggered: {
|
||||
getUsername.running = true
|
||||
fileOsRelease.reload()
|
||||
const textOsRelease = fileOsRelease.text()
|
||||
|
||||
// Extract the friendly name (PRETTY_NAME field, fallback to NAME)
|
||||
const prettyNameMatch = textOsRelease.match(/^PRETTY_NAME="(.+?)"/m)
|
||||
const nameMatch = textOsRelease.match(/^NAME="(.+?)"/m)
|
||||
distroName = prettyNameMatch ? prettyNameMatch[1] : (nameMatch ? nameMatch[1].replace(/Linux/i, "").trim() : "Unknown")
|
||||
|
||||
// Extract the ID
|
||||
const idMatch = textOsRelease.match(/^ID="?(.+?)"?$/m)
|
||||
distroId = idMatch ? idMatch[1] : "unknown"
|
||||
|
||||
// Extract additional URLs and logo
|
||||
const homeUrlMatch = textOsRelease.match(/^HOME_URL="(.+?)"/m)
|
||||
homeUrl = homeUrlMatch ? homeUrlMatch[1] : ""
|
||||
const documentationUrlMatch = textOsRelease.match(/^DOCUMENTATION_URL="(.+?)"/m)
|
||||
documentationUrl = documentationUrlMatch ? documentationUrlMatch[1] : ""
|
||||
const supportUrlMatch = textOsRelease.match(/^SUPPORT_URL="(.+?)"/m)
|
||||
supportUrl = supportUrlMatch ? supportUrlMatch[1] : ""
|
||||
const bugReportUrlMatch = textOsRelease.match(/^BUG_REPORT_URL="(.+?)"/m)
|
||||
bugReportUrl = bugReportUrlMatch ? bugReportUrlMatch[1] : ""
|
||||
const privacyPolicyUrlMatch = textOsRelease.match(/^PRIVACY_POLICY_URL="(.+?)"/m)
|
||||
privacyPolicyUrl = privacyPolicyUrlMatch ? privacyPolicyUrlMatch[1] : ""
|
||||
const logoFieldMatch = textOsRelease.match(/^LOGO="?(.+?)"?$/m)
|
||||
logo = logoFieldMatch ? logoFieldMatch[1] : ""
|
||||
|
||||
// Update the distroIcon property based on distroId
|
||||
switch (distroId) {
|
||||
case "artix":
|
||||
case "arch": distroIcon = "arch-symbolic"; break;
|
||||
case "endeavouros": distroIcon = "endeavouros-symbolic"; break;
|
||||
case "cachyos": distroIcon = "cachyos-symbolic"; break;
|
||||
case "nixos": distroIcon = "nixos-symbolic"; break;
|
||||
case "fedora": distroIcon = "fedora-symbolic"; break;
|
||||
case "linuxmint":
|
||||
case "ubuntu":
|
||||
case "zorin":
|
||||
case "popos": distroIcon = "ubuntu-symbolic"; break;
|
||||
case "debian":
|
||||
case "raspbian":
|
||||
case "kali": distroIcon = "debian-symbolic"; break;
|
||||
case "funtoo":
|
||||
case "gentoo": distroIcon = "gentoo-symbolic"; break;
|
||||
default: distroIcon = "linux-symbolic"; break;
|
||||
}
|
||||
if (textOsRelease.toLowerCase().includes("nyarch")) {
|
||||
distroIcon = "nyarch-symbolic"
|
||||
}
|
||||
|
||||
if (logo.trim().length === 0) {
|
||||
logo = distroIcon
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getUsername
|
||||
command: ["whoami"]
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
root.username = data.trim()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: getDesktopEnvironment
|
||||
running: true
|
||||
command: ["bash", "-c", "echo $XDG_CURRENT_DESKTOP,$WAYLAND_DISPLAY"]
|
||||
stdout: StdioCollector {
|
||||
id: deCollector
|
||||
onStreamFinished: {
|
||||
const [desktop, wayland] = deCollector.text.split(",")
|
||||
root.desktopEnvironment = desktop.trim()
|
||||
root.windowingSystem = wayland.trim().length > 0 ? "Wayland" : "X11" // Are there others? 🤔
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: fileOsRelease
|
||||
path: "/etc/os-release"
|
||||
}
|
||||
}
|
||||
72
surfaces/quickshell/ii-base/services/TaskbarApps.qml
Normal file
72
surfaces/quickshell/ii-base/services/TaskbarApps.qml
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
pragma Singleton
|
||||
|
||||
import qs.modules.common
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Wayland
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
function isPinned(appId) {
|
||||
return Config.options.dock.pinnedApps.indexOf(appId) !== -1;
|
||||
}
|
||||
|
||||
function togglePin(appId) {
|
||||
if (root.isPinned(appId)) {
|
||||
Config.options.dock.pinnedApps = Config.options.dock.pinnedApps.filter(id => id !== appId)
|
||||
} else {
|
||||
Config.options.dock.pinnedApps = Config.options.dock.pinnedApps.concat([appId])
|
||||
}
|
||||
}
|
||||
|
||||
property list<var> apps: {
|
||||
var map = new Map();
|
||||
|
||||
// Pinned apps
|
||||
const pinnedApps = Config.options?.dock.pinnedApps ?? [];
|
||||
for (const appId of pinnedApps) {
|
||||
if (!map.has(appId.toLowerCase())) map.set(appId.toLowerCase(), ({
|
||||
pinned: true,
|
||||
toplevels: []
|
||||
}));
|
||||
}
|
||||
|
||||
// Separator
|
||||
if (pinnedApps.length > 0) {
|
||||
map.set("SEPARATOR", { pinned: false, toplevels: [] });
|
||||
}
|
||||
|
||||
// Ignored apps
|
||||
const ignoredRegexStrings = Config.options?.dock.ignoredAppRegexes ?? [];
|
||||
const ignoredRegexes = ignoredRegexStrings.map(pattern => new RegExp(pattern, "i"));
|
||||
// Open windows
|
||||
for (const toplevel of ToplevelManager.toplevels.values) {
|
||||
if (ignoredRegexes.some(re => re.test(toplevel.appId))) continue;
|
||||
if (!map.has(toplevel.appId.toLowerCase())) map.set(toplevel.appId.toLowerCase(), ({
|
||||
pinned: false,
|
||||
toplevels: []
|
||||
}));
|
||||
map.get(toplevel.appId.toLowerCase()).toplevels.push(toplevel);
|
||||
}
|
||||
|
||||
var values = [];
|
||||
|
||||
for (const [key, value] of map) {
|
||||
values.push(appEntryComp.createObject(null, { appId: key, toplevels: value.toplevels, pinned: value.pinned }));
|
||||
}
|
||||
|
||||
return values;
|
||||
}
|
||||
|
||||
component TaskbarAppEntry: QtObject {
|
||||
id: wrapper
|
||||
required property string appId
|
||||
required property list<var> toplevels
|
||||
required property bool pinned
|
||||
}
|
||||
Component {
|
||||
id: appEntryComp
|
||||
TaskbarAppEntry {}
|
||||
}
|
||||
}
|
||||
144
surfaces/quickshell/ii-base/services/TimerService.qml
Normal file
144
surfaces/quickshell/ii-base/services/TimerService.qml
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs.services
|
||||
import qs.modules.common
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
/**
|
||||
* Simple Pomodoro time manager.
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property int focusTime: Config.options.time.pomodoro.focus
|
||||
property int breakTime: Config.options.time.pomodoro.breakTime
|
||||
property int longBreakTime: Config.options.time.pomodoro.longBreak
|
||||
property int cyclesBeforeLongBreak: Config.options.time.pomodoro.cyclesBeforeLongBreak
|
||||
|
||||
property bool pomodoroRunning: Persistent.states.timer.pomodoro.running
|
||||
property bool pomodoroBreak: Persistent.states.timer.pomodoro.isBreak
|
||||
property bool pomodoroLongBreak: Persistent.states.timer.pomodoro.isBreak && (pomodoroCycle + 1 == cyclesBeforeLongBreak);
|
||||
property int pomodoroLapDuration: pomodoroLongBreak ? longBreakTime : pomodoroBreak ? breakTime : focusTime // This is a binding that's to be kept
|
||||
property int pomodoroSecondsLeft: pomodoroLapDuration // Reasonable init value, to be changed
|
||||
property int pomodoroCycle: Persistent.states.timer.pomodoro.cycle
|
||||
|
||||
property bool stopwatchRunning: Persistent.states.timer.stopwatch.running
|
||||
property int stopwatchTime: 0
|
||||
property int stopwatchStart: Persistent.states.timer.stopwatch.start
|
||||
property var stopwatchLaps: Persistent.states.timer.stopwatch.laps
|
||||
|
||||
// General
|
||||
Component.onCompleted: {
|
||||
if (!stopwatchRunning)
|
||||
stopwatchReset();
|
||||
}
|
||||
|
||||
function getCurrentTimeInSeconds() { // Pomodoro uses Seconds
|
||||
return Math.floor(Date.now() / 1000);
|
||||
}
|
||||
|
||||
function getCurrentTimeIn10ms() { // Stopwatch uses 10ms
|
||||
return Math.floor(Date.now() / 10);
|
||||
}
|
||||
|
||||
// Pomodoro
|
||||
function refreshPomodoro() {
|
||||
// Work <-> break ?
|
||||
if (getCurrentTimeInSeconds() >= Persistent.states.timer.pomodoro.start + pomodoroLapDuration) {
|
||||
// Reset counts
|
||||
Persistent.states.timer.pomodoro.isBreak = !Persistent.states.timer.pomodoro.isBreak;
|
||||
Persistent.states.timer.pomodoro.start = getCurrentTimeInSeconds();
|
||||
|
||||
// Send notification
|
||||
let notificationMessage;
|
||||
if (Persistent.states.timer.pomodoro.isBreak && (pomodoroCycle + 1 == cyclesBeforeLongBreak)) {
|
||||
notificationMessage = Translation.tr(`🌿 Long break: %1 minutes`).arg(Math.floor(longBreakTime / 60));
|
||||
} else if (Persistent.states.timer.pomodoro.isBreak) {
|
||||
notificationMessage = Translation.tr(`☕ Break: %1 minutes`).arg(Math.floor(breakTime / 60));
|
||||
} else {
|
||||
notificationMessage = Translation.tr(`🔴 Focus: %1 minutes`).arg(Math.floor(focusTime / 60));
|
||||
}
|
||||
|
||||
if (Config.options.time.pomodoro.notifications) {
|
||||
Quickshell.execDetached(["notify-send", "Pomodoro", notificationMessage, "-a", "Shell"]);
|
||||
}
|
||||
if (Config.options.sounds.pomodoro) {
|
||||
Audio.playSystemSound("alarm-clock-elapsed")
|
||||
}
|
||||
|
||||
if (!pomodoroBreak) {
|
||||
Persistent.states.timer.pomodoro.cycle = (Persistent.states.timer.pomodoro.cycle + 1) % root.cyclesBeforeLongBreak;
|
||||
}
|
||||
}
|
||||
|
||||
pomodoroSecondsLeft = pomodoroLapDuration - (getCurrentTimeInSeconds() - Persistent.states.timer.pomodoro.start);
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: pomodoroTimer
|
||||
interval: 200
|
||||
running: root.pomodoroRunning
|
||||
repeat: true
|
||||
onTriggered: refreshPomodoro()
|
||||
}
|
||||
|
||||
function togglePomodoro() {
|
||||
Persistent.states.timer.pomodoro.running = !pomodoroRunning;
|
||||
if (Persistent.states.timer.pomodoro.running) {
|
||||
// Start/Resume
|
||||
Persistent.states.timer.pomodoro.start = getCurrentTimeInSeconds() + pomodoroSecondsLeft - pomodoroLapDuration;
|
||||
}
|
||||
}
|
||||
|
||||
function resetPomodoro() {
|
||||
Persistent.states.timer.pomodoro.running = false;
|
||||
Persistent.states.timer.pomodoro.isBreak = false;
|
||||
Persistent.states.timer.pomodoro.start = getCurrentTimeInSeconds();
|
||||
Persistent.states.timer.pomodoro.cycle = 0;
|
||||
refreshPomodoro();
|
||||
}
|
||||
|
||||
// Stopwatch
|
||||
function refreshStopwatch() { // Stopwatch stores time in 10ms
|
||||
stopwatchTime = getCurrentTimeIn10ms() - stopwatchStart;
|
||||
}
|
||||
|
||||
Timer {
|
||||
id: stopwatchTimer
|
||||
interval: 10
|
||||
running: root.stopwatchRunning
|
||||
repeat: true
|
||||
onTriggered: refreshStopwatch()
|
||||
}
|
||||
|
||||
function toggleStopwatch() {
|
||||
if (root.stopwatchRunning)
|
||||
stopwatchPause();
|
||||
else
|
||||
stopwatchResume();
|
||||
}
|
||||
|
||||
function stopwatchPause() {
|
||||
Persistent.states.timer.stopwatch.running = false;
|
||||
}
|
||||
|
||||
function stopwatchResume() {
|
||||
if (stopwatchTime === 0) Persistent.states.timer.stopwatch.laps = [];
|
||||
Persistent.states.timer.stopwatch.running = true;
|
||||
Persistent.states.timer.stopwatch.start = getCurrentTimeIn10ms() - stopwatchTime;
|
||||
}
|
||||
|
||||
function stopwatchReset() {
|
||||
stopwatchTime = 0;
|
||||
Persistent.states.timer.stopwatch.laps = [];
|
||||
Persistent.states.timer.stopwatch.running = false;
|
||||
}
|
||||
|
||||
function stopwatchRecordLap() {
|
||||
Persistent.states.timer.stopwatch.laps.push(stopwatchTime);
|
||||
}
|
||||
}
|
||||
87
surfaces/quickshell/ii-base/services/Todo.qml
Normal file
87
surfaces/quickshell/ii-base/services/Todo.qml
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import qs.modules.common
|
||||
import Quickshell;
|
||||
import Quickshell.Io;
|
||||
import QtQuick;
|
||||
|
||||
/**
|
||||
* Simple to-do list manager.
|
||||
* Each item is an object with "content" and "done" properties.
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
property var filePath: Directories.todoPath
|
||||
property var list: []
|
||||
|
||||
function addItem(item) {
|
||||
list.push(item)
|
||||
// Reassign to trigger onListChanged
|
||||
root.list = list.slice(0)
|
||||
todoFileView.setText(JSON.stringify(root.list))
|
||||
}
|
||||
|
||||
function addTask(desc) {
|
||||
const item = {
|
||||
"content": desc,
|
||||
"done": false,
|
||||
}
|
||||
addItem(item)
|
||||
}
|
||||
|
||||
function markDone(index) {
|
||||
if (index >= 0 && index < list.length) {
|
||||
list[index].done = true
|
||||
// Reassign to trigger onListChanged
|
||||
root.list = list.slice(0)
|
||||
todoFileView.setText(JSON.stringify(root.list))
|
||||
}
|
||||
}
|
||||
|
||||
function markUnfinished(index) {
|
||||
if (index >= 0 && index < list.length) {
|
||||
list[index].done = false
|
||||
// Reassign to trigger onListChanged
|
||||
root.list = list.slice(0)
|
||||
todoFileView.setText(JSON.stringify(root.list))
|
||||
}
|
||||
}
|
||||
|
||||
function deleteItem(index) {
|
||||
if (index >= 0 && index < list.length) {
|
||||
list.splice(index, 1)
|
||||
// Reassign to trigger onListChanged
|
||||
root.list = list.slice(0)
|
||||
todoFileView.setText(JSON.stringify(root.list))
|
||||
}
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
todoFileView.reload()
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
refresh()
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: todoFileView
|
||||
path: Qt.resolvedUrl(root.filePath)
|
||||
onLoaded: {
|
||||
const fileContents = todoFileView.text()
|
||||
root.list = JSON.parse(fileContents)
|
||||
console.log("[To Do] File loaded")
|
||||
}
|
||||
onLoadFailed: (error) => {
|
||||
if(error == FileViewError.FileNotFound) {
|
||||
console.log("[To Do] File not found, creating new file.")
|
||||
root.list = []
|
||||
todoFileView.setText(JSON.stringify(root.list))
|
||||
} else {
|
||||
console.log("[To Do] Error loading file: " + error)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
146
surfaces/quickshell/ii-base/services/Translation.qml
Normal file
146
surfaces/quickshell/ii-base/services/Translation.qml
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
pragma Singleton
|
||||
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import qs.modules.common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property var translations: ({})
|
||||
property var generatedTranslations: ({})
|
||||
property var availableLanguages: ["en_US"]
|
||||
property var availableGeneratedLanguages: []
|
||||
property var allAvailableLanguages: {
|
||||
const combined = new Set([...root.availableLanguages, ...root.availableGeneratedLanguages]);
|
||||
return Array.from(combined).sort();
|
||||
}
|
||||
property bool isScanning: scanLanguagesProcess.running
|
||||
property bool isLoading: false
|
||||
property string translationKeepSuffix: "/*keep*/"
|
||||
property string translationsDir: Quickshell.shellPath("translations")
|
||||
property string generatedTranslationsDir: Directories.shellConfig + "/translations"
|
||||
|
||||
property string languageCode: {
|
||||
var configLang = Config?.options.language.ui ?? "auto";
|
||||
|
||||
if (configLang !== "auto")
|
||||
return configLang;
|
||||
|
||||
return Qt.locale().name;
|
||||
}
|
||||
|
||||
TranslationScanner {
|
||||
id: scanLanguagesProcess
|
||||
translationsDir: root.translationsDir
|
||||
onLanguagesScanned: (languages) => {
|
||||
root.availableLanguages = [...languages];
|
||||
}
|
||||
}
|
||||
|
||||
TranslationScanner {
|
||||
id: scanGeneratedLanguagesProcess
|
||||
translationsDir: root.generatedTranslationsDir
|
||||
onLanguagesScanned: (languages) => {
|
||||
root.availableGeneratedLanguages = [...languages];
|
||||
}
|
||||
}
|
||||
|
||||
onLanguageCodeChanged: {
|
||||
print("[Translation] Language changed to", root.languageCode);
|
||||
translationFileView.languageCode = root.languageCode;
|
||||
generatedTranslationFileView.languageCode = root.languageCode;
|
||||
translationFileView.reread();
|
||||
generatedTranslationFileView.reread();
|
||||
}
|
||||
|
||||
TranslationReader {
|
||||
id: translationFileView
|
||||
translationsDir: root.translationsDir
|
||||
languageCode: root.languageCode
|
||||
onContentLoaded: (data) => {
|
||||
root.translations = data;
|
||||
root.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
TranslationReader {
|
||||
id: generatedTranslationFileView
|
||||
translationsDir: root.generatedTranslationsDir
|
||||
languageCode: root.languageCode
|
||||
onContentLoaded: (data) => {
|
||||
root.generatedTranslations = data;
|
||||
root.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
function tr(text) {
|
||||
// Special cases
|
||||
if (!text) return "";
|
||||
var key = text.toString();
|
||||
if (root.isLoading || (!root?.translations?.hasOwnProperty(key) && !root?.generatedTranslations?.hasOwnProperty(key)))
|
||||
return key;
|
||||
|
||||
// Normal cases
|
||||
var translation = root.translations[key] || root.generatedTranslations[key] || key;
|
||||
// print(key, "-> [", root.translations[key], root.generatedTranslations[key], key, "] ->", translation);
|
||||
if (translation.endsWith(root.translationKeepSuffix)) {
|
||||
translation = translation.substring(0, translation.length - root.translationKeepSuffix.length).trim();
|
||||
}
|
||||
return translation;
|
||||
}
|
||||
|
||||
component TranslationScanner: Process {
|
||||
id: translationScanner
|
||||
required property string translationsDir
|
||||
signal languagesScanned(var languages)
|
||||
|
||||
command: ["find", translationScanner.translationsDir, "-name", "*.json", "-exec", "basename", "{}", ".json", ";"]
|
||||
running: true
|
||||
|
||||
stdout: StdioCollector {
|
||||
id: languagesCollector
|
||||
onStreamFinished: {
|
||||
const output = languagesCollector.text;
|
||||
const files = output.trim().split('\n').map(f => f.trim());
|
||||
translationScanner.languagesScanned(files);
|
||||
}
|
||||
}
|
||||
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode !== 0) {
|
||||
translationScanner.languagesScanned(["en_US"]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
component TranslationReader: FileView {
|
||||
id: translationReader
|
||||
required property string translationsDir
|
||||
property string languageCode: root.languageCode
|
||||
signal contentLoaded(var data)
|
||||
|
||||
function reread() { // Proper reload in case the file was incorrect before
|
||||
translationReader.path = "";
|
||||
translationReader.path = `${translationReader.translationsDir}/${translationReader.languageCode}.json`;
|
||||
translationReader.reload();
|
||||
}
|
||||
path: ""
|
||||
|
||||
onLoaded: {
|
||||
var textContent = "";
|
||||
try {
|
||||
textContent = text();
|
||||
var jsonData = JSON.parse(textContent);
|
||||
translationReader.contentLoaded(jsonData);
|
||||
} catch (e) {
|
||||
console.log("[Translation] Failed to load translations:", e);
|
||||
translationReader.contentLoaded({});
|
||||
}
|
||||
}
|
||||
onLoadFailed: error => {
|
||||
translationReader.contentLoaded({});
|
||||
}
|
||||
}
|
||||
}
|
||||
53
surfaces/quickshell/ii-base/services/TrayService.qml
Normal file
53
surfaces/quickshell/ii-base/services/TrayService.qml
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
pragma Singleton
|
||||
|
||||
import qs.modules.common
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Services.SystemTray
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property bool smartTray: Config.options.tray.filterPassive
|
||||
property list<var> itemsInUserList: SystemTray.items.values.filter(i => (Config.options.tray.pinnedItems.includes(i.id) && (!smartTray || i.status !== Status.Passive)))
|
||||
property list<var> itemsNotInUserList: SystemTray.items.values.filter(i => (!Config.options.tray.pinnedItems.includes(i.id) && (!smartTray || i.status !== Status.Passive)))
|
||||
|
||||
property bool invertPins: Config.options.tray.invertPinnedItems
|
||||
property list<var> pinnedItems: invertPins ? itemsNotInUserList : itemsInUserList
|
||||
property list<var> unpinnedItems: invertPins ? itemsInUserList : itemsNotInUserList
|
||||
|
||||
function getTooltipForItem(item) {
|
||||
var result = item.tooltipTitle.length > 0 ? item.tooltipTitle
|
||||
: (item.title.length > 0 ? item.title : item.id);
|
||||
if (item.tooltipDescription.length > 0) result += " • " + item.tooltipDescription;
|
||||
if (Config.options.tray.showItemId) result += "\n[" + item.id + "]";
|
||||
return result;
|
||||
}
|
||||
|
||||
// Pinning
|
||||
function pin(itemId) {
|
||||
var pins = Config.options.tray.pinnedItems;
|
||||
if (pins.includes(itemId)) return;
|
||||
Config.options.tray.pinnedItems.push(itemId);
|
||||
}
|
||||
function unpin(itemId) {
|
||||
Config.options.tray.pinnedItems = Config.options.tray.pinnedItems.filter(id => id !== itemId);
|
||||
}
|
||||
function isPinned(itemId) {
|
||||
for (var i = 0; i < root.pinnedItems.length; i++) {
|
||||
if (root.pinnedItems[i].id === itemId)
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function togglePin(itemId) {
|
||||
var pins = Config.options.tray.pinnedItems;
|
||||
if (pins.includes(itemId)) {
|
||||
unpin(itemId)
|
||||
} else {
|
||||
pin(itemId)
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
58
surfaces/quickshell/ii-base/services/Updates.qml
Normal file
58
surfaces/quickshell/ii-base/services/Updates.qml
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
pragma Singleton
|
||||
|
||||
import qs.modules.common
|
||||
import qs.modules.common.functions
|
||||
import QtQuick
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
|
||||
/*
|
||||
* System updates service. Currently only supports Arch.
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property bool available: false
|
||||
property alias checking: checkUpdatesProc.running
|
||||
property int count: 0
|
||||
|
||||
readonly property bool updateAdvised: available && count > Config.options.updates.adviseUpdateThreshold
|
||||
readonly property bool updateStronglyAdvised: available && count > Config.options.updates.stronglyAdviseUpdateThreshold
|
||||
|
||||
function load() {}
|
||||
function refresh() {
|
||||
if (!available) return;
|
||||
print("[Updates] Checking for system updates")
|
||||
checkUpdatesProc.running = true;
|
||||
}
|
||||
|
||||
Timer {
|
||||
interval: Config.options.updates.checkInterval * 60 * 1000
|
||||
repeat: true
|
||||
running: Config.ready && Config.options.updates.enableCheck
|
||||
onTriggered: {
|
||||
print("[Updates] Periodic update check due")
|
||||
root.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: checkAvailabilityProc
|
||||
running: Config.ready && Config.options.updates.enableCheck
|
||||
command: ["which", "checkupdates"]
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
root.available = (exitCode === 0);
|
||||
root.refresh();
|
||||
}
|
||||
}
|
||||
|
||||
Process {
|
||||
id: checkUpdatesProc
|
||||
command: ["bash", "-c", "checkupdates | wc -l"]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
root.count = parseInt(text.trim());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
182
surfaces/quickshell/ii-base/services/Wallpapers.qml
Normal file
182
surfaces/quickshell/ii-base/services/Wallpapers.qml
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
import qs.modules.common
|
||||
import qs.modules.common.models
|
||||
import qs.modules.common.functions
|
||||
import QtQuick
|
||||
import Qt.labs.folderlistmodel
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
/**
|
||||
* Provides a list of wallpapers and an "apply" action that calls the existing
|
||||
* switchwall.sh script. Pretty much a limited file browsing service.
|
||||
*/
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property string thumbgenScriptPath: `${FileUtils.trimFileProtocol(Directories.scriptPath)}/thumbnails/thumbgen-venv.sh`
|
||||
property string generateThumbnailsMagickScriptPath: `${FileUtils.trimFileProtocol(Directories.scriptPath)}/thumbnails/generate-thumbnails-magick.sh`
|
||||
property alias directory: folderModel.folder
|
||||
readonly property string effectiveDirectory: FileUtils.trimFileProtocol(folderModel.folder.toString())
|
||||
property url defaultFolder: Qt.resolvedUrl(FileUtils.parentDirectory(Config.options.background.wallpaperPath) || `${Directories.pictures}/Wallpapers`)
|
||||
property alias folderModel: folderModel // Expose for direct binding when needed
|
||||
property string searchQuery: ""
|
||||
readonly property list<string> extensions: [
|
||||
"jpg", "jpeg", "png", "webp", "avif", "bmp", "svg",
|
||||
"mp4", "webm", "mkv", "avi", "mov"
|
||||
]
|
||||
readonly property list<string> videoExtensions: ["mp4", "webm", "mkv", "avi", "mov"]
|
||||
property list<string> wallpapers: [] // List of absolute file paths (without file://)
|
||||
readonly property bool thumbnailGenerationRunning: thumbgenProc.running
|
||||
property real thumbnailGenerationProgress: 0
|
||||
|
||||
signal changed()
|
||||
signal thumbnailGenerated(directory: string)
|
||||
signal thumbnailGeneratedFile(filePath: string)
|
||||
|
||||
function load () {} // For forcing initialization
|
||||
|
||||
function openFallbackPicker(darkMode = Appearance.m3colors.darkmode) {
|
||||
Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--mode", darkMode ? "dark" : "light"]);
|
||||
}
|
||||
|
||||
function apply(path, darkMode = Appearance.m3colors.darkmode) {
|
||||
if (!path || path.length === 0) return;
|
||||
Quickshell.execDetached([Directories.wallpaperSwitchScriptPath, "--mode", darkMode ? "dark" : "light", "--image", path]);
|
||||
root.changed()
|
||||
}
|
||||
|
||||
Process {
|
||||
id: selectProc
|
||||
property string filePath: ""
|
||||
property bool darkMode: Appearance.m3colors.darkmode
|
||||
function select(filePath, darkMode = Appearance.m3colors.darkmode) {
|
||||
selectProc.filePath = filePath
|
||||
selectProc.darkMode = darkMode
|
||||
selectProc.exec(["test", "-d", FileUtils.trimFileProtocol(filePath)])
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode === 0) {
|
||||
setDirectory(selectProc.filePath);
|
||||
return;
|
||||
}
|
||||
root.apply(selectProc.filePath, selectProc.darkMode);
|
||||
}
|
||||
}
|
||||
|
||||
function select(filePath, darkMode = Appearance.m3colors.darkmode) {
|
||||
selectProc.select(filePath, darkMode);
|
||||
}
|
||||
|
||||
function randomFromCurrentFolder(darkMode = Appearance.m3colors.darkmode) {
|
||||
if (folderModel.count === 0) return;
|
||||
const randomIndex = Math.floor(Math.random() * folderModel.count);
|
||||
const filePath = folderModel.get(randomIndex, "filePath");
|
||||
print("Randomly selected wallpaper:", filePath);
|
||||
root.select(filePath, darkMode);
|
||||
}
|
||||
|
||||
Process {
|
||||
id: validateDirProc
|
||||
property string nicePath: ""
|
||||
function setDirectoryIfValid(path) {
|
||||
validateDirProc.nicePath = FileUtils.trimFileProtocol(path).replace(/\/+$/, "")
|
||||
if (/^\/*$/.test(validateDirProc.nicePath)) validateDirProc.nicePath = "/";
|
||||
validateDirProc.exec([
|
||||
"bash", "-c",
|
||||
`if [ -d "${validateDirProc.nicePath}" ]; then echo dir; elif [ -f "${validateDirProc.nicePath}" ]; then echo file; else echo invalid; fi`
|
||||
])
|
||||
}
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
root.directory = Qt.resolvedUrl(validateDirProc.nicePath)
|
||||
const result = text.trim()
|
||||
if (result === "dir") {
|
||||
} else if (result === "file") {
|
||||
root.directory = Qt.resolvedUrl(FileUtils.parentDirectory(validateDirProc.nicePath))
|
||||
} else {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
function setDirectory(path) {
|
||||
validateDirProc.setDirectoryIfValid(path)
|
||||
}
|
||||
function navigateUp() {
|
||||
folderModel.navigateUp()
|
||||
}
|
||||
function navigateBack() {
|
||||
folderModel.navigateBack()
|
||||
}
|
||||
function navigateForward() {
|
||||
folderModel.navigateForward()
|
||||
}
|
||||
|
||||
// Folder model
|
||||
FolderListModelWithHistory {
|
||||
id: folderModel
|
||||
folder: Qt.resolvedUrl(root.defaultFolder)
|
||||
caseSensitive: false
|
||||
nameFilters: root.extensions.map(ext => `*${searchQuery.split(" ").filter(s => s.length > 0).map(s => `*${s}*`)}*.${ext}`)
|
||||
showDirs: true
|
||||
showDotAndDotDot: false
|
||||
showOnlyReadable: true
|
||||
sortField: FolderListModel.Time
|
||||
sortReversed: false
|
||||
onCountChanged: {
|
||||
root.wallpapers = []
|
||||
for (let i = 0; i < folderModel.count; i++) {
|
||||
const path = folderModel.get(i, "filePath") || FileUtils.trimFileProtocol(folderModel.get(i, "fileURL"))
|
||||
if (path && path.length) root.wallpapers.push(path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Thumbnail generation
|
||||
function generateThumbnail(size: string) {
|
||||
if (!["normal", "large", "x-large", "xx-large"].includes(size)) throw new Error("Invalid thumbnail size");
|
||||
thumbgenProc.directory = root.directory
|
||||
thumbgenProc.running = false
|
||||
thumbgenProc.command = [
|
||||
"bash", "-c",
|
||||
`${thumbgenScriptPath} --size ${size} --machine_progress -d ${FileUtils.trimFileProtocol(root.directory)} || ${generateThumbnailsMagickScriptPath} --size ${size} -d ${FileUtils.trimFileProtocol(root.directory)}`,
|
||||
]
|
||||
// console.log("[Wallpapers] Updating thumbnails with command ", thumbgenProc.command.join(" "))
|
||||
root.thumbnailGenerationProgress = 0
|
||||
thumbgenProc.running = true
|
||||
}
|
||||
Process {
|
||||
id: thumbgenProc
|
||||
property string directory
|
||||
stdout: SplitParser {
|
||||
onRead: data => {
|
||||
// print("thumb gen proc:", data)
|
||||
let match = data.match(/PROGRESS (\d+)\/(\d+)/)
|
||||
if (match) {
|
||||
const completed = parseInt(match[1])
|
||||
const total = parseInt(match[2])
|
||||
root.thumbnailGenerationProgress = completed / total
|
||||
}
|
||||
match = data.match(/FILE (.+)/)
|
||||
if (match) {
|
||||
const filePath = match[1]
|
||||
root.thumbnailGeneratedFile(filePath)
|
||||
}
|
||||
}
|
||||
}
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
// print("[Wallpapers] Thumbnail generation completed with exit code", exitCode)
|
||||
root.thumbnailGenerated(thumbgenProc.directory)
|
||||
}
|
||||
}
|
||||
|
||||
IpcHandler {
|
||||
target: "wallpapers"
|
||||
|
||||
function apply(path: string): void {
|
||||
root.apply(path);
|
||||
}
|
||||
}
|
||||
}
|
||||
167
surfaces/quickshell/ii-base/services/Weather.qml
Normal file
167
surfaces/quickshell/ii-base/services/Weather.qml
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
pragma Singleton
|
||||
pragma ComponentBehavior: Bound
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
import QtPositioning
|
||||
|
||||
import qs.modules.common
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
// 10 minute
|
||||
readonly property int fetchInterval: Config.options.bar.weather.fetchInterval * 60 * 1000
|
||||
readonly property string city: Config.options.bar.weather.city
|
||||
readonly property bool useUSCS: Config.options.bar.weather.useUSCS
|
||||
property bool gpsActive: Config.options.bar.weather.enableGPS
|
||||
|
||||
onUseUSCSChanged: {
|
||||
root.getData();
|
||||
}
|
||||
onCityChanged: {
|
||||
root.getData();
|
||||
}
|
||||
|
||||
property var location: ({
|
||||
valid: false,
|
||||
lat: 0,
|
||||
lon: 0
|
||||
})
|
||||
|
||||
property var data: ({
|
||||
uv: 0,
|
||||
humidity: 0,
|
||||
sunrise: 0,
|
||||
sunset: 0,
|
||||
windDir: 0,
|
||||
wCode: 0,
|
||||
city: 0,
|
||||
wind: 0,
|
||||
precip: 0,
|
||||
visib: 0,
|
||||
press: 0,
|
||||
temp: 0,
|
||||
tempFeelsLike: 0,
|
||||
lastRefresh: 0,
|
||||
})
|
||||
|
||||
function refineData(data) {
|
||||
let temp = {};
|
||||
temp.uv = data?.current?.uvIndex || 0;
|
||||
temp.humidity = (data?.current?.humidity || 0) + "%";
|
||||
temp.sunrise = data?.astronomy?.sunrise || "0.0";
|
||||
temp.sunset = data?.astronomy?.sunset || "0.0";
|
||||
temp.windDir = data?.current?.winddir16Point || "N";
|
||||
temp.wCode = data?.current?.weatherCode || "113";
|
||||
temp.city = data?.location?.areaName[0]?.value || "City";
|
||||
temp.temp = "";
|
||||
temp.tempFeelsLike = "";
|
||||
if (root.useUSCS) {
|
||||
temp.wind = (data?.current?.windspeedMiles || 0) + " mph";
|
||||
temp.precip = (data?.current?.precipInches || 0) + " in";
|
||||
temp.visib = (data?.current?.visibilityMiles || 0) + " m";
|
||||
temp.press = (data?.current?.pressureInches || 0) + " psi";
|
||||
temp.temp += Math.round(data?.current?.temp_F || 0) + 0;
|
||||
temp.tempFeelsLike += Math.round(data?.current?.FeelsLikeF || 0) + 0;
|
||||
temp.temp += "°F";
|
||||
temp.tempFeelsLike += "°F";
|
||||
} else {
|
||||
temp.wind = (data?.current?.windspeedKmph || 0) + " km/h";
|
||||
temp.precip = (data?.current?.precipMM || 0) + " mm";
|
||||
temp.visib = (data?.current?.visibility || 0) + " km";
|
||||
temp.press = (data?.current?.pressure || 0) + " hPa";
|
||||
temp.temp += Math.round(data?.current?.temp_C || 0) + 0;
|
||||
temp.tempFeelsLike += Math.round(data?.current?.FeelsLikeC || 0) + 0;
|
||||
temp.temp += "°C";
|
||||
temp.tempFeelsLike += "°C";
|
||||
}
|
||||
temp.lastRefresh = DateTime.time + " • " + DateTime.date;
|
||||
root.data = temp;
|
||||
}
|
||||
|
||||
function getData() {
|
||||
let command = "curl -s wttr.in";
|
||||
|
||||
if (root.gpsActive && root.location.valid) {
|
||||
command += `/${root.location.lat},${root.location.long}`;
|
||||
} else {
|
||||
command += `/${formatCityName(root.city)}`;
|
||||
}
|
||||
|
||||
// format as json
|
||||
command += "?format=j1";
|
||||
command += " | ";
|
||||
// only take the current weather, location, asytronmy data
|
||||
command += "jq '{current: .current_condition[0], location: .nearest_area[0], astronomy: .weather[0].astronomy[0]}'";
|
||||
fetcher.command[2] = command;
|
||||
fetcher.running = true;
|
||||
}
|
||||
|
||||
function formatCityName(cityName) {
|
||||
return cityName.trim().split(/\s+/).join('+');
|
||||
}
|
||||
|
||||
Component.onCompleted: {
|
||||
if (!root.gpsActive) return;
|
||||
console.info("[WeatherService] Starting the GPS service.");
|
||||
positionSource.start();
|
||||
}
|
||||
|
||||
Process {
|
||||
id: fetcher
|
||||
command: ["bash", "-c", ""]
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: {
|
||||
if (text.length === 0)
|
||||
return;
|
||||
try {
|
||||
const parsedData = JSON.parse(text);
|
||||
root.refineData(parsedData);
|
||||
// console.info(`[ data: ${JSON.stringify(parsedData)}`);
|
||||
} catch (e) {
|
||||
console.error(`[WeatherService] ${e.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PositionSource {
|
||||
id: positionSource
|
||||
updateInterval: root.fetchInterval
|
||||
|
||||
onPositionChanged: {
|
||||
// update the location if the given location is valid
|
||||
// if it fails getting the location, use the last valid location
|
||||
if (position.latitudeValid && position.longitudeValid) {
|
||||
root.location.lat = position.coordinate.latitude;
|
||||
root.location.long = position.coordinate.longitude;
|
||||
root.location.valid = true;
|
||||
// console.info(`📍 Location: ${position.coordinate.latitude}, ${position.coordinate.longitude}`);
|
||||
root.getData();
|
||||
// if can't get initialized with valid location deactivate the GPS
|
||||
} else {
|
||||
root.gpsActive = root.location.valid ? true : false;
|
||||
console.error("[WeatherService] Failed to get the GPS location.");
|
||||
}
|
||||
}
|
||||
|
||||
onValidityChanged: {
|
||||
if (!positionSource.valid) {
|
||||
positionSource.stop();
|
||||
root.location.valid = false;
|
||||
root.gpsActive = false;
|
||||
Quickshell.execDetached(["notify-send", Translation.tr("Weather Service"), Translation.tr("Cannot find a GPS service. Using the fallback method instead."), "-a", "Shell"]);
|
||||
console.error("[WeatherService] Could not aquire a valid backend plugin.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Timer {
|
||||
running: !root.gpsActive
|
||||
repeat: true
|
||||
interval: root.fetchInterval
|
||||
triggeredOnStart: !root.gpsActive
|
||||
onTriggered: root.getData()
|
||||
}
|
||||
}
|
||||
47
surfaces/quickshell/ii-base/services/Ydotool.qml
Normal file
47
surfaces/quickshell/ii-base/services/Ydotool.qml
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
pragma Singleton
|
||||
|
||||
import qs.modules.common
|
||||
import Quickshell
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
property int shiftMode: 0 // 0: off, 1: on, 2: lock
|
||||
property list<int> shiftKeys: [42, 54] // Keycodes for Shift keys (left and right)
|
||||
property list<int> altKeys: [56, 100] // Keycodes for Alt keys (left and right)
|
||||
property list<int> ctrlKeys: [29, 97] // Keycodes for Ctrl keys (left and right)
|
||||
|
||||
function releaseAllKeys() {
|
||||
const keycodes = Array.from(Array(249).keys());
|
||||
Quickshell.execDetached([
|
||||
"ydotool",
|
||||
"key", "--key-delay", "0",
|
||||
...keycodes.map(keycode => `${keycode}:0`)
|
||||
])
|
||||
root.shiftMode = 0; // Reset shift mode
|
||||
}
|
||||
|
||||
function releaseShiftKeys() {
|
||||
Quickshell.execDetached([
|
||||
"ydotool",
|
||||
"key", "--key-delay", "0",
|
||||
...root.shiftKeys.map(keycode => `${keycode}:0`)
|
||||
])
|
||||
root.shiftMode = 0; // Reset shift mode
|
||||
}
|
||||
|
||||
function press(keycode) {
|
||||
Quickshell.execDetached([
|
||||
"ydotool",
|
||||
"key", "--key-delay", "0",
|
||||
`${keycode}:1`
|
||||
]);
|
||||
}
|
||||
|
||||
function release(keycode) {
|
||||
Quickshell.execDetached([
|
||||
"ydotool",
|
||||
"key", "--key-delay", "0",
|
||||
`${keycode}:0`
|
||||
]);
|
||||
}
|
||||
}
|
||||
29
surfaces/quickshell/ii-base/services/ai/AiMessageData.qml
Normal file
29
surfaces/quickshell/ii-base/services/ai/AiMessageData.qml
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import QtQuick;
|
||||
|
||||
/**
|
||||
* Represents a message in an AI conversation. (Kind of) follows the OpenAI API message structure.
|
||||
*/
|
||||
QtObject {
|
||||
property string role
|
||||
property string content
|
||||
property string rawContent
|
||||
// First-class stream blocks. The Souveraine adapter fills these from the
|
||||
// typed SSE events; legacy/provider messages leave this empty and the
|
||||
// existing markdown splitter remains their renderer.
|
||||
property var segments: []
|
||||
property string fileMimeType
|
||||
property string fileUri
|
||||
property string localFilePath
|
||||
property string model
|
||||
property bool thinking: true
|
||||
property bool done: false
|
||||
property var annotations: []
|
||||
property var annotationSources: []
|
||||
property list<string> searchQueries: []
|
||||
property string functionName
|
||||
property var functionCall
|
||||
property string thoughtSignature
|
||||
property string functionResponse
|
||||
property bool functionPending: false
|
||||
property bool visibleToUser: true
|
||||
}
|
||||
32
surfaces/quickshell/ii-base/services/ai/AiModel.qml
Normal file
32
surfaces/quickshell/ii-base/services/ai/AiModel.qml
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
import QtQuick;
|
||||
|
||||
/**
|
||||
* An AI model representation.
|
||||
* - name: Friendly name of the model
|
||||
* - icon: Icon name of the model
|
||||
* - description: Description of the model
|
||||
* - endpoint: Endpoint of the model
|
||||
* - model: Model code (like gpt-4.1 or gemini-2.5-flash)
|
||||
* - requires_key: Whether the model requires an API key
|
||||
* - key_id: The identifier of the API key. Use the same identifier for models that can be accessed with the same key.
|
||||
* - key_get_link: Link to get an API key
|
||||
* - key_get_description: Description of pricing and how to get an API key
|
||||
* - api_format: The API format of the model. Can be "openai" or "gemini". Default is "openai".
|
||||
* - extraParams: Extra parameters to be passed to the model. This is a JSON object.
|
||||
*/
|
||||
|
||||
QtObject {
|
||||
property string name
|
||||
property string icon
|
||||
property string description
|
||||
property string homepage
|
||||
property string endpoint
|
||||
property string model
|
||||
property bool requires_key: true
|
||||
property string key_id
|
||||
property string key_get_link
|
||||
property string key_get_description
|
||||
property string api_format: "openai"
|
||||
property var tools
|
||||
property var extraParams: ({})
|
||||
}
|
||||
12
surfaces/quickshell/ii-base/services/ai/ApiStrategy.qml
Normal file
12
surfaces/quickshell/ii-base/services/ai/ApiStrategy.qml
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import QtQuick
|
||||
|
||||
QtObject {
|
||||
function buildEndpoint(model: AiModel): string { throw new Error("Not implemented") }
|
||||
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>, filePath: string) { throw new Error("Not implemented") }
|
||||
function buildAuthorizationHeader(apiKeyEnvVarName: string): string { throw new Error("Not implemented") }
|
||||
function parseResponseLine(line: string, message: AiMessageData) { throw new Error("Not implemented") }
|
||||
function onRequestFinished(message: AiMessageData): var { return {} } // Default: no special handling
|
||||
function reset() { } // Reset any internal state if needed
|
||||
function buildScriptFileSetup(filePath) { return "" } // Default: no setup
|
||||
function finalizeScriptContent(scriptContent: string): string { return scriptContent } // Optionally modify/finalize script
|
||||
}
|
||||
272
surfaces/quickshell/ii-base/services/ai/GeminiApiStrategy.qml
Normal file
272
surfaces/quickshell/ii-base/services/ai/GeminiApiStrategy.qml
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
import QtQuick
|
||||
import qs.modules.common.functions as CF
|
||||
|
||||
ApiStrategy {
|
||||
readonly property string apiKeyEnvVarName: "API_KEY"
|
||||
readonly property string fileListVarName: "UPLOADED_FILES_JSON"
|
||||
readonly property string fileListSubstitutionString: "{{ uploadedFilesJson }}"
|
||||
property string buffer: ""
|
||||
|
||||
function buildEndpoint(model: AiModel): string {
|
||||
const result = model.endpoint + `?key=\$\{${root.apiKeyEnvVarName}\}`
|
||||
// console.log("[AI] Endpoint: " + result);
|
||||
return result;
|
||||
}
|
||||
|
||||
function attachmentPart(attachment) {
|
||||
return {
|
||||
"file_data": {
|
||||
"mime_type": attachment.fileMimeType,
|
||||
"file_uri": attachment.fileUri
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>, pendingFiles: var) {
|
||||
let contents = messages.map(message => {
|
||||
// console.log("[AI] Building request data for message:", JSON.stringify(message, null, 2));
|
||||
const geminiApiRoleName = (message.role === "assistant") ? "model" : message.role;
|
||||
const usingSearch = tools[0]?.google_search !== undefined
|
||||
if (!usingSearch && message.functionCall != undefined && message.functionName.length > 0) {
|
||||
const part = { functionCall: message.functionCall };
|
||||
if (message.thoughtSignature && message.thoughtSignature.length > 0) {
|
||||
part.thoughtSignature = message.thoughtSignature;
|
||||
}
|
||||
return {
|
||||
"role": geminiApiRoleName,
|
||||
"parts": [part]
|
||||
}
|
||||
}
|
||||
if (!usingSearch && message.functionResponse != undefined && message.functionName.length > 0) {
|
||||
return {
|
||||
"role": geminiApiRoleName,
|
||||
"parts": [{
|
||||
functionResponse: {
|
||||
"name": message.functionName,
|
||||
"response": { "content": message.functionResponse }
|
||||
}
|
||||
}]
|
||||
}
|
||||
}
|
||||
const messageAttachments = (message.attachments ?? [])
|
||||
.filter(attachment => attachment.fileUri && attachment.fileUri.length > 0)
|
||||
.map(attachment => attachmentPart(attachment));
|
||||
return {
|
||||
"role": geminiApiRoleName,
|
||||
"parts": [
|
||||
...messageAttachments,
|
||||
{ text: message.rawContent },
|
||||
...(messageAttachments.length === 0 && message.fileUri && message.fileUri.length > 0 ? [attachmentPart(message)] : [])
|
||||
]
|
||||
}
|
||||
})
|
||||
if (pendingFiles && pendingFiles.length > 0) {
|
||||
contents[contents.length - 1].parts = [
|
||||
...contents[contents.length - 1].parts,
|
||||
fileListSubstitutionString,
|
||||
];
|
||||
}
|
||||
let baseData = {
|
||||
"contents": contents,
|
||||
"tools": tools,
|
||||
"system_instruction": {
|
||||
"parts": [{ text: systemPrompt }]
|
||||
},
|
||||
"generationConfig": {
|
||||
"temperature": temperature,
|
||||
},
|
||||
};
|
||||
return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
|
||||
}
|
||||
|
||||
function buildAuthorizationHeader(apiKeyEnvVarName: string): string {
|
||||
return "";
|
||||
}
|
||||
|
||||
function parseResponseLine(line, message) {
|
||||
if (line.startsWith("[")) {
|
||||
buffer += line.slice(1).trim();
|
||||
} else if (line === "]") {
|
||||
buffer += line.slice(0, -1).trim();
|
||||
return parseBuffer(message);
|
||||
} else if (line.startsWith(",")) {
|
||||
return parseBuffer(message);
|
||||
} else {
|
||||
buffer += line.trim();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function parseBuffer(message) {
|
||||
let finished = false;
|
||||
try {
|
||||
if (buffer.length === 0) return {};
|
||||
const dataJson = JSON.parse(buffer);
|
||||
|
||||
if (dataJson.uploadedFile) {
|
||||
const targetMessage = root.attachmentTargetMessage || message;
|
||||
if (!targetMessage) return {};
|
||||
|
||||
const attachments = [...(targetMessage.attachments ?? [])];
|
||||
const attachmentIndex = attachments.findIndex(attachment => !attachment.fileUri || attachment.fileUri.length === 0);
|
||||
if (attachmentIndex !== -1) {
|
||||
attachments[attachmentIndex] = Object.assign({}, attachments[attachmentIndex], {
|
||||
"fileUri": dataJson.uploadedFile.uri,
|
||||
"fileMimeType": dataJson.uploadedFile.mimeType,
|
||||
});
|
||||
targetMessage.attachments = attachments;
|
||||
if (attachmentIndex === 0) {
|
||||
targetMessage.fileUri = dataJson.uploadedFile.uri;
|
||||
targetMessage.fileMimeType = dataJson.uploadedFile.mimeType;
|
||||
}
|
||||
} else {
|
||||
targetMessage.fileUri = dataJson.uploadedFile.uri;
|
||||
targetMessage.fileMimeType = dataJson.uploadedFile.mimeType;
|
||||
}
|
||||
targetMessage.localFilePath = targetMessage.attachments?.[0]?.localFilePath ?? targetMessage.localFilePath;
|
||||
return ({})
|
||||
}
|
||||
|
||||
if (dataJson.error) {
|
||||
const errorMsg = `**Error ${dataJson.error.code}**: ${dataJson.error.message}`;
|
||||
message.rawContent += errorMsg;
|
||||
message.content += errorMsg;
|
||||
return { finished: true };
|
||||
}
|
||||
|
||||
if (!dataJson.candidates) return {};
|
||||
|
||||
if (dataJson.candidates[0]?.finishReason) {
|
||||
finished = true;
|
||||
}
|
||||
|
||||
if (dataJson.candidates[0]?.content?.parts[0]?.functionCall) {
|
||||
const part = dataJson.candidates[0].content.parts[0];
|
||||
const functionCall = part.functionCall;
|
||||
message.functionName = functionCall.name;
|
||||
message.functionCall = functionCall;
|
||||
if (part.thoughtSignature) {
|
||||
message.thoughtSignature = part.thoughtSignature;
|
||||
}
|
||||
const newContent = `\n\n[[ Function: ${functionCall.name}(${JSON.stringify(functionCall.args, null, 2)}) ]]\n`
|
||||
message.rawContent += newContent;
|
||||
message.content += newContent;
|
||||
return { functionCall: functionCall, finished: finished };
|
||||
}
|
||||
|
||||
const responseContent = dataJson.candidates[0]?.content?.parts[0]?.text
|
||||
message.rawContent += responseContent;
|
||||
message.content += responseContent;
|
||||
|
||||
const annotationSources = dataJson.candidates[0]?.groundingMetadata?.groundingChunks?.map(chunk => {
|
||||
return {
|
||||
"type": "url_citation",
|
||||
"text": chunk?.web?.title,
|
||||
"url": chunk?.web?.uri,
|
||||
}
|
||||
}) ?? [];
|
||||
|
||||
const annotations = dataJson.candidates[0]?.groundingMetadata?.groundingSupports?.map(citation => {
|
||||
return {
|
||||
"type": "url_citation",
|
||||
"start_index": citation.segment?.startIndex,
|
||||
"end_index": citation.segment?.endIndex,
|
||||
"text": citation?.segment.text,
|
||||
"url": annotationSources[citation.groundingChunkIndices[0]]?.url,
|
||||
"sources": citation.groundingChunkIndices
|
||||
}
|
||||
});
|
||||
message.annotationSources = annotationSources;
|
||||
message.annotations = annotations;
|
||||
message.searchQueries = dataJson.candidates[0]?.groundingMetadata?.webSearchQueries ?? [];
|
||||
|
||||
if (dataJson.usageMetadata) {
|
||||
return {
|
||||
tokenUsage: {
|
||||
input: dataJson.usageMetadata.promptTokenCount ?? -1,
|
||||
output: dataJson.usageMetadata.candidatesTokenCount ?? -1,
|
||||
total: dataJson.usageMetadata.totalTokenCount ?? -1
|
||||
},
|
||||
finished: finished
|
||||
};
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.log("[AI] Gemini: Could not parse buffer: ", e);
|
||||
message.rawContent += buffer;
|
||||
message.content += buffer;
|
||||
} finally {
|
||||
buffer = "";
|
||||
}
|
||||
return { finished: finished };
|
||||
}
|
||||
|
||||
function onRequestFinished(message) {
|
||||
return parseBuffer(message);
|
||||
}
|
||||
|
||||
function reset() {
|
||||
buffer = "";
|
||||
}
|
||||
|
||||
function buildScriptFileSetup(pendingFiles: var) {
|
||||
if (!pendingFiles || pendingFiles.length === 0) return "";
|
||||
|
||||
let content = ""
|
||||
content += `${fileListVarName}=""\n`;
|
||||
content += 'mkdir -p "/tmp/quickshell/ai"\n';
|
||||
|
||||
pendingFiles.forEach((filePath, index) => {
|
||||
const trimmedFilePath = CF.FileUtils.trimFileProtocol(filePath);
|
||||
const imagePathVarName = `IMAGE_PATH_${index}`;
|
||||
const fileMimeTypeVarName = `MIME_TYPE_${index}`;
|
||||
const fileUriVarName = `FILE_URI_${index}`;
|
||||
const numBytesVarName = `NUM_BYTES_${index}`;
|
||||
const tmpHeaderVarName = `TMP_HEADER_FILE_${index}`;
|
||||
const tmpFileInfoVarName = `TMP_FILE_INFO_${index}`;
|
||||
const uploadUrlVarName = `UPLOAD_URL_${index}`;
|
||||
const uploadErrorVarName = `UPLOAD_ERROR_${index}`;
|
||||
|
||||
content += `${imagePathVarName}='${CF.StringUtils.shellSingleQuoteEscape(trimmedFilePath)}'\n`;
|
||||
content += `if [ ! -f "$${imagePathVarName}" ] || [ ! -s "$${imagePathVarName}" ]; then printf '{"error": {"code": 400, "message": "Attached file is missing or unreadable: %s"}}\n' "$${imagePathVarName}"; exit 1; fi\n`;
|
||||
content += `${fileMimeTypeVarName}=$(file -b --mime-type "$${imagePathVarName}")\n`;
|
||||
content += `${numBytesVarName}=$(wc -c < "$${imagePathVarName}")\n`;
|
||||
content += `${tmpHeaderVarName}="/tmp/quickshell/ai/upload-header-${index}.tmp"\n`;
|
||||
content += `${tmpFileInfoVarName}="/tmp/quickshell/ai/file-info-${index}.json.tmp"\n`;
|
||||
content += 'curl "https://generativelanguage.googleapis.com/upload/v1beta/files"'
|
||||
+ ` -H "x-goog-api-key: \$${apiKeyEnvVarName}"`
|
||||
+ ` -D "$${tmpHeaderVarName}"`
|
||||
+ ' -H "X-Goog-Upload-Protocol: resumable"'
|
||||
+ ' -H "X-Goog-Upload-Command: start"'
|
||||
+ ` -H "X-Goog-Upload-Header-Content-Length: \$\{${numBytesVarName}\}"`
|
||||
+ ` -H "X-Goog-Upload-Header-Content-Type: \$\{${fileMimeTypeVarName}\}"`
|
||||
+ ' -H "Content-Type: application/json"'
|
||||
+ ` -d '{"file": {"display_name": "Attachment ${index + 1}"}}' 2> /dev/null`
|
||||
+ '\n';
|
||||
content += `${uploadUrlVarName}=$(grep -i "x-goog-upload-url: " "$${tmpHeaderVarName}" | cut -d" " -f2 | tr -d "\r")\n`;
|
||||
content += `rm "$${tmpHeaderVarName}"\n`;
|
||||
content += `if [ -z "$${uploadUrlVarName}" ]; then printf '{"error": {"code": 400, "message": "Failed to start Gemini file upload for %s"}}\n' "$${imagePathVarName}"; exit 1; fi\n`;
|
||||
content += 'curl "$'
|
||||
+ `{${uploadUrlVarName}}"`
|
||||
+ ` -H "x-goog-api-key: \$${apiKeyEnvVarName}"`
|
||||
+ ` -H "Content-Length: \$\{${numBytesVarName}\}"`
|
||||
+ ' -H "X-Goog-Upload-Offset: 0"'
|
||||
+ ' -H "X-Goog-Upload-Command: upload, finalize"'
|
||||
+ ` --data-binary "@$${imagePathVarName}" 2> /dev/null > "$${tmpFileInfoVarName}"`
|
||||
+ '\n';
|
||||
content += `${fileUriVarName}=$(jq -r '.file.uri // empty' "$${tmpFileInfoVarName}")\n`;
|
||||
content += `${uploadErrorVarName}=$(jq -r '.error.message // empty' "$${tmpFileInfoVarName}")\n`;
|
||||
content += `if [ -z "$${fileUriVarName}" ]; then [ -n "$${uploadErrorVarName}" ] || ${uploadErrorVarName}='No file URI returned from Gemini file upload'; printf '{"error": {"code": 400, "message": "Gemini file upload failed for %s: %s"}}\n' "$${imagePathVarName}" "$${uploadErrorVarName}"; exit 1; fi\n`;
|
||||
content += `${fileListVarName}+=$(jq -cn --arg uri "$${fileUriVarName}" --arg mimeType "$${fileMimeTypeVarName}" '{"file_data": {"mime_type": $mimeType, "file_uri": $uri}}')\n`;
|
||||
content += `${fileListVarName}+=','\n`;
|
||||
content += `printf '{"uploadedFile": {"uri": "%s", "mimeType": "%s"}}\n,\n' "$${fileUriVarName}" "$${fileMimeTypeVarName}"\n`;
|
||||
});
|
||||
|
||||
return content
|
||||
}
|
||||
|
||||
function finalizeScriptContent(scriptContent: string): string {
|
||||
const uploadedPartsReference = "'\"${" + fileListVarName + "%,}\"'";
|
||||
return scriptContent.replace(`"${fileListSubstitutionString}"`, uploadedPartsReference);
|
||||
}
|
||||
}
|
||||
144
surfaces/quickshell/ii-base/services/ai/MistralApiStrategy.qml
Normal file
144
surfaces/quickshell/ii-base/services/ai/MistralApiStrategy.qml
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import QtQuick
|
||||
|
||||
ApiStrategy {
|
||||
property bool isReasoning: false
|
||||
|
||||
function buildEndpoint(model: AiModel): string {
|
||||
// console.log("[AI] Endpoint: " + model.endpoint);
|
||||
return model.endpoint;
|
||||
}
|
||||
|
||||
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>, filePath: string) {
|
||||
let baseData = {
|
||||
"model": model.model,
|
||||
"messages": [
|
||||
{role: "system", content: systemPrompt},
|
||||
...messages.map(message => {
|
||||
const hasFunctionCall = message.functionCall != undefined && message.functionName.length > 0
|
||||
let messageData = {
|
||||
"role": message.role,
|
||||
"content": message.rawContent,
|
||||
}
|
||||
if (hasFunctionCall) {
|
||||
if (message.functionResponse?.length > 0) {
|
||||
messageData.name = message.functionName; // Does the func call also need this name? or just the func output?
|
||||
messageData.role = "tool";
|
||||
messageData.content = message.functionResponse;
|
||||
messageData.tool_call_id = message.functionCall.id
|
||||
}
|
||||
}
|
||||
return messageData
|
||||
}),
|
||||
],
|
||||
"stream": true,
|
||||
"temperature": temperature,
|
||||
"tools": tools,
|
||||
};
|
||||
// console.log("[AI] Request data: ", JSON.stringify(baseData, null, 2));
|
||||
return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
|
||||
}
|
||||
|
||||
function buildAuthorizationHeader(apiKeyEnvVarName: string): string {
|
||||
return `-H "Authorization: Bearer \$\{${apiKeyEnvVarName}\}"`;
|
||||
}
|
||||
|
||||
function parseResponseLine(line, message) {
|
||||
// Remove 'data: ' prefix if present and trim whitespace
|
||||
let cleanData = line.trim();
|
||||
if (cleanData.startsWith("data:")) {
|
||||
cleanData = cleanData.slice(5).trim();
|
||||
}
|
||||
|
||||
// Handle special cases
|
||||
if (!cleanData || cleanData.startsWith(":")) return {};
|
||||
if (cleanData === "[DONE]") {
|
||||
return { finished: true };
|
||||
}
|
||||
|
||||
// Real stuff
|
||||
try {
|
||||
const dataJson = JSON.parse(cleanData);
|
||||
|
||||
// Error response handling
|
||||
if (dataJson.error) {
|
||||
const errorMsg = `**Error**: ${dataJson.error.message || JSON.stringify(dataJson.error)}`;
|
||||
message.rawContent += errorMsg;
|
||||
message.content += errorMsg;
|
||||
return { finished: true };
|
||||
}
|
||||
|
||||
let newContent = "";
|
||||
|
||||
const responseContent = dataJson.choices[0]?.delta?.content || dataJson.message?.content;
|
||||
const responseReasoning = dataJson.choices[0]?.delta?.reasoning || dataJson.choices[0]?.delta?.reasoning_content;
|
||||
|
||||
// Function call
|
||||
if (dataJson.choices[0]?.delta?.tool_calls) {
|
||||
const functionCall = dataJson.choices[0].delta.tool_calls[0];
|
||||
const functionName = functionCall.function.name;
|
||||
const functionArgs = JSON.parse(functionCall.function.arguments) || {}; // Args are given as string???
|
||||
const functionId = functionCall.id;
|
||||
const newContent = `\n\n[[ Function: ${functionName}(${JSON.stringify(functionArgs, null, 2)}) ]]\n`;
|
||||
message.rawContent += newContent;
|
||||
message.content += newContent;
|
||||
message.functionName = functionName;
|
||||
message.functionCall = functionName;
|
||||
return { functionCall: { name: functionName, args: functionArgs, id: functionId } };
|
||||
}
|
||||
|
||||
// Thinking?
|
||||
if (responseContent && responseContent.length > 0) {
|
||||
if (isReasoning) {
|
||||
isReasoning = false;
|
||||
const endBlock = "\n\n</think>\n\n";
|
||||
message.content += endBlock;
|
||||
message.rawContent += endBlock;
|
||||
}
|
||||
newContent = responseContent;
|
||||
} else if (responseReasoning && responseReasoning.length > 0) {
|
||||
if (!isReasoning) {
|
||||
isReasoning = true;
|
||||
const startBlock = "\n\n<think>\n\n";
|
||||
message.rawContent += startBlock;
|
||||
message.content += startBlock;
|
||||
}
|
||||
newContent = responseReasoning;
|
||||
}
|
||||
|
||||
// Text
|
||||
message.content += newContent;
|
||||
message.rawContent += newContent;
|
||||
|
||||
// Usage metadata
|
||||
if (dataJson.usage) {
|
||||
return {
|
||||
tokenUsage: {
|
||||
input: dataJson.usage.prompt_tokens ?? -1,
|
||||
output: dataJson.usage.completion_tokens ?? -1,
|
||||
total: dataJson.usage.total_tokens ?? -1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (`dataJson`.done) {
|
||||
return { finished: true };
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.log("[AI] Mistral: Could not parse line: ", e);
|
||||
message.rawContent += line;
|
||||
message.content += line;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function onRequestFinished(message) {
|
||||
return {};
|
||||
}
|
||||
|
||||
function reset() {
|
||||
isReasoning = false;
|
||||
}
|
||||
|
||||
}
|
||||
120
surfaces/quickshell/ii-base/services/ai/OpenAiApiStrategy.qml
Normal file
120
surfaces/quickshell/ii-base/services/ai/OpenAiApiStrategy.qml
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
import QtQuick
|
||||
|
||||
ApiStrategy {
|
||||
property bool isReasoning: false
|
||||
|
||||
function buildEndpoint(model: AiModel): string {
|
||||
// console.log("[AI] Endpoint: " + model.endpoint);
|
||||
return model.endpoint;
|
||||
}
|
||||
|
||||
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>, filePath: string) {
|
||||
let baseData = {
|
||||
"model": model.model,
|
||||
"messages": [
|
||||
{role: "system", content: systemPrompt},
|
||||
...messages.map(message => {
|
||||
return {
|
||||
"role": message.role,
|
||||
"content": message.rawContent,
|
||||
}
|
||||
}),
|
||||
],
|
||||
"stream": true,
|
||||
"tools": tools,
|
||||
"temperature": temperature,
|
||||
};
|
||||
return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
|
||||
}
|
||||
|
||||
function buildAuthorizationHeader(apiKeyEnvVarName: string): string {
|
||||
return `-H "Authorization: Bearer \$\{${apiKeyEnvVarName}\}"`;
|
||||
}
|
||||
|
||||
function parseResponseLine(line, message) {
|
||||
// Remove 'data: ' prefix if present and trim whitespace
|
||||
let cleanData = line.trim();
|
||||
if (cleanData.startsWith("data:")) {
|
||||
cleanData = cleanData.slice(5).trim();
|
||||
}
|
||||
|
||||
// console.log("[AI] OpenAI: Data:", cleanData);
|
||||
|
||||
// Handle special cases
|
||||
if (!cleanData || cleanData.startsWith(":")) return {};
|
||||
if (cleanData === "[DONE]") {
|
||||
return { finished: true };
|
||||
}
|
||||
|
||||
// Real stuff
|
||||
try {
|
||||
const dataJson = JSON.parse(cleanData);
|
||||
|
||||
// Error response handling
|
||||
if (dataJson.error) {
|
||||
const errorMsg = `**Error**: ${dataJson.error.message || JSON.stringify(dataJson.error)}`;
|
||||
message.rawContent += errorMsg;
|
||||
message.content += errorMsg;
|
||||
return { finished: true };
|
||||
}
|
||||
|
||||
let newContent = "";
|
||||
|
||||
const responseContent = dataJson.choices[0]?.delta?.content || dataJson.message?.content;
|
||||
const responseReasoning = dataJson.choices[0]?.delta?.reasoning || dataJson.choices[0]?.delta?.reasoning_content;
|
||||
|
||||
if (responseContent && responseContent.length > 0) {
|
||||
if (isReasoning) {
|
||||
isReasoning = false;
|
||||
const endBlock = "\n\n</think>\n\n";
|
||||
message.content += endBlock;
|
||||
message.rawContent += endBlock;
|
||||
}
|
||||
newContent = responseContent;
|
||||
} else if (responseReasoning && responseReasoning.length > 0) {
|
||||
if (!isReasoning) {
|
||||
isReasoning = true;
|
||||
const startBlock = "\n\n<think>\n\n";
|
||||
message.rawContent += startBlock;
|
||||
message.content += startBlock;
|
||||
}
|
||||
newContent = responseReasoning;
|
||||
}
|
||||
|
||||
message.content += newContent;
|
||||
message.rawContent += newContent;
|
||||
|
||||
// Usage metadata
|
||||
if (dataJson.usage) {
|
||||
return {
|
||||
tokenUsage: {
|
||||
input: dataJson.usage.prompt_tokens ?? -1,
|
||||
output: dataJson.usage.completion_tokens ?? -1,
|
||||
total: dataJson.usage.total_tokens ?? -1
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
if (dataJson.done) {
|
||||
return { finished: true };
|
||||
}
|
||||
|
||||
} catch (e) {
|
||||
console.log("[AI] OpenAI: Could not parse line: ", e);
|
||||
message.rawContent += line;
|
||||
message.content += line;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function onRequestFinished(message) {
|
||||
// OpenAI format doesn't need special finish handling
|
||||
return {};
|
||||
}
|
||||
|
||||
function reset() {
|
||||
isReasoning = false;
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,123 @@
|
|||
import QtQuick
|
||||
|
||||
ApiStrategy {
|
||||
property bool isReasoning: false
|
||||
|
||||
function buildEndpoint(model: AiModel): string {
|
||||
// console.log("[AI] Endpoint: " + model.endpoint);
|
||||
return model.endpoint;
|
||||
}
|
||||
|
||||
function buildRequestData(model: AiModel, messages, systemPrompt: string, temperature: real, tools: list<var>, filePath: string) {
|
||||
let baseData = {
|
||||
"model": model.model,
|
||||
"instructions": systemPrompt,
|
||||
"input": [...messages.map(message => ({
|
||||
role: message.role,
|
||||
content: message.rawContent
|
||||
}))],
|
||||
"stream": true,
|
||||
"tools": tools,
|
||||
"temperature": temperature
|
||||
};
|
||||
return model.extraParams ? Object.assign({}, baseData, model.extraParams) : baseData;
|
||||
}
|
||||
|
||||
function buildAuthorizationHeader(apiKeyEnvVarName: string): string {
|
||||
return `-H "Authorization: Bearer \$\{${apiKeyEnvVarName}\}"`;
|
||||
}
|
||||
|
||||
function parseResponseLine(line, message) {
|
||||
let cleanData = line.trim();
|
||||
// event line
|
||||
if (cleanData.startsWith("event:")) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// Remove 'data: ' prefix if present and trim whitespace
|
||||
if (cleanData.startsWith("data:")) {
|
||||
cleanData = cleanData.slice(5).trim();
|
||||
}
|
||||
|
||||
// console.log("[AI] OpenAI: Data:", cleanData);
|
||||
|
||||
// Handle special cases
|
||||
if (!cleanData || cleanData.startsWith(":"))
|
||||
return {};
|
||||
|
||||
// Real stuff
|
||||
try {
|
||||
const dataJson = JSON.parse(cleanData);
|
||||
|
||||
// Error response handling
|
||||
if (dataJson.error) {
|
||||
const errorMsg = `**Error**: ${dataJson.error.message || JSON.stringify(dataJson.error)}`;
|
||||
message.rawContent += errorMsg;
|
||||
message.content += errorMsg;
|
||||
return {
|
||||
finished: true
|
||||
};
|
||||
}
|
||||
|
||||
let newContent = "";
|
||||
|
||||
if (dataJson.type === "response.output_text.delta") {
|
||||
if (isReasoning) {
|
||||
isReasoning = false;
|
||||
|
||||
const endBlock = "\n\n</think>\n\n";
|
||||
message.content += endBlock;
|
||||
message.rawContent += endBlock;
|
||||
}
|
||||
|
||||
newContent = dataJson.delta ?? "";
|
||||
} else if (dataJson.type === "response.output_item.added") {
|
||||
if (dataJson.item.type === "reasoning") {
|
||||
if (!isReasoning) {
|
||||
isReasoning = true;
|
||||
|
||||
const startBlock = "\n\n<think>\n\n";
|
||||
message.content += startBlock;
|
||||
message.rawContent += startBlock;
|
||||
}
|
||||
|
||||
newContent = dataJson.item.summary ?? "";
|
||||
}
|
||||
}
|
||||
|
||||
message.content += newContent;
|
||||
message.rawContent += newContent;
|
||||
|
||||
if (dataJson.type === "response.completed") {
|
||||
let result = {
|
||||
finished: true
|
||||
};
|
||||
|
||||
if (dataJson.response && dataJson.response.usage) {
|
||||
const usage = dataJson.response.usage;
|
||||
result.tokenUsage = {
|
||||
input: usage.input_tokens ?? -1,
|
||||
output: usage.output_tokens ?? -1,
|
||||
total: usage.total_tokens ?? -1
|
||||
};
|
||||
}
|
||||
return result;
|
||||
}
|
||||
} catch (e) {
|
||||
console.log("[AI] OpenAI: Could not parse line: ", e);
|
||||
message.rawContent += line;
|
||||
message.content += line;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
function onRequestFinished(message) {
|
||||
// OpenAI format doesn't need special finish handling
|
||||
return {};
|
||||
}
|
||||
|
||||
function reset() {
|
||||
isReasoning = false;
|
||||
}
|
||||
}
|
||||
6
surfaces/quickshell/ii-base/services/gCloud/token-from-key-venv.sh
Executable file
6
surfaces/quickshell/ii-base/services/gCloud/token-from-key-venv.sh
Executable file
|
|
@ -0,0 +1,6 @@
|
|||
#!/usr/bin/env bash
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
source "$HOME/.local/state/quickshell/.venv/bin/activate"
|
||||
"$SCRIPT_DIR/token_from_key.py" "$@"
|
||||
deactivate
|
||||
38
surfaces/quickshell/ii-base/services/gCloud/token_from_key.py
Executable file
38
surfaces/quickshell/ii-base/services/gCloud/token_from_key.py
Executable file
|
|
@ -0,0 +1,38 @@
|
|||
#!/usr/bin/env python3
|
||||
import calendar
|
||||
import sys
|
||||
import json
|
||||
import google.auth.transport.requests
|
||||
import google.oauth2.service_account
|
||||
|
||||
def get_token(json_str):
|
||||
try:
|
||||
# Load the string into a dictionary
|
||||
info = json.loads(json_str)
|
||||
|
||||
# Initialize credentials
|
||||
creds = google.oauth2.service_account.Credentials.from_service_account_info(info)
|
||||
scoped_creds = creds.with_scopes(['https://www.googleapis.com/auth/cloud-platform'])
|
||||
|
||||
# Refresh to get the access token
|
||||
request = google.auth.transport.requests.Request()
|
||||
scoped_creds.refresh(request)
|
||||
|
||||
token = scoped_creds.token
|
||||
expiry = int(calendar.timegm(scoped_creds.expiry.utctimetuple()))
|
||||
|
||||
print(json.dumps({
|
||||
"token": token,
|
||||
"expiry": expiry
|
||||
}))
|
||||
|
||||
except Exception as e:
|
||||
sys.stderr.write(f"Error: {str(e)}\n")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) < 2:
|
||||
sys.stderr.write("Usage: python3 get_token.py '<json_string>'\n")
|
||||
sys.exit(1)
|
||||
|
||||
get_token(sys.argv[1])
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
#version 300 es
|
||||
precision highp float;
|
||||
|
||||
in vec2 v_texcoord;
|
||||
uniform sampler2D tex;
|
||||
out vec4 fragColor;
|
||||
|
||||
float overlayOpacityForBrightness(float x) {
|
||||
// Note: range 0 to 1
|
||||
|
||||
// Will a fancy curve help?... I'll have to experiment more at night
|
||||
// float y = pow(x, 2.0) * 0.75;
|
||||
// float y = (1.0 - exp(-x))*1.19;
|
||||
// float y = (1.0 - exp(-pow((x-0.15), 0.6)))*1.18;
|
||||
|
||||
float y = x*0.42;
|
||||
return min(max(y, 0.001), 1.0);
|
||||
}
|
||||
|
||||
void main() {
|
||||
// 1. Get the current pixel color
|
||||
vec4 pixColor = texture(tex, v_texcoord);
|
||||
|
||||
// 2. Calculate average screen brightness
|
||||
vec3 totalRGB = vec3(0.0);
|
||||
float samples = 0.0;
|
||||
|
||||
// We use a nested loop to create a 10x10 grid (100 samples)
|
||||
// This is dense enough to catch small icons/text but light enough to run fast.
|
||||
for(float x = 0.05; x < 1.0; x += 0.1) {
|
||||
for(float y = 0.05; y < 1.0; y += 0.1) {
|
||||
totalRGB += texture(tex, vec2(x, y)).rgb;
|
||||
samples++;
|
||||
}
|
||||
}
|
||||
|
||||
vec3 avgColor = totalRGB / samples;
|
||||
float globalBrightness = dot(avgColor, vec3(0.2126, 0.7152, 0.0722));
|
||||
|
||||
// 3. Get the specific opacity for this brightness level
|
||||
float opacity = overlayOpacityForBrightness(globalBrightness);
|
||||
|
||||
// 4. Apply the "black overlay" effect
|
||||
vec3 outColor = mix(pixColor.rgb, vec3(0.0), opacity);
|
||||
|
||||
fragColor = vec4(outColor, pixColor.a);
|
||||
}
|
||||
|
|
@ -0,0 +1,47 @@
|
|||
#version 300 es
|
||||
precision highp float;
|
||||
|
||||
in vec2 v_texcoord;
|
||||
uniform sampler2D tex;
|
||||
out vec4 fragColor;
|
||||
|
||||
float overlayOpacityForBrightness(float x) {
|
||||
// Note: range 0 to 1
|
||||
|
||||
// Will a fancy curve help?... I'll have to experiment more at night
|
||||
// float y = pow(x, 2.0) * 0.75;
|
||||
// float y = (1.0 - exp(-x))*1.15;
|
||||
// float y = (1.0 - exp(-pow((x-0.15), 0.6)))*1.18;
|
||||
|
||||
float y = x*0.75;
|
||||
return min(max(y, 0.001), 1.0);
|
||||
}
|
||||
|
||||
void main() {
|
||||
// 1. Get the current pixel color
|
||||
vec4 pixColor = texture(tex, v_texcoord);
|
||||
|
||||
// 2. Calculate average screen brightness
|
||||
vec3 totalRGB = vec3(0.0);
|
||||
float samples = 0.0;
|
||||
|
||||
// We use a nested loop to create a 10x10 grid (100 samples)
|
||||
// This is dense enough to catch small icons/text but light enough to run fast.
|
||||
for(float x = 0.05; x < 1.0; x += 0.1) {
|
||||
for(float y = 0.05; y < 1.0; y += 0.1) {
|
||||
totalRGB += texture(tex, vec2(x, y)).rgb;
|
||||
samples++;
|
||||
}
|
||||
}
|
||||
|
||||
vec3 avgColor = totalRGB / samples;
|
||||
float globalBrightness = dot(avgColor, vec3(0.2126, 0.7152, 0.0722));
|
||||
|
||||
// 3. Get the specific opacity for this brightness level
|
||||
float opacity = overlayOpacityForBrightness(globalBrightness);
|
||||
|
||||
// 4. Apply the "black overlay" effect
|
||||
vec3 outColor = mix(pixColor.rgb, vec3(0.0), opacity);
|
||||
|
||||
fragColor = vec4(outColor, pixColor.a);
|
||||
}
|
||||
|
|
@ -0,0 +1,14 @@
|
|||
import QtQuick
|
||||
|
||||
QtObject {
|
||||
required property var lastIpcObject
|
||||
readonly property string ssid: lastIpcObject.ssid
|
||||
readonly property string bssid: lastIpcObject.bssid
|
||||
readonly property int strength: lastIpcObject.strength
|
||||
readonly property int frequency: lastIpcObject.frequency
|
||||
readonly property bool active: lastIpcObject.active
|
||||
readonly property string security: lastIpcObject.security
|
||||
readonly property bool isSecure: security.length > 0
|
||||
|
||||
property bool askingPassword: false
|
||||
}
|
||||
Loading…
Reference in a new issue