From 6b938bb1fa0b9ff39549846c6f594b8939602a35 Mon Sep 17 00:00:00 2001 From: bubblegump30 <49097353+bubblegump30@users.noreply.github.com> Date: Sun, 8 Nov 2026 03:41:42 -0330 Subject: [PATCH] feat: add account groups, reviewed bulk actions and session history --- .github/workflows/windows-release.yml | 32 ++++++------ CHANGELOG.md | 9 ++++ README.md | 25 ++++++--- VERSION | 2 +- docs/ACCOUNT-SESSION-CENTER-v2.5.0.md | 2 +- docs/ACCOUNT-WORKFLOWS-v2.6.0.md | 26 ++++++++++ docs/RELEASE-v2.6.0.md | 27 ++++++++++ package-lock.json | 4 +- package.json | 2 +- renderer/account-workspace.js | 68 +++++++++++++++++++++++++ renderer/index.html | 9 ++-- renderer/manager.css | 3 ++ renderer/manager.js | 20 ++++---- scripts/check-source.js | 2 +- scripts/verify-manager-ui.js | 20 ++++++-- src/core/account-manager-ipc.js | 5 ++ src/services/account-manager-store.js | 23 +++++++-- src/services/account-session-service.js | 24 +++++++-- tests/account-manager.test.js | 24 +++++++++ 19 files changed, 275 insertions(+), 52 deletions(-) create mode 100644 docs/ACCOUNT-WORKFLOWS-v2.6.0.md create mode 100644 docs/RELEASE-v2.6.0.md create mode 100644 renderer/account-workspace.js diff --git a/.github/workflows/windows-release.yml b/.github/workflows/windows-release.yml index ebd91d0..dbf1883 100644 --- a/.github/workflows/windows-release.yml +++ b/.github/workflows/windows-release.yml @@ -3,7 +3,7 @@ name: DragonStrap Windows release on: push: branches: - - release/v2.5.1-windows + - release/v2.6.0-windows permissions: contents: write @@ -32,7 +32,7 @@ jobs: shell: pwsh run: | $version = (Get-Content VERSION -Raw).Trim() - if ($version -ne '2.5.1') { throw "Unexpected release version: $version" } + if ($version -ne '2.6.0') { throw "Unexpected release version: $version" } foreach ($name in @("DragonStrap-Portable-$version-x64.exe", "DragonStrap-Setup-$version-x64.exe", "DragonStrap-FastFolder-$version-x64.zip", "DragonStrap-v$version-Source.zip")) { $file = Join-Path dist $name if (-not (Test-Path $file)) { throw "Missing $file" } @@ -44,12 +44,12 @@ jobs: - name: Preserve release packages uses: actions/upload-artifact@v6 with: - name: DragonStrap-v2.5.1-Windows-x64 + name: DragonStrap-v2.6.0-Windows-x64 path: | - dist/DragonStrap-Portable-2.5.1-x64.exe - dist/DragonStrap-Setup-2.5.1-x64.exe - dist/DragonStrap-FastFolder-2.5.1-x64.zip - dist/DragonStrap-v2.5.1-Source.zip + dist/DragonStrap-Portable-2.6.0-x64.exe + dist/DragonStrap-Setup-2.6.0-x64.exe + dist/DragonStrap-FastFolder-2.6.0-x64.zip + dist/DragonStrap-v2.6.0-Source.zip dist/SHA256SUMS.txt if-no-files-found: error retention-days: 30 @@ -58,17 +58,17 @@ jobs: env: GH_TOKEN: ${{ github.token }} run: | - if (gh release view v2.5.1 --repo ${{ github.repository }} *> $null) { - throw 'v2.5.1 already exists; refusing to overwrite it' + if (gh release view v2.6.0 --repo ${{ github.repository }} *> $null) { + throw 'v2.6.0 already exists; refusing to overwrite it' } - gh release create v2.5.1 ` - dist/DragonStrap-Portable-2.5.1-x64.exe ` - dist/DragonStrap-Setup-2.5.1-x64.exe ` - dist/DragonStrap-FastFolder-2.5.1-x64.zip ` - dist/DragonStrap-v2.5.1-Source.zip ` + gh release create v2.6.0 ` + dist/DragonStrap-Portable-2.6.0-x64.exe ` + dist/DragonStrap-Setup-2.6.0-x64.exe ` + dist/DragonStrap-FastFolder-2.6.0-x64.zip ` + dist/DragonStrap-v2.6.0-Source.zip ` dist/SHA256SUMS.txt ` --repo ${{ github.repository }} ` --target ${{ github.sha }} ` - --title 'DragonStrap v2.5.1' ` - --notes-file docs/RELEASE-v2.5.1.md ` + --title 'DragonStrap v2.6.0' ` + --notes-file docs/RELEASE-v2.6.0.md ` --latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e5af14..e0c80f5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,12 @@ +## v2.6.0 — Account groups, bulk actions and session history + +- Add optional local account groups with atomic create/rename/assignment/removal. Existing accounts load ungrouped; credentials remain in the separate encrypted vault. +- Add retained multi-selection, visible-row selection, group filtering and serial bulk actions with per-account results. +- Review favorite-game and managed-session targets before launch/stop; reject changed targets, require explicit concurrency for multi-account launches, and support cancelling remaining work. +- Separate active Sessions from persisted History with search, account/outcome filters, 50-entry pages and truthful detached/exit labels. +- Retain account labels in history and block removing an account while it is launching. +- Add 16 backend regressions plus real renderer checks for groups, review/cancel, selection retention, bulk results, history pagination/privacy and dialog layouts. + ## v2.5.1 — Account & Session Center reliability - Keep newly restarted sessions out of the previous process snapshot scan, preventing false immediate exit reports. diff --git a/README.md b/README.md index b234ec4..fb6ab05 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # DragonStrap -**v2.5.1 — Account & Session Center reliability · Windows stable release** +**v2.6.0 — Account groups, bulk actions & session history · Windows stable release** DragonStrap is a Windows Roblox bootstrapper and launcher with a custom Purple Dragon UI/UX. v2.4.1 adds Disable all flags, including locked Performance Center overrides, and Restore disabled flags. Disabling also turns off pre-launch auto-apply. Windows Setup and Portable x64 packages are available on GitHub Releases. @@ -10,11 +10,21 @@ The Accounts sidebar entry opens saved accounts, per-account games and servers, See [the feature and acceptance matrix](docs/ACCOUNT-SESSION-CENTER-v2.5.0.md) and [preview verification report](docs/PREVIEW-DEVELOPMENT.md). Normal saved-account game launching and live CPU/GPU/FPS readings were verified on the test PC, and the user accepted preview.6 as the baseline for stable v2.5.0. Private/specific-server joins, concurrency, anti-AFK, restart recovery and administrator tuning remain acceptance items. FPS targets remain legacy overrides; measured FPS is separate. +## Account groups and bulk workflows + +Create or rename a group, then select accounts and use **Move accounts to group**. Each account belongs to one optional group; removing a group keeps its accounts and encrypted credentials. Older account files load as ungrouped without an automatic rewrite. + +Selection survives search/group filtering and refresh. The selected count includes hidden rows. Bulk actions refresh account details, mark accounts favorite, assign groups, launch each account’s favorite game, or stop selected managed sessions. Launch and stop actions have a review screen with account-specific targets. Changed favorites or replacement sessions are rejected instead of silently using a new target. **Cancel remaining** leaves the current request to finish and skips later accounts. Multiple launch attempts require the existing concurrent-launch preference; Roblox still decides which clients remain open. + +**Sessions** shows active managed clients. **History** retains the newest 1,000 completed/detached sessions, with account/game search, account/outcome filters, local timestamps, tracked duration and join-confirmation context. Pages contain 50 entries. Closing the manager is labeled as detaching, and an exit is not automatically called a crash. + +See [the v2.6 workflow guide](docs/ACCOUNT-WORKFLOWS-v2.6.0.md) for details. + ## Monitoring requirements The integrated CPU reader uses the bundled LibreHardwareMonitor library and an existing PawnIO driver. On the verified Ryzen PC, run DragonStrap as administrator for CPU sensor and PresentMon FPS access. No sensor driver is installed automatically. NVIDIA GPU temperature uses the driver sensor. Unsupported or inaccessible sensors show an explicit status. Sampling runs only while Monitoring is open. -For fastest portable startup, extract the complete Fast Folder ZIP and run DragonStrap.exe. Keep all files together. Folder updates replace the complete extracted folder and retain the normal user-data directory. Stable update preferences remain the default; v2.5.1 is available on that channel. +For fastest portable startup, extract the complete Fast Folder ZIP and run DragonStrap.exe. Keep all files together. Folder updates replace the complete extracted folder and retain the normal user-data directory. Stable update preferences remain the default; v2.6.0 is available on that channel. ## DragonStrap 2.0 architecture @@ -51,18 +61,19 @@ Installation/update/recovery operations use one coordinated destructive-operatio | Item | Version | Where | | --- | --- | --- | -| Latest stable Windows release | v2.5.1 | [Setup, Portable, Fast Folder and checksums](https://github.com/bubblegump30/DragonStrap/releases/tag/v2.5.1) | +| Latest stable Windows release | v2.6.0 | [Setup, Portable, Fast Folder and checksums](https://github.com/bubblegump30/DragonStrap/releases/tag/v2.6.0) | | Previous accepted prerelease | v2.5.0-preview.6 | [Preview archive](https://github.com/bubblegump30/DragonStrap/releases/tag/v2.5.0-preview.6) | -| Current repository source | v2.5.1 | GitHub main; optional features retain documented limitations | +| Current repository source | v2.6.0 | GitHub main; optional features retain documented limitations | -Download the desired package from the v2.5.1 release and compare its SHA-256 hash with `SHA256SUMS.txt`. +Download the desired package from the v2.6.0 release and compare its SHA-256 hash with `SHA256SUMS.txt`. -The [VirusTotal report for the v2.4.0 Setup executable](https://www.virustotal.com/gui/file/3f016485826eb77591e8a616b27dbaa5706f4f59f2d25e4e84e546299e373930?nocache=1) corresponds to SHA-256 `3f016485826eb77591e8a616b27dbaa5706f4f59f2d25e4e84e546299e373930`. This report describes the previous v2.4.0 Setup file and does not cover v2.4.1, v2.5.0, or any v2.5.0 preview executable. Portable is a different file with a different hash. Source ZIPs and repository code are for development; they are not Windows installers or Update Center binaries. The Windows packages are unsigned, so Windows may display a SmartScreen warning. +The [VirusTotal report for the v2.4.0 Setup executable](https://www.virustotal.com/gui/file/3f016485826eb77591e8a616b27dbaa5706f4f59f2d25e4e84e546299e373930?nocache=1) corresponds to SHA-256 `3f016485826eb77591e8a616b27dbaa5706f4f59f2d25e4e84e546299e373930`. This report describes the previous v2.4.0 Setup file and does not cover v2.4.1, v2.5.x, v2.6.0, or any preview executable. Portable is a different file with a different hash. Source ZIPs and repository code are for development; they are not Windows installers or Update Center binaries. The Windows packages are unsigned, so Windows may display a SmartScreen warning. ## What changed since v2.0.4 | Version | Milestone | Improvements | | --- | --- | --- | +| **v2.6.0** | Account workflows | Local groups, multi-select actions with reviewed targets and per-account results, cancellable remaining work, and searchable paginated session history. | | **v2.5.1** | Session reliability | Cancel pending launches on closing/Stop all, retry failed Windows policies, release completed-session join tracking and preserve unfinished forms during refresh. | | **v2.5.0** | Account & Session Center | Accepted saved-account launch and CPU/GPU/FPS telemetry, Fast Folder distribution, startup improvements and reliability fixes; see release notes for optional-feature limits. | | **v2.4.1** | Disable all Player FastFlags | Removes every Player override, including locked keys, saves a restorable copy, and switches off pre-launch auto-apply. Restore brings back the complete set; restart Roblox for changes to take effect. | @@ -120,7 +131,7 @@ Official releases should upload both Windows executables and the generated `dist ## Version -`2.5.1` +`2.6.0` ## Project links diff --git a/VERSION b/VERSION index 73462a5..e70b452 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.5.1 +2.6.0 diff --git a/docs/ACCOUNT-SESSION-CENTER-v2.5.0.md b/docs/ACCOUNT-SESSION-CENTER-v2.5.0.md index 16526a9..40f7160 100644 --- a/docs/ACCOUNT-SESSION-CENTER-v2.5.0.md +++ b/docs/ACCOUNT-SESSION-CENTER-v2.5.0.md @@ -1,6 +1,6 @@ # DragonStrap Account & Session Center — v2.5.0 development -Build version: **2.5.1** (accepted preview.6 functionality plus reliability fixes). +Build version: **2.6.0**. See ACCOUNT-WORKFLOWS-v2.6.0.md for groups, bulk actions and session history. This accepted preview extends the v2.4.1 baseline with account/session features, startup improvements and maintenance fixes. Stable v2.5.0 uses the accepted preview.6 implementation; the earlier preview remains available separately. New code is isolated in renderer/manager.js, renderer/manager.css, account/session services, a Windows helper and typed preload calls. diff --git a/docs/ACCOUNT-WORKFLOWS-v2.6.0.md b/docs/ACCOUNT-WORKFLOWS-v2.6.0.md new file mode 100644 index 0000000..4f0a607 --- /dev/null +++ b/docs/ACCOUNT-WORKFLOWS-v2.6.0.md @@ -0,0 +1,26 @@ +# Account workflows — v2.6.0 + +## Groups + +Open Accounts. Use New group, or select a group in the filter before Rename group/Remove group. Group names are unique ignoring case, with a 40-character name and a maximum of 32 groups. Removing a group makes its accounts ungrouped; it does not delete accounts, games, schedules or credentials. + +To assign accounts, select their checkboxes, choose Move accounts to group, choose a destination (or Ungrouped), and Apply to selected. The change commits atomically for the whole selection. Existing account files need no manual migration; older accounts start ungrouped. Encryption remains in the existing account vault. + +## Bulk actions + +Select visible adds all currently filtered accounts to the selection. Search and group changes retain selection. Clear selection removes it. The selected count includes rows hidden by filters, so inspect it before applying an action. + +- Refresh Roblox details verifies each stored session and refreshes available profile data. An expired session fails that account without blocking later accounts. +- Favorite/unfavorite applies to account ordering; a favorite saved game is a separate setting in each account editor. +- Launch favorite games opens a review with each chosen game/Place ID. Missing favorites are reported without substituting a different game. A favorite changed after review is refused. More than one selected launch requires enabling concurrent launch attempts in Manager settings. Roblox may close another client; DragonStrap does not patch Roblox. +- Stop selected managed sessions reviews the managed sessions for each account. Unmanaged Roblox Players are not targeted. A replacement session not included in the review is refused; review again to stop it. + +Remote/process actions run one at a time. A result table reports each account independently. Cancel remaining skips accounts not started yet; the current request may finish. Stop all also cancels remaining favorite launches. Closing DragonStrap stops later batch work and retains the existing detach behavior. Dispatched Windows protocol handoffs cannot be recalled. + +## History + +Sessions displays active managed clients. History retains the newest 1,000 ended records, while lifetime statistics retain their existing totals. Search games/accounts and filter by account/outcome. The view uses 50-entry pages and includes local start/end times, tracked time and join confirmation when available. + +Stopped means DragonStrap stopped the managed session. Exited means the process disappeared; it is not proof of a crash. Detached means the manager closed and left Roblox running. Historical account labels remain after account removal. Hide details masks account/game labels in the history view. + +History, groups and results contain metadata, not stored session cookies or authentication tickets. Older history without name/join fields remains readable. Group migration and synthetic test evidence are described in RELEASE-v2.6.0.md. Real multi-account compatibility remains dependent on Roblox and needs separate live acceptance. diff --git a/docs/RELEASE-v2.6.0.md b/docs/RELEASE-v2.6.0.md new file mode 100644 index 0000000..730c401 --- /dev/null +++ b/docs/RELEASE-v2.6.0.md @@ -0,0 +1,27 @@ +# DragonStrap v2.6.0 — Account groups, bulk actions and session history + +## New account workflows + +- Create/rename/remove local groups and move selected accounts between them. Each account has one optional group. Removing a group keeps its accounts, saved games and encrypted credentials. +- Select accounts individually or select visible rows. Selection survives filters and refresh; selected counts include hidden rows. +- Bulk refresh Roblox details, favorite/unfavorite accounts, assign groups, launch favorite games, or stop selected managed sessions. Remote/process operations run serially and report an outcome for every account. +- Review account-specific favorite games and managed sessions before launching/stopping. The backend rejects changed favorite targets and replacement sessions outside the review. Multiple launches require explicit concurrency preference; Roblox controls concurrent-client compatibility. +- Cancel remaining bulk work while the current request finishes. Stop all also invalidates remaining batch launches. Accounts cannot be removed while their launch is pending. + +## Session history + +Sessions now shows active managed clients. History shows retained completed/detached records with account and outcome filters, account/game search, local start/end timestamps, tracked duration and join-confirmation context. Entries use 50-row pages; the existing 1,000-entry retention and lifetime totals are preserved. New history records retain account labels after account removal. Exits are not classified as confirmed crashes; manager closure is described as detaching and leaving Roblox running. + +## Data and verification + +The account schema remains additive and backward compatible. Older records load as ungrouped without rewriting the file on read. Group and metadata batch writes are atomic. Credentials are not moved or included in history/results. Studio configuration, FastFlag behavior, presets, monitoring and v2.5.1 cancellation/retry fixes remain intact. + +239 automated tests passed locally. Coverage includes legacy-file preservation, group CRUD/atomic failures, invalid selections, serialized/partial batch results, overlap prevention, remaining-work cancellation, reviewed favorite/session changes and persisted history labels. Electron interaction/layout checks cover group creation/assignment, review and cancellation, selection retention, per-account results, history pagination/privacy and account/group/bulk dialogs across supported sizes/scales. Hosted Windows CI repeats source, test, release and UI checks before publication. + +These checks use synthetic accounts/providers/processes. They do not establish new live multi-account/concurrency acceptance or authorize changing real Roblox sessions. The existing normal-game launch and telemetry acceptance is retained; private/specific-server behavior, concurrency, anti-AFK, process-policy compatibility, recovery and admin tuning retain documented limits. + +## Packages + +Setup x64, single-file Portable x64, Fast Folder x64, source archive and SHA256SUMS.txt. Extract every Fast Folder file and keep the complete folder together; replace the complete folder for future updates. Existing settings and accounts remain in the normal user-data directory. + +Direct CPU temperature and FPS still require their documented sensor/Windows permissions. No driver is installed automatically. Packages remain unsigned. No VirusTotal scan was performed for these newly built files; older reports apply only to their exact hashes. diff --git a/package-lock.json b/package-lock.json index 6175c83..a38a9ea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "dragonstrap", - "version": "2.5.1", + "version": "2.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dragonstrap", - "version": "2.5.1", + "version": "2.6.0", "devDependencies": { "electron": "45.2.3", "electron-builder": "26.18.0" diff --git a/package.json b/package.json index 1ed5fd4..b695707 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "dragonstrap", "productName": "DragonStrap", - "version": "2.5.1", + "version": "2.6.0", "private": true, "description": "DragonStrap 2.0 Windows Roblox bootstrapper with consolidated service architecture, coordinated bootstrap/update pipeline, plugin-ready contracts, lazy-loaded UI centers, recovery, profiles, and verified updates", "main": "main.js", diff --git a/renderer/account-workspace.js b/renderer/account-workspace.js new file mode 100644 index 0000000..2332935 --- /dev/null +++ b/renderer/account-workspace.js @@ -0,0 +1,68 @@ +'use strict'; +(() => { + const escape=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); + const selected=new Set();let search='',group='all',bulkAction='refresh',targetGroup='',busy=false,lastResults=[],historyAccount='all',historyStatus='all',historySearch='',page=0,context=null,groupDialog=null,editingGroup=null,bulkDialog=null,review=null; + const action=(label,name,attrs='',disabled=false)=>``; + const label=(account,data)=>data.options.hideDetails?'Hidden account':account?.alias||account?.username||'Removed account'; + const options=data=>(data.groups||[]).map(g=>``).join(''); + function visibleAccounts(data){return data.accounts.filter(a=>(group==='all'||(group==='ungrouped'?!a.groupId:a.groupId===group))&&[a.username,a.alias,...a.tags].join(' ').toLowerCase().includes(search.toLowerCase())).sort((a,b)=>Number(b.favorite)-Number(a.favorite)||a.username.localeCompare(b.username));} + function accounts(data){ + for(const id of selected)if(!data.accounts.some(a=>a.id===id))selected.delete(id); + if(!['all','ungrouped'].includes(group)&&!(data.groups||[]).some(g=>g.id===group))group='all'; + if(targetGroup&&!(data.groups||[]).some(g=>g.id===targetGroup))targetGroup=''; + const visible=visibleAccounts(data),blocked=busy||data.bulkBusy;const activeGroup=(data.groups||[]).find(g=>g.id===group); + return `
+
${action('NEW GROUP','group-new','',blocked)}${action('RENAME GROUP','group-rename','',blocked||!activeGroup)}${action('REMOVE GROUP','group-remove','',blocked||!activeGroup)}
+

Groups organize saved accounts locally. Sign-in credentials remain encrypted separately. Removing a group keeps its accounts.

+
${action('SELECT VISIBLE','select-visible','',blocked||!visible.length)}${action('CLEAR SELECTION','clear-selection','',blocked||!selected.size)}${selected.size} selected · ${visible.length} visible${action(busy?'WORKING…':'APPLY TO SELECTED','bulk-apply','',blocked||!selected.size)}${blocked?action('CANCEL REMAINING','bulk-cancel-active'):''}

Selection is retained across filters, including hidden rows. Bulk launches run one at a time and use each account’s favorite saved game. Concurrent launches still depend on Roblox. Cancelling a batch stops remaining accounts; the current request may finish.

+ ${visible.length?`
${visible.map(a=>{const running=data.sessions.some(s=>s.accountId===a.id&&s.status==='running');return ``;}).join('')}
SELECTFAVACCOUNTGROUP / TAGSGAMESFPS TARGETSTATER$ACTIONS
${escape((data.groups||[]).find(g=>g.id===a.groupId)?.name||'Ungrouped')}
${(data.options.hideDetails?[]:a.tags).map(t=>`${escape(t)}`).join('')}
${a.games.length}${a.policy.fpsCap||'Default'}${running?'Running':'Not running'}${data.options.hideDetails?'Hidden':Number.isSafeInteger(a.robux)&&a.robux>=0?a.robux:'Unavailable'}
`:'
No accounts match this group and search. Add an account or change the filter.
'} + ${lastResults.length?`

LAST BULK ACTION

${lastResults.map(r=>``).join('')}
ACCOUNTRESULTDETAIL
${escape(label(data.accounts.find(a=>a.id===r.accountId),data))}${r.ok?'Completed':'Not completed'}${escape(r.message)}
`:''}
`; + } + const outcomes={stopped:'Stopped by DragonStrap',exited:'Exited · reason unknown',detached:'Manager closed · Player left running'}; + const date=value=>Number.isFinite(value)?new Date(value).toLocaleString():'—'; + const duration=value=>{const seconds=Math.max(0,Math.floor(Number(value)||0));return `${Math.floor(seconds/3600)}h ${Math.floor(seconds%3600/60)}m ${seconds%60}s`;}; + function history(data){ + const choices=new Map(data.accounts.map(a=>[a.id,label(a,data)]));for(const item of data.history||[])if(item.accountId&&!choices.has(item.accountId))choices.set(item.accountId,data.options.hideDetails?'Hidden account':(item.accountName||'Removed account')+' (removed)'); + if(historyAccount!=='all'&&!choices.has(historyAccount))historyAccount='all'; + const rows=(data.history||[]).filter(h=>(historyAccount==='all'||h.accountId===historyAccount)&&(historyStatus==='all'||h.status===historyStatus)&&[h.gameName,h.accountName,data.accounts.find(a=>a.id===h.accountId)?.username,data.accounts.find(a=>a.id===h.accountId)?.alias].join(' ').toLowerCase().includes(historySearch.toLowerCase())); + const pages=Math.max(1,Math.ceil(rows.length/50));page=Math.min(page,pages-1);const current=rows.slice(page*50,page*50+50); + return `

SESSION HISTORY

The newest 1,000 completed or detached sessions are retained. An exit is not automatically classified as a crash. Detaching records tracked time and leaves Roblox running.

${rows.length} matching entries · page ${page+1} of ${pages}

${current.map(h=>``).join('')||''}
ACCOUNTGAMESTARTEDENDEDTRACKEDOUTCOME
${escape(data.options.hideDetails?'Hidden account':data.accounts.find(a=>a.id===h.accountId)?.alias||data.accounts.find(a=>a.id===h.accountId)?.username||h.accountName||'Removed account')}${escape(data.options.hideDetails?'Hidden game':h.gameName||'Unknown game')}${escape(date(h.startedAt))}${escape(date(h.endedAt))}${duration(h.durationSeconds)}${escape(outcomes[h.status]||h.status||'Unknown')}${h.joinState==='joined'?'Game join was confirmed':h.joinState?'Game join was not confirmed':''}
No session history matches these filters.
${action('PREVIOUS','history-previous','',page===0)}${action('NEXT','history-next','',page>=pages-1)}
`; + } + function sync(){const set=(id,value)=>{const el=document.getElementById(id);if(el)el.value=value;};set('workspaceGroup',group);set('workspaceBulkAction',bulkAction);set('workspaceTargetGroup',targetGroup);set('workspaceHistoryAccount',historyAccount);set('workspaceHistoryStatus',historyStatus);} + function editGroup(id){const item=context.getData().groups?.find(g=>g.id===id);editingGroup=id||null;groupDialog.innerHTML=`

${item?'Rename group':'New account group'}

`;groupDialog.showModal();groupDialog.querySelector('input').focus();} + async function executeBulk(ids,requestedAction,expectedFavorites=null,expectedSessions=null){ + if(busy)return;busy=true;lastResults=[];context.render(); + try{if(['favorite','unfavorite','group'].includes(requestedAction)){await context.act('accounts-edit',{ids,patch:requestedAction==='group'?{groupId:targetGroup||null}:{favorite:requestedAction==='favorite'}});lastResults=ids.map(accountId=>({accountId,ok:true,message:requestedAction==='group'?'Group updated.':'Favorite updated.'}));}else{const result=await context.act('accounts-bulk',{action:requestedAction,ids,expectedFavorites,expectedSessions});lastResults=result.bulkResults||[];}} + finally{busy=false;context.render();} + context.status(`${lastResults.filter(r=>r.ok).length} of ${lastResults.length} accounts completed. Review individual results.`,lastResults.some(r=>!r.ok)); + } + function reviewBulk(ids,requestedAction,data){ + if(requestedAction==='launch-favorite'&&ids.length>1&&!data.options.multiInstance)throw new Error('Enable concurrent launch attempts in Manager settings before launching multiple accounts.'); + review={ids:[...ids],action:requestedAction,expectedFavorites:Object.fromEntries(ids.map(id=>[id,data.accounts.find(a=>a.id===id)?.games.find(g=>g.favorite)?.id??null])),expectedSessions:Object.fromEntries(ids.map(id=>[id,data.sessions.filter(s=>s.accountId===id&&s.status==='running').map(s=>s.id)]))}; + bulkDialog.innerHTML=`

Review ${requestedAction==='stop'?'session stops':'favorite game launches'}

${ids.length} selected accounts, including any hidden by the account filter. Each result is reported separately. Roblox decides whether concurrent clients remain open.

${ids.map(id=>{const account=data.accounts.find(a=>a.id===id),game=account?.games.find(g=>g.favorite);const target=requestedAction==='stop'?String(data.sessions.filter(s=>s.accountId===id&&s.status==='running').length)+' running':!game?'No favorite game — this account will not launch':data.options.hideDetails?'Hidden game':game.name+' · Place '+game.placeId;return ``;}).join('')}
ACCOUNT${requestedAction==='stop'?'MANAGED SESSIONS':'FAVORITE GAME'}
${escape(label(account,data))}${escape(target)}
`; + bulkDialog.showModal(); + } + async function click(event){const el=event.target.closest('[data-workspace-action]');if(!el||el.disabled)return;const data=context.getData(),name=el.dataset.workspaceAction; + try{switch(name){ + case 'group-cancel':groupDialog.close();return; + case 'group-new':editGroup(null);return; + case 'group-rename':editGroup(group);return; + case 'group-remove':if(window.confirm('Remove this group? Its accounts will become ungrouped; accounts and credentials are kept.')){await context.act('group-remove',{id:group});group='all';context.render();}return; + case 'select-visible':visibleAccounts(data).forEach(a=>selected.add(a.id));context.render();return; + case 'clear-selection':selected.clear();context.render();return; + case 'history-next':page++;context.render();return; + case 'history-previous':page=Math.max(0,page-1);context.render();return; + case 'bulk-cancel-active':await context.act('accounts-bulk-cancel',{});context.render();context.status('Cancellation requested. The current account may finish; remaining accounts will be skipped.');return; + case 'bulk-cancel':bulkDialog.close();return; + case 'bulk-run':{if(!review)return;const plan=review;bulkDialog.close();await executeBulk(plan.ids,plan.action,plan.expectedFavorites,plan.expectedSessions);return;} + case 'bulk-apply':{const ids=[...selected];if(!ids.length||busy)return;if(['launch-favorite','stop'].includes(bulkAction))reviewBulk(ids,bulkAction,data);else await executeBulk(ids,bulkAction);return;} + }}catch(e){busy=false;context.render();context.status(e.message,true);} + } + + function initialize(root,handlers){context=handlers;groupDialog=document.createElement('dialog');groupDialog.className='manager-dialog workspace-group-dialog';groupDialog.setAttribute('aria-label','Account group');document.body.append(groupDialog);root.addEventListener('click',click);groupDialog.addEventListener('click',click);bulkDialog=document.createElement('dialog');bulkDialog.className='manager-dialog workspace-bulk-dialog';bulkDialog.setAttribute('aria-label','Review selected accounts');document.body.append(bulkDialog);bulkDialog.addEventListener('click',click);bulkDialog.addEventListener('close',()=>{review=null;}); + groupDialog.addEventListener('submit',async event=>{event.preventDefault();const save=groupDialog.querySelector('button:not([type=button])');save.disabled=true;try{await context.act('group-save',{id:editingGroup||undefined,name:new FormData(event.target).get('groupName')});groupDialog.close();context.render();}catch(e){groupDialog.querySelector('[role=status]').textContent=e.message;}finally{save.disabled=false;}}); + root.addEventListener('change',event=>{const e=event.target;if(e.dataset.workspaceSelect){if(e.checked)selected.add(e.dataset.workspaceSelect);else selected.delete(e.dataset.workspaceSelect);}else if(e.id==='workspaceGroup')group=e.value;else if(e.id==='workspaceBulkAction')bulkAction=e.value;else if(e.id==='workspaceTargetGroup')targetGroup=e.value;else if(e.id==='workspaceHistoryAccount'){historyAccount=e.value;page=0;}else if(e.id==='workspaceHistoryStatus'){historyStatus=e.value;page=0;}else return;context.render();}); + root.addEventListener('input',event=>{const e=event.target;if(!['workspaceSearch','workspaceHistorySearch'].includes(e.id))return;const id=e.id,caret=e.selectionStart;if(id==='workspaceSearch')search=e.value;else{historySearch=e.value;page=0;}context.render();const input=document.getElementById(id);input.focus();input.setSelectionRange(caret,caret);}); + } + window.DragonAccountWorkspace={accounts,history,sync,initialize}; +})(); diff --git a/renderer/index.html b/renderer/index.html index b1adca4..ac2367c 100644 --- a/renderer/index.html +++ b/renderer/index.html @@ -41,7 +41,7 @@
+ diff --git a/renderer/manager.css b/renderer/manager.css index 6a7030c..f2c6b4c 100644 --- a/renderer/manager.css +++ b/renderer/manager.css @@ -1,2 +1,5 @@ .manager-tabs{display:flex;gap:7px;flex-wrap:wrap;margin-bottom:18px}.manager-tabs button,.manager-toolbar button,.manager-table button,.manager-dialog button,.manager-panel button{font:inherit;cursor:pointer}.manager-tabs button{padding:10px 15px;color:#bdaccb;background:#181022;border:1px solid #59376f;border-radius:10px}.manager-tabs button[aria-selected="true"]{color:#fff;background:linear-gradient(110deg,#67319b,#361b50);border-color:var(--manager-accent,#b66dff)}.manager-panel{background:var(--manager-surface,#100b1b);border:1px solid #573369;border-radius:16px;padding:20px;margin-bottom:15px;color:#e8dcf3}.manager-panel h2{font-size:15px;letter-spacing:.08em;margin:0 0 14px}.manager-panel p,.manager-help{font-size:12px;line-height:1.65;color:#b5a0c5}.manager-toolbar{display:flex;align-items:center;gap:9px;flex-wrap:wrap;margin-bottom:16px}.manager-toolbar input{flex:1;min-width:180px}.manager-button{border:1px solid #76518f;border-radius:9px;padding:9px 12px;background:#261633;color:#f2e6fc;min-height:38px}.manager-button:hover{border-color:var(--manager-accent,#b66dff);background:#3a204d}.manager-button.primary{background:#66318f;border-color:#bc79e8}.manager-button.danger{border-color:#a44566;color:#ffa7bd}.manager-grid{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:14px}.manager-stat{padding:16px;border:1px solid #533763;border-radius:12px;background:#1b1126}.manager-stat b{display:block;font-size:24px;color:#dfb2ff;margin:5px 0}.manager-stat span{font-size:11px;color:#af99c0}.manager-table-scroll{overflow:auto}.manager-table{width:100%;border-collapse:collapse;min-width:760px;text-align:left}.manager-table th{font-size:10px;letter-spacing:.08em;color:#ae95c1;padding:12px 8px}.manager-table td{padding:13px 8px;border-top:1px solid #3c274c;font-size:12px;vertical-align:middle}.manager-account-name{display:flex;gap:10px;align-items:center}.manager-avatar{width:38px;height:38px;border-radius:50%;background:#46245f;display:grid;place-items:center;color:#efd8ff;font-weight:700;flex-shrink:0;object-fit:cover}.manager-table small{display:block;color:#a58ab8;font-size:10px;margin-top:4px}.manager-tags{display:flex;flex-wrap:wrap;gap:4px}.manager-tag{font-size:10px;padding:3px 7px;background:#3b214f;border-radius:8px;color:#d9b7ef}.manager-empty{padding:28px;text-align:center;border:1px dashed #644277;border-radius:12px;color:#b39ac3;font-size:12px}.manager-status{min-height:23px;color:#bb9ace;font-size:12px;line-height:1.6;margin-bottom:12px;overflow-wrap:anywhere}.manager-status.error{color:#ff9fbd}.manager-form{display:grid;gap:14px}.manager-form label{font-size:12px;display:grid;gap:7px;color:#d9c6e6}.manager-form input,.manager-form select,.manager-toolbar input,.manager-dialog input,.manager-dialog select{min-width:0;width:100%;padding:10px 12px;background:#110b19;border:1px solid #68447e;border-radius:9px;color:#eee1f7;box-sizing:border-box;font:inherit;font-size:12px}.manager-form input[type=checkbox]{width:18px;height:18px;accent-color:var(--manager-accent,#b66dff)}.manager-form input[type=color]{width:90px;height:40px;padding:3px}.manager-form input[type=range]{padding:0;accent-color:var(--manager-accent,#b66dff)}.manager-toggle{display:grid!important;grid-template-columns:minmax(0,1fr) 24px;gap:16px!important;align-items:center;padding:13px 0;border-bottom:1px solid #392645}.manager-toggle strong{display:block;font-size:12px;margin-bottom:4px}.manager-toggle small{display:block;font-size:11px;line-height:1.6;color:#ac94bd;font-weight:400}.manager-two{display:grid;grid-template-columns:1fr 1fr;gap:18px}.manager-dialog{width:min(850px,calc(100vw - 70px));max-height:85vh;overflow:auto;padding:24px;background:#150d20;color:#eee1f7;border:1px solid #a467c5;border-radius:19px;box-shadow:0 15px 90px #0009}.manager-dialog::backdrop{background:#050208c9;backdrop-filter:blur(4px)}.manager-dialog header{display:flex;align-items:center;justify-content:space-between;gap:20px;margin-bottom:18px}.manager-dialog h2{margin:0;font-size:21px}.manager-dialog footer{display:flex;justify-content:flex-end;gap:9px;margin-top:20px}.manager-dialog .manager-tabs{margin-top:16px}.manager-game{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:10px;padding:14px 0;border-bottom:1px solid #40284f}.manager-game strong{font-size:13px}.manager-game small{display:block;font-size:11px;color:#ac91c0;margin-top:6px}.manager-actions{display:flex;gap:7px;flex-wrap:wrap;align-items:center}.manager-session{border:1px solid #5f3b73;padding:16px;border-radius:12px;margin-top:12px}.manager-session h3{margin:0 0 8px;font-size:14px}.manager-session p{margin:6px 0}.manager-progress{height:7px;background:#352040;border-radius:8px;overflow:hidden}.manager-progress i{display:block;height:100%;background:var(--manager-accent,#b66dff)}.manager-layout{display:grid;gap:16px}.manager-hidden{display:none!important}.manager-achievement{padding:15px;border:1px solid #4d355c;border-radius:12px;opacity:.6}.manager-achievement.earned{opacity:1;border-color:#ba75e6;background:#321b45}.manager-color-label{color:var(--manager-accent,#b66dff)} html[data-manager-effects=light] *,html[data-manager-effects=light] *::before,html[data-manager-effects=light] *::after{animation-duration:.1s!important;transition-duration:.1s!important}html[data-manager-effects=low] *,html[data-manager-effects=low] *::before,html[data-manager-effects=low] *::after{animation:none!important;transition:none!important;box-shadow:none!important;text-shadow:none!important;backdrop-filter:none!important;filter:none!important}.sidebar nav{overflow-y:auto;min-height:0}.sidebar{min-height:0}.manager-dialog [hidden]{display:none!important}@media(max-width:1250px){.manager-grid{grid-template-columns:repeat(2,minmax(0,1fr))}.manager-two{grid-template-columns:1fr}}@media(prefers-reduced-motion:reduce){.manager-panel *{animation:none!important;transition:none!important}} +.manager-bulk{border:1px solid #533763;border-radius:12px;padding:12px;margin:14px 0;background:#21142c}.manager-table small{display:block;color:#b5a0c5;margin-top:4px}.manager-toolbar label{display:flex;align-items:center;gap:8px}.manager-toolbar select{max-width:100%;min-width:150px}.manager-bulk strong{color:#dfb2ff;font-size:12px}.manager-table input[type=checkbox]{width:18px;height:18px;accent-color:var(--manager-accent,#b66dff)}.workspace-group-dialog form>label{display:grid;gap:8px}.workspace-group-dialog form{padding:16px} +.workspace-accounts select,.workspace-history select,.workspace-group-dialog input{font:inherit;color:#eadff5;background:#1a1024;border:1px solid #66427d;border-radius:9px;padding:9px 12px;min-height:38px}.workspace-accounts select:focus,.workspace-history select:focus,.workspace-group-dialog input:focus{outline:2px solid var(--manager-accent,#b66dff);outline-offset:2px}.workspace-group-dialog input{width:100%;box-sizing:border-box} +.workspace-bulk-dialog{padding:20px}.workspace-bulk-dialog .manager-table-scroll{max-height:50vh;overflow:auto}.workspace-bulk-dialog footer{display:flex;justify-content:flex-end;gap:10px;flex-wrap:wrap} diff --git a/renderer/manager.js b/renderer/manager.js index 2733d99..36325f6 100644 --- a/renderer/manager.js +++ b/renderer/manager.js @@ -1,11 +1,12 @@ 'use strict'; (() => { const root=document.querySelector('#managerRoot');if(!root)return; + const workspace=window.DragonAccountWorkspace; const api=window.dragonStrap;const esc=value=>String(value??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); const bytes=value=>Number.isFinite(value)?`${(value/1048576).toFixed(0)} MB`:'—'; const duration=value=>`${Math.floor(value/3600)}h ${Math.floor(value%3600/60)}m`; - let data=null,pane='accounts',accountTab='details',editing=null,loading=false,search='',message='',error=false; - const tabs=[['accounts','Accounts'],['sessions','Sessions'],['monitor','Monitoring'],['scheduler','Scheduler'],['levels','Statistics & levels'],['appearance','Appearance'],['settings','Manager settings']]; + let data=null,pane='accounts',accountTab='details',editing=null,loading=false,message='',error=false; + const tabs=[['accounts','Accounts'],['sessions','Sessions'],['history','History'],['monitor','Monitoring'],['scheduler','Scheduler'],['levels','Statistics & levels'],['appearance','Appearance'],['settings','Manager settings']]; const button=(label,action,attrs='',style='')=>``; const toggle=(name,title,help,checked)=>``; const selectAccount=(name='accountId')=>``; @@ -14,15 +15,15 @@ async function refresh(renderNow=true){if(loading)return;loading=true;try{const result=await api.getAccountManager();if(!result.ok)throw new Error(result.message);data=result.data;applyAppearance();if(renderNow&&!dialog.open)render();}catch(e){status(e.message,true);}finally{loading=false;}} async function act(action,input={}){status('Working…');const result=await api.accountManagerAction(action,input);if(!result.ok)throw new Error(result.message||'Operation failed.');if(result.data)data=result.data;else await refresh(false);applyAppearance();status('Saved.');return result;} function render(){if(!data)return;const running=data.sessions.filter(s=>s.status==='running'); - root.innerHTML=`
${esc(message||data.lastError||'')}
SAVED ACCOUNTS${data.accounts.length}
MANAGED SESSIONS RUNNING${running.length}
TRACKED TIME${duration(data.stats.seconds)}
${content()}
`; + root.innerHTML=`
${esc(message||data.lastError||'')}
SAVED ACCOUNTS${data.accounts.length}
MANAGED SESSIONS RUNNING${running.length}
TRACKED TIME${duration(data.stats.seconds)}
${content()}
`;workspace.sync(); } function content(){switch(pane){ - case 'accounts':return accounts();case 'sessions':return sessions();case 'monitor':return monitor();case 'scheduler':return scheduler();case 'levels':return levels();case 'appearance':return appearance();default:return settings(); + case 'accounts':return accounts();case 'sessions':return sessions();case 'history':return workspace.history(data);case 'monitor':return monitor();case 'scheduler':return scheduler();case 'levels':return levels();case 'appearance':return appearance();default:return settings(); }} - function accounts(){const filtered=data.accounts.filter(a=>[a.username,a.alias,...a.tags].join(' ').toLowerCase().includes(search.toLowerCase())).sort((a,b)=>Number(b.favorite)-Number(a.favorite)||a.username.localeCompare(b.username)); - return `
${button('+ ADD ACCOUNT','sign-in','','primary')}${button('IMPORT SESSION COOKIE','import')}${button(data.options.hideDetails?'SHOW DETAILS':'HIDE DETAILS','hide')}

Sign in on Roblox’s own page. Saved session credentials are encrypted for this Windows user. Passwords are never stored by DragonStrap.

${filtered.length?`
${filtered.map(a=>{const s=data.sessions.find(s=>s.accountId===a.id&&s.status==='running');return ``;}).join('')}
FAVACCOUNTALIAS / TAGSGAMESFPS TARGETSTATER$ACTIONS
${button(a.favorite?'★':'☆','favorite',`data-id="${a.id}" aria-label="Toggle favorite for ${esc(a.username)}"`)}${data.options.hideDetails?'Hidden':esc(a.alias||'—')}
${(data.options.hideDetails?[]:a.tags).map(t=>`${esc(t)}`).join('')}
${a.games.length}${a.policy.fpsCap||'Default'}${s?'Running':'Not running'}${data.options.hideDetails?'Hidden':a.robux==null?'Unavailable':a.robux}
${button('EDIT','edit',`data-id="${a.id}"`)}${button('LAUNCH','quick-launch',`data-id="${a.id}"`,'primary')}
`:`
${search?'No accounts match this search.':'Add your first account to save games, tune sessions, and create schedules.'}
`}
`; - } - function sessions(){return `

SESSION VIEWER

${button('REFRESH','refresh')}${button('STOP ALL MANAGED','stop-all','','danger')}

Controls apply only to identified sessions launched by DragonStrap. Closing the manager stops automation and leaves Roblox running. Exits are detected; an exit is not automatically classified as a crash.

${data.sessions.length?data.sessions.map(s=>{const a=data.accounts.find(a=>a.id===s.accountId);return `

${esc(a?.alias||a?.username||'Removed account')} · ${esc(s.gameName)}

${esc(s.status==='running'?(s.joinState==='joined'?'Game join confirmed':s.joinState==='unconfirmed'?'Player running · game join not confirmed':'Player running · checking game join'):s.status)} · PID ${s.pid} · ${bytes(s.memoryBytes)} · CPU ${s.cpuPercent==null?'—':s.cpuPercent.toFixed(1)+'%'} · ${duration(Math.max(0,Math.floor(((s.endedAt||Date.now())-s.startedAt)/1000)))}

${esc(s.message||'')}

${s.status==='running'?`
${['focus','minimize','trim','stop'].map(action=>button(({focus:'FOCUS',minimize:'MINIMIZE',trim:'TRIM WORKING SET',stop:'STOP'})[action],'session',`data-id="${s.id}" data-control="${action}"`,action==='stop'?'danger':'')).join('')}
`:''}
`;}).join(''):'
No managed sessions yet.
'}
`;} + function accounts(){return workspace.accounts(data);} + + function sessions(){const running=data.sessions.filter(s=>s.status==='running');return `

ACTIVE MANAGED SESSIONS

${button('REFRESH','refresh')}${button('STOP ALL MANAGED','stop-all','','danger')}

Controls apply only to identified sessions. Closing DragonStrap leaves Roblox running. Completed and detached sessions are recorded in History.

${running.length?running.map(s=>{const a=data.accounts.find(a=>a.id===s.accountId);return `

${data.options.hideDetails?'Hidden account':esc(a?.alias||a?.username||'Removed account')} · ${data.options.hideDetails?'Hidden game':esc(s.gameName)}

${esc(s.joinState==='joined'?'Game join confirmed':s.joinState==='unconfirmed'?'Player running · game join not confirmed':'Player running · checking game join')} · PID ${s.pid} · ${bytes(s.memoryBytes)} · CPU ${s.cpuPercent==null?'—':s.cpuPercent.toFixed(1)+'%'} · ${duration(Math.max(0,Math.floor((Date.now()-s.startedAt)/1000)))}

${esc(s.message||'')}

${['focus','minimize','trim','stop'].map(action=>button(({focus:'FOCUS',minimize:'MINIMIZE',trim:'TRIM WORKING SET',stop:'STOP'})[action],'session',`data-id="${s.id}" data-control="${action}"`,action==='stop'?'danger':'')).join('')}
`;}).join(''):'
No running managed sessions. View History for completed sessions.
'}
`;} + let telemetry=null,telemetryBusy=false; async function updateTelemetry(){if(telemetryBusy||pane!=='monitor'||document.documentElement.dataset.view!=='accounts')return;telemetryBusy=true;try{const r=await api.accountManagerAction('telemetry',{});if(r.ok)telemetry=r.telemetry;if(pane==='monitor'){const target=root.querySelector('#managerPane');if(target)target.innerHTML=monitor();}}catch(e){status('Telemetry sampling failed: '+e.message,true);}finally{telemetryBusy=false;}} function monitor(){const p=data.probe;const t=telemetry;const sensor=(name,value,help)=>'
'+name+''+esc(value)+''+esc(help)+'
';return '

LIVE WINDOWS MONITORING

FPS measures Roblox frame presentations over three seconds. Temperatures come from hardware sensors. Readings refresh while this tab is open.

'+sensor('SYSTEM FREE MEMORY',p?bytes(p.freeMemoryBytes):'Checking…','Windows process counters')+sensor('GPU TEMPERATURE',t?.gpu.celsius!=null?t.gpu.celsius+' °C':'No reading',t?.gpu.status||'Checking NVIDIA driver…')+sensor('CPU TEMPERATURE',t?.cpu.celsius!=null?t.cpu.celsius+' °C':'Sensor provider needed',t?.cpu.status||'Checking hardware sensor providers…')+sensor('MEASURED ROBLOX FPS',t?.fps.readings.length?t.fps.readings.map(r=>r.fps+' FPS · PID '+r.pid).join(' / '):'No sample',t?.fps.status||'Starting frame capture…')+'

'+(t?'Last sample: '+esc(new Date(t.sampledAt).toLocaleTimeString()):'Sampling sensors…')+'

'+(p?.processes||[]).map(process=>'').join('')+'
PIDMEMORYCPU TIMERESPONDINGMANAGED
'+process.pid+''+bytes(process.memoryBytes)+''+Number(process.cpuSeconds).toFixed(1)+'s'+(process.responding?'Yes':'No')+''+(data.sessions.some(s=>s.pid===process.pid&&s.status==='running')?'Yes':'Read only')+'
';} @@ -77,8 +78,9 @@ case 'options':{const options={antiAfkDelayMs:Number(f.get('antiAfkDelayMs')),antiAfkMinutes:Number(f.get('antiAfkMinutes'))};for(const key of ['nativeWindows','multiInstance','autoCleanLogs','cleanOldVersions','cleanDumps'])options[key]=f.has(key);await act('options',options);render();break;} }}catch(e){status(e.message,true);const n=dialog.querySelector('.manager-dialog-status');if(n)n.textContent=e.message;}finally{if(submit?.isConnected)submit.disabled=false;} } + workspace.initialize(root,{getData:()=>data,act,render,status}); root.addEventListener('click',handleButton);dialog.addEventListener('click',handleButton);root.addEventListener('submit',submit);dialog.addEventListener('submit',submit);dialog.addEventListener('cancel',()=>{dialog.querySelectorAll('input[type=password]').forEach(i=>i.value='');}); - root.addEventListener('input',event=>{if(event.target.id==='managerSearch'){search=event.target.value;const caret=event.target.selectionStart;render();const i=root.querySelector('#managerSearch');i.focus();i.setSelectionRange(caret,caret);}if(event.target.type==='range'){event.target.parentElement.querySelector('output').textContent=event.target.value+(event.target.name==='antiAfkDelayMs'?' ms':' min');}}); + root.addEventListener('input',event=>{if(event.target.type==='range'){event.target.parentElement.querySelector('output').textContent=event.target.value+(event.target.name==='antiAfkDelayMs'?' ms':' min');}}); root.addEventListener('change',event=>{if(event.target.name==='accountId'){const form=event.target.closest('form'),games=form.querySelector('[name=gameId]');if(games){const a=data.accounts.find(a=>a.id===event.target.value);games.innerHTML=''+(a?.games||[]).map(g=>``).join('');}}}); api.onAccountConnected?.(()=>{status('Roblox account connected.');refresh();}); const isVisible=()=>document.documentElement.dataset.view==='accounts'; diff --git a/scripts/check-source.js b/scripts/check-source.js index 093affb..871df03 100644 --- a/scripts/check-source.js +++ b/scripts/check-source.js @@ -4,7 +4,7 @@ const fs = require('fs'); const path = require('path'); const root = path.resolve(__dirname, '..'); const required = [ - 'main.js', 'preload.js', 'package.json', 'renderer/manager.js', 'renderer/manager.css', 'src/services/account-session-service.js', 'src/services/account-manager-store.js', 'src/services/windows-session-control.js', 'src/services/manager-cleanup-service.js', 'src/core/account-manager-ipc.js', 'src/native/session-control.ps1', + 'main.js', 'preload.js', 'package.json', 'renderer/manager.js', 'renderer/account-workspace.js', 'renderer/manager.css', 'src/services/account-session-service.js', 'src/services/account-manager-store.js', 'src/services/windows-session-control.js', 'src/services/manager-cleanup-service.js', 'src/core/account-manager-ipc.js', 'src/native/session-control.ps1', 'renderer/index.html', 'renderer/styles.css', 'renderer/purple-dragon.css', 'renderer/dragon-2.css', 'renderer/reliability.css', 'renderer/update-center.css', 'renderer/fastflags.css', 'renderer/performance-center.css', 'renderer/servers.css', 'renderer/studio.css', 'renderer/profiles.css', 'renderer/channels.css', 'renderer/maintenance.css', 'renderer/app.js', 'renderer/assets/logo.svg', 'renderer/assets/dragon-app-icon.png', 'assets/DragonStrap.ico', 'assets/DragonStrap.png', 'src/core/app-kernel.js', 'src/core/service-registry.js', 'src/core/operation-coordinator.js', 'src/core/bootstrap-pipeline.js', 'src/core/plugin-host.js', 'src/core/roblox-status-cache.js', 'src/core/api-contract.js', 'src/services/settings-store.js', 'src/services/roblox-installation.js', 'src/services/launch-service.js', 'src/services/self-update-service.js', 'src/services/recovery-service.js', diff --git a/scripts/verify-manager-ui.js b/scripts/verify-manager-ui.js index 651a4d6..115ae40 100644 --- a/scripts/verify-manager-ui.js +++ b/scripts/verify-manager-ui.js @@ -5,7 +5,9 @@ const source=fs.readFileSync(path.join(root,'renderer/index.html'),'utf8').repla fs.writeFileSync(fixture,source);const output=process.env.DRAGONSTRAP_UI_OUTPUT;let failures=0; const {DEFAULT_OPTIONS,DEFAULT_POLICY}=require('../src/services/account-manager-store'); const account={id:'00000000-0000-4000-8000-000000000001',userId:'123',username:'PreviewDragon',displayName:'Preview Dragon',alias:'Layout test account',tags:['creator','test'],favorite:true,connected:true,policy:{...DEFAULT_POLICY},games:[{id:'00000000-0000-4000-8000-000000000002',placeId:'123',name:'Saved game preview',uri:'roblox://experiences/start?placeId=123',favorite:true}]}; -const data={accounts:[account],options:{...DEFAULT_OPTIONS},schedules:[],history:[],sessions:[{id:'test-session',accountId:account.id,gameId:account.games[0].id,pid:123,start:'fixture',startedAt:Date.now()-120000,status:'running',gameName:'Layout test session',memoryBytes:256*1048576,cpuPercent:2.5}],stats:{seconds:120,launches:1,level:1,achievements:[{name:'First launch',earned:true},{name:'One hour',earned:false}]},probe:{processes:[],freeMemoryBytes:8*1073741824},shutdownAt:0,lastError:'Layout verification uses synthetic test data.'}; +account.groupId='00000000-0000-4000-8000-000000000003'; +const secondAccount={...account,id:'00000000-0000-4000-8000-000000000004',userId:'456',username:'SecondDragon',alias:'Second account',favorite:false,groupId:null,games:[]}; +const data={groups:[{id:account.groupId,name:'Creators'}],accounts:[account,secondAccount],options:{...DEFAULT_OPTIONS},schedules:[],history:[{id:'history-one',accountId:account.id,accountName:account.alias,gameName:'Detached game',status:'detached',joinState:'joined',startedAt:Date.now()-180000,endedAt:Date.now()-60000,durationSeconds:120},{id:'history-two',accountId:secondAccount.id,gameName:'Stopped game',status:'stopped',startedAt:Date.now()-120000,endedAt:Date.now()-60000,durationSeconds:60}],sessions:[{id:'test-session',accountId:account.id,gameId:account.games[0].id,pid:123,start:'fixture',startedAt:Date.now()-120000,status:'running',gameName:'Layout test session',memoryBytes:256*1048576,cpuPercent:2.5}],stats:{seconds:120,launches:1,level:1,achievements:[{name:'First launch',earned:true},{name:'One hour',earned:false}]},probe:{processes:[],freeMemoryBytes:8*1073741824},shutdownAt:0,lastError:'Layout verification uses synthetic test data.'}; async function paint(win){await win.webContents.executeJavaScript('new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)))');} async function inspect(win,label){const issues=await win.webContents.executeJavaScript(`(()=>{const issues=[];if(document.documentElement.scrollWidth>innerWidth+3)issues.push('page horizontal overflow');for(const b of document.querySelectorAll('#managerRoot button,.manager-dialog[open] button')){if(!b.offsetParent||b.clientWidth<1||b.closest('.manager-table-scroll'))continue;const r=document.createRange();r.selectNodeContents(b);const text=r.getBoundingClientRect(),box=b.getBoundingClientRect();if(text.height&&(text.topbox.bottom+2))issues.push('clipped button: '+b.textContent.trim());}return issues;})()`);if(issues.length){failures+=issues.length;console.error(label+': '+issues.join('; '));}} (async()=>{let win;try{ @@ -13,8 +15,18 @@ async function inspect(win,label){const issues=await win.webContents.executeJava win=new BrowserWindow({show:false,width:1600,height:980,webPreferences:{contextIsolation:true,sandbox:true,nodeIntegration:false,backgroundThrottling:false}}); win.webContents.on('console-message',event=>{if(event.level==='error')console.error(event.message);}); await win.loadFile(fixture); - await win.webContents.executeJavaScript(`window.__managerIntervals=[];const originalInterval=window.setInterval.bind(window);window.setInterval=(callback,ms)=>{window.__managerIntervals.push({callback,ms});return originalInterval(callback,ms);};window.dragonStrap={getAccountManager:async()=>({ok:true,data:${JSON.stringify(data)}}),accountManagerAction:async()=>({ok:true,data:${JSON.stringify(data)}}),signInRobloxAccount:async()=>({ok:true})};document.querySelectorAll('.view').forEach(v=>v.classList.toggle('active',v.dataset.view==='accounts'));document.documentElement.dataset.view='accounts';document.querySelectorAll('.nav-item').forEach(n=>n.classList.toggle('active',n.dataset.tab==='accounts'));`); + await win.webContents.executeJavaScript(`window.__managerIntervals=[];const originalInterval=window.setInterval.bind(window);window.setInterval=(callback,ms)=>{window.__managerIntervals.push({callback,ms});return originalInterval(callback,ms);};window.__managerData=${JSON.stringify(data)};window.__managerActions=[];window.dragonStrap={getAccountManager:async()=>({ok:true,data:window.__managerData}),accountManagerAction:async(action,input)=>{window.__managerActions.push({action,input});if(action==='accounts-edit')for(const id of input.ids)Object.assign(window.__managerData.accounts.find(a=>a.id===id),input.patch);if(action==='group-save'){const group=input.id?window.__managerData.groups.find(g=>g.id===input.id):{id:'00000000-0000-4000-8000-000000000005'};group.name=input.name;if(!input.id)window.__managerData.groups.push(group);}return {ok:true,data:window.__managerData,bulkResults:action==='accounts-bulk'?input.ids.map((accountId,index)=>({accountId,ok:index!==0,message:index===0?'Synthetic session expired':'Details refreshed'})):undefined};},signInRobloxAccount:async()=>({ok:true})};document.querySelectorAll('.view').forEach(v=>v.classList.toggle('active',v.dataset.view==='accounts'));document.documentElement.dataset.view='accounts';document.querySelectorAll('.nav-item').forEach(n=>n.classList.toggle('active',n.dataset.tab==='accounts'));`); await win.webContents.executeJavaScript(fs.readFileSync(path.join(root,'renderer/manager.js'),'utf8'));await paint(win); + const workspaceChecks=await win.webContents.executeJavaScript(`(async()=>{const pause=()=>new Promise(r=>setTimeout(r,0));const choose=(id,value)=>{const e=document.getElementById(id);e.value=value;e.dispatchEvent(new Event('change',{bubbles:true}));}; + choose('workspaceGroup','00000000-0000-4000-8000-000000000003');document.querySelector('[data-workspace-action="select-visible"]').click();if(document.querySelectorAll('[data-workspace-select]:checked').length!==1)return 'group selection';choose('workspaceGroup','all');window.__managerIntervals.find(i=>i.ms===15000).callback();await pause();if(document.querySelectorAll('[data-workspace-select]:checked').length!==1)return 'selection after refresh'; + choose('workspaceBulkAction','favorite');document.querySelector('[data-workspace-action="bulk-apply"]').click();await pause();const metadata=window.__managerActions.find(a=>a.action==='accounts-edit');if(metadata?.input.ids.length!==1||!metadata.input.patch.favorite)return 'bulk metadata payload'; + document.querySelector('[data-workspace-action="select-visible"]').click();choose('workspaceBulkAction','refresh');document.querySelector('[data-workspace-action="bulk-apply"]').click();await pause();if(!document.querySelector('#managerPane').textContent.includes('Synthetic session expired')||!document.querySelector('#managerPane').textContent.includes('Details refreshed'))return 'independent batch results'; + document.querySelector('[data-workspace-action="group-new"]').click();const modal=document.querySelector('.workspace-group-dialog');modal.querySelector('input').value='New group';modal.querySelector('form').requestSubmit();await pause();if(modal.open||!window.__managerData.groups.some(g=>g.name==='New group'))return 'group creation'; + window.__managerData.options.multiInstance=true;choose('workspaceBulkAction','launch-favorite');document.querySelector('[data-workspace-action="bulk-apply"]').click();const reviewDialog=document.querySelector('.workspace-bulk-dialog');if(!reviewDialog.open||!reviewDialog.textContent.includes('Saved game preview'))return 'bulk launch review';const beforeLaunch=window.__managerActions.filter(a=>a.action==='accounts-bulk'&&a.input.action==='launch-favorite').length;reviewDialog.querySelector('[data-workspace-action="bulk-cancel"]').click();await pause();if(window.__managerActions.filter(a=>a.action==='accounts-bulk'&&a.input.action==='launch-favorite').length!==beforeLaunch)return 'review cancel dispatched action';choose('workspaceBulkAction','launch-favorite');document.querySelector('[data-workspace-action="bulk-apply"]').click();reviewDialog.querySelector('[data-workspace-action="bulk-run"]').click();await pause();const reviewed=window.__managerActions.find(a=>a.action==='accounts-bulk'&&a.input.action==='launch-favorite');if(!reviewed?.input.expectedFavorites?.['00000000-0000-4000-8000-000000000001'])return 'reviewed favorite binding'; + choose('workspaceBulkAction','group');choose('workspaceTargetGroup','00000000-0000-4000-8000-000000000005');document.querySelector('[data-workspace-action="bulk-apply"]').click();await pause();choose('workspaceGroup','00000000-0000-4000-8000-000000000005');if(document.querySelectorAll('[data-workspace-select]').length!==2)return 'bulk group assignment'; + document.querySelector('[data-manager-pane="history"]').click();choose('workspaceHistoryStatus','detached');if(document.querySelectorAll('#managerPane tbody tr').length!==1||!document.querySelector('#managerPane').textContent.includes('Player left running'))return 'history outcome filter'; + for(let i=0;i<60;i++)window.__managerData.history.push({...window.__managerData.history[1],id:'extra-'+i});choose('workspaceHistoryStatus','all');if(document.querySelectorAll('#managerPane tbody tr').length!==50)return 'history page limit';document.querySelector('[data-workspace-action="history-next"]').click();if(document.querySelectorAll('#managerPane tbody tr').length!==12)return 'history next page';window.__managerData.history.splice(2);window.__managerData.options.hideDetails=true;document.querySelector('[data-manager-pane="history"]').click();if(document.querySelector('#managerPane').textContent.includes('Layout test account')||!document.querySelector('#managerPane').textContent.includes('Hidden account'))return 'history privacy';window.__managerData.options.hideDetails=false; + document.querySelector('[data-manager-pane="accounts"]').click();return '';})()`);if(workspaceChecks)throw new Error('Account workspace regression: '+workspaceChecks);console.log('Groups, retained selection, per-account batch results and history filtering passed.'); for(const draft of [{pane:'scheduler',selector:'[name=at]',value:'2030-12-01T12:30'},{pane:'appearance',selector:'[name=skinName]',value:'Unsaved palette'},{pane:'settings',selector:'[name=antiAfkMinutes]',value:'9'}]){ const preserved=await win.webContents.executeJavaScript(`(async()=>{document.querySelector('[data-manager-pane="${draft.pane}"]').click();const input=document.querySelector('#managerPane ${draft.selector}');input.value=${JSON.stringify(draft.value)};document.querySelector('[data-manager-pane="${draft.pane}"]').focus();window.__managerIntervals.find(i=>i.ms===15000).callback();await new Promise(resolve=>setTimeout(resolve,0));return input.isConnected&&input.value===${JSON.stringify(draft.value)};})()`); if(!preserved)throw new Error('Background refresh discarded '+draft.pane+' draft'); @@ -22,9 +34,11 @@ async function inspect(win,label){const issues=await win.webContents.executeJava console.log('Background refresh preserved blurred scheduler, appearance and settings drafts.'); for(const size of [{width:1600,height:980,zoom:1},{width:1600,height:980,zoom:1.25},{width:1600,height:980,zoom:1.5},{width:1180,height:760,zoom:1}]){ win.setContentSize(size.width,size.height);await paint(win);win.webContents.setZoomFactor(size.zoom);await paint(win); - for(const pane of ['accounts','sessions','monitor','scheduler','levels','appearance','settings']){await win.webContents.executeJavaScript(`document.querySelector('[data-manager-pane="${pane}"]').click()`);await paint(win);await inspect(win,`${size.width}@${size.zoom} ${pane}`); + for(const pane of ['accounts','sessions','history','monitor','scheduler','levels','appearance','settings']){await win.webContents.executeJavaScript(`document.querySelector('[data-manager-pane="${pane}"]').click()`);await paint(win);await inspect(win,`${size.width}@${size.zoom} ${pane}`); if(output&&size.width===1600&&size.zoom===1&&['accounts','sessions','settings'].includes(pane)){fs.mkdirSync(output,{recursive:true});fs.writeFileSync(path.join(output,`DragonStrap-${pane}-preview.png`),(await win.webContents.capturePage()).toPNG());} } + await win.webContents.executeJavaScript(`document.querySelector('[data-manager-pane="accounts"]').click();document.getElementById('workspaceBulkAction').value='stop';document.getElementById('workspaceBulkAction').dispatchEvent(new Event('change',{bubbles:true}));document.querySelector('[data-workspace-action="bulk-apply"]').click();`);await paint(win);await inspect(win,`${size.width}@${size.zoom} bulk review`);await win.webContents.executeJavaScript(`document.querySelector('.workspace-bulk-dialog [data-workspace-action="bulk-cancel"]').click();`); + await win.webContents.executeJavaScript(`document.querySelector('[data-manager-pane="accounts"]').click();document.querySelector('[data-workspace-action="group-new"]').click();`);await paint(win);await inspect(win,`${size.width}@${size.zoom} group editor`);await win.webContents.executeJavaScript(`document.querySelector('.workspace-group-dialog [data-workspace-action="group-cancel"]').click();`); await win.webContents.executeJavaScript(`document.querySelector('[data-manager-pane="accounts"]').click();document.querySelector('[data-manager="edit"]').click();`);await paint(win); for(const tab of ['details','games','policy']){await win.webContents.executeJavaScript(`document.querySelector('[data-account-tab="${tab}"]').click()`);await paint(win);await inspect(win,`${size.width}@${size.zoom} account ${tab}`);} await win.webContents.executeJavaScript(`document.querySelector('.manager-dialog [data-manager="close-dialog"]').click()`); diff --git a/src/core/account-manager-ipc.js b/src/core/account-manager-ipc.js index ec6ae09..f59d353 100644 --- a/src/core/account-manager-ipc.js +++ b/src/core/account-manager-ipc.js @@ -25,6 +25,11 @@ function installAccountManagerIpc({kernel,electron,getWindow}){ ipcMain.handle('dragonstrap:manager-action',guard(async(action,input={})=>{ if(!input||typeof input!=='object'||Array.isArray(input))throw new Error('Invalid manager request.'); switch(action){ + case 'group-save':service.store.saveGroup(input);break; + case 'group-remove':service.store.removeGroup(input.id);break; + case 'accounts-edit':service.store.bulkEditAccounts(input.ids,input.patch);break; + case 'accounts-bulk-cancel':service.cancelBulk();break; + case 'accounts-bulk':{const bulkResults=await service.bulkAccounts(input.action,input.ids,input.expectedFavorites,input.expectedSessions);return {data:service.state(),bulkResults};} case 'account-import':await service.connect(input.cookie);break; case 'account-edit':service.store.editAccount(input.id,input.patch);break; case 'account-refresh':await service.refreshAccount(input.id);break; diff --git a/src/services/account-manager-store.js b/src/services/account-manager-store.js index 9bb7662..b2d39f4 100644 --- a/src/services/account-manager-store.js +++ b/src/services/account-manager-store.js @@ -15,13 +15,16 @@ function policy(input={}) { class AccountManagerStore { constructor(userData,{fsImpl=fs,now=()=>Date.now()}={}) { this.fs=fsImpl;this.now=now;this.file=path.join(userData,'account-manager.json'); - this.data={schema:1,accounts:[],schedules:[],history:[],skins:[],totals:{seconds:0,launches:0},options:{...DEFAULT_OPTIONS}}; + this.data={schema:1,accounts:[],groups:[],schedules:[],history:[],skins:[],totals:{seconds:0,launches:0},options:{...DEFAULT_OPTIONS}}; if(this.fs.existsSync(this.file)) { const data=JSON.parse(this.fs.readFileSync(this.file,'utf8')); if(data?.schema!==1||!Array.isArray(data.accounts)||!Array.isArray(data.schedules)||!Array.isArray(data.history)) throw new Error('Account manager data is invalid; restore the file before continuing.'); const uuid=/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; if(data.accounts.length>100||data.schedules.length>200||data.history.length>1000||data.accounts.some(a=>!uuid.test(a.id)||!(/^[1-9]\d{0,19}$/.test(a.userId))||typeof a.username!=='string'||!Array.isArray(a.tags)||!Array.isArray(a.games)||a.games.some(g=>!uuid.test(g.id))))throw new Error('Account manager records are invalid.'); - this.data={...data,skins:Array.isArray(data.skins)?data.skins:[],totals:data.totals||{seconds:data.history.reduce((n,s)=>n+(s.durationSeconds||0),0),launches:data.history.length},accounts:data.accounts.map(a=>({...a,policy:policy(a.policy)})),options:{...DEFAULT_OPTIONS,...data.options}}; + const groups=data.groups===undefined?[]:data.groups; + if(!Array.isArray(groups)||groups.length>32||groups.some(g=>!g||!uuid.test(g.id)||!text(g.name,40))||new Set(groups.map(g=>g.id)).size!==groups.length||new Set(groups.map(g=>text(g.name,40).toLowerCase())).size!==groups.length)throw new Error('Account groups are invalid.'); + if(data.accounts.some(a=>a.groupId!=null&&!groups.some(g=>g.id===a.groupId)))throw new Error('Account group membership is invalid.'); + this.data={...data,groups:groups.map(g=>({id:g.id,name:text(g.name,40)})),skins:Array.isArray(data.skins)?data.skins:[],totals:data.totals||{seconds:data.history.reduce((n,s)=>n+(s.durationSeconds||0),0),launches:data.history.length},accounts:data.accounts.map(a=>({...a,groupId:a.groupId??null,policy:policy(a.policy)})),options:{...DEFAULT_OPTIONS,...data.options}}; } } commit(task) { @@ -33,11 +36,25 @@ class AccountManagerStore { state(){return structuredClone(this.data);} getOptions(){return {...this.data.options};} account(id){return this.data.accounts.find(a=>a.id===id)||null;} + accountIds(ids){ + if(!Array.isArray(ids)||ids.length<1||ids.length>100||new Set(ids).size!==ids.length||ids.some(id=>typeof id!=='string'||!this.account(id)))throw new Error('Select 1–100 distinct saved accounts.'); + return [...ids]; + } + saveGroup(input={}){ + const name=text(input.name,40);if(!name)throw new Error('Choose a group name.'); + return this.commit(data=>{let group=input.id?data.groups.find(g=>g.id===input.id):null;if(input.id&&!group)throw new Error('Group not found.');if(data.groups.some(g=>g.id!==group?.id&&g.name.toLowerCase()===name.toLowerCase()))throw new Error('A group with that name already exists.');if(!group){if(data.groups.length>=32)throw new Error('The group limit is 32.');group={id:crypto.randomUUID()};data.groups.push(group);}group.name=name;return {...group};}); + } + removeGroup(id){return this.commit(data=>{if(!data.groups.some(g=>g.id===id))throw new Error('Group not found.');data.groups=data.groups.filter(g=>g.id!==id);for(const account of data.accounts)if(account.groupId===id)account.groupId=null;});} + bulkEditAccounts(ids,patch={}){ + const selected=this.accountIds(ids);if(!('groupId' in patch)&&typeof patch.favorite!=='boolean')throw new Error('Choose a supported account change.'); + if('groupId'in patch&&patch.groupId!==null&&!this.data.groups.some(g=>g.id===patch.groupId))throw new Error('Group not found.'); + return this.commit(data=>{for(const id of selected){const account=data.accounts.find(a=>a.id===id);if('groupId'in patch)account.groupId=patch.groupId;if(typeof patch.favorite==='boolean')account.favorite=patch.favorite;}return selected;}); + } saveAccount(input) { if(!input||!/^[1-9]\d{0,19}$/.test(String(input.userId||''))||!text(input.username,20)) throw new Error('A verified Roblox account is required.'); return this.commit(data=>{ let account=data.accounts.find(a=>a.userId===String(input.userId)); - if(!account){if(data.accounts.length>=100) throw new Error('The account limit is 100.');account={id:crypto.randomUUID(),userId:String(input.userId),alias:'',tags:[],favorite:false,games:[],policy:{...DEFAULT_POLICY},createdAt:this.now()};data.accounts.push(account);} + if(!account){if(data.accounts.length>=100) throw new Error('The account limit is 100.');account={id:crypto.randomUUID(),userId:String(input.userId),alias:'',tags:[],groupId:null,favorite:false,games:[],policy:{...DEFAULT_POLICY},createdAt:this.now()};data.accounts.push(account);} if(typeof input.avatarUrl==='string'&&/^https:\/\/[^/]+\.rbxcdn\.com\//i.test(input.avatarUrl))account.avatarUrl=input.avatarUrl; if(input.robux===null||Number.isSafeInteger(input.robux)&&input.robux>=0)account.robux=input.robux; if(Number.isSafeInteger(input.refreshedAt))account.refreshedAt=input.refreshedAt; diff --git a/src/services/account-session-service.js b/src/services/account-session-service.js index 1f61f29..f8560d7 100644 --- a/src/services/account-session-service.js +++ b/src/services/account-session-service.js @@ -49,7 +49,7 @@ class RobloxAccountProvider { class AccountSessionService { constructor({userData,safeStorage,status,settingsStore,launchService,performanceService,recoveryService,configurationProfileStore,operations,provider=new RobloxAccountProvider(),native=new WindowsSessionControl(),launchProtocol=null,joinObserver=new RobloxJoinObserver(),now=()=>Date.now(),store=null}={}){ this.store=store||new AccountManagerStore(userData,{now});this.userData=userData;this.safeStorage=safeStorage;this.status=status;this.settingsStore=settingsStore;this.launchService=launchService;this.performance=performanceService;this.recovery=recoveryService;this.configurationProfiles=configurationProfileStore;this.operations=operations;this.provider=provider;this.native=native;this.launchProtocol=launchProtocol;this.joinObserver=joinObserver;this.joinSnapshots=new Map();this.now=now; - this.sessions=new Map();this.launchEpoch=0;this.busy=new Set();this.tickBusy=false;this.lastProbe=null;this.lastError='';this.timer=null;this.stopping=false;this.vaultFile=path.join(userData,'account-vault.json');this.vault={};this.shutdownAt=0; + this.sessions=new Map();this.bulkBusy=false;this.bulkEpoch=0;this.launchEpoch=0;this.busy=new Set();this.tickBusy=false;this.lastProbe=null;this.lastError='';this.timer=null;this.stopping=false;this.vaultFile=path.join(userData,'account-vault.json');this.vault={};this.shutdownAt=0; if(fs.existsSync(this.vaultFile)){const data=JSON.parse(fs.readFileSync(this.vaultFile,'utf8'));if(data?.schema!==1||!data.secrets||typeof data.secrets!=='object'||Array.isArray(data.secrets))throw new Error('Account vault is invalid.');this.vault=data.secrets;} // Jobs which were in progress when the app exited are not replayed automatically. for(const job of this.store.state().schedules)if(job.status==='running')this.store.updateSchedule(job.id,'interrupted','DragonStrap closed during this job.'); @@ -64,13 +64,29 @@ class AccountSessionService { this.saveVault({...this.vault,[account.id]:encrypted});return account; } async refreshAccount(id){const secret=await this.cookie(id);const user=await (this.provider.profile?this.provider.profile(secret):this.provider.identity(secret));if(this.store.account(id)?.userId!==user.userId)throw new Error('The saved credential belongs to a different account.');return this.store.saveAccount(user);} - removeAccount(id){if([...this.sessions.values()].some(s=>s.accountId===id&&s.status==='running'))throw new Error('Stop this account’s managed session before removing it.');const next={...this.vault};delete next[id];this.saveVault(next);this.store.removeAccount(id);} + removeAccount(id){if(this.busy.has(id))throw new Error('Wait for this account launch to finish or cancel it before removing the account.');if([...this.sessions.values()].some(s=>s.accountId===id&&s.status==='running'))throw new Error('Stop this account’s managed session before removing it.');const next={...this.vault};delete next[id];this.saveVault(next);this.store.removeAccount(id);} async saveGame(accountId,input){const game=normalizeGame(input.target);const name=String(input.name||'').trim()||await this.provider.gameName(game.placeId)||`Place ${game.placeId}`;const imageUrl=this.provider.thumbnail?await this.provider.thumbnail(`https://thumbnails.roblox.com/v1/places/gameicons?placeIds=${game.placeId}&size=150x150&format=Png&isCircular=false`):'';return this.store.saveGame(accountId,{...game,id:input.id,name,favorite:input.favorite,imageUrl});} state(){ const data=this.store.state();const sessions=[...this.sessions.values()].map(({path,...s})=>s); const history=data.history;const seconds=(data.totals?.seconds||0)+sessions.filter(s=>s.status==='running').reduce((n,s)=>n+Math.max(0,Math.floor((this.now()-s.startedAt)/1000)),0); const launches=(data.totals?.launches||0)+sessions.filter(s=>s.status==='running').length; - return {...data,accounts:data.accounts.map(a=>({...a,connected:Boolean(this.vault[a.id])})),sessions,probe:this.lastProbe,capabilities:{encryptedStorage:Boolean(this.safeStorage),native:process.platform==='win32',multiInstance:'Roblox decides whether concurrent clients remain open; no client patching or singleton-handle removal is performed.',antiAfk:'Foreground managed window only, after 60 seconds of Windows inactivity. Games may ignore synthetic input.',fps:'Legacy ClientSettings target; current Roblox builds may ignore it.'},stats:{seconds,launches,level:1+Math.floor(seconds/36000),achievements:[{name:'First launch',earned:launches>=1},{name:'Ten sessions',earned:launches>=10},{name:'One hour',earned:seconds>=3600},{name:'Ten hours',earned:seconds>=36000}]},shutdownAt:this.shutdownAt,lastError:this.lastError}; + return {...data,accounts:data.accounts.map(a=>({...a,connected:Boolean(this.vault[a.id])})),sessions,bulkBusy:this.bulkBusy,probe:this.lastProbe,capabilities:{encryptedStorage:Boolean(this.safeStorage),native:process.platform==='win32',multiInstance:'Roblox decides whether concurrent clients remain open; no client patching or singleton-handle removal is performed.',antiAfk:'Foreground managed window only, after 60 seconds of Windows inactivity. Games may ignore synthetic input.',fps:'Legacy ClientSettings target; current Roblox builds may ignore it.'},stats:{seconds,launches,level:1+Math.floor(seconds/36000),achievements:[{name:'First launch',earned:launches>=1},{name:'Ten sessions',earned:launches>=10},{name:'One hour',earned:seconds>=3600},{name:'Ten hours',earned:seconds>=36000}]},shutdownAt:this.shutdownAt,lastError:this.lastError}; + } + cancelBulk(){this.bulkEpoch++;} + async bulkAccounts(action,ids,expectedFavorites=null,expectedSessions=null){ + this.ensureOpen();if(!['refresh','launch-favorite','stop'].includes(action))throw new Error('Unsupported bulk account action.'); + const selected=this.store.accountIds(ids);if(this.bulkBusy)throw new Error('Another bulk account action is still running.'); + if(action==='launch-favorite'&&selected.length>1&&!this.store.getOptions().multiInstance)throw new Error('Enable concurrent launch attempts in Manager settings before launching multiple accounts.'); + if(action==='launch-favorite'&&(!expectedFavorites||typeof expectedFavorites!=='object'||Array.isArray(expectedFavorites)||selected.some(id=>!Object.prototype.hasOwnProperty.call(expectedFavorites,id)||!(expectedFavorites[id]===null||typeof expectedFavorites[id]==='string'))))throw new Error('Review favorite game targets before launching the batch.'); + if(action==='stop'&&(!expectedSessions||typeof expectedSessions!=='object'||Array.isArray(expectedSessions)||selected.some(id=>!Array.isArray(expectedSessions[id])||expectedSessions[id].some(value=>typeof value!=='string'))))throw new Error('Review managed sessions before stopping the batch.'); + this.bulkBusy=true;const batchEpoch=this.bulkEpoch,epoch=this.launchEpoch;const results=[]; + try{for(const accountId of selected){try{this.ensureOpen();if(batchEpoch!==this.bulkEpoch)throw new Error('Bulk action cancelled before this account started.');if(action==='launch-favorite'&&epoch!==this.launchEpoch)throw new Error('Batch launch cancelled by Stop all.'); + if(action==='refresh'){await this.refreshAccount(accountId);results.push({accountId,ok:true,message:'Roblox details refreshed.'});} + else if(action==='launch-favorite'){const game=this.store.account(accountId)?.games.find(g=>g.favorite);if((game?.id??null)!==expectedFavorites[accountId])throw new Error('Favorite game changed after review. Review this account again.');if(!game)throw new Error('No favorite saved game. Edit this account and favorite a game first.');const result=await this.launch(accountId,game.id);if(!result.ok)throw new Error(result.message);results.push({accountId,ok:true,message:'Launch requested.',sessionId:result.sessionId});} + else {const sessions=[...this.sessions.values()].filter(s=>s.accountId===accountId&&s.status==='running');if(sessions.some(session=>!expectedSessions[accountId].includes(session.id)))throw new Error('Managed session changed after review. Review this account again.');for(const session of sessions)await this.control(session.id,'stop');results.push({accountId,ok:true,message:sessions.length?'Managed session stopped.':'No running managed session.'});} + }catch(e){results.push({accountId,ok:false,message:e.message});}} + return results; + }finally{this.bulkBusy=false;} } ensureOpen(){if(this.stopping)throw new Error('DragonStrap is closing; account launch cancelled.');} async launch(accountId,gameId,{restartCount=0}={}){ @@ -111,7 +127,7 @@ class AccountSessionService { const result=await this.native.run({action,...this.identity(s),policy:this.store.account(s.accountId)?.policy,delayMs:this.store.state().options.antiAfkDelayMs}); if(!result.ok){if(action==='stop')s.stopRequested=false;throw new Error(result.message);}if(action==='stop')this.finish(s,'stopped');return result; } - finish(s,status){if(s.status!=='running')return;s.status=status;this.joinSnapshots.delete(s.id);s.endedAt=this.now();this.store.history({accountId:s.accountId,gameId:s.gameId,gameName:s.gameName,startedAt:s.startedAt,endedAt:s.endedAt,durationSeconds:Math.max(0,Math.floor((s.endedAt-s.startedAt)/1000)),status});} + finish(s,status){if(s.status!=='running')return;s.status=status;this.joinSnapshots.delete(s.id);s.endedAt=this.now();this.store.history({accountId:s.accountId,accountName:this.store.account(s.accountId)?.alias||this.store.account(s.accountId)?.username||'Removed account',joinState:s.joinState||'unknown',restartCount:s.restartCount||0,gameId:s.gameId,gameName:s.gameName,startedAt:s.startedAt,endedAt:s.endedAt,durationSeconds:Math.max(0,Math.floor((s.endedAt-s.startedAt)/1000)),status});} async tick(){if(this.tickBusy||this.stopping)return;this.tickBusy=true;try{ const scanning=[...this.sessions.values()];const probe=await this.native.run({action:'probe'});if(this.stopping)return;if(probe.ok){this.lastProbe=probe;for(const s of scanning)if(s.status==='running'){ const p=probe.processes.find(p=>p.pid===s.pid&&p.start===s.start&&p.path===s.path); diff --git a/tests/account-manager.test.js b/tests/account-manager.test.js index 5ea8014..498fe81 100644 --- a/tests/account-manager.test.js +++ b/tests/account-manager.test.js @@ -6,6 +6,8 @@ const {ManagerCleanupService}=require('../src/services/manager-cleanup-service') const {WindowsSessionControl}=require('../src/services/windows-session-control'); const {OperationCoordinator}=require('../src/core/operation-coordinator'); const SECRET='test-only-session-value-1234567890'; +const reviewedSessions=(f,ids)=>Object.fromEntries(ids.map(id=>[id,[...f.service.sessions.values()].filter(s=>s.accountId===id&&s.status==='running').map(s=>s.id)])); +const reviewedFavorites=(f,ids)=>Object.fromEntries(ids.map(id=>[id,f.store.account(id).games.find(g=>g.favorite)?.id??null])); function fixture(t){const dir=fs.mkdtempSync(path.join(os.tmpdir(),'dragon-manager-'));t.after(()=>fs.rmSync(dir,{recursive:true,force:true}));let time=2000000000000; const store=new AccountManagerStore(dir,{now:()=>time});const provider={identity:async()=>({userId:'123',username:'TestAccount',displayName:'Test'}),ticket:async()=>'test-ticket',gameName:async()=>null}; const calls=[];const native={run:async r=>{calls.push(r);return {ok:true,processes:[],logicalCores:4};}}; @@ -87,3 +89,25 @@ test('a replacement session created during an exit scan is not evaluated against const f=fixture(t),a=await f.service.connect(SECRET);f.store.editAccount(a.id,{policy:{autoRestart:true}});const old={id:'old-session',accountId:a.id,status:'running',pid:123,start:'old',path:'player',startedAt:1999999900000,restartCount:0,gameId:'game'};f.service.sessions.set(old.id,old); const replacement={...old,id:'replacement',pid:456,start:'new',startedAt:2000000000000,restartCount:1};f.service.launch=async()=>{f.service.sessions.set(replacement.id,replacement);return {ok:true};};await f.service.tick();assert.equal(old.status,'exited');assert.equal(replacement.status,'running');assert.equal(f.store.state().history.length,1); }); + +test('v2.5 account metadata loads as ungrouped without rewriting credentials or the file',async t=>{const f=fixture(t),a=await f.service.connect(SECRET);const legacy=f.store.state();delete legacy.groups;delete legacy.accounts[0].groupId;fs.writeFileSync(f.store.file,JSON.stringify(legacy));const before=fs.readFileSync(f.store.file,'utf8'),vault=fs.readFileSync(f.service.vaultFile,'utf8');const upgraded=new AccountManagerStore(f.dir);assert.deepEqual(upgraded.state().groups,[]);assert.equal(upgraded.account(a.id).groupId,null);assert.equal(fs.readFileSync(f.store.file,'utf8'),before);assert.equal(fs.readFileSync(f.service.vaultFile,'utf8'),vault);}); +test('group creation, rename, assignment and removal retain account games and credentials',async t=>{const f=fixture(t),a=await f.service.connect(SECRET),game=await f.service.saveGame(a.id,{target:'123'});const group=f.store.saveGroup({name:'Creators'});f.store.bulkEditAccounts([a.id],{groupId:group.id});f.store.saveGroup({id:group.id,name:'Main accounts'});assert.equal(new AccountManagerStore(f.dir).account(a.id).groupId,group.id);assert.throws(()=>f.store.saveGroup({name:'main ACCOUNTS'}),/already exists/);f.store.removeGroup(group.id);assert.equal(f.store.account(a.id).groupId,null);assert.equal(f.store.account(a.id).games[0].id,game.id);assert.equal(await f.service.cookie(a.id),SECRET);}); +test('invalid bulk metadata selection makes no partial account changes',async t=>{const f=fixture(t),a=await f.service.connect(SECRET);assert.throws(()=>f.store.bulkEditAccounts([a.id,'missing'],{favorite:true}),/distinct saved/);assert.equal(f.store.account(a.id).favorite,false);assert.throws(()=>f.store.bulkEditAccounts([a.id,a.id],{favorite:true}),/distinct saved/);assert.throws(()=>f.store.bulkEditAccounts([a.id],{groupId:'unknown'}),/Group not found/);}); +test('bulk refresh runs serially and reports independent account failures',async t=>{const f=fixture(t),a=await f.service.connect(SECRET),b=f.store.saveAccount({userId:'456',username:'Second'});const order=[];let active=0;f.service.refreshAccount=async id=>{assert.equal(active++,0);order.push(id);await new Promise(r=>setImmediate(r));active--;if(id===a.id)throw new Error('Session expired');};const results=await f.service.bulkAccounts('refresh',[a.id,b.id]);assert.deepEqual(order,[a.id,b.id]);assert.equal(results[0].ok,false);assert.equal(results[1].ok,true);assert.equal(f.service.bulkBusy,false);}); +test('bulk favorite launches require explicit concurrency and use each account own favorite',async t=>{const f=fixture(t),a=await f.service.connect(SECRET),b=f.store.saveAccount({userId:'456',username:'Second'});const first=f.store.saveGame(a.id,{placeId:'123',uri:'roblox://experiences/start?placeId=123',favorite:true}),second=f.store.saveGame(b.id,{placeId:'456',uri:'roblox://experiences/start?placeId=456',favorite:true});let calls=[];f.service.launch=async(...args)=>{calls.push(args);return {ok:true,sessionId:'fake'};};await assert.rejects(f.service.bulkAccounts('launch-favorite',[a.id,b.id],reviewedFavorites(f,[a.id,b.id])),/concurrent/);assert.equal(calls.length,0);f.store.options({multiInstance:true});const results=await f.service.bulkAccounts('launch-favorite',[a.id,b.id],reviewedFavorites(f,[a.id,b.id]));assert.deepEqual(calls,[[a.id,first.id],[b.id,second.id]]);assert.ok(results.every(r=>r.ok));}); +test('Stop all cancels remaining favorite launches in a batch',async t=>{const f=fixture(t),a=await f.service.connect(SECRET),b=f.store.saveAccount({userId:'456',username:'Second'});for(const id of [a.id,b.id])f.store.saveGame(id,{placeId:'123',uri:'roblox://experiences/start?placeId=123',favorite:true});f.store.options({multiInstance:true});let calls=0;f.service.launch=async()=>{calls++;await f.service.stopAll();return {ok:true};};const result=await f.service.bulkAccounts('launch-favorite',[a.id,b.id],reviewedFavorites(f,[a.id,b.id]));assert.equal(calls,1);assert.equal(result[1].ok,false);assert.match(result[1].message,/cancelled/);}); +test('bulk stop targets only selected managed accounts',async t=>{const f=fixture(t),a=await f.service.connect(SECRET),b=f.store.saveAccount({userId:'456',username:'Second'});for(const [id,accountId]of [['one',a.id],['two',b.id]])f.service.sessions.set(id,{id,accountId,status:'running',startedAt:1999999900000});const results=await f.service.bulkAccounts('stop',[a.id],null,reviewedSessions(f,[a.id]));assert.equal(results[0].ok,true);assert.equal(f.service.sessions.get('one').status,'stopped');assert.equal(f.service.sessions.get('two').status,'running');}); +test('history retains account name and truthful outcome after account removal',async t=>{const f=fixture(t),a=await f.service.connect(SECRET);f.store.editAccount(a.id,{alias:'Main'});const s={id:'history',accountId:a.id,status:'running',startedAt:1999999900000,gameName:'My game',joinState:'joined',restartCount:1};f.service.sessions.set(s.id,s);f.service.finish(s,'detached');f.service.removeAccount(a.id);const item=new AccountManagerStore(f.dir).state().history[0];assert.equal(item.accountName,'Main');assert.equal(item.status,'detached');assert.equal(item.joinState,'joined');assert.equal(item.restartCount,1);assert.ok(!JSON.stringify(item).includes(SECRET));}); + +test('bulk operations reject overlaps and release the busy guard after completion',async t=>{const f=fixture(t),a=await f.service.connect(SECRET);let entered,resume;const waiting=new Promise(r=>entered=r);f.service.refreshAccount=async()=>{entered();await new Promise(r=>resume=r);};const first=f.service.bulkAccounts('refresh',[a.id]);await waiting;await assert.rejects(f.service.bulkAccounts('refresh',[a.id]),/still running/);resume();await first;assert.equal(f.service.bulkBusy,false);}); +test('one missing favorite does not substitute another game or block later accounts',async t=>{const f=fixture(t),a=await f.service.connect(SECRET),b=f.store.saveAccount({userId:'456',username:'Second'});f.store.saveGame(a.id,{placeId:'123',uri:'roblox://experiences/start?placeId=123'});const favorite=f.store.saveGame(b.id,{placeId:'456',uri:'roblox://experiences/start?placeId=456',favorite:true});f.store.options({multiInstance:true});const calls=[];f.service.launch=async(...args)=>{calls.push(args);return {ok:true};};const results=await f.service.bulkAccounts('launch-favorite',[a.id,b.id],reviewedFavorites(f,[a.id,b.id]));assert.equal(results[0].ok,false);assert.match(results[0].message,/No favorite/);assert.equal(results[1].ok,true);assert.deepEqual(calls,[[b.id,favorite.id]]);}); +test('failed group persistence leaves memberships and group list unchanged',async t=>{const f=fixture(t),a=await f.service.connect(SECRET),g=f.store.saveGroup({name:'Main'});f.store.bulkEditAccounts([a.id],{groupId:g.id});const before=f.store.state(),original=f.store.fs;f.store.fs=Object.create(fs);f.store.fs.renameSync=()=>{throw new Error('disk failed');};assert.throws(()=>f.store.removeGroup(g.id),/disk failed/);assert.deepEqual(f.store.state(),before);f.store.fs=original;}); + +test('a changed favorite target is rejected after bulk review before any launch',async t=>{const f=fixture(t),a=await f.service.connect(SECRET);const old=f.store.saveGame(a.id,{placeId:'123',uri:'roblox://experiences/start?placeId=123',favorite:true});const review=reviewedFavorites(f,[a.id]);f.store.saveGame(a.id,{...old,favorite:false});f.store.saveGame(a.id,{placeId:'456',uri:'roblox://experiences/start?placeId=456',favorite:true});let launches=0;f.service.launch=async()=>{launches++;return {ok:true};};const results=await f.service.bulkAccounts('launch-favorite',[a.id],review);assert.equal(launches,0);assert.equal(results[0].ok,false);assert.match(results[0].message,/changed after review/);}); + +test('cancelling a refresh batch stops remaining accounts without changing a running session',async t=>{const f=fixture(t),a=await f.service.connect(SECRET),b=f.store.saveAccount({userId:'456',username:'Second'});const session={id:'running',accountId:a.id,status:'running'};f.service.sessions.set(session.id,session);const seen=[];f.service.refreshAccount=async id=>{seen.push(id);f.service.cancelBulk();};const results=await f.service.bulkAccounts('refresh',[a.id,b.id]);assert.deepEqual(seen,[a.id]);assert.equal(results[0].ok,true);assert.equal(results[1].ok,false);assert.match(results[1].message,/cancelled/);assert.equal(session.status,'running');assert.equal(f.service.bulkBusy,false);}); +test('bulk launch requires an explicit reviewed favorite map before dispatch',async t=>{const f=fixture(t),a=await f.service.connect(SECRET);let calls=0;f.service.launch=async()=>{calls++;return {ok:true};};await assert.rejects(f.service.bulkAccounts('launch-favorite',[a.id]),/Review favorite/);assert.equal(calls,0);}); + +test('removing an account with a pending launch is refused before changing metadata or credentials',async t=>{const f=fixture(t),a=await f.service.connect(SECRET);f.service.busy.add(a.id);assert.throws(()=>f.service.removeAccount(a.id),/launch to finish/);assert.ok(f.store.account(a.id));assert.equal(await f.service.cookie(a.id),SECRET);}); + +test('bulk stop refuses a replacement session that was not in the reviewed plan',async t=>{const f=fixture(t),a=await f.service.connect(SECRET),review={[a.id]:['old-session']};const fresh={id:'new-session',accountId:a.id,status:'running',startedAt:1999999900000};f.service.sessions.set(fresh.id,fresh);const results=await f.service.bulkAccounts('stop',[a.id],null,review);assert.equal(results[0].ok,false);assert.match(results[0].message,/changed after review/);assert.equal(fresh.status,'running');assert.equal(f.calls.length,0);});