diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index c6438f9..0815b96 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -11,15 +11,7 @@ permissions: jobs: build: - strategy: - fail-fast: false - matrix: - include: - - platform: macos-latest - - platform: ubuntu-22.04 - - platform: windows-latest - - runs-on: ${{ matrix.platform }} + runs-on: windows-latest steps: - name: Checkout @@ -34,33 +26,45 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@stable - - name: Install Linux dependencies - if: startsWith(matrix.platform, 'ubuntu') - run: | - sudo apt-get update - sudo apt-get install -y \ - libwebkit2gtk-4.1-dev \ - libappindicator3-dev \ - librsvg2-dev \ - patchelf - - name: Install dependencies run: npm ci - - name: Build & Release - uses: tauri-apps/tauri-action@v0 + - name: Build installer + run: npm run tauri:build:windows + + - name: Prepare release asset + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + mkdir -p release-assets + matches=(src-tauri/target/release/bundle/nsis/*.exe) + if [ "${#matches[@]}" -ne 1 ]; then + printf 'Expected one asset for src-tauri/target/release/bundle/nsis/*.exe, found %s\n' "${#matches[@]}" + printf '%s\n' "${matches[@]}" + exit 1 + fi + version="${GITHUB_REF_NAME#v}" + asset_name="codex-manager_${version}_x64-setup.exe" + cp "${matches[0]}" "release-assets/${asset_name}" + printf 'ASSET_PATH=release-assets/%s\n' "${asset_name}" >> "${GITHUB_ENV}" + + - name: Create release + if: startsWith(github.ref, 'refs/tags/') + shell: bash env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - with: - tagName: ${{ github.ref_name }} - releaseName: "Codex Manager ${{ github.ref_name }}" - releaseBody: "See the assets to download and install this version." - releaseDraft: false - prerelease: false + run: | + if ! gh release view "${GITHUB_REF_NAME}" >/dev/null 2>&1; then + gh release create "${GITHUB_REF_NAME}" \ + --title "Codex Manager ${GITHUB_REF_NAME}" \ + --notes "See the assets to download and install this version." || \ + gh release view "${GITHUB_REF_NAME}" >/dev/null + fi - - name: Upload macOS Unix helper - if: matrix.platform == 'macos-latest' && startsWith(github.ref, 'refs/tags/') + - name: Upload installer + if: startsWith(github.ref, 'refs/tags/') + shell: bash env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - run: | - gh release upload "${GITHUB_REF_NAME}" scripts/install-unix-cli.sh --clobber + run: gh release upload "${GITHUB_REF_NAME}" "${ASSET_PATH}" --clobber diff --git a/README.en.md b/README.en.md index 8951671..dbdf205 100644 --- a/README.en.md +++ b/README.en.md @@ -48,30 +48,27 @@ Codex Manager reduces both to a few desktop and tray actions. ## Installation -Recommended: download a packaged build from GitHub Releases. +Recommended: download the Windows packaged build from GitHub Releases. -- Windows: `.msi` or `.exe` -- macOS: `.dmg` -- Linux: `.deb`, `.rpm`, or `.AppImage` +- Windows: `codex-manager__x64-setup.exe` Releases: +For macOS, build the DMG locally with `npm run tauri:build:macos`. The output is written to `src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/`. + ### CLI Availability After installation, the `codex-manager` command behaves like this: | Platform | Recommended package | CLI availability | | --- | --- | --- | -| Windows | `.exe` or `.msi` | Installed to `PATH` automatically | -| macOS | `.dmg` | Use the bundled helper script once | -| Linux | `.deb` or `.rpm` | Available directly as `codex-manager` | -| Linux | `.AppImage` | Use the helper script once, or keep it portable | +| Windows | `codex-manager__x64-setup.exe` | Installed to `PATH` automatically | +| macOS | Locally built `codex-manager__aarch64.dmg` | Use the repo helper script to add it to `PATH` | Notes: - On Windows, reopen your terminal after installation so the new `PATH` is picked up. -- On macOS, the release ships as a `.dmg`. If you also want a global `codex-manager` command, run the bundled helper script once after dragging the app into `/Applications`. -- On Linux, prefer `.deb` or `.rpm` if you want a package-managed CLI experience. +- After building the macOS DMG locally, if you also want a global `codex-manager` command, drag the app into `/Applications`, then run `scripts/install-unix-cli.sh` from this repo. - The app reads and writes `~/.codex/auth.json`, so Codex CLI should already be installed and working. ## Command Line Switching @@ -89,7 +86,7 @@ The CLI updates both the managed `accounts.json` state and the live `~/.codex/au If Codex CLI or the desktop app is already running, restart it after switching so the new auth takes effect. -For `.dmg` and `.AppImage` installs, the release helper script can expose the command globally: +For `.dmg` installs, the repo helper script can expose the command globally: ```bash sudo bash ./install-unix-cli.sh /Applications/codex-manager.app /usr/local/bin/codex-manager @@ -141,9 +138,9 @@ If the auth state already belongs to an existing account, the app updates that a Current rule set: -- Prefer the account with the lowest `5h` usage -- If `5h` usage is tied, compare weekly usage -- If the active account is already the best choice, do nothing +- Switch when the active account has less than 5% `5h` quota left, or less than 2% weekly quota left +- Candidate accounts must have valid quota data +- Candidate accounts are ranked by `5h` remaining quota first, then weekly remaining quota ## Token Tracking diff --git a/README.md b/README.md index a4f55bb..7d396e5 100644 --- a/README.md +++ b/README.md @@ -48,30 +48,27 @@ Codex Manager 把这些操作收敛成桌面窗口、托盘面板和命令行里 ## 安装 -推荐直接从 GitHub Releases 下载打包产物: +推荐直接从 GitHub Releases 下载 Windows 打包产物: -- Windows:`.msi` 或 `.exe` -- macOS:`.dmg` -- Linux:`.deb`、`.rpm` 或 `.AppImage` +- Windows:`codex-manager_<版本号>_x64-setup.exe` 下载地址: +macOS 安装包请在本机执行 `npm run tauri:build:macos` 构建,产物位于 `src-tauri/target/aarch64-apple-darwin/release/bundle/dmg/`。 + ### 安装后 CLI 可用性 安装完成后,`codex-manager` 命令在各平台的行为如下: | 平台 | 推荐安装包 | CLI 可用性 | | --- | --- | --- | -| Windows | `.exe` 或 `.msi` | 自动加入 `PATH` | -| macOS | `.dmg` | 需要额外执行一次 helper 脚本 | -| Linux | `.deb` 或 `.rpm` | 安装后可直接使用 `codex-manager` | -| Linux | `.AppImage` | 需要额外执行一次 helper 脚本,或保持便携运行 | +| Windows | `codex-manager_<版本号>_x64-setup.exe` | 自动加入 `PATH` | +| macOS | 本地构建的 `codex-manager_<版本号>_aarch64.dmg` | 可使用仓库脚本加入 `PATH` | 说明: - Windows 安装后请重新打开一个终端窗口,让新的 `PATH` 生效。 -- macOS 版本只提供 `.dmg`。如果还希望在 Terminal 里直接使用 `codex-manager`,把应用拖到 `/Applications` 后再执行一次 helper 脚本即可。 -- Linux 如果希望获得最稳定的系统级 CLI 体验,优先使用 `.deb` 或 `.rpm`。 +- macOS 本地 DMG 构建完成后,如果还希望在 Terminal 里直接使用 `codex-manager`,把应用拖到 `/Applications` 后再执行仓库里的 `scripts/install-unix-cli.sh`。 - 应用会读写 `~/.codex/auth.json`,所以机器上需要先能正常使用 Codex CLI。 ## 命令行切换 @@ -89,7 +86,7 @@ CLI 会同时更新受管账号状态和当前生效的 `~/.codex/auth.json`。 如果 Codex CLI 或桌面应用已经在运行,切换后请重启它们,让新的 auth 生效。 -对于 `.dmg` 和 `.AppImage` 安装方式,可以使用发布包里的 helper 脚本暴露全局命令: +对于 `.dmg` 安装方式,可以使用仓库里的 helper 脚本暴露全局命令: ```bash sudo bash ./install-unix-cli.sh /Applications/codex-manager.app /usr/local/bin/codex-manager @@ -141,9 +138,9 @@ npm link 当前规则: -- 优先选择 `5h` 使用比例更低的账号 -- 如果 `5h` 相同,再比较每周使用比例 -- 如果当前账号已经是最佳选择,则不重复切换 +- 当前账号 `5h` 剩余小于 5%,或每周剩余小于 2% 时触发切换 +- 候选账号必须拥有有效额度数据 +- 候选账号按 `5h` 剩余优先、每周剩余次之排序 ## Token 统计 diff --git a/package-lock.json b/package-lock.json index 068caef..3249cef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "codex-manager", - "version": "1.2.4", + "version": "1.2.19", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "codex-manager", - "version": "1.2.4", + "version": "1.2.19", "dependencies": { "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2.5.3", diff --git a/package.json b/package.json index 98c2a3a..461f209 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "codex-manager", - "version": "1.2.4", + "version": "1.2.19", "type": "module", "bin": { "codex-manager": "./bin/codex-manager.mjs", @@ -12,6 +12,8 @@ "build": "tsc && vite build", "test": "vitest run", "preview": "vite preview", + "tauri:build:windows": "tauri build --bundles nsis", + "tauri:build:macos": "tauri build --target aarch64-apple-darwin --bundles dmg", "tauri": "tauri" }, "dependencies": { diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 20d9951..fe3478f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -521,7 +521,7 @@ dependencies = [ [[package]] name = "codex-manager" -version = "1.2.4" +version = "1.2.19" dependencies = [ "anyhow", "axum", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index ef7a4a4..50a7dfa 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "codex-manager" -version = "1.2.4" +version = "1.2.19" edition = "2021" default-run = "codex-manager" diff --git a/src-tauri/gen/schemas/macOS-schema.json b/src-tauri/gen/schemas/macOS-schema.json new file mode 100644 index 0000000..4917625 --- /dev/null +++ b/src-tauri/gen/schemas/macOS-schema.json @@ -0,0 +1,2477 @@ +{ + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "CapabilityFile", + "description": "Capability formats accepted in a capability file.", + "anyOf": [ + { + "description": "A single capability.", + "allOf": [ + { + "$ref": "#/definitions/Capability" + } + ] + }, + { + "description": "A list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + }, + { + "description": "A list of capabilities.", + "type": "object", + "required": [ + "capabilities" + ], + "properties": { + "capabilities": { + "description": "The list of capabilities.", + "type": "array", + "items": { + "$ref": "#/definitions/Capability" + } + } + } + } + ], + "definitions": { + "Capability": { + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows' and webviews' fine grained access to the Tauri core, application, or plugin commands. If a webview or its window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", + "type": "object", + "required": [ + "identifier", + "permissions" + ], + "properties": { + "identifier": { + "description": "Identifier of the capability.\n\n## Example\n\n`main-user-files-write`", + "type": "string" + }, + "description": { + "description": "Description of what the capability is intended to allow on associated windows.\n\nIt should contain a description of what the grouped permissions should allow.\n\n## Example\n\nThis capability allows the `main` window access to `filesystem` write related commands and `dialog` commands to enable programmatic access to files selected by the user.", + "default": "", + "type": "string" + }, + "remote": { + "description": "Configure remote URLs that can use the capability permissions.\n\nThis setting is optional and defaults to not being set, as our default use case is that the content is served from our local application.\n\n:::caution Make sure you understand the security implications of providing remote sources with local system access. :::\n\n## Example\n\n```json { \"urls\": [\"https://*.mydomain.dev\"] } ```", + "anyOf": [ + { + "$ref": "#/definitions/CapabilityRemote" + }, + { + "type": "null" + } + ] + }, + "local": { + "description": "Whether this capability is enabled for local app URLs or not. Defaults to `true`.", + "default": true, + "type": "boolean" + }, + "windows": { + "description": "List of windows that are affected by this capability. Can be a glob pattern.\n\nIf a window label matches any of the patterns in this list, the capability will be enabled on all the webviews of that window, regardless of the value of [`Self::webviews`].\n\nOn multiwebview windows, prefer specifying [`Self::webviews`] and omitting [`Self::windows`] for a fine grained access control.\n\n## Example\n\n`[\"main\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "webviews": { + "description": "List of webviews that are affected by this capability. Can be a glob pattern.\n\nThe capability will be enabled on all the webviews whose label matches any of the patterns in this list, regardless of whether the webview's window label matches a pattern in [`Self::windows`].\n\n## Example\n\n`[\"sub-webview-one\", \"sub-webview-two\"]`", + "type": "array", + "items": { + "type": "string" + } + }, + "permissions": { + "description": "List of permissions attached to this capability.\n\nMust include the plugin name as prefix in the form of `${plugin-name}:${permission-name}`. For commands directly implemented in the application itself only `${permission-name}` is required.\n\n## Example\n\n```json [ \"core:default\", \"shell:allow-open\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] } ] ```", + "type": "array", + "items": { + "$ref": "#/definitions/PermissionEntry" + }, + "uniqueItems": true + }, + "platforms": { + "description": "Limit which target platforms this capability applies to.\n\nBy default all platforms are targeted.\n\n## Example\n\n`[\"macOS\",\"windows\"]`", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Target" + } + } + } + }, + "CapabilityRemote": { + "description": "Configuration for remote URLs that are associated with the capability.", + "type": "object", + "required": [ + "urls" + ], + "properties": { + "urls": { + "description": "Remote domains this capability refers to using the [URLPattern standard](https://urlpattern.spec.whatwg.org/).\n\n## Examples\n\n- \"https://*.mydomain.dev\": allows subdomains of mydomain.dev - \"https://mydomain.dev/api/*\": allows any subpath of mydomain.dev/api", + "type": "array", + "items": { + "type": "string" + } + } + } + }, + "PermissionEntry": { + "description": "An entry for a permission value in a [`Capability`] can be either a raw permission [`Identifier`] or an object that references a permission and extends its scope.", + "anyOf": [ + { + "description": "Reference a permission or permission set by identifier.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + { + "description": "Reference a permission or permission set by identifier and extends its scope.", + "type": "object", + "allOf": [ + { + "if": { + "properties": { + "identifier": { + "anyOf": [ + { + "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", + "type": "string", + "const": "opener:default", + "markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`" + }, + { + "description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.", + "type": "string", + "const": "opener:allow-default-urls", + "markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application." + }, + { + "description": "Enables the open_path command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-open-path", + "markdownDescription": "Enables the open_path command without any pre-configured scope." + }, + { + "description": "Enables the open_url command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-open-url", + "markdownDescription": "Enables the open_url command without any pre-configured scope." + }, + { + "description": "Enables the reveal_item_in_dir command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-reveal-item-in-dir", + "markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope." + }, + { + "description": "Denies the open_path command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-open-path", + "markdownDescription": "Denies the open_path command without any pre-configured scope." + }, + { + "description": "Denies the open_url command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-open-url", + "markdownDescription": "Denies the open_url command without any pre-configured scope." + }, + { + "description": "Denies the reveal_item_in_dir command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-reveal-item-in-dir", + "markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope." + } + ] + } + } + }, + "then": { + "properties": { + "allow": { + "items": { + "title": "OpenerScopeEntry", + "description": "Opener scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "url" + ], + "properties": { + "app": { + "description": "An application to open this url with, for example: firefox.", + "allOf": [ + { + "$ref": "#/definitions/Application" + } + ] + }, + "url": { + "description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"", + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "path" + ], + "properties": { + "app": { + "description": "An application to open this path with, for example: xdg-open.", + "allOf": [ + { + "$ref": "#/definitions/Application" + } + ] + }, + "path": { + "description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + } + } + } + ] + } + }, + "deny": { + "items": { + "title": "OpenerScopeEntry", + "description": "Opener scope entry.", + "anyOf": [ + { + "type": "object", + "required": [ + "url" + ], + "properties": { + "app": { + "description": "An application to open this url with, for example: firefox.", + "allOf": [ + { + "$ref": "#/definitions/Application" + } + ] + }, + "url": { + "description": "A URL that can be opened by the webview when using the Opener APIs.\n\nWildcards can be used following the UNIX glob pattern.\n\nExamples:\n\n- \"https://*\" : allows all HTTPS origin\n\n- \"https://*.github.com/tauri-apps/tauri\": allows any subdomain of \"github.com\" with the \"tauri-apps/api\" path\n\n- \"https://myapi.service.com/users/*\": allows access to any URLs that begins with \"https://myapi.service.com/users/\"", + "type": "string" + } + } + }, + { + "type": "object", + "required": [ + "path" + ], + "properties": { + "app": { + "description": "An application to open this path with, for example: xdg-open.", + "allOf": [ + { + "$ref": "#/definitions/Application" + } + ] + }, + "path": { + "description": "A path that can be opened by the webview when using the Opener APIs.\n\nThe pattern can start with a variable that resolves to a system base directory. The variables are: `$AUDIO`, `$CACHE`, `$CONFIG`, `$DATA`, `$LOCALDATA`, `$DESKTOP`, `$DOCUMENT`, `$DOWNLOAD`, `$EXE`, `$FONT`, `$HOME`, `$PICTURE`, `$PUBLIC`, `$RUNTIME`, `$TEMPLATE`, `$VIDEO`, `$RESOURCE`, `$APP`, `$LOG`, `$TEMP`, `$APPCONFIG`, `$APPDATA`, `$APPLOCALDATA`, `$APPCACHE`, `$APPLOG`.", + "type": "string" + } + } + } + ] + } + } + } + }, + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + } + } + }, + { + "properties": { + "identifier": { + "description": "Identifier of the permission or permission set.", + "allOf": [ + { + "$ref": "#/definitions/Identifier" + } + ] + }, + "allow": { + "description": "Data that defines what is allowed by the scope.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + }, + "deny": { + "description": "Data that defines what is denied by the scope. This should be prioritized by validation logic.", + "type": [ + "array", + "null" + ], + "items": { + "$ref": "#/definitions/Value" + } + } + } + } + ], + "required": [ + "identifier" + ] + } + ] + }, + "Identifier": { + "description": "Permission identifier", + "oneOf": [ + { + "description": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`", + "type": "string", + "const": "core:default", + "markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`" + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`", + "type": "string", + "const": "core:app:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`" + }, + { + "description": "Enables the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-hide", + "markdownDescription": "Enables the app_hide command without any pre-configured scope." + }, + { + "description": "Enables the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-app-show", + "markdownDescription": "Enables the app_show command without any pre-configured scope." + }, + { + "description": "Enables the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-bundle-type", + "markdownDescription": "Enables the bundle_type command without any pre-configured scope." + }, + { + "description": "Enables the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-default-window-icon", + "markdownDescription": "Enables the default_window_icon command without any pre-configured scope." + }, + { + "description": "Enables the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-fetch-data-store-identifiers", + "markdownDescription": "Enables the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Enables the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-identifier", + "markdownDescription": "Enables the identifier command without any pre-configured scope." + }, + { + "description": "Enables the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-name", + "markdownDescription": "Enables the name command without any pre-configured scope." + }, + { + "description": "Enables the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-register-listener", + "markdownDescription": "Enables the register_listener command without any pre-configured scope." + }, + { + "description": "Enables the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-data-store", + "markdownDescription": "Enables the remove_data_store command without any pre-configured scope." + }, + { + "description": "Enables the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-remove-listener", + "markdownDescription": "Enables the remove_listener command without any pre-configured scope." + }, + { + "description": "Enables the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-app-theme", + "markdownDescription": "Enables the set_app_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-set-dock-visibility", + "markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Enables the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-tauri-version", + "markdownDescription": "Enables the tauri_version command without any pre-configured scope." + }, + { + "description": "Enables the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:allow-version", + "markdownDescription": "Enables the version command without any pre-configured scope." + }, + { + "description": "Denies the app_hide command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-hide", + "markdownDescription": "Denies the app_hide command without any pre-configured scope." + }, + { + "description": "Denies the app_show command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-app-show", + "markdownDescription": "Denies the app_show command without any pre-configured scope." + }, + { + "description": "Denies the bundle_type command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-bundle-type", + "markdownDescription": "Denies the bundle_type command without any pre-configured scope." + }, + { + "description": "Denies the default_window_icon command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-default-window-icon", + "markdownDescription": "Denies the default_window_icon command without any pre-configured scope." + }, + { + "description": "Denies the fetch_data_store_identifiers command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-fetch-data-store-identifiers", + "markdownDescription": "Denies the fetch_data_store_identifiers command without any pre-configured scope." + }, + { + "description": "Denies the identifier command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-identifier", + "markdownDescription": "Denies the identifier command without any pre-configured scope." + }, + { + "description": "Denies the name command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-name", + "markdownDescription": "Denies the name command without any pre-configured scope." + }, + { + "description": "Denies the register_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-register-listener", + "markdownDescription": "Denies the register_listener command without any pre-configured scope." + }, + { + "description": "Denies the remove_data_store command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-data-store", + "markdownDescription": "Denies the remove_data_store command without any pre-configured scope." + }, + { + "description": "Denies the remove_listener command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-remove-listener", + "markdownDescription": "Denies the remove_listener command without any pre-configured scope." + }, + { + "description": "Denies the set_app_theme command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-app-theme", + "markdownDescription": "Denies the set_app_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_dock_visibility command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-set-dock-visibility", + "markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope." + }, + { + "description": "Denies the tauri_version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-tauri-version", + "markdownDescription": "Denies the tauri_version command without any pre-configured scope." + }, + { + "description": "Denies the version command without any pre-configured scope.", + "type": "string", + "const": "core:app:deny-version", + "markdownDescription": "Denies the version command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`", + "type": "string", + "const": "core:event:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-listen`\n- `allow-unlisten`\n- `allow-emit`\n- `allow-emit-to`" + }, + { + "description": "Enables the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit", + "markdownDescription": "Enables the emit command without any pre-configured scope." + }, + { + "description": "Enables the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-emit-to", + "markdownDescription": "Enables the emit_to command without any pre-configured scope." + }, + { + "description": "Enables the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-listen", + "markdownDescription": "Enables the listen command without any pre-configured scope." + }, + { + "description": "Enables the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:allow-unlisten", + "markdownDescription": "Enables the unlisten command without any pre-configured scope." + }, + { + "description": "Denies the emit command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit", + "markdownDescription": "Denies the emit command without any pre-configured scope." + }, + { + "description": "Denies the emit_to command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-emit-to", + "markdownDescription": "Denies the emit_to command without any pre-configured scope." + }, + { + "description": "Denies the listen command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-listen", + "markdownDescription": "Denies the listen command without any pre-configured scope." + }, + { + "description": "Denies the unlisten command without any pre-configured scope.", + "type": "string", + "const": "core:event:deny-unlisten", + "markdownDescription": "Denies the unlisten command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`", + "type": "string", + "const": "core:image:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-from-bytes`\n- `allow-from-path`\n- `allow-rgba`\n- `allow-size`" + }, + { + "description": "Enables the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-bytes", + "markdownDescription": "Enables the from_bytes command without any pre-configured scope." + }, + { + "description": "Enables the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-from-path", + "markdownDescription": "Enables the from_path command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-rgba", + "markdownDescription": "Enables the rgba command without any pre-configured scope." + }, + { + "description": "Enables the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:allow-size", + "markdownDescription": "Enables the size command without any pre-configured scope." + }, + { + "description": "Denies the from_bytes command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-bytes", + "markdownDescription": "Denies the from_bytes command without any pre-configured scope." + }, + { + "description": "Denies the from_path command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-from-path", + "markdownDescription": "Denies the from_path command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the rgba command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-rgba", + "markdownDescription": "Denies the rgba command without any pre-configured scope." + }, + { + "description": "Denies the size command without any pre-configured scope.", + "type": "string", + "const": "core:image:deny-size", + "markdownDescription": "Denies the size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`", + "type": "string", + "const": "core:menu:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-append`\n- `allow-prepend`\n- `allow-insert`\n- `allow-remove`\n- `allow-remove-at`\n- `allow-items`\n- `allow-get`\n- `allow-popup`\n- `allow-create-default`\n- `allow-set-as-app-menu`\n- `allow-set-as-window-menu`\n- `allow-text`\n- `allow-set-text`\n- `allow-is-enabled`\n- `allow-set-enabled`\n- `allow-set-accelerator`\n- `allow-set-as-windows-menu-for-nsapp`\n- `allow-set-as-help-menu-for-nsapp`\n- `allow-is-checked`\n- `allow-set-checked`\n- `allow-set-icon`" + }, + { + "description": "Enables the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-append", + "markdownDescription": "Enables the append command without any pre-configured scope." + }, + { + "description": "Enables the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-create-default", + "markdownDescription": "Enables the create_default command without any pre-configured scope." + }, + { + "description": "Enables the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-get", + "markdownDescription": "Enables the get command without any pre-configured scope." + }, + { + "description": "Enables the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-insert", + "markdownDescription": "Enables the insert command without any pre-configured scope." + }, + { + "description": "Enables the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-checked", + "markdownDescription": "Enables the is_checked command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-items", + "markdownDescription": "Enables the items command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-popup", + "markdownDescription": "Enables the popup command without any pre-configured scope." + }, + { + "description": "Enables the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-prepend", + "markdownDescription": "Enables the prepend command without any pre-configured scope." + }, + { + "description": "Enables the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove", + "markdownDescription": "Enables the remove command without any pre-configured scope." + }, + { + "description": "Enables the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-remove-at", + "markdownDescription": "Enables the remove_at command without any pre-configured scope." + }, + { + "description": "Enables the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-accelerator", + "markdownDescription": "Enables the set_accelerator command without any pre-configured scope." + }, + { + "description": "Enables the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-app-menu", + "markdownDescription": "Enables the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-help-menu-for-nsapp", + "markdownDescription": "Enables the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-window-menu", + "markdownDescription": "Enables the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-as-windows-menu-for-nsapp", + "markdownDescription": "Enables the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Enables the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-checked", + "markdownDescription": "Enables the set_checked command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-set-text", + "markdownDescription": "Enables the set_text command without any pre-configured scope." + }, + { + "description": "Enables the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:allow-text", + "markdownDescription": "Enables the text command without any pre-configured scope." + }, + { + "description": "Denies the append command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-append", + "markdownDescription": "Denies the append command without any pre-configured scope." + }, + { + "description": "Denies the create_default command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-create-default", + "markdownDescription": "Denies the create_default command without any pre-configured scope." + }, + { + "description": "Denies the get command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-get", + "markdownDescription": "Denies the get command without any pre-configured scope." + }, + { + "description": "Denies the insert command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-insert", + "markdownDescription": "Denies the insert command without any pre-configured scope." + }, + { + "description": "Denies the is_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-checked", + "markdownDescription": "Denies the is_checked command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the items command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-items", + "markdownDescription": "Denies the items command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the popup command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-popup", + "markdownDescription": "Denies the popup command without any pre-configured scope." + }, + { + "description": "Denies the prepend command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-prepend", + "markdownDescription": "Denies the prepend command without any pre-configured scope." + }, + { + "description": "Denies the remove command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove", + "markdownDescription": "Denies the remove command without any pre-configured scope." + }, + { + "description": "Denies the remove_at command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-remove-at", + "markdownDescription": "Denies the remove_at command without any pre-configured scope." + }, + { + "description": "Denies the set_accelerator command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-accelerator", + "markdownDescription": "Denies the set_accelerator command without any pre-configured scope." + }, + { + "description": "Denies the set_as_app_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-app-menu", + "markdownDescription": "Denies the set_as_app_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-help-menu-for-nsapp", + "markdownDescription": "Denies the set_as_help_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_as_window_menu command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-window-menu", + "markdownDescription": "Denies the set_as_window_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-as-windows-menu-for-nsapp", + "markdownDescription": "Denies the set_as_windows_menu_for_nsapp command without any pre-configured scope." + }, + { + "description": "Denies the set_checked command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-checked", + "markdownDescription": "Denies the set_checked command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-set-text", + "markdownDescription": "Denies the set_text command without any pre-configured scope." + }, + { + "description": "Denies the text command without any pre-configured scope.", + "type": "string", + "const": "core:menu:deny-text", + "markdownDescription": "Denies the text command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`", + "type": "string", + "const": "core:path:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-resolve-directory`\n- `allow-resolve`\n- `allow-normalize`\n- `allow-join`\n- `allow-dirname`\n- `allow-extname`\n- `allow-basename`\n- `allow-is-absolute`" + }, + { + "description": "Enables the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-basename", + "markdownDescription": "Enables the basename command without any pre-configured scope." + }, + { + "description": "Enables the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-dirname", + "markdownDescription": "Enables the dirname command without any pre-configured scope." + }, + { + "description": "Enables the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-extname", + "markdownDescription": "Enables the extname command without any pre-configured scope." + }, + { + "description": "Enables the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-is-absolute", + "markdownDescription": "Enables the is_absolute command without any pre-configured scope." + }, + { + "description": "Enables the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-join", + "markdownDescription": "Enables the join command without any pre-configured scope." + }, + { + "description": "Enables the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-normalize", + "markdownDescription": "Enables the normalize command without any pre-configured scope." + }, + { + "description": "Enables the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve", + "markdownDescription": "Enables the resolve command without any pre-configured scope." + }, + { + "description": "Enables the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:allow-resolve-directory", + "markdownDescription": "Enables the resolve_directory command without any pre-configured scope." + }, + { + "description": "Denies the basename command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-basename", + "markdownDescription": "Denies the basename command without any pre-configured scope." + }, + { + "description": "Denies the dirname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-dirname", + "markdownDescription": "Denies the dirname command without any pre-configured scope." + }, + { + "description": "Denies the extname command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-extname", + "markdownDescription": "Denies the extname command without any pre-configured scope." + }, + { + "description": "Denies the is_absolute command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-is-absolute", + "markdownDescription": "Denies the is_absolute command without any pre-configured scope." + }, + { + "description": "Denies the join command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-join", + "markdownDescription": "Denies the join command without any pre-configured scope." + }, + { + "description": "Denies the normalize command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-normalize", + "markdownDescription": "Denies the normalize command without any pre-configured scope." + }, + { + "description": "Denies the resolve command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve", + "markdownDescription": "Denies the resolve command without any pre-configured scope." + }, + { + "description": "Denies the resolve_directory command without any pre-configured scope.", + "type": "string", + "const": "core:path:deny-resolve-directory", + "markdownDescription": "Denies the resolve_directory command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`", + "type": "string", + "const": "core:resources:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-close`" + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:resources:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`", + "type": "string", + "const": "core:tray:default", + "markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`" + }, + { + "description": "Enables the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-get-by-id", + "markdownDescription": "Enables the get_by_id command without any pre-configured scope." + }, + { + "description": "Enables the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-new", + "markdownDescription": "Enables the new command without any pre-configured scope." + }, + { + "description": "Enables the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-remove-by-id", + "markdownDescription": "Enables the remove_by_id command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-icon-as-template", + "markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Enables the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-menu", + "markdownDescription": "Enables the set_menu command without any pre-configured scope." + }, + { + "description": "Enables the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-show-menu-on-left-click", + "markdownDescription": "Enables the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Enables the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-temp-dir-path", + "markdownDescription": "Enables the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-tooltip", + "markdownDescription": "Enables the set_tooltip command without any pre-configured scope." + }, + { + "description": "Enables the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:allow-set-visible", + "markdownDescription": "Enables the set_visible command without any pre-configured scope." + }, + { + "description": "Denies the get_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-get-by-id", + "markdownDescription": "Denies the get_by_id command without any pre-configured scope." + }, + { + "description": "Denies the new command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-new", + "markdownDescription": "Denies the new command without any pre-configured scope." + }, + { + "description": "Denies the remove_by_id command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-remove-by-id", + "markdownDescription": "Denies the remove_by_id command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_icon_as_template command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-icon-as-template", + "markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope." + }, + { + "description": "Denies the set_menu command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-menu", + "markdownDescription": "Denies the set_menu command without any pre-configured scope." + }, + { + "description": "Denies the set_show_menu_on_left_click command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-show-menu-on-left-click", + "markdownDescription": "Denies the set_show_menu_on_left_click command without any pre-configured scope." + }, + { + "description": "Denies the set_temp_dir_path command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-temp-dir-path", + "markdownDescription": "Denies the set_temp_dir_path command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_tooltip command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-tooltip", + "markdownDescription": "Denies the set_tooltip command without any pre-configured scope." + }, + { + "description": "Denies the set_visible command without any pre-configured scope.", + "type": "string", + "const": "core:tray:deny-set-visible", + "markdownDescription": "Denies the set_visible command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`", + "type": "string", + "const": "core:webview:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-webviews`\n- `allow-webview-position`\n- `allow-webview-size`\n- `allow-internal-toggle-devtools`" + }, + { + "description": "Enables the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-clear-all-browsing-data", + "markdownDescription": "Enables the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Enables the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview", + "markdownDescription": "Enables the create_webview command without any pre-configured scope." + }, + { + "description": "Enables the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-create-webview-window", + "markdownDescription": "Enables the create_webview_window command without any pre-configured scope." + }, + { + "description": "Enables the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-get-all-webviews", + "markdownDescription": "Enables the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-internal-toggle-devtools", + "markdownDescription": "Enables the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Enables the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-print", + "markdownDescription": "Enables the print command without any pre-configured scope." + }, + { + "description": "Enables the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-reparent", + "markdownDescription": "Enables the reparent command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-auto-resize", + "markdownDescription": "Enables the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-background-color", + "markdownDescription": "Enables the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-focus", + "markdownDescription": "Enables the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-position", + "markdownDescription": "Enables the set_webview_position command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-size", + "markdownDescription": "Enables the set_webview_size command without any pre-configured scope." + }, + { + "description": "Enables the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-set-webview-zoom", + "markdownDescription": "Enables the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Enables the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-close", + "markdownDescription": "Enables the webview_close command without any pre-configured scope." + }, + { + "description": "Enables the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-hide", + "markdownDescription": "Enables the webview_hide command without any pre-configured scope." + }, + { + "description": "Enables the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-position", + "markdownDescription": "Enables the webview_position command without any pre-configured scope." + }, + { + "description": "Enables the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-show", + "markdownDescription": "Enables the webview_show command without any pre-configured scope." + }, + { + "description": "Enables the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:allow-webview-size", + "markdownDescription": "Enables the webview_size command without any pre-configured scope." + }, + { + "description": "Denies the clear_all_browsing_data command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-clear-all-browsing-data", + "markdownDescription": "Denies the clear_all_browsing_data command without any pre-configured scope." + }, + { + "description": "Denies the create_webview command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview", + "markdownDescription": "Denies the create_webview command without any pre-configured scope." + }, + { + "description": "Denies the create_webview_window command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-create-webview-window", + "markdownDescription": "Denies the create_webview_window command without any pre-configured scope." + }, + { + "description": "Denies the get_all_webviews command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-get-all-webviews", + "markdownDescription": "Denies the get_all_webviews command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_devtools command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-internal-toggle-devtools", + "markdownDescription": "Denies the internal_toggle_devtools command without any pre-configured scope." + }, + { + "description": "Denies the print command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-print", + "markdownDescription": "Denies the print command without any pre-configured scope." + }, + { + "description": "Denies the reparent command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-reparent", + "markdownDescription": "Denies the reparent command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_auto_resize command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-auto-resize", + "markdownDescription": "Denies the set_webview_auto_resize command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-background-color", + "markdownDescription": "Denies the set_webview_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_focus command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-focus", + "markdownDescription": "Denies the set_webview_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-position", + "markdownDescription": "Denies the set_webview_position command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-size", + "markdownDescription": "Denies the set_webview_size command without any pre-configured scope." + }, + { + "description": "Denies the set_webview_zoom command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-set-webview-zoom", + "markdownDescription": "Denies the set_webview_zoom command without any pre-configured scope." + }, + { + "description": "Denies the webview_close command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-close", + "markdownDescription": "Denies the webview_close command without any pre-configured scope." + }, + { + "description": "Denies the webview_hide command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-hide", + "markdownDescription": "Denies the webview_hide command without any pre-configured scope." + }, + { + "description": "Denies the webview_position command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-position", + "markdownDescription": "Denies the webview_position command without any pre-configured scope." + }, + { + "description": "Denies the webview_show command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-show", + "markdownDescription": "Denies the webview_show command without any pre-configured scope." + }, + { + "description": "Denies the webview_size command without any pre-configured scope.", + "type": "string", + "const": "core:webview:deny-webview-size", + "markdownDescription": "Denies the webview_size command without any pre-configured scope." + }, + { + "description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`", + "type": "string", + "const": "core:window:default", + "markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`" + }, + { + "description": "Enables the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-available-monitors", + "markdownDescription": "Enables the available_monitors command without any pre-configured scope." + }, + { + "description": "Enables the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-center", + "markdownDescription": "Enables the center command without any pre-configured scope." + }, + { + "description": "Enables the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-close", + "markdownDescription": "Enables the close command without any pre-configured scope." + }, + { + "description": "Enables the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-create", + "markdownDescription": "Enables the create command without any pre-configured scope." + }, + { + "description": "Enables the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-current-monitor", + "markdownDescription": "Enables the current_monitor command without any pre-configured scope." + }, + { + "description": "Enables the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-cursor-position", + "markdownDescription": "Enables the cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-destroy", + "markdownDescription": "Enables the destroy command without any pre-configured scope." + }, + { + "description": "Enables the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-get-all-windows", + "markdownDescription": "Enables the get_all_windows command without any pre-configured scope." + }, + { + "description": "Enables the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-hide", + "markdownDescription": "Enables the hide command without any pre-configured scope." + }, + { + "description": "Enables the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-position", + "markdownDescription": "Enables the inner_position command without any pre-configured scope." + }, + { + "description": "Enables the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-inner-size", + "markdownDescription": "Enables the inner_size command without any pre-configured scope." + }, + { + "description": "Enables the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-internal-toggle-maximize", + "markdownDescription": "Enables the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-always-on-top", + "markdownDescription": "Enables the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-closable", + "markdownDescription": "Enables the is_closable command without any pre-configured scope." + }, + { + "description": "Enables the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-decorated", + "markdownDescription": "Enables the is_decorated command without any pre-configured scope." + }, + { + "description": "Enables the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-enabled", + "markdownDescription": "Enables the is_enabled command without any pre-configured scope." + }, + { + "description": "Enables the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-focused", + "markdownDescription": "Enables the is_focused command without any pre-configured scope." + }, + { + "description": "Enables the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-fullscreen", + "markdownDescription": "Enables the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximizable", + "markdownDescription": "Enables the is_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-maximized", + "markdownDescription": "Enables the is_maximized command without any pre-configured scope." + }, + { + "description": "Enables the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimizable", + "markdownDescription": "Enables the is_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-minimized", + "markdownDescription": "Enables the is_minimized command without any pre-configured scope." + }, + { + "description": "Enables the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-resizable", + "markdownDescription": "Enables the is_resizable command without any pre-configured scope." + }, + { + "description": "Enables the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-is-visible", + "markdownDescription": "Enables the is_visible command without any pre-configured scope." + }, + { + "description": "Enables the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-maximize", + "markdownDescription": "Enables the maximize command without any pre-configured scope." + }, + { + "description": "Enables the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-minimize", + "markdownDescription": "Enables the minimize command without any pre-configured scope." + }, + { + "description": "Enables the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-monitor-from-point", + "markdownDescription": "Enables the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Enables the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-position", + "markdownDescription": "Enables the outer_position command without any pre-configured scope." + }, + { + "description": "Enables the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-outer-size", + "markdownDescription": "Enables the outer_size command without any pre-configured scope." + }, + { + "description": "Enables the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-primary-monitor", + "markdownDescription": "Enables the primary_monitor command without any pre-configured scope." + }, + { + "description": "Enables the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-request-user-attention", + "markdownDescription": "Enables the request_user_attention command without any pre-configured scope." + }, + { + "description": "Enables the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-scale-factor", + "markdownDescription": "Enables the scale_factor command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-bottom", + "markdownDescription": "Enables the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Enables the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-always-on-top", + "markdownDescription": "Enables the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Enables the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-background-color", + "markdownDescription": "Enables the set_background_color command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-count", + "markdownDescription": "Enables the set_badge_count command without any pre-configured scope." + }, + { + "description": "Enables the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-badge-label", + "markdownDescription": "Enables the set_badge_label command without any pre-configured scope." + }, + { + "description": "Enables the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-closable", + "markdownDescription": "Enables the set_closable command without any pre-configured scope." + }, + { + "description": "Enables the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-content-protected", + "markdownDescription": "Enables the set_content_protected command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-grab", + "markdownDescription": "Enables the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-icon", + "markdownDescription": "Enables the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-position", + "markdownDescription": "Enables the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Enables the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-cursor-visible", + "markdownDescription": "Enables the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Enables the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-decorations", + "markdownDescription": "Enables the set_decorations command without any pre-configured scope." + }, + { + "description": "Enables the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-effects", + "markdownDescription": "Enables the set_effects command without any pre-configured scope." + }, + { + "description": "Enables the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-enabled", + "markdownDescription": "Enables the set_enabled command without any pre-configured scope." + }, + { + "description": "Enables the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focus", + "markdownDescription": "Enables the set_focus command without any pre-configured scope." + }, + { + "description": "Enables the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-focusable", + "markdownDescription": "Enables the set_focusable command without any pre-configured scope." + }, + { + "description": "Enables the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-fullscreen", + "markdownDescription": "Enables the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-icon", + "markdownDescription": "Enables the set_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-ignore-cursor-events", + "markdownDescription": "Enables the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Enables the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-max-size", + "markdownDescription": "Enables the set_max_size command without any pre-configured scope." + }, + { + "description": "Enables the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-maximizable", + "markdownDescription": "Enables the set_maximizable command without any pre-configured scope." + }, + { + "description": "Enables the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-min-size", + "markdownDescription": "Enables the set_min_size command without any pre-configured scope." + }, + { + "description": "Enables the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-minimizable", + "markdownDescription": "Enables the set_minimizable command without any pre-configured scope." + }, + { + "description": "Enables the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-overlay-icon", + "markdownDescription": "Enables the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Enables the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-position", + "markdownDescription": "Enables the set_position command without any pre-configured scope." + }, + { + "description": "Enables the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-progress-bar", + "markdownDescription": "Enables the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Enables the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-resizable", + "markdownDescription": "Enables the set_resizable command without any pre-configured scope." + }, + { + "description": "Enables the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-shadow", + "markdownDescription": "Enables the set_shadow command without any pre-configured scope." + }, + { + "description": "Enables the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-simple-fullscreen", + "markdownDescription": "Enables the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Enables the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size", + "markdownDescription": "Enables the set_size command without any pre-configured scope." + }, + { + "description": "Enables the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-size-constraints", + "markdownDescription": "Enables the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Enables the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-skip-taskbar", + "markdownDescription": "Enables the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Enables the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-theme", + "markdownDescription": "Enables the set_theme command without any pre-configured scope." + }, + { + "description": "Enables the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title", + "markdownDescription": "Enables the set_title command without any pre-configured scope." + }, + { + "description": "Enables the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-title-bar-style", + "markdownDescription": "Enables the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Enables the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-set-visible-on-all-workspaces", + "markdownDescription": "Enables the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Enables the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-show", + "markdownDescription": "Enables the show command without any pre-configured scope." + }, + { + "description": "Enables the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-dragging", + "markdownDescription": "Enables the start_dragging command without any pre-configured scope." + }, + { + "description": "Enables the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-start-resize-dragging", + "markdownDescription": "Enables the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Enables the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-theme", + "markdownDescription": "Enables the theme command without any pre-configured scope." + }, + { + "description": "Enables the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-title", + "markdownDescription": "Enables the title command without any pre-configured scope." + }, + { + "description": "Enables the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-toggle-maximize", + "markdownDescription": "Enables the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Enables the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unmaximize", + "markdownDescription": "Enables the unmaximize command without any pre-configured scope." + }, + { + "description": "Enables the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:allow-unminimize", + "markdownDescription": "Enables the unminimize command without any pre-configured scope." + }, + { + "description": "Denies the available_monitors command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-available-monitors", + "markdownDescription": "Denies the available_monitors command without any pre-configured scope." + }, + { + "description": "Denies the center command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-center", + "markdownDescription": "Denies the center command without any pre-configured scope." + }, + { + "description": "Denies the close command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-close", + "markdownDescription": "Denies the close command without any pre-configured scope." + }, + { + "description": "Denies the create command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-create", + "markdownDescription": "Denies the create command without any pre-configured scope." + }, + { + "description": "Denies the current_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-current-monitor", + "markdownDescription": "Denies the current_monitor command without any pre-configured scope." + }, + { + "description": "Denies the cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-cursor-position", + "markdownDescription": "Denies the cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the destroy command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-destroy", + "markdownDescription": "Denies the destroy command without any pre-configured scope." + }, + { + "description": "Denies the get_all_windows command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-get-all-windows", + "markdownDescription": "Denies the get_all_windows command without any pre-configured scope." + }, + { + "description": "Denies the hide command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-hide", + "markdownDescription": "Denies the hide command without any pre-configured scope." + }, + { + "description": "Denies the inner_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-position", + "markdownDescription": "Denies the inner_position command without any pre-configured scope." + }, + { + "description": "Denies the inner_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-inner-size", + "markdownDescription": "Denies the inner_size command without any pre-configured scope." + }, + { + "description": "Denies the internal_toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-internal-toggle-maximize", + "markdownDescription": "Denies the internal_toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the is_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-always-on-top", + "markdownDescription": "Denies the is_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the is_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-closable", + "markdownDescription": "Denies the is_closable command without any pre-configured scope." + }, + { + "description": "Denies the is_decorated command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-decorated", + "markdownDescription": "Denies the is_decorated command without any pre-configured scope." + }, + { + "description": "Denies the is_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-enabled", + "markdownDescription": "Denies the is_enabled command without any pre-configured scope." + }, + { + "description": "Denies the is_focused command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-focused", + "markdownDescription": "Denies the is_focused command without any pre-configured scope." + }, + { + "description": "Denies the is_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-fullscreen", + "markdownDescription": "Denies the is_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the is_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximizable", + "markdownDescription": "Denies the is_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the is_maximized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-maximized", + "markdownDescription": "Denies the is_maximized command without any pre-configured scope." + }, + { + "description": "Denies the is_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimizable", + "markdownDescription": "Denies the is_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the is_minimized command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-minimized", + "markdownDescription": "Denies the is_minimized command without any pre-configured scope." + }, + { + "description": "Denies the is_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-resizable", + "markdownDescription": "Denies the is_resizable command without any pre-configured scope." + }, + { + "description": "Denies the is_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-is-visible", + "markdownDescription": "Denies the is_visible command without any pre-configured scope." + }, + { + "description": "Denies the maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-maximize", + "markdownDescription": "Denies the maximize command without any pre-configured scope." + }, + { + "description": "Denies the minimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-minimize", + "markdownDescription": "Denies the minimize command without any pre-configured scope." + }, + { + "description": "Denies the monitor_from_point command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-monitor-from-point", + "markdownDescription": "Denies the monitor_from_point command without any pre-configured scope." + }, + { + "description": "Denies the outer_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-position", + "markdownDescription": "Denies the outer_position command without any pre-configured scope." + }, + { + "description": "Denies the outer_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-outer-size", + "markdownDescription": "Denies the outer_size command without any pre-configured scope." + }, + { + "description": "Denies the primary_monitor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-primary-monitor", + "markdownDescription": "Denies the primary_monitor command without any pre-configured scope." + }, + { + "description": "Denies the request_user_attention command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-request-user-attention", + "markdownDescription": "Denies the request_user_attention command without any pre-configured scope." + }, + { + "description": "Denies the scale_factor command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-scale-factor", + "markdownDescription": "Denies the scale_factor command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_bottom command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-bottom", + "markdownDescription": "Denies the set_always_on_bottom command without any pre-configured scope." + }, + { + "description": "Denies the set_always_on_top command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-always-on-top", + "markdownDescription": "Denies the set_always_on_top command without any pre-configured scope." + }, + { + "description": "Denies the set_background_color command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-background-color", + "markdownDescription": "Denies the set_background_color command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_count command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-count", + "markdownDescription": "Denies the set_badge_count command without any pre-configured scope." + }, + { + "description": "Denies the set_badge_label command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-badge-label", + "markdownDescription": "Denies the set_badge_label command without any pre-configured scope." + }, + { + "description": "Denies the set_closable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-closable", + "markdownDescription": "Denies the set_closable command without any pre-configured scope." + }, + { + "description": "Denies the set_content_protected command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-content-protected", + "markdownDescription": "Denies the set_content_protected command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_grab command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-grab", + "markdownDescription": "Denies the set_cursor_grab command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-icon", + "markdownDescription": "Denies the set_cursor_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-position", + "markdownDescription": "Denies the set_cursor_position command without any pre-configured scope." + }, + { + "description": "Denies the set_cursor_visible command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-cursor-visible", + "markdownDescription": "Denies the set_cursor_visible command without any pre-configured scope." + }, + { + "description": "Denies the set_decorations command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-decorations", + "markdownDescription": "Denies the set_decorations command without any pre-configured scope." + }, + { + "description": "Denies the set_effects command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-effects", + "markdownDescription": "Denies the set_effects command without any pre-configured scope." + }, + { + "description": "Denies the set_enabled command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-enabled", + "markdownDescription": "Denies the set_enabled command without any pre-configured scope." + }, + { + "description": "Denies the set_focus command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focus", + "markdownDescription": "Denies the set_focus command without any pre-configured scope." + }, + { + "description": "Denies the set_focusable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-focusable", + "markdownDescription": "Denies the set_focusable command without any pre-configured scope." + }, + { + "description": "Denies the set_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-fullscreen", + "markdownDescription": "Denies the set_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-icon", + "markdownDescription": "Denies the set_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_ignore_cursor_events command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-ignore-cursor-events", + "markdownDescription": "Denies the set_ignore_cursor_events command without any pre-configured scope." + }, + { + "description": "Denies the set_max_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-max-size", + "markdownDescription": "Denies the set_max_size command without any pre-configured scope." + }, + { + "description": "Denies the set_maximizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-maximizable", + "markdownDescription": "Denies the set_maximizable command without any pre-configured scope." + }, + { + "description": "Denies the set_min_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-min-size", + "markdownDescription": "Denies the set_min_size command without any pre-configured scope." + }, + { + "description": "Denies the set_minimizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-minimizable", + "markdownDescription": "Denies the set_minimizable command without any pre-configured scope." + }, + { + "description": "Denies the set_overlay_icon command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-overlay-icon", + "markdownDescription": "Denies the set_overlay_icon command without any pre-configured scope." + }, + { + "description": "Denies the set_position command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-position", + "markdownDescription": "Denies the set_position command without any pre-configured scope." + }, + { + "description": "Denies the set_progress_bar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-progress-bar", + "markdownDescription": "Denies the set_progress_bar command without any pre-configured scope." + }, + { + "description": "Denies the set_resizable command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-resizable", + "markdownDescription": "Denies the set_resizable command without any pre-configured scope." + }, + { + "description": "Denies the set_shadow command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-shadow", + "markdownDescription": "Denies the set_shadow command without any pre-configured scope." + }, + { + "description": "Denies the set_simple_fullscreen command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-simple-fullscreen", + "markdownDescription": "Denies the set_simple_fullscreen command without any pre-configured scope." + }, + { + "description": "Denies the set_size command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size", + "markdownDescription": "Denies the set_size command without any pre-configured scope." + }, + { + "description": "Denies the set_size_constraints command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-size-constraints", + "markdownDescription": "Denies the set_size_constraints command without any pre-configured scope." + }, + { + "description": "Denies the set_skip_taskbar command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-skip-taskbar", + "markdownDescription": "Denies the set_skip_taskbar command without any pre-configured scope." + }, + { + "description": "Denies the set_theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-theme", + "markdownDescription": "Denies the set_theme command without any pre-configured scope." + }, + { + "description": "Denies the set_title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title", + "markdownDescription": "Denies the set_title command without any pre-configured scope." + }, + { + "description": "Denies the set_title_bar_style command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-title-bar-style", + "markdownDescription": "Denies the set_title_bar_style command without any pre-configured scope." + }, + { + "description": "Denies the set_visible_on_all_workspaces command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-set-visible-on-all-workspaces", + "markdownDescription": "Denies the set_visible_on_all_workspaces command without any pre-configured scope." + }, + { + "description": "Denies the show command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-show", + "markdownDescription": "Denies the show command without any pre-configured scope." + }, + { + "description": "Denies the start_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-dragging", + "markdownDescription": "Denies the start_dragging command without any pre-configured scope." + }, + { + "description": "Denies the start_resize_dragging command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-start-resize-dragging", + "markdownDescription": "Denies the start_resize_dragging command without any pre-configured scope." + }, + { + "description": "Denies the theme command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-theme", + "markdownDescription": "Denies the theme command without any pre-configured scope." + }, + { + "description": "Denies the title command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-title", + "markdownDescription": "Denies the title command without any pre-configured scope." + }, + { + "description": "Denies the toggle_maximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-toggle-maximize", + "markdownDescription": "Denies the toggle_maximize command without any pre-configured scope." + }, + { + "description": "Denies the unmaximize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unmaximize", + "markdownDescription": "Denies the unmaximize command without any pre-configured scope." + }, + { + "description": "Denies the unminimize command without any pre-configured scope.", + "type": "string", + "const": "core:window:deny-unminimize", + "markdownDescription": "Denies the unminimize command without any pre-configured scope." + }, + { + "description": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`", + "type": "string", + "const": "opener:default", + "markdownDescription": "This permission set allows opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application\nas well as reveal file in directories using default file explorer\n#### This default permission set includes:\n\n- `allow-open-url`\n- `allow-reveal-item-in-dir`\n- `allow-default-urls`" + }, + { + "description": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application.", + "type": "string", + "const": "opener:allow-default-urls", + "markdownDescription": "This enables opening `mailto:`, `tel:`, `https://` and `http://` urls using their default application." + }, + { + "description": "Enables the open_path command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-open-path", + "markdownDescription": "Enables the open_path command without any pre-configured scope." + }, + { + "description": "Enables the open_url command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-open-url", + "markdownDescription": "Enables the open_url command without any pre-configured scope." + }, + { + "description": "Enables the reveal_item_in_dir command without any pre-configured scope.", + "type": "string", + "const": "opener:allow-reveal-item-in-dir", + "markdownDescription": "Enables the reveal_item_in_dir command without any pre-configured scope." + }, + { + "description": "Denies the open_path command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-open-path", + "markdownDescription": "Denies the open_path command without any pre-configured scope." + }, + { + "description": "Denies the open_url command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-open-url", + "markdownDescription": "Denies the open_url command without any pre-configured scope." + }, + { + "description": "Denies the reveal_item_in_dir command without any pre-configured scope.", + "type": "string", + "const": "opener:deny-reveal-item-in-dir", + "markdownDescription": "Denies the reveal_item_in_dir command without any pre-configured scope." + } + ] + }, + "Value": { + "description": "All supported ACL values.", + "anyOf": [ + { + "description": "Represents a null JSON value.", + "type": "null" + }, + { + "description": "Represents a [`bool`].", + "type": "boolean" + }, + { + "description": "Represents a valid ACL [`Number`].", + "allOf": [ + { + "$ref": "#/definitions/Number" + } + ] + }, + { + "description": "Represents a [`String`].", + "type": "string" + }, + { + "description": "Represents a list of other [`Value`]s.", + "type": "array", + "items": { + "$ref": "#/definitions/Value" + } + }, + { + "description": "Represents a map of [`String`] keys to [`Value`]s.", + "type": "object", + "additionalProperties": { + "$ref": "#/definitions/Value" + } + } + ] + }, + "Number": { + "description": "A valid ACL number.", + "anyOf": [ + { + "description": "Represents an [`i64`].", + "type": "integer", + "format": "int64" + }, + { + "description": "Represents a [`f64`].", + "type": "number", + "format": "double" + } + ] + }, + "Target": { + "description": "Platform target.", + "oneOf": [ + { + "description": "MacOS.", + "type": "string", + "enum": [ + "macOS" + ] + }, + { + "description": "Windows.", + "type": "string", + "enum": [ + "windows" + ] + }, + { + "description": "Linux.", + "type": "string", + "enum": [ + "linux" + ] + }, + { + "description": "Android.", + "type": "string", + "enum": [ + "android" + ] + }, + { + "description": "iOS.", + "type": "string", + "enum": [ + "iOS" + ] + } + ] + }, + "Application": { + "description": "Opener scope application.", + "anyOf": [ + { + "description": "Open in default application.", + "type": "null" + }, + { + "description": "If true, allow open with any application.", + "type": "boolean" + }, + { + "description": "Allow specific application to open with.", + "type": "string" + } + ] + } + } +} \ No newline at end of file diff --git a/src-tauri/src/commands/accounts.rs b/src-tauri/src/commands/accounts.rs index 98e4c32..bedd231 100644 --- a/src-tauri/src/commands/accounts.rs +++ b/src-tauri/src/commands/accounts.rs @@ -96,6 +96,7 @@ fn default_settings() -> AppSettings { AppSettings { auto_refresh_interval: 0, auto_restart_codex_after_switch: true, + auto_restart_vscode_after_switch: false, theme: "system".to_string(), proxy_url: String::new(), } diff --git a/src-tauri/src/commands/desktop.rs b/src-tauri/src/commands/desktop.rs index a8931f5..320c11e 100644 --- a/src-tauri/src/commands/desktop.rs +++ b/src-tauri/src/commands/desktop.rs @@ -12,6 +12,13 @@ pub async fn restart_codex_desktop() -> Result<(), String> { .map_err(|e| e.to_string())? } +#[tauri::command] +pub async fn restart_vscode() -> Result<(), String> { + tokio::task::spawn_blocking(codex::restart_vscode) + .await + .map_err(|e| e.to_string())? +} + #[tauri::command] pub async fn resume_session_in_terminal(session_id: String) -> Result<(), String> { tokio::task::spawn_blocking(move || codex::resume_session_in_terminal(session_id)) diff --git a/src-tauri/src/commands/oauth.rs b/src-tauri/src/commands/oauth.rs index 918083d..7be6e5d 100644 --- a/src-tauri/src/commands/oauth.rs +++ b/src-tauri/src/commands/oauth.rs @@ -15,6 +15,7 @@ use tokio::sync::{oneshot, Mutex}; use crate::commands::accounts; use crate::models::{AuthJson, AuthTokens, OAuthResult, TokenResponse}; +use crate::net::build_http_client; const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; const AUTH_ENDPOINT: &str = "https://auth.openai.com/oauth/authorize"; @@ -149,15 +150,11 @@ async fn exchange_code( verifier: &str, ) -> Result { let settings = accounts::load_settings(app.clone()).await?; - let mut client_builder = reqwest::Client::builder(); - - if !settings.proxy_url.trim().is_empty() { - let proxy = reqwest::Proxy::all(settings.proxy_url.trim()) - .map_err(|e| format!("Invalid proxy URL: {}", e))?; - client_builder = client_builder.proxy(proxy); - } - - let client = client_builder.build().map_err(|e| e.to_string())?; + let client = build_http_client( + &settings, + "codex-manager/1.0", + std::time::Duration::from_secs(30), + )?; let params = [ ("grant_type", "authorization_code"), ("client_id", CLIENT_ID), @@ -176,6 +173,11 @@ async fn exchange_code( if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); + if body.contains("unsupported_country_region_territory") { + return Err(format!( + "Token exchange failed ({status}): 当前网络被 OpenAI 判定为不支持的地区。请在设置中配置可用代理,或启用系统代理后重试。{body}" + )); + } return Err(format!("Token exchange failed ({}): {}", status, body)); } diff --git a/src-tauri/src/commands/usage.rs b/src-tauri/src/commands/usage.rs index 1b30919..c6da61a 100644 --- a/src-tauri/src/commands/usage.rs +++ b/src-tauri/src/commands/usage.rs @@ -13,15 +13,17 @@ use crate::{ atomic_io::write_text_atomic_async, commands::{accounts, paths::app_data_dir}, models::{ - AccountRateLimitStatus, AppSettings, AuthJson, CreditsSnapshot, + AccountRateLimitStatus, AuthJson, CreditsSnapshot, DailyWorkspaceUsageResponse, GetAccountRateLimitsResponse, RateLimitSnapshot, RateLimitWindow, TokenResponse, }, + net::build_http_client, }; const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; const DEFAULT_CHATGPT_BASE_URL: &str = "https://chatgpt.com"; const CODEX_USAGE_PATH: &str = "/api/codex/usage"; const WHAM_USAGE_PATH: &str = "/wham/usage"; +const DAILY_WORKSPACE_USAGE_PATH: &str = "/wham/analytics/daily-workspace-usage-counts"; const BACKEND_API_PREFIX: &str = "/backend-api"; fn validate_uuid(account_id: &str) -> Result { @@ -182,6 +184,36 @@ fn resolve_usage_urls() -> Vec { deduped } +fn resolve_daily_workspace_usage_urls(start_date: &str, end_date: &str) -> Vec { + let normalized = resolve_chatgpt_base_origin(); + let query = format!("start_date={start_date}&end_date={end_date}&group_by=day"); + let mut candidates = Vec::new(); + + if let Some(origin) = normalized.strip_suffix(BACKEND_API_PREFIX) { + candidates.push(format!("{normalized}{DAILY_WORKSPACE_USAGE_PATH}?{query}")); + candidates.push(format!( + "{origin}{BACKEND_API_PREFIX}{DAILY_WORKSPACE_USAGE_PATH}?{query}" + )); + } else { + candidates.push(format!( + "{normalized}{BACKEND_API_PREFIX}{DAILY_WORKSPACE_USAGE_PATH}?{query}" + )); + candidates.push(format!("{normalized}{DAILY_WORKSPACE_USAGE_PATH}?{query}")); + } + + candidates.push(format!( + "https://chatgpt.com{BACKEND_API_PREFIX}{DAILY_WORKSPACE_USAGE_PATH}?{query}" + )); + + let mut deduped = Vec::new(); + for url in candidates { + if !deduped.iter().any(|existing| existing == &url) { + deduped.push(url); + } + } + deduped +} + fn read_chatgpt_base_url_from_config() -> Option { let home = dirs::home_dir()?; let config_path = home.join(".codex").join("config.toml"); @@ -255,22 +287,6 @@ fn invalid_account_reason(detail: impl Into) -> String { format!("账号已失效或不可用,无法读取官方配额。{}", detail.into()) } -fn build_http_client(settings: &AppSettings) -> Result { - let mut builder = reqwest::Client::builder() - .user_agent("codex-manager/0.1") - .timeout(std::time::Duration::from_secs(18)); - - if !settings.proxy_url.trim().is_empty() { - let proxy = reqwest::Proxy::all(settings.proxy_url.trim()) - .map_err(|e| format!("Invalid proxy URL: {e}"))?; - builder = builder.proxy(proxy); - } - - builder - .build() - .map_err(|e| format!("创建 HTTP 客户端失败: {e}")) -} - async fn request_usage_payload( client: &reqwest::Client, access_token: &str, @@ -335,6 +351,74 @@ async fn request_usage_payload( }) } +async fn request_daily_workspace_usage_payload( + client: &reqwest::Client, + access_token: &str, + account_id: &str, + start_date: &str, + end_date: &str, +) -> Result { + let urls = resolve_daily_workspace_usage_urls(start_date, end_date); + let mut errors: Vec = Vec::new(); + let mut should_refresh_auth = false; + let mut invalid_account = false; + + for url in urls { + let response = match client + .get(&url) + .header("Authorization", format!("Bearer {access_token}")) + .header("ChatGPT-Account-Id", account_id) + .header("Accept", "application/json") + .send() + .await + { + Ok(response) => response, + Err(err) => { + errors.push(format!("{url} -> {}", format_reqwest_error(&err))); + continue; + } + }; + + let status = response.status(); + if status == StatusCode::UNAUTHORIZED || status == StatusCode::FORBIDDEN { + should_refresh_auth = true; + } + + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + invalid_account |= looks_like_invalid_account_text(&body); + errors.push(format!( + "{url} -> {status}: {}", + truncate_for_error(&body, 160) + )); + continue; + } + + let mut payload: DailyWorkspaceUsageResponse = match response.json().await { + Ok(payload) => payload, + Err(err) => { + errors.push(format!("{url} -> 解析返回失败: {err}")); + continue; + } + }; + payload.start_date = start_date.to_string(); + payload.end_date = end_date.to_string(); + return Ok(payload); + } + + let preview = if errors.is_empty() { + "未命中任何候选地址".to_string() + } else { + errors.into_iter().take(2).collect::>().join(" | ") + }; + + Err(UsageFetchError { + message: format!("请求每日用量接口失败: {preview}"), + should_refresh_auth, + invalid_account, + }) +} + async fn refresh_auth_tokens( client: &reqwest::Client, auth: &mut AuthJson, @@ -412,16 +496,24 @@ async fn refresh_auth_tokens( Ok(()) } -fn pick_nearest_window(windows: &[UsageWindowRaw], target_seconds: i64) -> Option { +fn pick_nearest_window( + windows: &[UsageWindowRaw], + target_seconds: i64, + max_delta_seconds: i64, +) -> Option { windows .iter() + .filter(|window| (window.limit_window_seconds - target_seconds).abs() <= max_delta_seconds) .min_by_key(|window| (window.limit_window_seconds - target_seconds).abs()) .cloned() } fn to_usage_window(window: UsageWindowRaw) -> RateLimitWindow { + let remaining_percent = (100.0 - window.used_percent).clamp(0.0, 100.0).round() as i32; + RateLimitWindow { - used_percent: window.used_percent.round() as i32, + remaining_percent, + used_percent: Some(window.used_percent), resets_at: Some(window.reset_at), window_duration_mins: Some(window.limit_window_seconds / 60), } @@ -461,8 +553,9 @@ fn map_usage_payload(payload: UsageApiResponse) -> GetAccountRateLimitsResponse unlimited: Some(credit.unlimited), balance: credit.balance, }), - primary: pick_nearest_window(&windows, 5 * 60 * 60).map(to_usage_window), - secondary: pick_nearest_window(&windows, 7 * 24 * 60 * 60).map(to_usage_window), + primary: pick_nearest_window(&windows, 5 * 60 * 60, 60 * 60).map(to_usage_window), + secondary: pick_nearest_window(&windows, 7 * 24 * 60 * 60, 24 * 60 * 60) + .map(to_usage_window), }; let mut by_limit_id = HashMap::new(); @@ -498,7 +591,11 @@ pub async fn read_account_rate_limits( serde_json::from_str(&auth_json).map_err(|e| format!("auth.json 解析失败: {e}"))?; let settings = accounts::load_settings(app.clone()).await?; - let client = build_http_client(&settings)?; + let client = build_http_client( + &settings, + "codex-manager/1.0", + std::time::Duration::from_secs(18), + )?; let mut resolved_account_id = match extract_account_id(&auth) { Some(id) => id, @@ -575,3 +672,132 @@ pub async fn read_account_rate_limits( Err(err) => Err(err.message), } } + +#[tauri::command] +pub async fn read_account_daily_workspace_usage( + app: AppHandle, + account_id: String, + days: Option, +) -> Result { + let credentials_path = credentials_path(&app, &account_id)?; + let auth_json = fs::read_to_string(&credentials_path) + .await + .map_err(|_| format!("Credentials not found for account {}", account_id))?; + let mut auth: AuthJson = + serde_json::from_str(&auth_json).map_err(|e| format!("auth.json 解析失败: {e}"))?; + + let settings = accounts::load_settings(app.clone()).await?; + let client = build_http_client( + &settings, + "codex-manager/1.0", + std::time::Duration::from_secs(18), + )?; + + let now = chrono::Utc::now().date_naive(); + let days_back = i64::from(days.unwrap_or(30).clamp(1, 120)); + let start_date = (now - chrono::Duration::days(days_back)).to_string(); + let end_date = (now + chrono::Duration::days(1)).to_string(); + + let mut resolved_account_id = match extract_account_id(&auth) { + Some(id) => id, + None => return Err("凭证中缺少账号标识,请重新登录该账号。".to_string()), + }; + + let current_access_token = access_token(&auth)?.to_string(); + match request_daily_workspace_usage_payload( + &client, + ¤t_access_token, + &resolved_account_id, + &start_date, + &end_date, + ) + .await + { + Ok(payload) => Ok(payload), + Err(err) if err.should_refresh_auth => { + if let Err(refresh_err) = refresh_auth_tokens(&client, &mut auth).await { + return Err(refresh_err.message); + } + + resolved_account_id = extract_account_id(&auth) + .ok_or_else(|| "刷新后仍无法识别账号标识,请重新登录该账号。".to_string())?; + let serialized = serde_json::to_string_pretty(&auth) + .map_err(|e| format!("auth.json 序列化失败: {e}"))?; + write_text_atomic_async(credentials_path.clone(), serialized) + .await + .map_err(|e| format!("更新账号凭证失败: {e}"))?; + let refreshed_access_token = access_token(&auth)?.to_string(); + + request_daily_workspace_usage_payload( + &client, + &refreshed_access_token, + &resolved_account_id, + &start_date, + &end_date, + ) + .await + .map_err(|refresh_err| { + format!( + "{} | 刷新令牌后重试仍失败: {}", + err.message, refresh_err.message + ) + }) + } + Err(err) => Err(err.message), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn window(used_percent: f64, limit_window_seconds: i64, reset_at: i64) -> UsageWindowRaw { + UsageWindowRaw { + used_percent, + limit_window_seconds, + reset_at, + } + } + + #[test] + fn maps_weekly_quota_from_week_window_not_daily_window() { + let response = map_usage_payload(UsageApiResponse { + plan_type: Some("team".to_string()), + rate_limit: Some(RateLimitDetails { + primary_window: Some(window(1.0, 5 * 60 * 60, 1_800)), + secondary_window: Some(window(0.0, 24 * 60 * 60, 86_400)), + }), + additional_rate_limits: Some(vec![AdditionalRateLimitDetails { + rate_limit: Some(RateLimitDetails { + primary_window: None, + secondary_window: Some(window(41.0, 7 * 24 * 60 * 60, 604_800)), + }), + }]), + credits: None, + }); + + let snapshot = response.rate_limits.expect("rate limits should be present"); + assert_eq!(snapshot.primary.expect("primary").remaining_percent, 99); + + let weekly = snapshot.secondary.expect("weekly"); + assert_eq!(weekly.remaining_percent, 59); + assert_eq!(weekly.resets_at, Some(604_800)); + assert_eq!(weekly.window_duration_mins, Some(7 * 24 * 60)); + } + + #[test] + fn does_not_treat_daily_window_as_weekly_quota() { + let response = map_usage_payload(UsageApiResponse { + plan_type: Some("team".to_string()), + rate_limit: Some(RateLimitDetails { + primary_window: Some(window(1.0, 5 * 60 * 60, 1_800)), + secondary_window: Some(window(0.0, 24 * 60 * 60, 86_400)), + }), + additional_rate_limits: None, + credits: None, + }); + + let snapshot = response.rate_limits.expect("rate limits should be present"); + assert!(snapshot.secondary.is_none()); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f85b7e7..71300dd 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -2,6 +2,7 @@ pub mod atomic_io; pub mod cli; pub mod commands; pub mod models; +pub mod net; pub mod platform; use commands::{accounts, desktop, oauth, paths, sessions, usage}; @@ -500,8 +501,10 @@ pub fn run() { sessions::delete_account_sessions, desktop::resume_session_in_terminal, desktop::restart_codex_desktop, + desktop::restart_vscode, desktop::get_platform_capabilities, usage::read_account_rate_limits, + usage::read_account_daily_workspace_usage, // oauth oauth::start_oauth_flow, oauth::cancel_oauth_flow, diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 2f2fa2e..e693380 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -85,6 +85,8 @@ pub struct AppSettings { pub auto_refresh_interval: u32, #[serde(default = "default_auto_restart_codex_after_switch")] pub auto_restart_codex_after_switch: bool, + #[serde(default)] + pub auto_restart_vscode_after_switch: bool, pub theme: String, pub proxy_url: String, } @@ -98,6 +100,7 @@ fn default_auto_restart_codex_after_switch() -> bool { pub struct DesktopPlatformCapabilities { pub platform: String, pub supports_auto_restart_codex_desktop: bool, + pub supports_auto_restart_vscode: bool, pub supports_resume_session_in_terminal: bool, pub supports_system_tray: bool, pub supports_taskbar_shortcuts: bool, @@ -116,7 +119,9 @@ pub struct CreditsSnapshot { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct RateLimitWindow { - pub used_percent: i32, + pub remaining_percent: i32, + #[serde(default)] + pub used_percent: Option, pub resets_at: Option, pub window_duration_mins: Option, } @@ -184,6 +189,82 @@ pub struct UsageStatsSummary { pub models: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct DailyWorkspaceUsageTotals { + #[serde(default)] + pub credits: Option, + #[serde(default)] + pub turns: Option, + #[serde(default)] + #[serde(alias = "text_total_tokens")] + pub text_total_tokens: Option, + #[serde(default)] + #[serde(alias = "cached_text_input_tokens")] + pub cached_text_input_tokens: Option, + #[serde(default)] + #[serde(alias = "uncached_text_input_tokens")] + pub uncached_text_input_tokens: Option, + #[serde(default)] + #[serde(alias = "text_output_tokens")] + pub text_output_tokens: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +#[serde(rename_all = "camelCase")] +pub struct DailyWorkspaceUsageBreakdown { + #[serde(default)] + pub model: Option, + #[serde(default)] + #[serde(alias = "client_id")] + pub client_id: Option, + #[serde(default)] + pub users: Option, + #[serde(default)] + pub threads: Option, + #[serde(default)] + pub turns: Option, + #[serde(default)] + pub credits: Option, + #[serde(default)] + pub on_demand_credits: Option, + #[serde(default)] + #[serde(alias = "text_total_tokens")] + pub text_total_tokens: Option, + #[serde(default)] + #[serde(alias = "cached_text_input_tokens")] + pub cached_text_input_tokens: Option, + #[serde(default)] + #[serde(alias = "uncached_text_input_tokens")] + pub uncached_text_input_tokens: Option, + #[serde(default)] + #[serde(alias = "text_output_tokens")] + pub text_output_tokens: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DailyWorkspaceUsage { + pub date: String, + #[serde(default)] + pub totals: Option, + #[serde(default)] + pub models: Option>, + #[serde(default)] + pub clients: Option>, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DailyWorkspaceUsageResponse { + #[serde(default)] + pub data: Vec, + #[serde(default)] + pub start_date: String, + #[serde(default)] + pub end_date: String, +} + // ─── File-format structs (snake_case, matches on-disk JSON) ────────────────── #[derive(Debug, Clone, Serialize, Deserialize)] diff --git a/src-tauri/src/net.rs b/src-tauri/src/net.rs new file mode 100644 index 0000000..a8620b4 --- /dev/null +++ b/src-tauri/src/net.rs @@ -0,0 +1,269 @@ +use crate::models::AppSettings; + +#[cfg(any(target_os = "macos", test))] +use std::collections::HashMap; + +fn env_proxy_url() -> Option { + [ + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + "HTTP_PROXY", + "http_proxy", + ] + .iter() + .filter_map(|key| std::env::var(key).ok()) + .map(|value| value.trim().to_string()) + .find(|value| !value.is_empty()) +} + +fn add_default_scheme(value: &str, scheme: &str) -> String { + if value.contains("://") { + value.to_string() + } else { + format!("{scheme}://{value}") + } +} + +fn parse_proxy_server_entry(proxy_server: &str) -> Option { + let trimmed = proxy_server.trim(); + if trimmed.is_empty() { + return None; + } + + if !trimmed.contains(';') && !trimmed.contains('=') { + return Some(add_default_scheme(trimmed, "http")); + } + + let mut http_proxy = None; + let mut socks_proxy = None; + + for part in trimmed + .split(';') + .map(str::trim) + .filter(|part| !part.is_empty()) + { + let Some((raw_kind, raw_value)) = part.split_once('=') else { + continue; + }; + let kind = raw_kind.trim().to_ascii_lowercase(); + let value = raw_value.trim(); + if value.is_empty() { + continue; + } + + match kind.as_str() { + "https" => return Some(add_default_scheme(value, "http")), + "http" => http_proxy = Some(add_default_scheme(value, "http")), + "socks" | "socks5" => socks_proxy = Some(add_default_scheme(value, "socks5")), + _ => {} + } + } + + http_proxy.or(socks_proxy) +} + +#[cfg(target_os = "windows")] +fn windows_system_proxy_url() -> Option { + use winreg::{enums::HKEY_CURRENT_USER, RegKey}; + + let hkcu = RegKey::predef(HKEY_CURRENT_USER); + let settings = hkcu + .open_subkey("Software\\Microsoft\\Windows\\CurrentVersion\\Internet Settings") + .ok()?; + let enabled = settings.get_value::("ProxyEnable").unwrap_or(0); + if enabled == 0 { + return None; + } + + let proxy_server = settings.get_value::("ProxyServer").ok()?; + parse_proxy_server_entry(&proxy_server) +} + +#[cfg(not(target_os = "windows"))] +fn windows_system_proxy_url() -> Option { + None +} + +#[cfg(any(target_os = "macos", test))] +fn parse_scutil_proxy_output(output: &str) -> Option { + let mut values = HashMap::new(); + + for line in output.lines() { + let Some((raw_key, raw_value)) = line.split_once(':') else { + continue; + }; + let key = raw_key.trim(); + let value = raw_value.trim().trim_matches('"').trim_matches('\''); + if !key.is_empty() && !value.is_empty() { + values.insert(key.to_string(), value.to_string()); + } + } + + fn enabled(values: &HashMap, key: &str) -> bool { + matches!( + values.get(key).map(|value| value.as_str()), + Some("1") | Some("true") | Some("TRUE") + ) + } + + fn proxy_url( + values: &HashMap, + enable_key: &str, + host_key: &str, + port_key: &str, + scheme: &str, + ) -> Option { + if !enabled(values, enable_key) { + return None; + } + let host = values.get(host_key)?.trim(); + let port = values.get(port_key)?.trim(); + if host.is_empty() || port.is_empty() { + return None; + } + let normalized_host = if host.contains(':') && !host.starts_with('[') { + format!("[{host}]") + } else { + host.to_string() + }; + Some(format!("{scheme}://{normalized_host}:{port}")) + } + + proxy_url(&values, "HTTPSEnable", "HTTPSProxy", "HTTPSPort", "http") + .or_else(|| proxy_url(&values, "HTTPEnable", "HTTPProxy", "HTTPPort", "http")) + .or_else(|| proxy_url(&values, "SOCKSEnable", "SOCKSProxy", "SOCKSPort", "socks5")) +} + +#[cfg(target_os = "macos")] +fn macos_system_proxy_url() -> Option { + let output = std::process::Command::new("/usr/sbin/scutil") + .arg("--proxy") + .output() + .ok()?; + if !output.status.success() { + return None; + } + let stdout = std::str::from_utf8(&output.stdout).ok()?; + parse_scutil_proxy_output(stdout) +} + +#[cfg(not(target_os = "macos"))] +fn macos_system_proxy_url() -> Option { + None +} + +fn configured_proxy_url(settings: &AppSettings) -> Option { + let explicit = settings.proxy_url.trim(); + if !explicit.is_empty() { + return Some(explicit.to_string()); + } + + env_proxy_url() + .or_else(macos_system_proxy_url) + .or_else(windows_system_proxy_url) +} + +pub fn build_http_client( + settings: &AppSettings, + user_agent: &str, + timeout: std::time::Duration, +) -> Result { + let mut builder = reqwest::Client::builder() + .user_agent(user_agent) + .timeout(timeout); + + if let Some(proxy_url) = configured_proxy_url(settings) { + let proxy = reqwest::Proxy::all(&proxy_url) + .map_err(|e| format!("Invalid proxy URL {proxy_url}: {e}"))?; + builder = builder.proxy(proxy); + } + + builder + .build() + .map_err(|e| format!("创建 HTTP 客户端失败: {e}")) +} + +#[cfg(test)] +mod tests { + use super::{parse_proxy_server_entry, parse_scutil_proxy_output}; + + #[test] + fn parses_single_proxy_server() { + assert_eq!( + parse_proxy_server_entry("127.0.0.1:7890").as_deref(), + Some("http://127.0.0.1:7890") + ); + } + + #[test] + fn prefers_https_proxy_server_entry() { + assert_eq!( + parse_proxy_server_entry("http=127.0.0.1:7890;https=127.0.0.1:7891").as_deref(), + Some("http://127.0.0.1:7891") + ); + } + + #[test] + fn parses_socks_proxy_server_entry() { + assert_eq!( + parse_proxy_server_entry("socks=127.0.0.1:1080").as_deref(), + Some("socks5://127.0.0.1:1080") + ); + } + + #[test] + fn parses_macos_https_system_proxy() { + let output = r#" + { + HTTPEnable : 1 + HTTPPort : 7890 + HTTPProxy : 127.0.0.1 + HTTPSEnable : 1 + HTTPSPort : 7891 + HTTPSProxy : 127.0.0.1 +} +"#; + + assert_eq!( + parse_scutil_proxy_output(output).as_deref(), + Some("http://127.0.0.1:7891") + ); + } + + #[test] + fn parses_macos_http_system_proxy() { + let output = r#" + { + HTTPEnable : 1 + HTTPPort : 7890 + HTTPProxy : 127.0.0.1 + HTTPSEnable : 0 +} +"#; + + assert_eq!( + parse_scutil_proxy_output(output).as_deref(), + Some("http://127.0.0.1:7890") + ); + } + + #[test] + fn parses_macos_socks_system_proxy() { + let output = r#" + { + HTTPEnable : 0 + HTTPSEnable : 0 + SOCKSEnable : 1 + SOCKSPort : 1080 + SOCKSProxy : 127.0.0.1 +} +"#; + + assert_eq!( + parse_scutil_proxy_output(output).as_deref(), + Some("socks5://127.0.0.1:1080") + ); + } +} diff --git a/src-tauri/src/platform/codex.rs b/src-tauri/src/platform/codex.rs index 627f3ec..6263760 100644 --- a/src-tauri/src/platform/codex.rs +++ b/src-tauri/src/platform/codex.rs @@ -1,10 +1,11 @@ use std::{ env, path::{Path, PathBuf}, + process::Command, }; -#[cfg(target_os = "windows")] -use std::process::Command; +#[cfg(any(target_os = "macos", target_os = "linux"))] +use std::{thread, time::Duration}; use crate::models::DesktopPlatformCapabilities; @@ -36,6 +37,11 @@ pub fn desktop_platform_capabilities() -> DesktopPlatformCapabilities { DesktopPlatformCapabilities { platform: current_platform().to_string(), supports_auto_restart_codex_desktop: cfg!(target_os = "windows"), + supports_auto_restart_vscode: cfg!(any( + target_os = "windows", + target_os = "macos", + target_os = "linux" + )), supports_resume_session_in_terminal: cfg!(target_os = "windows"), supports_system_tray: true, supports_taskbar_shortcuts: cfg!(target_os = "windows"), @@ -176,6 +182,121 @@ pub fn restart_codex_desktop() -> Result<(), String> { Err("当前仅支持 Windows 自动重启 Codex 桌面应用".to_string()) } +#[cfg(target_os = "windows")] +pub fn restart_vscode() -> Result<(), String> { + let restart_script = r#"$ErrorActionPreference = 'Stop' +$candidates = @( + "$env:LOCALAPPDATA\Programs\Microsoft VS Code\Code.exe", + "$env:ProgramFiles\Microsoft VS Code\Code.exe", + "${env:ProgramFiles(x86)}\Microsoft VS Code\Code.exe" +) +$code = $candidates | Where-Object { Test-Path $_ } | Select-Object -First 1 +if (-not $code) { + $command = Get-Command code -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($command) { + $code = $command.Source + } +} +if (-not $code) { + throw '未找到 VSCode 可执行文件' +} +$targets = Get-Process -Name 'Code' -ErrorAction SilentlyContinue +if ($targets) { + $targets | Stop-Process -Force +} +Start-Sleep -Milliseconds 900 +Start-Process -FilePath $code"#; + + let output = Command::new("powershell.exe") + .args([ + "-NoProfile", + "-NonInteractive", + "-WindowStyle", + "Hidden", + "-Command", + restart_script, + ]) + .output() + .map_err(|e| format!("重启 VSCode 失败: {e}"))?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let detail = if !stderr.is_empty() { stderr } else { stdout }; + + if detail.is_empty() { + Err("重启 VSCode 失败".to_string()) + } else { + Err(format!("重启 VSCode 失败: {detail}")) + } +} + +#[cfg(target_os = "macos")] +pub fn restart_vscode() -> Result<(), String> { + let _ = Command::new("osascript") + .args(["-e", r#"tell application "Visual Studio Code" to quit"#]) + .output(); + + thread::sleep(Duration::from_millis(900)); + + let output = Command::new("open") + .args(["-a", "Visual Studio Code"]) + .output() + .map_err(|e| format!("重启 VSCode 失败: {e}"))?; + + if output.status.success() { + return Ok(()); + } + + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let detail = if !stderr.is_empty() { stderr } else { stdout }; + + if detail.is_empty() { + Err("重启 VSCode 失败".to_string()) + } else { + Err(format!("重启 VSCode 失败: {detail}")) + } +} + +#[cfg(target_os = "linux")] +fn resolve_vscode_cli_executable() -> Result { + if let Some(path_var) = env::var_os("PATH") { + for path in env::split_paths(&path_var) { + for name in ["code", "code-insiders"] { + let candidate = path.join(name); + if path_has_file(&candidate) { + return Ok(candidate); + } + } + } + } + + Err("未找到 VSCode 可执行文件".to_string()) +} + +#[cfg(target_os = "linux")] +pub fn restart_vscode() -> Result<(), String> { + let code_path = resolve_vscode_cli_executable()?; + let _ = Command::new("pkill").args(["-x", "code"]).output(); + let _ = Command::new("pkill").args(["-x", "code-insiders"]).output(); + + thread::sleep(Duration::from_millis(900)); + + Command::new(code_path) + .spawn() + .map(|_| ()) + .map_err(|e| format!("重启 VSCode 失败: {e}")) +} + +#[cfg(not(any(target_os = "windows", target_os = "macos", target_os = "linux")))] +pub fn restart_vscode() -> Result<(), String> { + Err("当前平台暂不支持自动重启 VSCode".to_string()) +} + #[cfg(target_os = "windows")] pub fn resume_session_in_terminal(session_id: String) -> Result<(), String> { if session_id.trim().is_empty() { diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 5e0b658..f6baa81 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -2,7 +2,7 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "codex-manager", "mainBinaryName": "codex-manager", - "version": "1.2.4", + "version": "1.2.19", "identifier": "com.codex-manager.app", "build": { "frontendDist": "../dist", @@ -28,7 +28,7 @@ }, "bundle": { "active": true, - "targets": "all", + "targets": ["dmg"], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/src-tauri/tauri.windows.conf.json b/src-tauri/tauri.windows.conf.json index a6d3cf1..303b7f4 100644 --- a/src-tauri/tauri.windows.conf.json +++ b/src-tauri/tauri.windows.conf.json @@ -3,6 +3,7 @@ "beforeBundleCommand": "powershell -NoProfile -NonInteractive -ExecutionPolicy Bypass -File scripts/build-windows-cli.ps1" }, "bundle": { + "targets": ["nsis"], "windows": { "nsis": { "installerHooks": "windows/installer-hooks.nsh" diff --git a/src/App.tsx b/src/App.tsx index a1be73b..60336cb 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -19,7 +19,11 @@ import { formatAuthIdentityLabel, parseAuthIdentity, } from "./utils/auth"; -import { getAccountStatusReason, getBestQuotaAccount, isAccountInvalid } from "./utils/dashboard"; +import { + getAccountStatusReason, + getSmartSwitchDecision, + isAccountInvalid, +} from "./utils/dashboard"; import { useAccountSwitch } from "./hooks/useAccountSwitch"; import { Account } from "./types"; import { MOTION_EASE, revealUp } from "./utils/motion"; @@ -29,6 +33,10 @@ type ConfirmState = | { kind: "switch"; account: Account } | null; +function formatRestartTargets(targets: string[]): string { + return targets.length <= 1 ? targets.join("") : targets.join(" 和 "); +} + const App: React.FC = () => { const { setAccounts, @@ -60,6 +68,23 @@ const App: React.FC = () => { (new URLSearchParams(window.location.search).get("tray") === "1" || window.location.hash === "#tray"); + const getEnabledRestartTargets = () => { + const targets: string[] = []; + if ( + settings.autoRestartCodexAfterSwitch && + platformCapabilities?.supportsAutoRestartCodexDesktop === true + ) { + targets.push("Codex"); + } + if ( + settings.autoRestartVscodeAfterSwitch && + platformCapabilities?.supportsAutoRestartVscode === true + ) { + targets.push("VSCode"); + } + return targets; + }; + const executeSwitch = async (account: Account) => { await switchAccount(account); }; @@ -69,11 +94,9 @@ const App: React.FC = () => { return; } - const canAutoRestartCodex = - settings.autoRestartCodexAfterSwitch && - platformCapabilities?.supportsAutoRestartCodexDesktop === true; + const restartTargets = getEnabledRestartTargets(); - if (canAutoRestartCodex) { + if (restartTargets.length > 0) { setConfirmState({ kind: "switch", account }); return; } @@ -93,7 +116,7 @@ const App: React.FC = () => { setIsRefreshing(true); try { const store = await api.loadAccounts(); - const hydrated = await hydrateAccounts(store.accounts); + const hydrated = await hydrateAccounts(store.accounts, { refreshAllRateLimits: true }); setAccounts(hydrated); const invalidAccounts = hydrated.filter((account) => isAccountInvalid(account)); const rateLimitFailures = hydrated.filter( @@ -144,7 +167,9 @@ const App: React.FC = () => { setRefreshingAccountIds((current) => [...current, accountId]); try { - const [hydrated] = await hydrateAccounts([target]); + const [hydrated] = await hydrateAccounts([target], { + refreshRateLimitAccountIds: new Set([accountId]), + }); if (!hydrated) { throw new Error("未获取到账号数据"); } @@ -233,21 +258,22 @@ const App: React.FC = () => { const hydrated = await hydrateAccounts(accounts); await persistAccounts(hydrated); const invalidCount = hydrated.filter((account) => isAccountInvalid(account)).length; + const smartSwitchDecision = getSmartSwitchDecision(hydrated); + + if (smartSwitchDecision.status === "hold") { + showToast(`${smartSwitchDecision.activeAccount.displayName} 当前额度仍充足`); + return; + } - const bestAccount = getBestQuotaAccount(hydrated); - if (!bestAccount) { + if (smartSwitchDecision.status !== "switch") { throw new Error( invalidCount > 0 ? `当前没有可用账号,已检测到 ${invalidCount} 个失效账号` : "当前没有足够数据", ); } - if (bestAccount.isActive) { - showToast(`${bestAccount.displayName} 已是当前最佳选择`); - return; - } - await requestSwitch(bestAccount); + await requestSwitch(smartSwitchDecision.targetAccount); } catch (error) { showToast(`智能切换失败 · ${error instanceof Error ? error.message : String(error)}`); } finally { @@ -401,6 +427,10 @@ const App: React.FC = () => { } }; + const switchRestartTargets = + confirmState?.kind === "switch" ? getEnabledRestartTargets() : []; + const switchRestartTargetLabel = formatRestartTargets(switchRestartTargets) || "相关应用"; + return ( @@ -466,7 +496,7 @@ const App: React.FC = () => { className={ isTrayMode ? "h-full" - : "relative z-10 mx-auto w-full max-w-[1520px] overflow-auto px-4 pb-10 pt-1 sm:px-6 sm:pt-2 lg:px-8 lg:pb-14" + : "relative z-10 mx-auto w-full max-w-[1320px] overflow-auto px-4 pb-8 pt-1 sm:px-6 sm:pt-2 lg:px-7 lg:pb-12" } > {isTrayMode ? ( @@ -485,7 +515,6 @@ const App: React.FC = () => { refreshingAccountIds={refreshingAccountIds} onDelete={(id) => setConfirmState({ kind: "delete", accountId: id })} onRefreshAccount={refreshAccount} - onRefreshUsage={() => refreshAccounts(false)} onRename={handleRename} onSwitch={(account) => void requestSwitch(account)} /> @@ -515,7 +544,7 @@ const App: React.FC = () => { {confirmState?.kind === "switch" && ( void handleConfirmSwitch()} diff --git a/src/components/AccountCard.tsx b/src/components/AccountCard.tsx index 860fb46..7bc248f 100644 --- a/src/components/AccountCard.tsx +++ b/src/components/AccountCard.tsx @@ -72,6 +72,19 @@ const AccountCard: React.FC = ({ ? "切换中" : "待命"; const invalidReason = getAccountStatusReason(account); + const renderCompactQuota = (metric: typeof insight.hourlyQuota) => ( +
+
+ {metric.label.startsWith("5") ? "5h" : "Week"} +
+
+ {typeof metric.percent === "number" ? `${Math.round(metric.percent)}%` : metric.valueLabel} +
+
+ {metric.resetLabel ? `重置 ${metric.resetLabel}` : metric.detail} +
+
+ ); useEffect(() => { setDraftName(account.displayName); @@ -95,7 +108,7 @@ const AccountCard: React.FC = ({ return (
@@ -147,23 +160,9 @@ const AccountCard: React.FC = ({
-
-
-
- 5h -
-
- {insight.hourlyQuota.valueLabel} -
-
-
-
- Week -
-
- {insight.weeklyQuota.valueLabel} -
-
+
+ {renderCompactQuota(insight.hourlyQuota)} + {renderCompactQuota(insight.weeklyQuota)}
Sync @@ -174,7 +173,7 @@ const AccountCard: React.FC = ({
-
+
- +
+

+ 5h 剩余 +

+
+

+ {typeof featuredQuota === "number" ? `${Math.round(featuredQuota)}%` : "--"} +

+ + {featuredInsight?.hourlyQuota.resetLabel + ? `重置 ${featuredInsight.hourlyQuota.resetLabel}` + : ""} +
-
-
-

- 5h 已用 +

+
+

+ 本周

-
-

- {typeof featuredQuota === "number" ? `${Math.round(featuredQuota)}%` : "--"} -

-
-

- {featuredInsight?.hourlyQuota.detail ?? "等待同步"} +

+ {featuredInsight?.weeklyQuota.valueLabel ?? "--"}

- -
- - -
+
+

+ 更新 +

+

+ {featuredInsight?.syncLabel ?? "--"} +

+
+
-
-
-

- 下一位 -

-

- {recommendedStandby?.displayName ?? "继续当前账号"} -

-

- {recommendedStandby ? "需要切换时,优先交给它。" : "当前账号仍然最合适。"} -

-
- -
-
-
-

- 本周 -

-

- {featuredInsight?.weeklyQuota.valueLabel ?? "--"} -

-
-
-

- 待命 -

-

- {standbyAccounts.length} -

-
-
-

- 账户数 -

-

- {sorted.length} -

-
-
-

- 上次切换 -

-

- {formatRelativeTime(featuredAccount.lastSwitchedAt)} -

-
-
-
+
+ + {!featuredAccount.isActive && ( + + )} + +
-
- - {featuredInvalid - ? `已失效 · ${getAccountStatusReason(featuredAccount) ?? "请重新登录该账号"}` - : `最近更新 ${featuredInsight?.syncLabel ?? "--"}`} - - -
+ {featuredInvalid && ( +
+ {getAccountStatusReason(featuredAccount) ?? "请重新登录该账号"}
-
+ )}

Next

-

+

{recommendedStandby?.displayName ?? "继续当前账号"}

-

- {recommendedStandby ? "需要切换时,优先交给它。" : "当前账号仍然最合适。"} +

+ {recommendedStandby ? "需要切换时优先交给它" : "当前账号仍然最合适"}

-
+
-
+
-

- 当前识别 +

+ 5h 剩余 +

+

+ {typeof featuredQuota === "number" ? `${Math.round(featuredQuota)}%` : "--"} +

+

+ {featuredInsight?.hourlyQuota.resetLabel + ? `重置 ${featuredInsight.hourlyQuota.resetLabel}` + : "--"}

-

{featuredIdentity}

-
-
-
-

- 5h 已用 -

-

- {typeof featuredQuota === "number" ? `${Math.round(featuredQuota)}%` : "--"} -

-
-
-

- 待命 -

-

- {standbyAccounts.length} -

-
-
-

- 账户数 -

-

- {sorted.length} -

-
-
-

- 上次切换 -

-

- {featuredAccount.lastSwitchedAt - ? formatRelativeTime(featuredAccount.lastSwitchedAt) - : "--"} -

-
-
- -
+
)}
diff --git a/src/components/Header.tsx b/src/components/Header.tsx index 78147ca..7965ff4 100644 --- a/src/components/Header.tsx +++ b/src/components/Header.tsx @@ -2,7 +2,9 @@ import React, { useRef } from "react"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; import { useAccountStore } from "../store/accountStore"; import { exportBackupBundle } from "../utils/backup"; +import { DISPLAY_TIME_ZONE_LABEL } from "../utils/dashboard"; import { hoverLift, revealUp } from "../utils/motion"; +import packageJson from "../../package.json"; interface HeaderProps { onImportConfig: (file: File) => Promise; @@ -15,7 +17,7 @@ interface HeaderProps { unmanagedCurrentAuthLabel: string | null; } -const APP_VERSION = "v1.2.4"; +const APP_VERSION = `v${packageJson.version}`; const Header: React.FC = ({ onImportConfig, @@ -81,7 +83,7 @@ const Header: React.FC = ({ className="sticky top-0 z-20 px-4 pb-3 pt-3 sm:px-6 lg:px-8" {...revealUp(prefersReducedMotion, 0)} > -
+
@@ -106,6 +108,9 @@ const Header: React.FC = ({ {APP_VERSION} + + 时间 {DISPLAY_TIME_ZONE_LABEL} +

{subtitle}

diff --git a/src/components/SettingsModal.tsx b/src/components/SettingsModal.tsx index 50d83e1..08256cd 100644 --- a/src/components/SettingsModal.tsx +++ b/src/components/SettingsModal.tsx @@ -13,6 +13,7 @@ const SettingsModal: React.FC = () => { const { setSettingsOpen, settings, platformCapabilities, settingsSaveState, updateSettings } = useAccountStore(); const canAutoRestartCodex = platformCapabilities?.supportsAutoRestartCodexDesktop ?? false; + const canAutoRestartVscode = platformCapabilities?.supportsAutoRestartVscode ?? false; const saveStateLabel = settingsSaveState === "saving" @@ -76,43 +77,82 @@ const SettingsModal: React.FC = () => {
-
-
- +
+
+ +
+
-
+ +
+
+
+ +
+ + > + + +
+ {!canAutoRestartVscode && ( +

+ 当前平台暂不支持自动重启 VSCode。 +

+ )}
- {!canAutoRestartCodex && ( -

- 当前平台暂不支持自动重启。 -

- )} -
- - -
- void handleRefreshStats()} + disabled={isRefreshing} + className="primary-action rounded-full px-4 py-2.5 text-sm font-semibold text-white disabled:opacity-60" > -
-
-
-
-
- -
-
-
- - Recommendation - -
-

- {bestAccount?.displayName ?? (invalidAccountsCount > 0 ? "暂无可用账号" : "暂无建议")} -

- - {bestAccount - ? bestAccount.isActive - ? "当前最优" - : "建议切换" - : invalidAccountsCount > 0 - ? "需要处理" - : "等待刷新"} - -
-

- {bestAccount - ? bestAccount.isActive - ? "当前账号就是最稳的选择。" - : "下一轮高强度请求更适合交给它。" - : invalidAccountsCount > 0 - ? `已检测到 ${invalidAccountsCount} 个失效账号,请先在主窗口重新登录或替换。` - : "刷新后再看会更准确。"} -

-
- -
-

- 当前账号 -

-

- {activeAccount?.displayName ?? "未匹配"} -

-

- {activeAccount - ? `最近切换 ${formatRelativeTime(activeAccount.lastSwitchedAt)}` - : "未匹配当前授权"} -

-
-
- -
-
-

- 5h 效率 -

-
-

- {averageEfficiency === null ? "--" : `${averageEfficiency}%`} -

-
-

100% 左右最稳

- -
-
-

- 当前最热 -

-

- {hottestAccount?.displayName ?? "暂无数据"} -

-

- 5h 已用 {formatPercent(hottestAccount?.rateLimits?.primary?.usedPercent)} -

-
-
-

- 当前主模型 -

-

- {usageStats?.latestModel ?? "暂无数据"} -

-
-
-

- 累计 Token -

-

- {usageStats ? formatTokenNumber(usageStats.totalTokens.totalTokens) : "--"} -

-
-
-
- -
-
-

- 最空闲 -

-

- {mostUnderused?.account.displayName ?? "暂无数据"} -

-

- {mostUnderused?.efficiency.detail ?? "当前还没有足够数据。"} -

-
- -
-
-
-

- 最近一轮 -

-

- {usageStats?.latestTotalTokens - ? formatTokenNumber(usageStats.latestTotalTokens.totalTokens) - : "--"} -

-
-
-

- 模型数 -

-

- {usageStats?.models.length ?? 0} -

-
-
-
-
-
-
- + {isRefreshing ? "刷新中..." : "刷新统计"} + + - -

Models

-
- {usageStats?.models.length ? ( - usageStats.models.slice(0, 4).map((model, index) => { - const ratio = - usageStats.totalTokens.totalTokens > 0 - ? (model.totalTokens / usageStats.totalTokens.totalTokens) * 100 - : 0; - return ( -
-
-
- - {index + 1} - -
-

{model.model}

-

{model.sessions} 个会话

-
-
- - {Math.round(ratio)}% - -
-
-
-
-
- ); - }) - ) : ( -

当前还没有模型分布数据。

- )} -
- +
+ {displayedAccounts.map((account, index) => ( + + ))}
- - -
-
-

Matrix

-

- 调度矩阵 -

-
-
- -
- {efficiencyRows.map(({ account, efficiency }, index) => ( - -
-
-
-

- {account.displayName} -

- {account.isActive && ( - - 当前 - - )} - {isAccountInvalid(account) && ( - - 失效 - - )} - {account.id === recommendedId && !account.isActive && ( - - 推荐 - - )} -
-

- {account.email ?? account.userId ?? "未识别身份"} -

-
- -
-
-

- 模型 -

-

- {formatAccountSessionModel(account, usageStats)} -

-
-
-

- Token -

-

- {formatAccountSessionToken(account, usageStats)} -

-
-
-

- 5h 已用 -

-

- {formatPercent(account.rateLimits?.primary?.usedPercent)} -

-
-
-

- 建议 -

-

- {describeAction(account, recommendedId)} -

-
-
-
- -
-
- - 5h 效率 {efficiency.label} - - - {isAccountInvalid(account) - ? getAccountStatusReason(account) ?? "账号已失效或不可用" - : efficiency.detail} - -
-
- 最近切换 {formatRelativeTime(account.lastSwitchedAt)} -
-
-
- ))} -
-
); }; diff --git a/src/hooks/useAccountSwitch.ts b/src/hooks/useAccountSwitch.ts index ac112ae..1e48644 100644 --- a/src/hooks/useAccountSwitch.ts +++ b/src/hooks/useAccountSwitch.ts @@ -14,6 +14,9 @@ export const useAccountSwitch = () => { const canAutoRestartCodex = settings.autoRestartCodexAfterSwitch && platformCapabilities?.supportsAutoRestartCodexDesktop === true; + const canAutoRestartVscode = + settings.autoRestartVscodeAfterSwitch && + platformCapabilities?.supportsAutoRestartVscode === true; setSwitchState({ phase: "preparing", @@ -115,21 +118,38 @@ export const useAccountSwitch = () => { } } + let vscodeRestartErrorMessage: string | null = null; + if (canAutoRestartVscode) { + try { + await api.restartVscode(); + } catch (restartError: unknown) { + vscodeRestartErrorMessage = + restartError instanceof Error ? restartError.message : String(restartError); + } + } + const issues = [ persistErrorMessage ? `保存失败 · ${persistErrorMessage}` : null, restartErrorMessage - ? `重启失败 · ${restartErrorMessage}` + ? `Codex 重启失败 · ${restartErrorMessage}` + : null, + vscodeRestartErrorMessage + ? `VSCode 重启失败 · ${vscodeRestartErrorMessage}` : null, ].filter((issue): issue is string => Boolean(issue)); + const restartedTargets = [ + canAutoRestartCodex ? "Codex" : null, + canAutoRestartVscode ? "VSCode" : null, + ].filter((target): target is string => Boolean(target)); if (issues.length > 0) { showToast(`已切换到 ${toAccount.displayName} · ${issues.join(";")}`); - } else if (canAutoRestartCodex) { - showToast(`已切换到 ${toAccount.displayName}`); + } else if (restartedTargets.length > 0) { + showToast(`已切换到 ${toAccount.displayName} · 已重启 ${restartedTargets.join("、")}`); } else { - showToast(`已切换到 ${toAccount.displayName} · 请重新打开 Codex`); + showToast(`已切换到 ${toAccount.displayName} · 请重新打开 Codex/VSCode`); } setTimeout( diff --git a/src/store/accountStore.ts b/src/store/accountStore.ts index 9109661..7f56852 100644 --- a/src/store/accountStore.ts +++ b/src/store/accountStore.ts @@ -38,6 +38,7 @@ const initialSwitchState: SwitchState = { const defaultSettings: AppSettings = { autoRefreshInterval: 0, autoRestartCodexAfterSwitch: true, + autoRestartVscodeAfterSwitch: false, theme: "system", proxyUrl: "", }; diff --git a/src/types/index.ts b/src/types/index.ts index 876faa6..e4255e8 100644 --- a/src/types/index.ts +++ b/src/types/index.ts @@ -80,6 +80,7 @@ export interface OAuthResult { export interface AppSettings { autoRefreshInterval: number; // minutes, 0 = disabled autoRestartCodexAfterSwitch: boolean; + autoRestartVscodeAfterSwitch: boolean; theme: 'light' | 'dark' | 'system'; proxyUrl: string; } @@ -87,6 +88,7 @@ export interface AppSettings { export interface DesktopPlatformCapabilities { platform: string; supportsAutoRestartCodexDesktop: boolean; + supportsAutoRestartVscode: boolean; supportsResumeSessionInTerminal: boolean; supportsSystemTray: boolean; supportsTaskbarShortcuts: boolean; @@ -122,6 +124,35 @@ export interface UsageStatsSummary { models: ModelUsageSummary[]; } +export interface DailyWorkspaceUsageTotals { + credits?: number | null; + turns?: number | null; + textTotalTokens?: number | null; + cachedTextInputTokens?: number | null; + uncachedTextInputTokens?: number | null; + textOutputTokens?: number | null; +} + +export interface DailyWorkspaceUsageBreakdown extends DailyWorkspaceUsageTotals { + model?: string | null; + clientId?: string | null; + users?: number | null; + threads?: number | null; +} + +export interface DailyWorkspaceUsage { + date: string; + totals?: DailyWorkspaceUsageTotals | null; + models?: DailyWorkspaceUsageBreakdown[] | null; + clients?: DailyWorkspaceUsageBreakdown[] | null; +} + +export interface DailyWorkspaceUsageResponse { + data: DailyWorkspaceUsage[]; + startDate: string; + endDate: string; +} + export interface CreditsSnapshot { hasCredits?: boolean | null; unlimited?: boolean | null; @@ -129,7 +160,8 @@ export interface CreditsSnapshot { } export interface RateLimitWindow { - usedPercent: number; + remainingPercent: number; + usedPercent?: number | null; resetsAt?: number | null; windowDurationMins?: number | null; } diff --git a/src/utils/accounts.ts b/src/utils/accounts.ts index 676838d..e9dfa55 100644 --- a/src/utils/accounts.ts +++ b/src/utils/accounts.ts @@ -13,6 +13,11 @@ export interface CurrentAuthState { preserveStoredActive: boolean; } +interface HydrateAccountsOptions { + refreshAllRateLimits?: boolean; + refreshRateLimitAccountIds?: ReadonlySet; +} + export async function resolveCurrentAuthState(accounts: Account[]): Promise { const storedActiveAccountId = accounts.find((account) => account.isActive)?.id ?? null; const currentAuth = await api.readAuthJson().catch(() => null); @@ -41,7 +46,10 @@ export async function resolveCurrentAuthState(accounts: Account[]): Promise { +export async function hydrateAccounts( + accounts: Account[], + options: HydrateAccountsOptions = {}, +): Promise { const currentAuthState = await resolveCurrentAuthState(accounts); const { activeAccountId, preserveStoredActive } = currentAuthState; const activeSessionInfo = activeAccountId @@ -50,29 +58,40 @@ export async function hydrateAccounts(accounts: Account[]): Promise { return Promise.all( accounts.map(async (account) => { - const rateLimitResult = await api - .readAccountRateLimits(account.id) - .then((result) => ({ - rateLimits: result.rateLimits ?? null, - rateLimitsError: - result.accountStatus === "invalid" - ? result.accountStatusReason ?? "账号已失效或不可用" - : null, - accountStatus: - result.accountStatus ?? (result.rateLimits ? "available" : "unknown"), - accountStatusReason: result.accountStatusReason ?? null, - })) - .catch((error: unknown) => ({ - rateLimits: null, - rateLimitsError: error instanceof Error ? error.message : String(error), - accountStatus: "unknown" as const, - accountStatusReason: null, - })); const isActive = preserveStoredActive ? account.isActive : activeAccountId ? account.id === activeAccountId : false; + const shouldRefreshRateLimits = + options.refreshAllRateLimits === true || + isActive || + options.refreshRateLimitAccountIds?.has(account.id) === true; + const rateLimitResult = shouldRefreshRateLimits + ? await api + .readAccountRateLimits(account.id) + .then((result) => ({ + rateLimits: result.rateLimits ?? null, + rateLimitsError: + result.accountStatus === "invalid" + ? result.accountStatusReason ?? "账号已失效或不可用" + : null, + accountStatus: + result.accountStatus ?? (result.rateLimits ? "available" : "unknown"), + accountStatusReason: result.accountStatusReason ?? null, + })) + .catch((error: unknown) => ({ + rateLimits: null, + rateLimitsError: error instanceof Error ? error.message : String(error), + accountStatus: "unknown" as const, + accountStatusReason: null, + })) + : { + rateLimits: account.rateLimits ?? null, + rateLimitsError: account.rateLimitsError ?? null, + accountStatus: account.accountStatus ?? (account.rateLimits ? "available" : "unknown"), + accountStatusReason: account.accountStatusReason ?? null, + }; if (isActive) { return { diff --git a/src/utils/backup.ts b/src/utils/backup.ts index 019ad49..3e428d6 100644 --- a/src/utils/backup.ts +++ b/src/utils/backup.ts @@ -1,6 +1,7 @@ import { Account, AppSettings, BackupBundle, BackupBundleAccount } from "../types"; import { api } from "./invoke"; import { hydrateAccounts } from "./accounts"; +import { matchesAccountIdentity, parseAuthIdentity } from "./auth"; interface NormalizedBackupImport { accounts: Account[]; @@ -31,6 +32,10 @@ function normalizeBackupSettings(settings: Partial): AppSettings { typeof settings.autoRestartCodexAfterSwitch === "boolean" ? settings.autoRestartCodexAfterSwitch : true, + autoRestartVscodeAfterSwitch: + typeof settings.autoRestartVscodeAfterSwitch === "boolean" + ? settings.autoRestartVscodeAfterSwitch + : false, theme: settings.theme === "light" || settings.theme === "dark" || @@ -61,6 +66,74 @@ function normalizeBackupImport(parsed: BackupBundle): NormalizedBackupImport { }; } +function isPresentCredential(value: unknown): value is string { + return typeof value === "string" && value.trim().length > 0; +} + +function matchesCurrentAuth(account: Account, authJson: string): boolean { + const identity = parseAuthIdentity(authJson); + const accountUserId = account.userId?.trim().toLowerCase() ?? null; + const identityAccountId = identity.accountId?.trim().toLowerCase() ?? null; + + return ( + matchesAccountIdentity(account, identity) || + Boolean(accountUserId && identityAccountId && accountUserId === identityAccountId) + ); +} + +function resolveBackupCredentials( + account: Account, + credentials: string | null | undefined, + currentAuthJson: string | null | undefined, +): string | null { + if (isPresentCredential(credentials)) { + return credentials; + } + + if ( + account.isActive && + isPresentCredential(currentAuthJson) && + matchesCurrentAuth(account, currentAuthJson) + ) { + return currentAuthJson; + } + + return null; +} + +function formatAccountList(accounts: Account[]): string { + return accounts.map((account) => account.displayName || account.id).join("、"); +} + +export function collectBackupCredentialsForImport(parsed: BackupBundle): Map { + const credentialsByAccountId = new Map(); + const missingAccounts: Account[] = []; + + for (const entry of parsed.accounts) { + const account = entry.account; + const resolved = resolveBackupCredentials( + account, + entry.credentials, + parsed.currentAuthJson, + ); + + if (!resolved) { + missingAccounts.push(account); + continue; + } + + credentialsByAccountId.set(account.id, resolved); + } + + if (missingAccounts.length > 0) { + throw new Error( + `备份缺少账号凭据:${formatAccountList(missingAccounts)}。请在源电脑重新导出完整备份。`, + ); + } + + return credentialsByAccountId; +} + function downloadJson(content: string, fileName: string) { const blob = new Blob([content], { type: "application/json;charset=utf-8" }); const url = URL.createObjectURL(blob); @@ -75,18 +148,32 @@ export async function exportBackupBundle( accounts: Account[], settings: AppSettings, ): Promise { + const currentAuthJson = await api.readAuthJson().catch(() => null); const exportedAccounts: BackupBundleAccount[] = await Promise.all( accounts.map(async (account) => ({ account, - credentials: await api.readAccountCredentials(account.id).catch(() => null), + credentials: resolveBackupCredentials( + account, + await api.readAccountCredentials(account.id).catch(() => null), + currentAuthJson, + ), })), ); + const missingAccounts = exportedAccounts + .filter((entry) => !entry.credentials) + .map((entry) => entry.account); + + if (missingAccounts.length > 0) { + throw new Error( + `无法导出完整备份,缺少账号凭据:${formatAccountList(missingAccounts)}。请先重新导入这些账号的当前授权。`, + ); + } const bundle: BackupBundle = { version: "1.0", exportedAt: new Date().toISOString(), settings, - currentAuthJson: await api.readAuthJson().catch(() => null), + currentAuthJson, accounts: exportedAccounts, }; @@ -109,14 +196,8 @@ export async function importBackupBundle( (account) => !nextAccounts.some((item) => item.id === account.id), ); const previousCredentials = new Map(); - const accountIdsToWrite = new Set( - parsed.accounts - .filter( - (entry): entry is BackupBundleAccount & { account: Account } => - Boolean(entry.account?.id), - ) - .map((entry) => entry.account.id), - ); + const credentialsByAccountId = collectBackupCredentialsForImport(parsed); + const accountIdsToWrite = new Set(credentialsByAccountId.keys()); const shouldReplaceCurrentAuth = Boolean(parsed.currentAuthJson) && nextAccounts.some((account) => account.isActive); const previousAuthJson = shouldReplaceCurrentAuth @@ -130,11 +211,8 @@ export async function importBackupBundle( previousCredentials.set(accountId, existingCredentials); } - for (const { account, credentials } of parsed.accounts) { - if (!credentials) { - continue; - } - await api.saveAccountCredentials(account.id, credentials); + for (const [accountId, credentials] of credentialsByAccountId) { + await api.saveAccountCredentials(accountId, credentials); } if (shouldReplaceCurrentAuth) { diff --git a/src/utils/dashboard.ts b/src/utils/dashboard.ts index e86e928..8d9638d 100644 --- a/src/utils/dashboard.ts +++ b/src/utils/dashboard.ts @@ -1,12 +1,16 @@ -import { format, formatDistanceToNowStrict, isToday, isYesterday } from "date-fns"; +import { formatDistanceToNowStrict } from "date-fns"; import { zhCN } from "date-fns/locale"; -import { Account } from "../types"; +import type { Account, RateLimitWindow } from "../types"; + +const RESET_TIME_ZONE = "Asia/Shanghai"; +export const DISPLAY_TIME_ZONE_LABEL = "UTC+8"; export interface QuotaMetric { label: string; percent: number | null; detail: string; valueLabel: string; + resetLabel: string | null; tone: "critical" | "warning" | "healthy"; available: boolean; } @@ -30,6 +34,7 @@ export interface AccountInsight { export interface UsageEfficiency { score: number | null; + remainingPercent: number | null; usedPercent: number | null; elapsedPercent: number | null; status: "unavailable" | "underused" | "balanced" | "aggressive"; @@ -39,30 +44,111 @@ export interface UsageEfficiency { interface RankedQuotaAccount { account: Account; - primaryUsed: number; - secondaryUsed: number; + primaryRemaining: number; + secondaryRemaining: number; } +export type SmartSwitchDecision = + | { status: "hold"; activeAccount: Account } + | { status: "switch"; targetAccount: Account } + | { status: "no_target"; activeAccount: Account } + | { status: "no_data" }; + +const SMART_SWITCH_HOURLY_MIN_REMAINING = 5; +const SMART_SWITCH_WEEKLY_MIN_REMAINING = 2; + function clamp(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)); } function metricTone(percent: number): QuotaMetric["tone"] { - if (percent >= 85) return "critical"; - if (percent >= 55) return "warning"; + if (percent <= 15) return "critical"; + if (percent <= 45) return "warning"; return "healthy"; } -function formatResetTimestamp(timestampSeconds: number | null | undefined): string { - if (!timestampSeconds) { - return "时间待定"; +export function getRemainingPercent( + window: RateLimitWindow | null | undefined, +): number | null { + if (!window) { + return null; + } + if (typeof window.remainingPercent === "number") { + return clamp(window.remainingPercent, 0, 100); } + return null; +} + +function getUsableRemainingPercent(window: RateLimitWindow | null | undefined): number | null { + const remainingPercent = getRemainingPercent(window); + return remainingPercent !== null && remainingPercent > 0 ? remainingPercent : null; +} +type ZonedParts = { + year: string; + month: string; + day: string; + hour: string; + minute: string; +}; + +function getZonedParts(date: Date): ZonedParts | null { try { - return format(new Date(timestampSeconds * 1000), "yyyy-MM-dd HH:mm"); + const parts = new Intl.DateTimeFormat("zh-CN", { + timeZone: RESET_TIME_ZONE, + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).formatToParts(date); + const part = (type: Intl.DateTimeFormatPartTypes) => + parts.find((item) => item.type === type)?.value; + const year = part("year"); + const month = part("month"); + const day = part("day"); + const hour = part("hour"); + const minute = part("minute"); + + if (!year || !month || !day || !hour || !minute) { + return null; + } + + return { year, month, day, hour, minute }; } catch { + return null; + } +} + +function formatZonedDateKey(parts: Pick): string { + return `${parts.year}-${parts.month}-${parts.day}`; +} + +function formatResetTimestamp(timestampSeconds: number | null | undefined): string { + if (typeof timestampSeconds !== "number" || !Number.isFinite(timestampSeconds)) { return "时间待定"; } + + const parts = getZonedParts(new Date(timestampSeconds * 1000)); + return parts ? `${formatZonedDateKey(parts)} ${parts.hour}:${parts.minute}` : "时间待定"; +} + +function formatResetShort(timestampSeconds: number | null | undefined, mode: "time" | "date"): string { + if (typeof timestampSeconds !== "number" || !Number.isFinite(timestampSeconds)) { + return "时间待定"; + } + + const parts = getZonedParts(new Date(timestampSeconds * 1000)); + if (!parts) { + return "时间待定"; + } + + if (mode === "time") { + return `${parts.hour}:${parts.minute}`; + } + + return `${Number(parts.month)}月${Number(parts.day)}日 ${parts.hour}:${parts.minute}`; } function formatSyncTime(iso: string | null): string { @@ -72,13 +158,12 @@ function formatSyncTime(iso: string | null): string { try { const date = new Date(iso); - if (isToday(date)) { - return `今天 ${format(date, "HH:mm")}`; - } - if (isYesterday(date)) { - return `昨天 ${format(date, "HH:mm")}`; + const parts = getZonedParts(date); + if (!parts) { + return "时间未知"; } - return format(date, "yyyy-MM-dd HH:mm"); + + return `${formatZonedDateKey(parts)} ${parts.hour}:${parts.minute}`; } catch { return "时间未知"; } @@ -124,6 +209,7 @@ function createUnavailableMetric(account: Account, label: string, suffix: string percent: null, detail: getAccountStatusReason(account) ?? "账号已失效或不可用", valueLabel: `失效 / ${suffix}`, + resetLabel: null, tone: "critical", available: false, }; @@ -134,41 +220,48 @@ function createUnavailableMetric(account: Account, label: string, suffix: string percent: null, detail: "官方数据未获取", valueLabel: `未获取 / ${suffix}`, + resetLabel: null, tone: "warning", available: false, }; } function deriveHourlyQuota(account: Account): QuotaMetric { - if (account.rateLimits?.primary) { - const percent = clamp(account.rateLimits.primary.usedPercent, 0, 100); + const primary = account.rateLimits?.primary; + const percent = getRemainingPercent(primary); + if (percent !== null) { + const resetLabel = formatResetShort(primary?.resetsAt, "time"); return { - label: "5小时已使用配额", + label: "5小时剩余额度", percent, - detail: `刷新时间 ${formatResetTimestamp(account.rateLimits.primary.resetsAt)}`, - valueLabel: `${percent}% / 5h`, + detail: `重置时间 ${formatResetTimestamp(primary?.resetsAt)}`, + valueLabel: `${percent}% · ${resetLabel}`, + resetLabel, tone: metricTone(percent), available: true, }; } - return createUnavailableMetric(account, "5小时已使用配额", "5h"); + return createUnavailableMetric(account, "5小时剩余额度", "5h"); } function deriveWeeklyQuota(account: Account): QuotaMetric { - if (account.rateLimits?.secondary) { - const percent = clamp(account.rateLimits.secondary.usedPercent, 0, 100); + const secondary = account.rateLimits?.secondary; + const percent = getRemainingPercent(secondary); + if (percent !== null) { + const resetLabel = formatResetShort(secondary?.resetsAt, "date"); return { - label: "每周已使用配额", + label: "每周剩余额度", percent, - detail: `刷新时间 ${formatResetTimestamp(account.rateLimits.secondary.resetsAt)}`, - valueLabel: `${percent}% / week`, + detail: `重置时间 ${formatResetTimestamp(secondary?.resetsAt)}`, + valueLabel: `${percent}% · ${resetLabel}`, + resetLabel, tone: metricTone(percent), available: true, }; } - return createUnavailableMetric(account, "每周已使用配额", "week"); + return createUnavailableMetric(account, "每周剩余额度", "week"); } export function getHourlyUsageEfficiency( @@ -176,16 +269,18 @@ export function getHourlyUsageEfficiency( now = Date.now(), ): UsageEfficiency { const primary = account.rateLimits?.primary; + const remainingPercent = getRemainingPercent(primary); if ( !primary || - typeof primary.usedPercent !== "number" || + remainingPercent === null || typeof primary.resetsAt !== "number" || typeof primary.windowDurationMins !== "number" || primary.windowDurationMins <= 0 ) { return { score: null, - usedPercent: typeof primary?.usedPercent === "number" ? clamp(primary.usedPercent, 0, 100) : null, + remainingPercent, + usedPercent: remainingPercent === null ? null : 100 - remainingPercent, elapsedPercent: null, status: "unavailable", label: "待接入", @@ -193,7 +288,7 @@ export function getHourlyUsageEfficiency( }; } - const usedPercent = clamp(primary.usedPercent, 0, 100); + const usedPercent = 100 - remainingPercent; const windowMs = primary.windowDurationMins * 60 * 1000; const resetAtMs = primary.resetsAt * 1000; const remainingMs = clamp(resetAtMs - now, 0, windowMs); @@ -202,6 +297,7 @@ export function getHourlyUsageEfficiency( if (elapsedPercent <= 0.5) { return { score: null, + remainingPercent, usedPercent, elapsedPercent, status: "unavailable", @@ -216,32 +312,35 @@ export function getHourlyUsageEfficiency( if (paceRatio < 0.75) { return { score, + remainingPercent, usedPercent, elapsedPercent, status: "underused", label: `${Math.round(score)}%`, - detail: "当前用量低于时间进度,节奏偏慢", + detail: "剩余额度消耗低于时间进度,节奏偏慢", }; } if (paceRatio <= 1.25) { return { score, + remainingPercent, usedPercent, elapsedPercent, status: "balanced", label: `${Math.round(score)}%`, - detail: "当前用量与时间进度基本同步", + detail: "剩余额度消耗与时间进度基本同步", }; } return { score, + remainingPercent, usedPercent, elapsedPercent, status: "aggressive", label: `${Math.round(score)}%`, - detail: "当前用量高于时间进度,账号压力偏高", + detail: "剩余额度消耗高于时间进度,账号压力偏高", }; } @@ -267,39 +366,64 @@ function getRankedQuotaAccounts(accounts: Account[]): RankedQuotaAccount[] { .filter( (account) => !isAccountInvalid(account) && - (typeof account.rateLimits?.primary?.usedPercent === "number" || - typeof account.rateLimits?.secondary?.usedPercent === "number"), + getUsableRemainingPercent(account.rateLimits?.primary) !== null && + getUsableRemainingPercent(account.rateLimits?.secondary) !== null, ) .map((account) => ({ account, - primaryUsed: - typeof account.rateLimits?.primary?.usedPercent === "number" - ? clamp(account.rateLimits.primary.usedPercent, 0, 100) - : Number.POSITIVE_INFINITY, - secondaryUsed: - typeof account.rateLimits?.secondary?.usedPercent === "number" - ? clamp(account.rateLimits.secondary.usedPercent, 0, 100) - : Number.POSITIVE_INFINITY, + primaryRemaining: getUsableRemainingPercent(account.rateLimits?.primary) ?? 0, + secondaryRemaining: getUsableRemainingPercent(account.rateLimits?.secondary) ?? 0, })) .sort((left, right) => { - if (left.primaryUsed !== right.primaryUsed) { - return left.primaryUsed - right.primaryUsed; + if (left.primaryRemaining !== right.primaryRemaining) { + return right.primaryRemaining - left.primaryRemaining; } - if (left.secondaryUsed !== right.secondaryUsed) { - return left.secondaryUsed - right.secondaryUsed; + if (left.secondaryRemaining !== right.secondaryRemaining) { + return right.secondaryRemaining - left.secondaryRemaining; } return left.account.createdAt.localeCompare(right.account.createdAt); }); } -export function getRecommendedAccountId(accounts: Account[]): string | null { +export function shouldSmartSwitchAccount(account: Account): boolean { + const primaryRemaining = getRemainingPercent(account.rateLimits?.primary); + const secondaryRemaining = getRemainingPercent(account.rateLimits?.secondary); + return ( - getRankedQuotaAccounts(accounts) - .find(({ account }) => !account.isActive) - ?.account.id ?? null + (primaryRemaining !== null && primaryRemaining < SMART_SWITCH_HOURLY_MIN_REMAINING) || + (secondaryRemaining !== null && secondaryRemaining < SMART_SWITCH_WEEKLY_MIN_REMAINING) ); } +export function getSmartSwitchDecision(accounts: Account[]): SmartSwitchDecision { + const activeAccount = accounts.find((account) => account.isActive); + if (activeAccount && !shouldSmartSwitchAccount(activeAccount)) { + return { status: "hold", activeAccount }; + } + + const rankedAccounts = getRankedQuotaAccounts(accounts); + const targetAccount = rankedAccounts.find(({ account }) => !account.isActive)?.account; + if (targetAccount) { + return { status: "switch", targetAccount }; + } + + if (activeAccount) { + return { status: "no_target", activeAccount }; + } + + return { status: "no_data" }; +} + +export function getRecommendedAccountId(accounts: Account[]): string | null { + const decision = getSmartSwitchDecision(accounts); + return decision.status === "switch" ? decision.targetAccount.id : null; +} + +export function getSmartSwitchAccount(accounts: Account[]): Account | null { + const decision = getSmartSwitchDecision(accounts); + return decision.status === "switch" ? decision.targetAccount : null; +} + export function getBestQuotaAccount(accounts: Account[]): Account | null { return getRankedQuotaAccounts(accounts)[0]?.account ?? null; } diff --git a/src/utils/invoke.ts b/src/utils/invoke.ts index 3225361..94d714c 100644 --- a/src/utils/invoke.ts +++ b/src/utils/invoke.ts @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { AppSettings, AccountsStore, + DailyWorkspaceUsageResponse, DesktopPlatformCapabilities, GetAccountRateLimitsResponse, OAuthResult, @@ -43,8 +44,8 @@ const demoAccounts: AccountsStore = { rateLimits: { limitId: "codex", planType: "plus", - primary: { usedPercent: 100, windowDurationMins: 300, resetsAt: 1773813607 }, - secondary: { usedPercent: 88, windowDurationMins: 10080, resetsAt: 1773878873 }, + primary: { remainingPercent: 100, windowDurationMins: 300, resetsAt: 1773813607 }, + secondary: { remainingPercent: 88, windowDurationMins: 10080, resetsAt: 1773878873 }, }, }, { @@ -66,8 +67,8 @@ const demoAccounts: AccountsStore = { rateLimits: { limitId: "codex", planType: "plus", - primary: { usedPercent: 19, windowDurationMins: 300, resetsAt: 1773806407 }, - secondary: { usedPercent: 29, windowDurationMins: 10080, resetsAt: 1774144400 }, + primary: { remainingPercent: 19, windowDurationMins: 300, resetsAt: 1773806407 }, + secondary: { remainingPercent: 29, windowDurationMins: 10080, resetsAt: 1774144400 }, }, }, { @@ -89,8 +90,8 @@ const demoAccounts: AccountsStore = { rateLimits: { limitId: "codex", planType: "plus", - primary: { usedPercent: 98, windowDurationMins: 300, resetsAt: 1773801007 }, - secondary: { usedPercent: 96, windowDurationMins: 10080, resetsAt: 1773965273 }, + primary: { remainingPercent: 98, windowDurationMins: 300, resetsAt: 1773801007 }, + secondary: { remainingPercent: 96, windowDurationMins: 10080, resetsAt: 1773965273 }, }, }, ], @@ -105,6 +106,7 @@ const demoCredentials: Record = { const demoSettings: AppSettings = { autoRefreshInterval: 0, autoRestartCodexAfterSwitch: true, + autoRestartVscodeAfterSwitch: false, theme: "system", proxyUrl: "", }; @@ -112,6 +114,7 @@ const demoSettings: AppSettings = { const mockPlatformCapabilities: DesktopPlatformCapabilities = { platform: "browser", supportsAutoRestartCodexDesktop: false, + supportsAutoRestartVscode: false, supportsResumeSessionInTerminal: false, supportsSystemTray: false, supportsTaskbarShortcuts: false, @@ -143,6 +146,55 @@ const mockUsageStatsSummary: UsageStatsSummary = { ], }; +const mockDailyWorkspaceUsage: DailyWorkspaceUsageResponse = { + startDate: "2026-02-17", + endDate: "2026-03-19", + data: [ + { + date: "2026-03-10", + totals: { + credits: 5.42, + turns: 18, + textTotalTokens: 1_820_000, + }, + }, + { + date: "2026-03-11", + totals: { + credits: 8.16, + turns: 27, + cachedTextInputTokens: 450_000, + uncachedTextInputTokens: 980_000, + textOutputTokens: 320_000, + }, + }, + { + date: "2026-03-13", + totals: { + credits: 12.74, + turns: 34, + textTotalTokens: 3_410_000, + }, + }, + { + date: "2026-03-15", + totals: { + credits: 6.08, + turns: 21, + textTotalTokens: 1_990_000, + }, + }, + { + date: "2026-03-17", + totals: { + credits: 3.95, + turns: 13, + textTotalTokens: 960_000, + }, + }, + ], +}; + function readJson(key: string, fallback: T): T { if (typeof window === "undefined") { return fallback; @@ -396,6 +448,12 @@ const browserApi = { async readUsageStatsSummary(): Promise { return mockUsageStatsSummary; }, + async readAccountDailyWorkspaceUsage( + _accountId: string, + _days = 30, + ): Promise { + return mockDailyWorkspaceUsage; + }, async deleteAccountSessions(accountId: string): Promise { const store = readMockAccounts(); writeMockAccounts({ @@ -411,6 +469,9 @@ const browserApi = { async restartCodexDesktop(): Promise { return; }, + async restartVscode(): Promise { + return; + }, async startOauthFlow(): Promise { const stamp = Date.now().toString().slice(-5); return { @@ -464,11 +525,17 @@ export const api = isTauriRuntime invoke("read_account_rate_limits", { accountId }), getCurrentSessionsInfo: () => invoke("get_current_sessions_info"), readUsageStatsSummary: () => invoke("read_usage_stats_summary"), + readAccountDailyWorkspaceUsage: (accountId: string, days = 30) => + invoke("read_account_daily_workspace_usage", { + accountId, + days, + }), deleteAccountSessions: (accountId: string) => invoke("delete_account_sessions", { accountId }), resumeSessionInTerminal: (sessionId: string) => invoke("resume_session_in_terminal", { sessionId }), restartCodexDesktop: () => invoke("restart_codex_desktop"), + restartVscode: () => invoke("restart_vscode"), // oauth startOauthFlow: () => invoke("start_oauth_flow"), diff --git a/src/utils/quotaCompass.ts b/src/utils/quotaCompass.ts new file mode 100644 index 0000000..77b8a5a --- /dev/null +++ b/src/utils/quotaCompass.ts @@ -0,0 +1,176 @@ +import type { + DailyWorkspaceUsage, + DailyWorkspaceUsageBreakdown, + DailyWorkspaceUsageTotals, + RateLimitWindow, + TokenUsageInfo, +} from "../types"; +import { estimateTokenSpendUsd } from "./quotaValue"; + +export const USD_PER_CODEX_CREDIT = 40 / 1000; + +export interface QuotaCompassStats { + credits: number; + turns: number; + tokens: number; + usd: number; +} + +export interface QuotaCompassSummary { + currentCycleList: DailyWorkspaceUsage[]; + historyList: DailyWorkspaceUsage[]; + currentStats: QuotaCompassStats; + historyStats: QuotaCompassStats; + usedPercent: number | null; + estimatedTotalCredits: number | null; + estimatedTotalUsd: number | null; +} + +function numberOrZero(value: number | null | undefined): number { + return Number.isFinite(Number(value)) ? Number(value) : 0; +} + +function dayTime(date: string): number { + const time = new Date(`${date}T00:00:00Z`).getTime(); + return Number.isFinite(time) ? time : 0; +} + +export function getDailyTokenTotal(totals: DailyWorkspaceUsageTotals | null | undefined): number { + if (!totals) { + return 0; + } + + const explicitTotal = numberOrZero(totals.textTotalTokens); + if (explicitTotal > 0) { + return explicitTotal; + } + + return ( + numberOrZero(totals.cachedTextInputTokens) + + numberOrZero(totals.uncachedTextInputTokens) + + numberOrZero(totals.textOutputTokens) + ); +} + +function toTokenUsage(usage: DailyWorkspaceUsageTotals | DailyWorkspaceUsageBreakdown): TokenUsageInfo { + const inputTokens = + numberOrZero(usage.cachedTextInputTokens) + numberOrZero(usage.uncachedTextInputTokens); + const outputTokens = numberOrZero(usage.textOutputTokens); + const totalTokens = getDailyTokenTotal(usage); + + return { + inputTokens, + cachedInputTokens: numberOrZero(usage.cachedTextInputTokens), + outputTokens, + reasoningOutputTokens: 0, + totalTokens: totalTokens > 0 ? totalTokens : inputTokens + outputTokens, + }; +} + +function getDailyUsdTotal(day: DailyWorkspaceUsage): number { + const creditedUsd = numberOrZero(day.totals?.credits) * USD_PER_CODEX_CREDIT; + if (creditedUsd > 0) { + return creditedUsd; + } + + return (day.models ?? []).reduce((sum, modelUsage) => { + const spentUsd = estimateTokenSpendUsd(toTokenUsage(modelUsage), modelUsage.model); + return sum + numberOrZero(spentUsd); + }, 0); +} + +export function getQuotaCompassStats(list: DailyWorkspaceUsage[]): QuotaCompassStats { + const totals = list.reduce( + (sum, day) => ({ + credits: sum.credits + numberOrZero(day.totals?.credits), + turns: sum.turns + numberOrZero(day.totals?.turns), + tokens: sum.tokens + getDailyTokenTotal(day.totals), + usd: sum.usd + getDailyUsdTotal(day), + }), + { credits: 0, turns: 0, tokens: 0, usd: 0 }, + ); + const credits = totals.credits > 0 ? totals.credits : totals.usd / USD_PER_CODEX_CREDIT; + + return { + credits, + turns: totals.turns, + tokens: totals.tokens, + usd: totals.usd, + }; +} + +export function getWindowUsedPercent(window: RateLimitWindow | null | undefined): number | null { + if (!window) { + return null; + } + + if (typeof window.usedPercent === "number" && Number.isFinite(window.usedPercent)) { + return Math.max(0, Math.min(100, window.usedPercent)); + } + + return Math.max(0, Math.min(100, 100 - window.remainingPercent)); +} + +export function getCycleStartDate( + weeklyWindow: RateLimitWindow | null | undefined, + fallbackStartDate: string, +): string { + if (!weeklyWindow?.resetsAt || !weeklyWindow.windowDurationMins) { + return fallbackStartDate; + } + + return new Date((weeklyWindow.resetsAt - weeklyWindow.windowDurationMins * 60) * 1000) + .toISOString() + .split("T")[0]; +} + +export function buildQuotaCompassSummary( + dailyList: DailyWorkspaceUsage[], + cycleStartDate: string, + weeklyWindow: RateLimitWindow | null | undefined, +): QuotaCompassSummary { + const cycleStartTime = dayTime(cycleStartDate); + const currentCycleList: DailyWorkspaceUsage[] = []; + const historyList: DailyWorkspaceUsage[] = []; + + [...dailyList] + .sort((left, right) => dayTime(left.date) - dayTime(right.date)) + .forEach((item) => { + if (dayTime(item.date) >= cycleStartTime) { + currentCycleList.push(item); + } else { + historyList.push(item); + } + }); + + const currentStats = getQuotaCompassStats(currentCycleList); + const historyStats = getQuotaCompassStats(historyList); + const usedPercent = getWindowUsedPercent(weeklyWindow); + const estimatedTotalCredits = + usedPercent && usedPercent > 0 ? currentStats.credits / (usedPercent / 100) : null; + const estimatedTotalUsd = + usedPercent && usedPercent > 0 ? currentStats.usd / (usedPercent / 100) : null; + + return { + currentCycleList, + historyList, + currentStats, + historyStats, + usedPercent, + estimatedTotalCredits, + estimatedTotalUsd, + }; +} + +export function formatCompactTokenNumber(value: number | null | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value)) { + return "--"; + } + if (value >= 1_000_000) { + return `${(value / 1_000_000).toFixed(2)}M`; + } + if (value >= 1_000) { + return `${(value / 1_000).toFixed(2)}K`; + } + return value.toLocaleString("zh-CN"); +} diff --git a/src/utils/quotaValue.ts b/src/utils/quotaValue.ts new file mode 100644 index 0000000..14e1647 --- /dev/null +++ b/src/utils/quotaValue.ts @@ -0,0 +1,128 @@ +import type { Account, TokenUsageInfo, UsageStatsSummary } from "../types"; +import { getRemainingPercent } from "./dashboard"; +import { getAccountTokenUsage } from "./tokenLedger"; + +type ModelTokenPrice = { + input: number; + cachedInput: number; + output: number; +}; + +export type QuotaUsdEstimate = { + model: string; + spentUsd: number; + hourlyLimitUsd: number | null; + weeklyLimitUsd: number | null; +}; + +const PRICE_PER_1M_TOKENS: Record = { + "gpt-5.5-pro": { input: 30, cachedInput: 30, output: 180 }, + "gpt-5.5": { input: 5, cachedInput: 0.5, output: 30 }, + "gpt-5.4-pro": { input: 30, cachedInput: 30, output: 180 }, + "gpt-5.4": { input: 2.5, cachedInput: 0.25, output: 15 }, + "gpt-5.4-mini": { input: 0.75, cachedInput: 0.075, output: 4.5 }, + "gpt-5.4-nano": { input: 0.2, cachedInput: 0.02, output: 1.25 }, + "gpt-5.3-codex": { input: 1.75, cachedInput: 0.175, output: 14 }, + "gpt-5.2-codex": { input: 1.75, cachedInput: 0.175, output: 14 }, + "gpt-5.2-pro": { input: 21, cachedInput: 21, output: 168 }, + "gpt-5.2": { input: 1.75, cachedInput: 0.175, output: 14 }, + "gpt-5-codex": { input: 1.75, cachedInput: 0.175, output: 14 }, + "gpt-5-pro": { input: 15, cachedInput: 15, output: 120 }, + "gpt-5": { input: 1.25, cachedInput: 0.125, output: 10 }, + "gpt-4.1": { input: 2, cachedInput: 0.5, output: 8 }, + "gpt-4.1-mini": { input: 0.4, cachedInput: 0.1, output: 1.6 }, + "gpt-4.1-nano": { input: 0.1, cachedInput: 0.025, output: 0.4 }, +}; + +function normalizeModel(model: string | null | undefined): string | null { + const normalized = model?.trim().toLowerCase(); + if (!normalized) { + return null; + } + + const exact = PRICE_PER_1M_TOKENS[normalized]; + if (exact) { + return normalized; + } + + const candidates = Object.keys(PRICE_PER_1M_TOKENS).sort((left, right) => right.length - left.length); + return candidates.find((candidate) => normalized.startsWith(candidate)) ?? null; +} + +export function estimateTokenSpendUsd( + usage: TokenUsageInfo | null | undefined, + model: string | null | undefined, +): number | null { + const normalizedModel = normalizeModel(model); + const price = normalizedModel ? PRICE_PER_1M_TOKENS[normalizedModel] : null; + if (!usage || !price || usage.totalTokens <= 0) { + return null; + } + + const cachedInputTokens = Math.min(Math.max(usage.cachedInputTokens, 0), usage.inputTokens); + const inputTokens = Math.max(usage.inputTokens - cachedInputTokens, 0); + const outputTokens = + usage.outputTokens > 0 ? usage.outputTokens : Math.max(usage.totalTokens - usage.inputTokens, 0); + + return ( + (inputTokens / 1_000_000) * price.input + + (cachedInputTokens / 1_000_000) * price.cachedInput + + (outputTokens / 1_000_000) * price.output + ); +} + +function estimateLimitUsd(spentUsd: number, remainingPercent: number | null): number | null { + if (spentUsd <= 0 || remainingPercent === null) { + return null; + } + + const usedPercent = 100 - remainingPercent; + if (usedPercent <= 0) { + return null; + } + + return spentUsd / (usedPercent / 100); +} + +export function getDominantUsageModel(usageStats: UsageStatsSummary | null): string | null { + if (usageStats?.latestModel) { + return usageStats.latestModel; + } + + return usageStats?.models[0]?.model ?? null; +} + +export function getAccountQuotaUsdEstimate( + account: Account, + usageStats: UsageStatsSummary | null, +): QuotaUsdEstimate | null { + const usage = getAccountTokenUsage(account, usageStats?.latestTotalTokens); + const model = account.isActive ? usageStats?.latestModel : getDominantUsageModel(usageStats); + const spentUsd = estimateTokenSpendUsd(usage, model); + if (!model || spentUsd === null) { + return null; + } + + return { + model, + spentUsd, + hourlyLimitUsd: estimateLimitUsd(spentUsd, getRemainingPercent(account.rateLimits?.primary)), + weeklyLimitUsd: estimateLimitUsd(spentUsd, getRemainingPercent(account.rateLimits?.secondary)), + }; +} + +export function formatUsdEstimate(value: number | null | undefined): string { + if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) { + return "--"; + } + + if (value < 0.01) { + return "<$0.01"; + } + + if (value < 10) { + return `$${value.toFixed(2)}`; + } + + return `$${Math.round(value).toLocaleString("zh-CN")}`; +} diff --git a/tests/accounts.test.ts b/tests/accounts.test.ts new file mode 100644 index 0000000..54f9300 --- /dev/null +++ b/tests/accounts.test.ts @@ -0,0 +1,136 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { Account } from "../src/types"; + +const apiMock = vi.hoisted(() => ({ + readAuthJson: vi.fn(), + readAccountCredentials: vi.fn(), + getCurrentSessionsInfo: vi.fn(), + readAccountRateLimits: vi.fn(), +})); + +vi.mock("../src/utils/invoke", () => ({ + api: apiMock, +})); + +import { hydrateAccounts } from "../src/utils/accounts"; + +function createAccount(overrides: Partial = {}): Account { + return { + id: "account-1", + displayName: "Account", + email: "account@example.com", + userId: "user-1", + isActive: false, + createdAt: "2026-05-01T00:00:00.000Z", + lastSwitchedAt: null, + sessionInfo: null, + rateLimits: null, + rateLimitsError: null, + accountStatus: "unknown", + accountStatusReason: null, + ...overrides, + }; +} + +describe("hydrateAccounts", () => { + beforeEach(() => { + vi.clearAllMocks(); + apiMock.readAuthJson.mockResolvedValue(null); + apiMock.readAccountCredentials.mockResolvedValue(null); + apiMock.getCurrentSessionsInfo.mockResolvedValue(null); + apiMock.readAccountRateLimits.mockResolvedValue({ + rateLimits: { + limitId: "codex", + planType: "team", + primary: { remainingPercent: 99, windowDurationMins: 300, resetsAt: 1_800 }, + secondary: { remainingPercent: 41, windowDurationMins: 10_080, resetsAt: 604_800 }, + }, + accountStatus: "available", + accountStatusReason: null, + }); + }); + + it("refreshes official quota only for the active account", async () => { + const active = createAccount({ id: "active", isActive: true }); + const standby = createAccount({ + id: "standby", + email: "standby@example.com", + rateLimits: { + limitId: "codex", + planType: "team", + primary: { remainingPercent: 23, windowDurationMins: 300, resetsAt: 900 }, + secondary: { remainingPercent: 65, windowDurationMins: 10_080, resetsAt: 500_000 }, + }, + accountStatus: "available", + }); + + const hydrated = await hydrateAccounts([active, standby]); + + expect(apiMock.readAccountRateLimits).toHaveBeenCalledTimes(1); + expect(apiMock.readAccountRateLimits).toHaveBeenCalledWith("active"); + expect(hydrated.find((account) => account.id === "active")?.rateLimits?.primary?.remainingPercent).toBe(99); + expect(hydrated.find((account) => account.id === "standby")?.rateLimits?.primary?.remainingPercent).toBe(23); + }); + + it("keeps standby quota unchanged during default hydration", async () => { + const standby = createAccount({ + id: "standby", + email: "standby@example.com", + rateLimits: { + limitId: "codex", + planType: "team", + primary: { remainingPercent: 23, windowDurationMins: 300, resetsAt: 900 }, + secondary: { remainingPercent: 65, windowDurationMins: 10_080, resetsAt: 500_000 }, + }, + accountStatus: "available", + }); + + const [hydrated] = await hydrateAccounts([standby]); + + expect(apiMock.readAccountRateLimits).not.toHaveBeenCalled(); + expect(hydrated.rateLimits?.primary?.remainingPercent).toBe(23); + expect(hydrated.rateLimits?.secondary?.remainingPercent).toBe(65); + }); + + it("refreshes quota for every account when requested", async () => { + const active = createAccount({ id: "active", isActive: true }); + const standby = createAccount({ + id: "standby", + email: "standby@example.com", + rateLimits: { + limitId: "codex", + planType: "team", + primary: { remainingPercent: 23, windowDurationMins: 300, resetsAt: 900 }, + secondary: { remainingPercent: 65, windowDurationMins: 10_080, resetsAt: 500_000 }, + }, + accountStatus: "available", + }); + + const hydrated = await hydrateAccounts([active, standby], { refreshAllRateLimits: true }); + + expect(apiMock.readAccountRateLimits).toHaveBeenCalledTimes(2); + expect(apiMock.readAccountRateLimits).toHaveBeenCalledWith("active"); + expect(apiMock.readAccountRateLimits).toHaveBeenCalledWith("standby"); + expect(hydrated.find((account) => account.id === "active")?.rateLimits?.primary?.remainingPercent).toBe(99); + expect(hydrated.find((account) => account.id === "standby")?.rateLimits?.primary?.remainingPercent).toBe(99); + expect(hydrated.find((account) => account.id === "standby")?.rateLimits?.secondary?.remainingPercent).toBe(41); + }); + + it("refreshes quota for a selected standby account", async () => { + const standby = createAccount({ + id: "standby", + email: "standby@example.com", + rateLimits: null, + accountStatus: "unknown", + }); + + const [hydrated] = await hydrateAccounts([standby], { + refreshRateLimitAccountIds: new Set(["standby"]), + }); + + expect(apiMock.readAccountRateLimits).toHaveBeenCalledTimes(1); + expect(apiMock.readAccountRateLimits).toHaveBeenCalledWith("standby"); + expect(hydrated.rateLimits?.primary?.remainingPercent).toBe(99); + expect(hydrated.rateLimits?.secondary?.remainingPercent).toBe(41); + }); +}); diff --git a/tests/backup.test.ts b/tests/backup.test.ts new file mode 100644 index 0000000..d163fd1 --- /dev/null +++ b/tests/backup.test.ts @@ -0,0 +1,54 @@ +import { describe, expect, it } from "vitest"; +import { collectBackupCredentialsForImport } from "../src/utils/backup"; +import { Account, BackupBundle } from "../src/types"; + +function createAccount(overrides: Partial = {}): Account { + return { + id: "73fb88a8-8637-4950-ac0c-2a4f9fc89930", + displayName: "Imported Account", + email: "imported@example.com", + userId: "acct-imported", + isActive: false, + createdAt: "2026-05-11T00:00:00.000Z", + lastSwitchedAt: null, + sessionInfo: null, + ...overrides, + }; +} + +function createBundle(account: Account, credentials: string | null): BackupBundle { + return { + version: "1.0", + exportedAt: "2026-05-11T00:00:00.000Z", + settings: { + autoRefreshInterval: 0, + autoRestartCodexAfterSwitch: true, + autoRestartVscodeAfterSwitch: false, + theme: "system", + proxyUrl: "", + }, + currentAuthJson: null, + accounts: [{ account, credentials }], + }; +} + +describe("backup import credentials", () => { + it("rejects account entries without credentials", () => { + const bundle = createBundle(createAccount(), null); + + expect(() => collectBackupCredentialsForImport(bundle)).toThrow( + "备份缺少账号凭据:Imported Account", + ); + }); + + it("uses current auth for the active matching account", () => { + const account = createAccount({ isActive: true }); + const currentAuthJson = JSON.stringify({ tokens: { account_id: account.userId } }); + const bundle = { + ...createBundle(account, null), + currentAuthJson, + }; + + expect(collectBackupCredentialsForImport(bundle).get(account.id)).toBe(currentAuthJson); + }); +}); diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 3be0924..0c4b1c8 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -5,6 +5,9 @@ import { getBestQuotaAccount, getHourlyUsageEfficiency, getRecommendedAccountId, + getSmartSwitchDecision, + getSmartSwitchAccount, + shouldSmartSwitchAccount, } from "../src/utils/dashboard"; import type { Account } from "../src/types"; @@ -20,7 +23,7 @@ function createAccount(overrides: Partial = {}): Account { sessionInfo: { fileCount: 12, totalBytes: 1_024, - lastSessionObservedAt: "2026-03-11T10:00:00", + lastSessionObservedAt: "2026-03-11T10:00:00Z", currentSessionId: null, currentThreadName: null, currentUpdatedAt: null, @@ -36,19 +39,27 @@ describe("getAccountInsight", () => { const account = createAccount({ rateLimits: { planType: "pro", - primary: { usedPercent: 92, resetsAt: 1_800_000_000 }, - secondary: { usedPercent: 44, resetsAt: 1_800_100_000 }, + primary: { + remainingPercent: 92, + resetsAt: Date.UTC(2026, 4, 9, 18, 43) / 1000, + }, + secondary: { + remainingPercent: 44, + resetsAt: Date.UTC(2026, 4, 15, 16, 30) / 1000, + }, }, }); const insight = getAccountInsight(account); expect(insight.roleLabel).toBe("Pro"); - expect(insight.hourlyQuota.valueLabel).toBe("92% / 5h"); - expect(insight.hourlyQuota.tone).toBe("critical"); - expect(insight.weeklyQuota.valueLabel).toBe("44% / week"); - expect(insight.weeklyQuota.tone).toBe("healthy"); - expect(insight.syncLabel).toBe("2026-03-11 10:00"); + expect(insight.hourlyQuota.valueLabel).toBe("92% · 02:43"); + expect(insight.hourlyQuota.detail).toBe("重置时间 2026-05-10 02:43"); + expect(insight.hourlyQuota.tone).toBe("healthy"); + expect(insight.weeklyQuota.valueLabel).toBe("44% · 5月16日 00:30"); + expect(insight.weeklyQuota.detail).toBe("重置时间 2026-05-16 00:30"); + expect(insight.weeklyQuota.tone).toBe("warning"); + expect(insight.syncLabel).toBe("2026-03-11 18:00"); expect(insight.hasRealRateLimits).toBe(true); }); @@ -69,14 +80,14 @@ describe("getAccountInsight", () => { }); describe("quota ranking", () => { - it("recommends the best non-active account and returns the best overall account", () => { + it("returns the best overall account without recommending a switch while active quota is healthy", () => { const active = createAccount({ id: "active", isActive: true, rateLimits: { planType: "plus", - primary: { usedPercent: 70 }, - secondary: { usedPercent: 30 }, + primary: { remainingPercent: 70 }, + secondary: { remainingPercent: 30 }, }, }); const candidate = createAccount({ @@ -85,8 +96,8 @@ describe("quota ranking", () => { isActive: false, rateLimits: { planType: "plus", - primary: { usedPercent: 15 }, - secondary: { usedPercent: 25 }, + primary: { remainingPercent: 15 }, + secondary: { remainingPercent: 25 }, }, }); const exhausted = createAccount({ @@ -94,13 +105,162 @@ describe("quota ranking", () => { displayName: "Exhausted", rateLimits: { planType: "plus", - primary: { usedPercent: 99 }, - secondary: { usedPercent: 99 }, + primary: { remainingPercent: 99 }, + secondary: { remainingPercent: 99 }, + }, + }); + + expect(getRecommendedAccountId([active, exhausted, candidate])).toBeNull(); + expect(getSmartSwitchAccount([active, exhausted, candidate])).toBeNull(); + expect(getBestQuotaAccount([active, exhausted, candidate])?.id).toBe("exhausted"); + }); + + it("returns a hold decision while active quota is healthy", () => { + const active = createAccount({ + id: "active", + isActive: true, + rateLimits: { + planType: "plus", + primary: { remainingPercent: 5 }, + secondary: { remainingPercent: 2 }, + }, + }); + const candidate = createAccount({ + id: "candidate", + displayName: "Backup", + rateLimits: { + planType: "plus", + primary: { remainingPercent: 80 }, + secondary: { remainingPercent: 80 }, + }, + }); + + expect(getSmartSwitchDecision([active, candidate])).toEqual({ + status: "hold", + activeAccount: active, + }); + }); + + it("recommends a switch when active 5h quota is below 5%", () => { + const active = createAccount({ + id: "active", + isActive: true, + rateLimits: { + planType: "plus", + primary: { remainingPercent: 4 }, + secondary: { remainingPercent: 80 }, + }, + }); + const candidate = createAccount({ + id: "candidate", + displayName: "Backup", + rateLimits: { + planType: "plus", + primary: { remainingPercent: 40 }, + secondary: { remainingPercent: 20 }, + }, + }); + + expect(shouldSmartSwitchAccount(active)).toBe(true); + expect(getRecommendedAccountId([active, candidate])).toBe("candidate"); + expect(getSmartSwitchAccount([active, candidate])?.id).toBe("candidate"); + }); + + it("recommends a switch when active weekly quota is below 2%", () => { + const active = createAccount({ + id: "active", + isActive: true, + rateLimits: { + planType: "plus", + primary: { remainingPercent: 50 }, + secondary: { remainingPercent: 1 }, + }, + }); + const candidate = createAccount({ + id: "candidate", + displayName: "Backup", + rateLimits: { + planType: "plus", + primary: { remainingPercent: 30 }, + secondary: { remainingPercent: 10 }, }, }); - expect(getRecommendedAccountId([active, exhausted, candidate])).toBe("candidate"); - expect(getBestQuotaAccount([active, exhausted, candidate])?.id).toBe("candidate"); + expect(shouldSmartSwitchAccount(active)).toBe(true); + expect(getRecommendedAccountId([active, candidate])).toBe("candidate"); + }); + + it("does not switch at the exact smart switch thresholds", () => { + const active = createAccount({ + id: "active", + isActive: true, + rateLimits: { + planType: "plus", + primary: { remainingPercent: 5 }, + secondary: { remainingPercent: 2 }, + }, + }); + const candidate = createAccount({ + id: "candidate", + displayName: "Backup", + rateLimits: { + planType: "plus", + primary: { remainingPercent: 80 }, + secondary: { remainingPercent: 80 }, + }, + }); + + expect(shouldSmartSwitchAccount(active)).toBe(false); + expect(getRecommendedAccountId([active, candidate])).toBeNull(); + }); + + it("can recommend another account even when it is below a smart switch threshold", () => { + const active = createAccount({ + id: "active", + isActive: true, + rateLimits: { + planType: "plus", + primary: { remainingPercent: 4 }, + secondary: { remainingPercent: 80 }, + }, + }); + const depletedCandidate = createAccount({ + id: "depleted", + displayName: "Depleted", + rateLimits: { + planType: "plus", + primary: { remainingPercent: 80 }, + secondary: { remainingPercent: 1 }, + }, + }); + + expect(getRecommendedAccountId([active, depletedCandidate])).toBe("depleted"); + expect(getSmartSwitchAccount([active, depletedCandidate])?.id).toBe("depleted"); + }); + + it("does not recommend an account with depleted weekly quota", () => { + const active = createAccount({ + id: "active", + isActive: true, + rateLimits: { + planType: "plus", + primary: { remainingPercent: 4 }, + secondary: { remainingPercent: 80 }, + }, + }); + const weeklyDepletedCandidate = createAccount({ + id: "weekly-depleted", + displayName: "Weekly Depleted", + rateLimits: { + planType: "plus", + primary: { remainingPercent: 80 }, + secondary: { remainingPercent: 0 }, + }, + }); + + expect(getRecommendedAccountId([active, weeklyDepletedCandidate])).toBeNull(); + expect(getSmartSwitchAccount([active, weeklyDepletedCandidate])).toBeNull(); + expect(getBestQuotaAccount([active, weeklyDepletedCandidate])?.id).toBe("active"); }); it("returns null when there is no usable quota data", () => { @@ -108,6 +268,22 @@ describe("quota ranking", () => { expect(getRecommendedAccountId([account])).toBeNull(); expect(getBestQuotaAccount([account])).toBeNull(); }); + + it("returns no target when active quota is low and no standby account has quota data", () => { + const account = createAccount({ + id: "active", + isActive: true, + rateLimits: { + planType: "plus", + primary: { remainingPercent: 4 }, + }, + }); + + expect(getSmartSwitchDecision([account])).toEqual({ + status: "no_target", + activeAccount: account, + }); + }); }); describe("getHourlyUsageEfficiency", () => { @@ -117,7 +293,7 @@ describe("getHourlyUsageEfficiency", () => { rateLimits: { planType: "plus", primary: { - usedPercent: 48, + remainingPercent: 52, resetsAt: Math.floor(new Date("2026-03-11T12:30:00Z").getTime() / 1000), windowDurationMins: 300, }, @@ -137,7 +313,7 @@ describe("getHourlyUsageEfficiency", () => { rateLimits: { planType: "plus", primary: { - usedPercent: 20, + remainingPercent: 80, resetsAt: Math.floor(new Date("2026-03-11T11:00:00Z").getTime() / 1000), windowDurationMins: 300, }, @@ -155,7 +331,7 @@ describe("getHourlyUsageEfficiency", () => { createAccount({ rateLimits: { planType: "plus", - primary: { usedPercent: 20 }, + primary: { remainingPercent: 80 }, }, }), ); diff --git a/tests/quotaCompass.test.ts b/tests/quotaCompass.test.ts new file mode 100644 index 0000000..f425513 --- /dev/null +++ b/tests/quotaCompass.test.ts @@ -0,0 +1,88 @@ +import { describe, expect, it } from "vitest"; +import { + buildQuotaCompassSummary, + formatCompactTokenNumber, + getDailyTokenTotal, +} from "../src/utils/quotaCompass"; + +describe("quota compass", () => { + it("uses explicit token totals before component totals", () => { + expect( + getDailyTokenTotal({ + textTotalTokens: 1_200_000, + cachedTextInputTokens: 100, + uncachedTextInputTokens: 100, + textOutputTokens: 100, + }), + ).toBe(1_200_000); + }); + + it("splits current cycle and history while projecting total credits", () => { + const summary = buildQuotaCompassSummary( + [ + { date: "2026-03-08", totals: { credits: 1, turns: 2, textTotalTokens: 1_000 } }, + { + date: "2026-03-11", + totals: { + credits: 2, + turns: 3, + cachedTextInputTokens: 100, + uncachedTextInputTokens: 200, + textOutputTokens: 300, + }, + }, + { date: "2026-03-12", totals: { credits: 3, turns: 4, textTotalTokens: 2_000 } }, + ], + "2026-03-10", + { remainingPercent: 75 }, + ); + + expect(summary.historyStats.credits).toBe(1); + expect(summary.currentStats.credits).toBe(5); + expect(summary.currentStats.turns).toBe(7); + expect(summary.currentStats.tokens).toBe(2_600); + expect(summary.usedPercent).toBe(25); + expect(summary.estimatedTotalCredits).toBe(20); + expect(summary.estimatedTotalUsd).toBe(0.8); + }); + + it("estimates credits from model token usage when official credits are zero", () => { + const summary = buildQuotaCompassSummary( + [ + { + date: "2026-03-12", + totals: { + credits: 0, + turns: 4, + cachedTextInputTokens: 1_000_000, + uncachedTextInputTokens: 1_000_000, + textOutputTokens: 100_000, + }, + models: [ + { + model: "gpt-5.5", + credits: 0, + cachedTextInputTokens: 1_000_000, + uncachedTextInputTokens: 1_000_000, + textOutputTokens: 100_000, + textTotalTokens: 2_100_000, + }, + ], + }, + ], + "2026-03-10", + { remainingPercent: 75 }, + ); + + expect(summary.currentStats.usd).toBeCloseTo(8.5, 6); + expect(summary.currentStats.credits).toBeCloseTo(212.5, 6); + expect(summary.estimatedTotalUsd).toBeCloseTo(34, 6); + expect(summary.estimatedTotalCredits).toBeCloseTo(850, 6); + }); + + it("formats token numbers with K and M units", () => { + expect(formatCompactTokenNumber(980)).toBe("980"); + expect(formatCompactTokenNumber(12_340)).toBe("12.34K"); + expect(formatCompactTokenNumber(1_234_000)).toBe("1.23M"); + }); +}); diff --git a/tests/quotaValue.test.ts b/tests/quotaValue.test.ts new file mode 100644 index 0000000..7d8aa5b --- /dev/null +++ b/tests/quotaValue.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, it } from "vitest"; +import type { Account, UsageStatsSummary } from "../src/types"; +import { + estimateTokenSpendUsd, + formatUsdEstimate, + getAccountQuotaUsdEstimate, +} from "../src/utils/quotaValue"; + +function createAccount(overrides: Partial = {}): Account { + return { + id: "account-1", + displayName: "Work", + email: "dev@example.com", + userId: "user-1", + isActive: true, + createdAt: "2026-03-01T00:00:00Z", + lastSwitchedAt: "2026-03-10T10:00:00Z", + sessionInfo: null, + rateLimits: { + primary: { remainingPercent: 80 }, + secondary: { remainingPercent: 50 }, + }, + rateLimitsError: null, + usageLedger: null, + ...overrides, + }; +} + +const usageStats: UsageStatsSummary = { + sessionsAnalyzed: 1, + latestModel: "gpt-5-codex", + totalTokens: { + inputTokens: 100_000, + cachedInputTokens: 20_000, + outputTokens: 10_000, + reasoningOutputTokens: 5_000, + totalTokens: 110_000, + }, + latestTotalTokens: { + inputTokens: 100_000, + cachedInputTokens: 20_000, + outputTokens: 10_000, + reasoningOutputTokens: 5_000, + totalTokens: 110_000, + }, + models: [{ model: "gpt-5-codex", sessions: 1, totalTokens: 110_000 }], +}; + +describe("quota value estimates", () => { + it("estimates token spend with cached input pricing", () => { + const spent = estimateTokenSpendUsd(usageStats.totalTokens, "gpt-5-codex"); + + expect(spent).toBeCloseTo(0.2835, 6); + }); + + it("projects hourly and weekly USD limits from remaining quota", () => { + const estimate = getAccountQuotaUsdEstimate(createAccount(), usageStats); + + expect(estimate?.spentUsd).toBeCloseTo(0.2835, 6); + expect(estimate?.hourlyLimitUsd).toBeCloseTo(1.4175, 6); + expect(estimate?.weeklyLimitUsd).toBeCloseTo(0.567, 6); + }); + + it("does not project a limit when quota usage is still zero", () => { + const estimate = getAccountQuotaUsdEstimate( + createAccount({ + rateLimits: { + primary: { remainingPercent: 100 }, + secondary: { remainingPercent: 100 }, + }, + }), + usageStats, + ); + + expect(estimate?.hourlyLimitUsd).toBeNull(); + expect(estimate?.weeklyLimitUsd).toBeNull(); + }); + + it("formats tiny and normal USD values", () => { + expect(formatUsdEstimate(0.004)).toBe("<$0.01"); + expect(formatUsdEstimate(1.235)).toBe("$1.24"); + expect(formatUsdEstimate(null)).toBe("--"); + }); +});