mirror of
https://github.com/GopeedLab/gopeed.git
synced 2026-11-05 16:15:43 +00:00
feat: queue extractions (#1227)
This commit is contained in:
1 parent
de0afb1481
commit
24db9bb58b
35 files changed
+1047
-159
No files matched your search
+118
-61
@@ -52,6 +52,8 @@ type ExtractStatus string
|
||||
const (
|
||||
// ExtractStatusNone indicates extraction has not started
|
||||
ExtractStatusNone ExtractStatus = ""
|
||||
// ExtractStatusQueued indicates extraction is waiting in the queue
|
||||
ExtractStatusQueued ExtractStatus = "queued"
|
||||
// ExtractStatusWaitingParts indicates waiting for other multi-part archive parts to complete
|
||||
ExtractStatusWaitingParts ExtractStatus = "waitingParts"
|
||||
// ExtractStatusExtracting indicates extraction is in progress
|
||||
@@ -102,6 +104,10 @@ type Downloader struct {
|
||||
checkDuplicateLock *sync.Mutex
|
||||
closed atomic.Bool
|
||||
|
||||
// claimedExtractions tracks which multi-part archives have been claimed for extraction
|
||||
// Key: fullBaseName (e.g., "/path/archive.7z"), Value: taskID that claimed it
|
||||
claimedExtractions sync.Map
|
||||
|
||||
extensions []*Extension
|
||||
}
|
||||
|
||||
@@ -736,6 +742,9 @@ func (d *Downloader) emit(eventKey EventKey, task *Task, errs ...error) {
|
||||
}
|
||||
|
||||
func (d *Downloader) GetTask(id string) *Task {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
for _, task := range d.tasks {
|
||||
if task.ID == id {
|
||||
return task
|
||||
@@ -745,12 +754,18 @@ func (d *Downloader) GetTask(id string) *Task {
|
||||
}
|
||||
|
||||
func (d *Downloader) GetTasks() []*Task {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
return d.tasks
|
||||
}
|
||||
|
||||
// GetTasksByFilter get tasks by filter, if filter is nil, return all tasks
|
||||
// return tasks and if match all tasks
|
||||
func (d *Downloader) GetTasksByFilter(filter *TaskFilter) []*Task {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
if filter == nil || filter.IsEmpty() {
|
||||
return d.tasks
|
||||
}
|
||||
@@ -905,9 +920,10 @@ func (d *Downloader) watch(task *Task) {
|
||||
}()
|
||||
}
|
||||
|
||||
// Auto-extract archive files
|
||||
// Auto-extract archive files using the extraction queue
|
||||
// This ensures only one extraction runs at a time to prevent resource exhaustion
|
||||
if e.AutoExtract && isArchiveFile(downloadFilePath) {
|
||||
go d.handleAutoExtract(task, downloadFilePath, e)
|
||||
d.enqueueExtraction(task, downloadFilePath, e)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1200,55 +1216,93 @@ func (d *Downloader) buildFetcher(url string) (fetcher.Fetcher, error) {
|
||||
return fetcher, nil
|
||||
}
|
||||
|
||||
// handleAutoExtract handles automatic extraction of archive files, including multi-part archives
|
||||
func (d *Downloader) handleAutoExtract(task *Task, downloadFilePath string, opts *http.OptsExtra) {
|
||||
// Check if this is a multi-part archive
|
||||
// enqueueExtraction adds an extraction job to the global extraction queue
|
||||
// This ensures only one extraction (or one multi-part archive extraction) runs at a time
|
||||
// to prevent resource exhaustion
|
||||
func (d *Downloader) enqueueExtraction(task *Task, downloadFilePath string, opts *http.OptsExtra) {
|
||||
partInfo := getArchivePartInfo(downloadFilePath)
|
||||
|
||||
if partInfo.IsMultiPart {
|
||||
// Set multi-part info on the task
|
||||
task.Progress.MultiPartBaseName = partInfo.BaseName
|
||||
task.Progress.MultiPartNumber = partInfo.PartNumber
|
||||
task.Progress.MultiPartIsFirst = isFirstPart(downloadFilePath)
|
||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
||||
// For multi-part archives, handle specially
|
||||
d.enqueueMultiPartExtraction(task, downloadFilePath, partInfo, opts)
|
||||
} else {
|
||||
// For single archives, queue immediately
|
||||
d.enqueueSingleExtraction(task, downloadFilePath, opts)
|
||||
}
|
||||
}
|
||||
|
||||
// Check if all parts are downloaded
|
||||
destDir := task.Meta.Opts.Path
|
||||
allPartsReady, missingParts := d.checkMultiPartArchiveReady(downloadFilePath, destDir, partInfo)
|
||||
// enqueueSingleExtraction queues extraction for a single (non-multi-part) archive
|
||||
func (d *Downloader) enqueueSingleExtraction(task *Task, downloadFilePath string, opts *http.OptsExtra) {
|
||||
jobID := "single:" + task.ID
|
||||
|
||||
if !allPartsReady {
|
||||
// Not all parts are ready yet, set status to waiting
|
||||
task.Progress.ExtractStatus = ExtractStatusWaitingParts
|
||||
d.emit(EventKeyProgress, task)
|
||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
||||
d.Logger.Info().Msgf("multi-part archive waiting for other parts, task id: %s, missing: %v", task.ID, missingParts)
|
||||
return
|
||||
}
|
||||
// Set extraction status to queued
|
||||
task.Progress.ExtractStatus = ExtractStatusQueued
|
||||
d.emit(EventKeyProgress, task)
|
||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
||||
d.Logger.Info().Msgf("extraction queued, task id: %s, job id: %s", task.ID, jobID)
|
||||
|
||||
// All parts are ready!
|
||||
// Only one task should perform extraction - we'll use the one that completes last
|
||||
// Check if extraction has already been started by another part
|
||||
if d.isMultiPartExtractionInProgress(partInfo.BaseName) {
|
||||
// Another part already started extraction, mark this as done
|
||||
task.Progress.ExtractStatus = ExtractStatusDone
|
||||
task.Progress.ExtractProgress = 100
|
||||
d.emit(EventKeyProgress, task)
|
||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
||||
d.Logger.Info().Msgf("multi-part archive part completed, extraction handled by another part, task id: %s", task.ID)
|
||||
return
|
||||
}
|
||||
// Create and enqueue the extraction job
|
||||
job := NewExtractionJob(jobID, func() {
|
||||
d.performExtraction(task, downloadFilePath, task.Meta.Opts.Path, opts)
|
||||
})
|
||||
|
||||
// This task will handle extraction - mark as extracting first to prevent others
|
||||
task.Progress.ExtractStatus = ExtractStatusExtracting
|
||||
go func() {
|
||||
GetExtractionQueue().Enqueue(job)
|
||||
}()
|
||||
}
|
||||
|
||||
// enqueueMultiPartExtraction handles queueing for multi-part archives
|
||||
// It ensures only ONE extraction job is queued when ALL parts are ready
|
||||
func (d *Downloader) enqueueMultiPartExtraction(task *Task, downloadFilePath string, partInfo ArchivePartInfo, opts *http.OptsExtra) {
|
||||
// Set multi-part info on the task
|
||||
task.Progress.MultiPartBaseName = partInfo.BaseName
|
||||
task.Progress.MultiPartNumber = partInfo.PartNumber
|
||||
task.Progress.MultiPartIsFirst = isFirstPart(downloadFilePath)
|
||||
|
||||
// Check if all parts are downloaded
|
||||
destDir := task.Meta.Opts.Path
|
||||
allPartsReady, missingParts := d.checkMultiPartArchiveReady(downloadFilePath, destDir, partInfo)
|
||||
|
||||
if !allPartsReady {
|
||||
// Not all parts are ready yet - just set status to waiting, don't queue anything
|
||||
task.Progress.ExtractStatus = ExtractStatusWaitingParts
|
||||
d.emit(EventKeyProgress, task)
|
||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
||||
|
||||
// Extract the multi-part archive
|
||||
d.performMultiPartExtraction(task, partInfo.FirstPartPath, destDir, opts)
|
||||
} else {
|
||||
// Regular (non-multi-part) archive
|
||||
d.performExtraction(task, downloadFilePath, task.Meta.Opts.Path, opts)
|
||||
d.Logger.Info().Msgf("multi-part archive waiting for other parts, task id: %s, missing: %v", task.ID, missingParts)
|
||||
return
|
||||
}
|
||||
|
||||
// All parts are ready! Atomically check if extraction has already been started/queued
|
||||
// and if not, mark this task as the one that will handle it
|
||||
// Use GetMultiPartArchiveBaseName to get the full path for comparison
|
||||
fullBaseName := GetMultiPartArchiveBaseName(downloadFilePath)
|
||||
shouldQueue := d.tryClaimMultiPartExtraction(task, fullBaseName)
|
||||
|
||||
if !shouldQueue {
|
||||
// Another part already started/queued extraction, mark this task as done
|
||||
task.Progress.ExtractStatus = ExtractStatusDone
|
||||
task.Progress.ExtractProgress = 100
|
||||
d.emit(EventKeyProgress, task)
|
||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
||||
d.Logger.Info().Msgf("multi-part archive extraction already handled by another part, task id: %s", task.ID)
|
||||
return
|
||||
}
|
||||
|
||||
// This task claimed the extraction - status already set to queued in tryClaimMultiPartExtraction
|
||||
d.emit(EventKeyProgress, task)
|
||||
d.storage.Put(bucketTask, task.ID, task.clone())
|
||||
|
||||
jobID := "multipart:" + fullBaseName
|
||||
d.Logger.Info().Msgf("multi-part extraction queued, task id: %s, job id: %s", task.ID, jobID)
|
||||
|
||||
// Create and enqueue the extraction job
|
||||
job := NewExtractionJob(jobID, func() {
|
||||
d.performMultiPartExtraction(task, partInfo.FirstPartPath, destDir, opts)
|
||||
})
|
||||
|
||||
go func() {
|
||||
GetExtractionQueue().Enqueue(job)
|
||||
}()
|
||||
}
|
||||
|
||||
// checkMultiPartArchiveReady checks if all parts of a multi-part archive are downloaded
|
||||
@@ -1298,28 +1352,25 @@ func (d *Downloader) checkAllMultiPartTasksDone(baseName string) (bool, []string
|
||||
return len(notDoneParts) == 0, notDoneParts
|
||||
}
|
||||
|
||||
// isMultiPartExtractionInProgress checks if any task for this multi-part archive
|
||||
// has already started extraction (to prevent duplicate extractions)
|
||||
func (d *Downloader) isMultiPartExtractionInProgress(baseName string) bool {
|
||||
d.lock.Lock()
|
||||
defer d.lock.Unlock()
|
||||
|
||||
for _, task := range d.tasks {
|
||||
taskBaseName := ""
|
||||
if task.Meta != nil && task.Meta.Res != nil && len(task.Meta.Res.Files) > 0 {
|
||||
taskBaseName = GetMultiPartArchiveBaseName(task.Meta.SingleFilepath())
|
||||
}
|
||||
if taskBaseName == baseName {
|
||||
// Check if this task is already extracting or has completed extraction
|
||||
if task.Progress.ExtractStatus == ExtractStatusExtracting ||
|
||||
task.Progress.ExtractStatus == ExtractStatusDone ||
|
||||
task.Progress.ExtractStatus == ExtractStatusError {
|
||||
return true
|
||||
}
|
||||
}
|
||||
// tryClaimMultiPartExtraction atomically checks if extraction can be claimed for a multi-part archive
|
||||
// and if so, marks the task as queued. Returns true if this task should proceed with queueing.
|
||||
// This uses sync.Map.LoadOrStore for atomic claim to prevent race conditions.
|
||||
func (d *Downloader) tryClaimMultiPartExtraction(task *Task, baseName string) bool {
|
||||
// Use LoadOrStore for atomic claim - if another goroutine already stored a value, we get that value back
|
||||
_, alreadyClaimed := d.claimedExtractions.LoadOrStore(baseName, task.ID)
|
||||
if alreadyClaimed {
|
||||
return false // Another task already claimed it
|
||||
}
|
||||
|
||||
return false
|
||||
// This task successfully claimed it
|
||||
task.Progress.ExtractStatus = ExtractStatusQueued
|
||||
return true
|
||||
}
|
||||
|
||||
// releaseMultiPartExtractionClaim releases the extraction claim for a multi-part archive
|
||||
// This is primarily used for testing purposes
|
||||
func (d *Downloader) releaseMultiPartExtractionClaim(baseName string) {
|
||||
d.claimedExtractions.Delete(baseName)
|
||||
}
|
||||
|
||||
// performExtraction performs extraction for a regular (non-multi-part) archive
|
||||
@@ -1341,6 +1392,9 @@ func (d *Downloader) performExtraction(task *Task, archivePath string, destDir s
|
||||
|
||||
// performMultiPartExtraction performs extraction for a multi-part archive
|
||||
func (d *Downloader) performMultiPartExtraction(task *Task, firstPartPath string, destDir string, opts *http.OptsExtra) {
|
||||
// Get the baseName for releasing the claim later
|
||||
fullBaseName := GetMultiPartArchiveBaseName(firstPartPath)
|
||||
|
||||
// Set extraction status to extracting
|
||||
task.Progress.ExtractStatus = ExtractStatusExtracting
|
||||
task.Progress.ExtractProgress = 0
|
||||
@@ -1362,6 +1416,9 @@ func (d *Downloader) performMultiPartExtraction(task *Task, firstPartPath string
|
||||
|
||||
// Update status for all related multi-part tasks
|
||||
d.updateMultiPartTasksStatus(task, extractErr)
|
||||
|
||||
// Release the claim so future downloads of the same archive can be extracted
|
||||
d.releaseMultiPartExtractionClaim(fullBaseName)
|
||||
}
|
||||
|
||||
// collectMultiPartFiles collects all files belonging to a multi-part archive
|
||||
|
||||
@@ -1420,6 +1420,7 @@ func TestExtractStatus(t *testing.T) {
|
||||
expected string
|
||||
}{
|
||||
{ExtractStatusNone, ""},
|
||||
{ExtractStatusQueued, "queued"},
|
||||
{ExtractStatusExtracting, "extracting"},
|
||||
{ExtractStatusDone, "done"},
|
||||
{ExtractStatusError, "error"},
|
||||
@@ -2083,7 +2084,7 @@ func TestDownloader_CheckAllMultiPartTasksDone_NoRelatedTasks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestDownloader_IsMultiPartExtractionInProgress(t *testing.T) {
|
||||
func TestDownloader_TryClaimMultiPartExtraction(t *testing.T) {
|
||||
downloader := NewDownloader(nil)
|
||||
if err := downloader.Setup(); err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -2097,50 +2098,58 @@ func TestDownloader_IsMultiPartExtractionInProgress(t *testing.T) {
|
||||
defer os.RemoveAll(tempDir)
|
||||
|
||||
baseName := "archive.7z"
|
||||
basePath := filepath.Join(tempDir, baseName)
|
||||
// GetMultiPartArchiveBaseName returns filepath.Join(dir, baseName)
|
||||
fullBaseName := filepath.Join(tempDir, baseName)
|
||||
|
||||
// Create task with no extraction status
|
||||
// Create tasks
|
||||
task1 := &Task{
|
||||
ID: "task1",
|
||||
Status: base.DownloadStatusDone,
|
||||
Meta: &fetcher.FetcherMeta{
|
||||
Opts: &base.Options{Path: tempDir},
|
||||
Res: &base.Resource{
|
||||
Files: []*base.FileInfo{{Name: baseName + ".001"}},
|
||||
Files: []*base.FileInfo{{Name: baseName + ".001", Path: ""}},
|
||||
},
|
||||
},
|
||||
Progress: &Progress{ExtractStatus: ExtractStatusNone},
|
||||
}
|
||||
initTask(task1)
|
||||
downloader.tasks = []*Task{task1}
|
||||
|
||||
// No extraction in progress
|
||||
if downloader.isMultiPartExtractionInProgress(basePath) {
|
||||
t.Error("isMultiPartExtractionInProgress() = true, want false")
|
||||
task2 := &Task{
|
||||
ID: "task2",
|
||||
Status: base.DownloadStatusDone,
|
||||
Meta: &fetcher.FetcherMeta{
|
||||
Opts: &base.Options{Path: tempDir},
|
||||
Res: &base.Resource{
|
||||
Files: []*base.FileInfo{{Name: baseName + ".002", Path: ""}},
|
||||
},
|
||||
},
|
||||
Progress: &Progress{ExtractStatus: ExtractStatusNone},
|
||||
}
|
||||
initTask(task2)
|
||||
|
||||
downloader.tasks = []*Task{task1, task2}
|
||||
|
||||
// Task1 should be able to claim extraction (no one has claimed yet)
|
||||
if !downloader.tryClaimMultiPartExtraction(task1, fullBaseName) {
|
||||
t.Error("tryClaimMultiPartExtraction() = false, want true (first claim)")
|
||||
}
|
||||
// task1's status should now be Queued
|
||||
if task1.Progress.ExtractStatus != ExtractStatusQueued {
|
||||
t.Errorf("task1.ExtractStatus = %v, want %v", task1.Progress.ExtractStatus, ExtractStatusQueued)
|
||||
}
|
||||
|
||||
// Set to extracting
|
||||
task1.Progress.ExtractStatus = ExtractStatusExtracting
|
||||
if !downloader.isMultiPartExtractionInProgress(basePath) {
|
||||
t.Error("isMultiPartExtractionInProgress() = false, want true (extracting)")
|
||||
// Task2 should NOT be able to claim (task1 already claimed via sync.Map)
|
||||
if downloader.tryClaimMultiPartExtraction(task2, fullBaseName) {
|
||||
t.Error("tryClaimMultiPartExtraction() = true, want false (already claimed)")
|
||||
}
|
||||
|
||||
// Set to done
|
||||
task1.Progress.ExtractStatus = ExtractStatusDone
|
||||
if !downloader.isMultiPartExtractionInProgress(basePath) {
|
||||
t.Error("isMultiPartExtractionInProgress() = false, want true (done)")
|
||||
}
|
||||
// Release the claim
|
||||
downloader.releaseMultiPartExtractionClaim(fullBaseName)
|
||||
|
||||
// Set to error
|
||||
task1.Progress.ExtractStatus = ExtractStatusError
|
||||
if !downloader.isMultiPartExtractionInProgress(basePath) {
|
||||
t.Error("isMultiPartExtractionInProgress() = false, want true (error)")
|
||||
}
|
||||
|
||||
// Set to waiting parts (not in progress)
|
||||
task1.Progress.ExtractStatus = ExtractStatusWaitingParts
|
||||
if downloader.isMultiPartExtractionInProgress(basePath) {
|
||||
t.Error("isMultiPartExtractionInProgress() = true, want false (waitingParts)")
|
||||
// Now task2 CAN claim (claim was released)
|
||||
if !downloader.tryClaimMultiPartExtraction(task2, fullBaseName) {
|
||||
t.Error("tryClaimMultiPartExtraction() = false, want true (claim was released)")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ExtractionJob represents a single extraction job in the queue
|
||||
type ExtractionJob struct {
|
||||
// ID is a unique identifier for this job (usually task ID or multi-part base name)
|
||||
ID string
|
||||
// Execute is the function that performs the actual extraction
|
||||
Execute func()
|
||||
// done channel is signaled when the job has been executed
|
||||
done chan struct{}
|
||||
}
|
||||
|
||||
// NewExtractionJob creates a new extraction job
|
||||
func NewExtractionJob(id string, execute func()) *ExtractionJob {
|
||||
return &ExtractionJob{
|
||||
ID: id,
|
||||
Execute: execute,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
// Wait blocks until the job has been executed
|
||||
func (j *ExtractionJob) Wait() {
|
||||
<-j.done
|
||||
}
|
||||
|
||||
// ExtractionQueue manages a queue of extraction jobs to prevent resource exhaustion
|
||||
// by ensuring only one extraction (or one multi-part archive extraction) runs at a time
|
||||
type ExtractionQueue struct {
|
||||
mu sync.Mutex
|
||||
cond *sync.Cond
|
||||
jobs []*ExtractionJob
|
||||
running bool
|
||||
shutdown bool
|
||||
wg sync.WaitGroup
|
||||
}
|
||||
|
||||
// NewExtractionQueue creates a new extraction queue
|
||||
func NewExtractionQueue() *ExtractionQueue {
|
||||
q := &ExtractionQueue{
|
||||
jobs: make([]*ExtractionJob, 0),
|
||||
}
|
||||
q.cond = sync.NewCond(&q.mu)
|
||||
return q
|
||||
}
|
||||
|
||||
// Start starts the queue worker that processes jobs sequentially
|
||||
func (q *ExtractionQueue) Start() {
|
||||
q.mu.Lock()
|
||||
if q.running {
|
||||
q.mu.Unlock()
|
||||
return
|
||||
}
|
||||
q.running = true
|
||||
q.shutdown = false
|
||||
q.mu.Unlock()
|
||||
|
||||
q.wg.Add(1)
|
||||
go q.worker()
|
||||
}
|
||||
|
||||
// Stop stops the queue worker gracefully
|
||||
// It waits for the current job to complete but discards pending jobs
|
||||
func (q *ExtractionQueue) Stop() {
|
||||
q.mu.Lock()
|
||||
if !q.running {
|
||||
q.mu.Unlock()
|
||||
return
|
||||
}
|
||||
q.shutdown = true
|
||||
q.cond.Signal()
|
||||
q.mu.Unlock()
|
||||
|
||||
q.wg.Wait()
|
||||
}
|
||||
|
||||
// Enqueue adds a new extraction job to the queue
|
||||
// The job will be executed when its turn comes (FIFO order)
|
||||
// Returns the job so the caller can wait for completion if needed
|
||||
func (q *ExtractionQueue) Enqueue(job *ExtractionJob) *ExtractionJob {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
if q.shutdown {
|
||||
// Queue is shutting down, signal done immediately without executing
|
||||
close(job.done)
|
||||
return job
|
||||
}
|
||||
|
||||
q.jobs = append(q.jobs, job)
|
||||
q.cond.Signal()
|
||||
return job
|
||||
}
|
||||
|
||||
// EnqueueAndWait adds a new extraction job to the queue and waits for it to complete
|
||||
func (q *ExtractionQueue) EnqueueAndWait(job *ExtractionJob) {
|
||||
q.Enqueue(job)
|
||||
job.Wait()
|
||||
}
|
||||
|
||||
// QueueLength returns the current number of pending jobs in the queue
|
||||
func (q *ExtractionQueue) QueueLength() int {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
return len(q.jobs)
|
||||
}
|
||||
|
||||
// IsRunning returns true if the queue worker is running
|
||||
func (q *ExtractionQueue) IsRunning() bool {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
return q.running
|
||||
}
|
||||
|
||||
// HasPendingJob checks if there's a pending job with the given ID
|
||||
func (q *ExtractionQueue) HasPendingJob(id string) bool {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
for _, job := range q.jobs {
|
||||
if job.ID == id {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// RemovePendingJob removes a pending job with the given ID from the queue
|
||||
// Returns true if a job was removed, false if not found
|
||||
// Note: This cannot remove a job that is currently being executed
|
||||
func (q *ExtractionQueue) RemovePendingJob(id string) bool {
|
||||
q.mu.Lock()
|
||||
defer q.mu.Unlock()
|
||||
|
||||
for i, job := range q.jobs {
|
||||
if job.ID == id {
|
||||
// Close the done channel to unblock any waiters
|
||||
close(job.done)
|
||||
// Remove from queue
|
||||
q.jobs = append(q.jobs[:i], q.jobs[i+1:]...)
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// worker is the main loop that processes jobs sequentially
|
||||
func (q *ExtractionQueue) worker() {
|
||||
defer q.wg.Done()
|
||||
|
||||
for {
|
||||
q.mu.Lock()
|
||||
// Wait for a job or shutdown signal
|
||||
for len(q.jobs) == 0 && !q.shutdown {
|
||||
q.cond.Wait()
|
||||
}
|
||||
|
||||
if q.shutdown {
|
||||
// Close done channels for all remaining jobs
|
||||
for _, job := range q.jobs {
|
||||
close(job.done)
|
||||
}
|
||||
q.jobs = nil
|
||||
q.running = false
|
||||
q.mu.Unlock()
|
||||
return
|
||||
}
|
||||
|
||||
// Dequeue the first job
|
||||
job := q.jobs[0]
|
||||
q.jobs = q.jobs[1:]
|
||||
q.mu.Unlock()
|
||||
|
||||
// Execute the job outside the lock
|
||||
if job.Execute != nil {
|
||||
job.Execute()
|
||||
}
|
||||
|
||||
// Signal that the job is done
|
||||
close(job.done)
|
||||
}
|
||||
}
|
||||
|
||||
// Global extraction queue instance
|
||||
var globalExtractionQueue *ExtractionQueue
|
||||
var extractionQueueOnce sync.Once
|
||||
|
||||
// GetExtractionQueue returns the global extraction queue instance
|
||||
func GetExtractionQueue() *ExtractionQueue {
|
||||
extractionQueueOnce.Do(func() {
|
||||
globalExtractionQueue = NewExtractionQueue()
|
||||
globalExtractionQueue.Start()
|
||||
})
|
||||
return globalExtractionQueue
|
||||
}
|
||||
@@ -0,0 +1,545 @@
|
||||
package download
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestNewExtractionQueue(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
if q == nil {
|
||||
t.Fatal("NewExtractionQueue returned nil")
|
||||
}
|
||||
if q.jobs == nil {
|
||||
t.Fatal("jobs slice should be initialized")
|
||||
}
|
||||
if len(q.jobs) != 0 {
|
||||
t.Fatalf("expected empty jobs slice, got %d", len(q.jobs))
|
||||
}
|
||||
if q.running {
|
||||
t.Fatal("queue should not be running initially")
|
||||
}
|
||||
if q.cond == nil {
|
||||
t.Fatal("condition variable should be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_StartStop(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
|
||||
// Test Start
|
||||
q.Start()
|
||||
if !q.IsRunning() {
|
||||
t.Fatal("queue should be running after Start")
|
||||
}
|
||||
|
||||
// Test double Start (should be idempotent)
|
||||
q.Start()
|
||||
if !q.IsRunning() {
|
||||
t.Fatal("queue should still be running after second Start")
|
||||
}
|
||||
|
||||
// Test Stop
|
||||
q.Stop()
|
||||
if q.IsRunning() {
|
||||
t.Fatal("queue should not be running after Stop")
|
||||
}
|
||||
|
||||
// Test double Stop (should be idempotent)
|
||||
q.Stop()
|
||||
if q.IsRunning() {
|
||||
t.Fatal("queue should still not be running after second Stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_EnqueueSingleJob(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
q.Start()
|
||||
defer q.Stop()
|
||||
|
||||
executed := false
|
||||
job := NewExtractionJob("test-1", func() {
|
||||
executed = true
|
||||
})
|
||||
|
||||
q.Enqueue(job)
|
||||
job.Wait()
|
||||
|
||||
if !executed {
|
||||
t.Fatal("job should have been executed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_EnqueueAndWait(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
q.Start()
|
||||
defer q.Stop()
|
||||
|
||||
executed := false
|
||||
job := NewExtractionJob("test-1", func() {
|
||||
executed = true
|
||||
})
|
||||
|
||||
q.EnqueueAndWait(job)
|
||||
|
||||
if !executed {
|
||||
t.Fatal("job should have been executed after EnqueueAndWait returns")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_FIFOOrder(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
// Don't start yet - we want to queue multiple jobs first
|
||||
|
||||
var executionOrder []string
|
||||
var mu sync.Mutex
|
||||
|
||||
addToOrder := func(id string) {
|
||||
mu.Lock()
|
||||
executionOrder = append(executionOrder, id)
|
||||
mu.Unlock()
|
||||
}
|
||||
|
||||
job1 := NewExtractionJob("job-1", func() {
|
||||
addToOrder("job-1")
|
||||
})
|
||||
job2 := NewExtractionJob("job-2", func() {
|
||||
addToOrder("job-2")
|
||||
})
|
||||
job3 := NewExtractionJob("job-3", func() {
|
||||
addToOrder("job-3")
|
||||
})
|
||||
|
||||
// Enqueue jobs before starting (they'll wait in queue)
|
||||
q.mu.Lock()
|
||||
q.jobs = append(q.jobs, job1, job2, job3)
|
||||
q.mu.Unlock()
|
||||
|
||||
// Now start processing
|
||||
q.Start()
|
||||
defer q.Stop()
|
||||
|
||||
// Wait for all jobs
|
||||
job1.Wait()
|
||||
job2.Wait()
|
||||
job3.Wait()
|
||||
|
||||
// Verify FIFO order
|
||||
if len(executionOrder) != 3 {
|
||||
t.Fatalf("expected 3 executions, got %d", len(executionOrder))
|
||||
}
|
||||
if executionOrder[0] != "job-1" || executionOrder[1] != "job-2" || executionOrder[2] != "job-3" {
|
||||
t.Fatalf("expected FIFO order [job-1, job-2, job-3], got %v", executionOrder)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_SequentialExecution(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
q.Start()
|
||||
defer q.Stop()
|
||||
|
||||
var activeJobs int32
|
||||
var maxConcurrent int32
|
||||
var mu sync.Mutex
|
||||
|
||||
// Create jobs that take some time to execute
|
||||
createJob := func(id string) *ExtractionJob {
|
||||
return NewExtractionJob(id, func() {
|
||||
current := atomic.AddInt32(&activeJobs, 1)
|
||||
mu.Lock()
|
||||
if current > maxConcurrent {
|
||||
maxConcurrent = current
|
||||
}
|
||||
mu.Unlock()
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
atomic.AddInt32(&activeJobs, -1)
|
||||
})
|
||||
}
|
||||
|
||||
jobs := make([]*ExtractionJob, 5)
|
||||
for i := range jobs {
|
||||
jobs[i] = q.Enqueue(createJob(string(rune('A' + i))))
|
||||
}
|
||||
|
||||
// Wait for all jobs
|
||||
for _, job := range jobs {
|
||||
job.Wait()
|
||||
}
|
||||
|
||||
// Verify only one job ran at a time
|
||||
if maxConcurrent > 1 {
|
||||
t.Fatalf("expected max 1 concurrent job, got %d", maxConcurrent)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_QueueLength(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
|
||||
if q.QueueLength() != 0 {
|
||||
t.Fatalf("expected queue length 0, got %d", q.QueueLength())
|
||||
}
|
||||
|
||||
// Add jobs without starting (they'll stay in queue)
|
||||
blockChan := make(chan struct{})
|
||||
blockingJob := NewExtractionJob("blocking", func() {
|
||||
<-blockChan // Block until signaled
|
||||
})
|
||||
|
||||
q.Start()
|
||||
defer q.Stop()
|
||||
|
||||
q.Enqueue(blockingJob)
|
||||
|
||||
// Give worker time to pick up the blocking job
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
|
||||
// Queue more jobs while blocking job is running
|
||||
job2 := q.Enqueue(NewExtractionJob("job-2", func() {}))
|
||||
job3 := q.Enqueue(NewExtractionJob("job-3", func() {}))
|
||||
|
||||
// Should have 2 jobs waiting
|
||||
if q.QueueLength() != 2 {
|
||||
t.Fatalf("expected queue length 2, got %d", q.QueueLength())
|
||||
}
|
||||
|
||||
// Unblock and wait
|
||||
close(blockChan)
|
||||
blockingJob.Wait()
|
||||
job2.Wait()
|
||||
job3.Wait()
|
||||
|
||||
if q.QueueLength() != 0 {
|
||||
t.Fatalf("expected queue length 0 after completion, got %d", q.QueueLength())
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_HasPendingJob(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
|
||||
blockChan := make(chan struct{})
|
||||
blockingJob := NewExtractionJob("blocking", func() {
|
||||
<-blockChan
|
||||
})
|
||||
|
||||
q.Start()
|
||||
defer q.Stop()
|
||||
|
||||
q.Enqueue(blockingJob)
|
||||
time.Sleep(20 * time.Millisecond) // Let blocking job start
|
||||
|
||||
// Queue more jobs
|
||||
job2 := q.Enqueue(NewExtractionJob("job-2", func() {}))
|
||||
|
||||
if !q.HasPendingJob("job-2") {
|
||||
t.Fatal("expected HasPendingJob to return true for job-2")
|
||||
}
|
||||
|
||||
if q.HasPendingJob("job-99") {
|
||||
t.Fatal("expected HasPendingJob to return false for non-existent job")
|
||||
}
|
||||
|
||||
// Unblock and wait
|
||||
close(blockChan)
|
||||
blockingJob.Wait()
|
||||
job2.Wait()
|
||||
|
||||
if q.HasPendingJob("job-2") {
|
||||
t.Fatal("expected HasPendingJob to return false after job completion")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_RemovePendingJob(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
|
||||
blockChan := make(chan struct{})
|
||||
blockingJob := NewExtractionJob("blocking", func() {
|
||||
<-blockChan
|
||||
})
|
||||
|
||||
executed := false
|
||||
job2 := NewExtractionJob("job-2", func() {
|
||||
executed = true
|
||||
})
|
||||
|
||||
q.Start()
|
||||
defer q.Stop()
|
||||
|
||||
q.Enqueue(blockingJob)
|
||||
time.Sleep(20 * time.Millisecond) // Let blocking job start
|
||||
q.Enqueue(job2)
|
||||
|
||||
// Remove job-2 before it executes
|
||||
removed := q.RemovePendingJob("job-2")
|
||||
if !removed {
|
||||
t.Fatal("expected RemovePendingJob to return true")
|
||||
}
|
||||
|
||||
// Try to remove again (should return false)
|
||||
removed = q.RemovePendingJob("job-2")
|
||||
if removed {
|
||||
t.Fatal("expected second RemovePendingJob to return false")
|
||||
}
|
||||
|
||||
// Unblock
|
||||
close(blockChan)
|
||||
blockingJob.Wait()
|
||||
|
||||
// Wait a bit for any potential execution
|
||||
time.Sleep(50 * time.Millisecond)
|
||||
|
||||
if executed {
|
||||
t.Fatal("removed job should not have been executed")
|
||||
}
|
||||
|
||||
// The removed job's done channel should be closed
|
||||
select {
|
||||
case <-job2.done:
|
||||
// Expected
|
||||
default:
|
||||
t.Fatal("removed job's done channel should be closed")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_StopDiscardsPendingJobs(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
|
||||
blockChan := make(chan struct{})
|
||||
executed1 := false
|
||||
executed2 := false
|
||||
|
||||
blockingJob := NewExtractionJob("blocking", func() {
|
||||
<-blockChan
|
||||
executed1 = true
|
||||
})
|
||||
pendingJob := NewExtractionJob("pending", func() {
|
||||
executed2 = true
|
||||
})
|
||||
|
||||
q.Start()
|
||||
|
||||
q.Enqueue(blockingJob)
|
||||
time.Sleep(20 * time.Millisecond) // Let blocking job start
|
||||
q.Enqueue(pendingJob)
|
||||
|
||||
// Stop without unblocking
|
||||
go func() {
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
close(blockChan)
|
||||
}()
|
||||
q.Stop()
|
||||
|
||||
// Blocking job should have completed, but pending job was discarded
|
||||
if !executed1 {
|
||||
t.Log("Note: blocking job may not complete if Stop() won races")
|
||||
}
|
||||
if executed2 {
|
||||
t.Fatal("pending job should not have been executed after Stop")
|
||||
}
|
||||
|
||||
// Pending job's done channel should be closed
|
||||
select {
|
||||
case <-pendingJob.done:
|
||||
// Expected
|
||||
default:
|
||||
t.Fatal("pending job's done channel should be closed after Stop")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_EnqueueAfterShutdown(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
q.Start()
|
||||
q.Stop()
|
||||
|
||||
executed := false
|
||||
job := NewExtractionJob("after-shutdown", func() {
|
||||
executed = true
|
||||
})
|
||||
|
||||
q.Enqueue(job)
|
||||
|
||||
// Job should be immediately done (without execution)
|
||||
select {
|
||||
case <-job.done:
|
||||
// Expected
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("job should be immediately done after shutdown")
|
||||
}
|
||||
|
||||
if executed {
|
||||
t.Fatal("job should not be executed after shutdown")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewExtractionJob(t *testing.T) {
|
||||
job := NewExtractionJob("test-id", func() {})
|
||||
|
||||
if job.ID != "test-id" {
|
||||
t.Fatalf("expected ID 'test-id', got '%s'", job.ID)
|
||||
}
|
||||
if job.Execute == nil {
|
||||
t.Fatal("Execute should not be nil")
|
||||
}
|
||||
if job.done == nil {
|
||||
t.Fatal("done channel should be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionJob_Wait(t *testing.T) {
|
||||
job := NewExtractionJob("test", func() {})
|
||||
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
job.Wait()
|
||||
close(done)
|
||||
}()
|
||||
|
||||
// Wait should block
|
||||
select {
|
||||
case <-done:
|
||||
t.Fatal("Wait should block until job is done")
|
||||
case <-time.After(50 * time.Millisecond):
|
||||
// Expected
|
||||
}
|
||||
|
||||
// Close done channel
|
||||
close(job.done)
|
||||
|
||||
// Now Wait should return
|
||||
select {
|
||||
case <-done:
|
||||
// Expected
|
||||
case <-time.After(100 * time.Millisecond):
|
||||
t.Fatal("Wait should return after job is done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_NilExecuteFunction(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
q.Start()
|
||||
defer q.Stop()
|
||||
|
||||
// Job with nil Execute should not panic
|
||||
job := &ExtractionJob{
|
||||
ID: "nil-execute",
|
||||
Execute: nil,
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
|
||||
q.Enqueue(job)
|
||||
job.Wait()
|
||||
// No panic means success
|
||||
}
|
||||
|
||||
func TestExtractionQueue_ConcurrentEnqueue(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
q.Start()
|
||||
defer q.Stop()
|
||||
|
||||
var counter int32
|
||||
var wg sync.WaitGroup
|
||||
numGoroutines := 10
|
||||
jobsPerGoroutine := 10
|
||||
|
||||
wg.Add(numGoroutines)
|
||||
for i := 0; i < numGoroutines; i++ {
|
||||
go func(goroutineID int) {
|
||||
defer wg.Done()
|
||||
for j := 0; j < jobsPerGoroutine; j++ {
|
||||
job := NewExtractionJob("", func() {
|
||||
atomic.AddInt32(&counter, 1)
|
||||
})
|
||||
q.EnqueueAndWait(job)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
|
||||
wg.Wait()
|
||||
|
||||
expected := int32(numGoroutines * jobsPerGoroutine)
|
||||
if counter != expected {
|
||||
t.Fatalf("expected counter %d, got %d", expected, counter)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_Restart(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
|
||||
// First run
|
||||
q.Start()
|
||||
executed1 := false
|
||||
job1 := NewExtractionJob("job-1", func() {
|
||||
executed1 = true
|
||||
})
|
||||
q.EnqueueAndWait(job1)
|
||||
q.Stop()
|
||||
|
||||
if !executed1 {
|
||||
t.Fatal("first job should have been executed")
|
||||
}
|
||||
|
||||
// Restart
|
||||
q.Start()
|
||||
executed2 := false
|
||||
job2 := NewExtractionJob("job-2", func() {
|
||||
executed2 = true
|
||||
})
|
||||
q.EnqueueAndWait(job2)
|
||||
q.Stop()
|
||||
|
||||
if !executed2 {
|
||||
t.Fatal("second job should have been executed after restart")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetExtractionQueue(t *testing.T) {
|
||||
// Note: This test modifies global state, so it should ideally be run in isolation
|
||||
// However, the implementation ensures the queue is created only once
|
||||
|
||||
queue := GetExtractionQueue()
|
||||
if queue == nil {
|
||||
t.Fatal("GetExtractionQueue should not return nil")
|
||||
}
|
||||
if !queue.IsRunning() {
|
||||
t.Fatal("global queue should be running")
|
||||
}
|
||||
|
||||
// Calling again should return the same instance
|
||||
queue2 := GetExtractionQueue()
|
||||
if queue != queue2 {
|
||||
t.Fatal("GetExtractionQueue should return the same instance")
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractionQueue_LongRunningJob(t *testing.T) {
|
||||
q := NewExtractionQueue()
|
||||
q.Start()
|
||||
defer q.Stop()
|
||||
|
||||
startTime := time.Now()
|
||||
longJobDuration := 100 * time.Millisecond
|
||||
|
||||
longJob := NewExtractionJob("long-job", func() {
|
||||
time.Sleep(longJobDuration)
|
||||
})
|
||||
|
||||
shortJobExecuted := false
|
||||
shortJob := NewExtractionJob("short-job", func() {
|
||||
shortJobExecuted = true
|
||||
})
|
||||
|
||||
q.Enqueue(longJob)
|
||||
q.Enqueue(shortJob)
|
||||
|
||||
shortJob.Wait()
|
||||
|
||||
elapsed := time.Since(startTime)
|
||||
if elapsed < longJobDuration {
|
||||
t.Fatalf("short job should wait for long job, elapsed: %v", elapsed)
|
||||
}
|
||||
|
||||
if !shortJobExecuted {
|
||||
t.Fatal("short job should have been executed")
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,9 @@ enum ExtractStatus {
|
||||
@JsonValue('done')
|
||||
done,
|
||||
@JsonValue('error')
|
||||
error
|
||||
error,
|
||||
@JsonValue('waitingParts')
|
||||
waitingParts
|
||||
}
|
||||
|
||||
@JsonSerializable(explicitToJson: true)
|
||||
|
||||
@@ -58,6 +58,7 @@ const _$ExtractStatusEnumMap = {
|
||||
ExtractStatus.extracting: 'extracting',
|
||||
ExtractStatus.done: 'done',
|
||||
ExtractStatus.error: 'error',
|
||||
ExtractStatus.waitingParts: 'waitingParts',
|
||||
};
|
||||
|
||||
Progress _$ProgressFromJson(Map<String, dynamic> json) => Progress(
|
||||
@@ -66,9 +67,9 @@ Progress _$ProgressFromJson(Map<String, dynamic> json) => Progress(
|
||||
downloaded: (json['downloaded'] as num).toInt(),
|
||||
uploadSpeed: (json['uploadSpeed'] as num).toInt(),
|
||||
uploaded: (json['uploaded'] as num).toInt(),
|
||||
extractStatus: $enumDecodeNullable(
|
||||
_$ExtractStatusEnumMap, json['extractStatus']) ??
|
||||
ExtractStatus.none,
|
||||
extractStatus:
|
||||
$enumDecodeNullable(_$ExtractStatusEnumMap, json['extractStatus']) ??
|
||||
ExtractStatus.none,
|
||||
extractProgress: (json['extractProgress'] as num?)?.toInt() ?? 0,
|
||||
);
|
||||
|
||||
|
||||
@@ -2,12 +2,12 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_foreground_task/flutter_foreground_task.dart';
|
||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:window_manager/window_manager.dart'; // Import the required packages
|
||||
import 'package:window_manager/window_manager.dart'; // Import the required packages
|
||||
|
||||
import '../../../../i18n/message.dart';
|
||||
import '../../../../theme/theme.dart';
|
||||
import '../../../../util/locale_manager.dart';
|
||||
import '../../../../util/util.dart'; // Import the required packages
|
||||
import '../../../../util/util.dart'; // Import the required packages
|
||||
import '../../../routes/app_pages.dart';
|
||||
import '../controllers/app_controller.dart';
|
||||
|
||||
@@ -38,7 +38,7 @@ class AppView extends GetView<AppController> {
|
||||
// Add listening to theme changes, set the title bar color according to the current system theme.
|
||||
builder: (context, child) {
|
||||
// if platform is desktop
|
||||
if (Util.isDesktop()){
|
||||
if (Util.isDesktop()) {
|
||||
// actual brightness of the UI
|
||||
Brightness brightness = Theme.of(context).brightness;
|
||||
// Set the title bar to use the actual brightness of the UI
|
||||
@@ -46,7 +46,6 @@ class AppView extends GetView<AppController> {
|
||||
}
|
||||
return child!;
|
||||
},
|
||||
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -626,10 +626,11 @@ class CreateView extends GetView<CreateController> {
|
||||
child: CompactCheckbox(
|
||||
label: 'autoExtract'.tr,
|
||||
value: _autoExtractController
|
||||
.value ?? false,
|
||||
.value ??
|
||||
false,
|
||||
onChanged: (bool? value) {
|
||||
_autoExtractController
|
||||
.value = value ?? false;
|
||||
_autoExtractController.value =
|
||||
value ?? false;
|
||||
},
|
||||
textStyle: const TextStyle(
|
||||
color: Colors.grey,
|
||||
@@ -638,33 +639,49 @@ class CreateView extends GetView<CreateController> {
|
||||
),
|
||||
Obx(
|
||||
() => Visibility(
|
||||
visible: _autoExtractController.value ?? false,
|
||||
visible: _autoExtractController
|
||||
.value ??
|
||||
false,
|
||||
child: Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(top: 10),
|
||||
const EdgeInsets.only(
|
||||
top: 10),
|
||||
child: TextFormField(
|
||||
controller: _archivePasswordController,
|
||||
controller:
|
||||
_archivePasswordController,
|
||||
obscureText: true,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'archivePassword'.tr,
|
||||
hintText: 'archivePasswordHint'.tr,
|
||||
decoration:
|
||||
InputDecoration(
|
||||
labelText:
|
||||
'archivePassword'
|
||||
.tr,
|
||||
hintText:
|
||||
'archivePasswordHint'
|
||||
.tr,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding:
|
||||
const EdgeInsets.only(top: 10),
|
||||
const EdgeInsets.only(
|
||||
top: 10),
|
||||
child: CompactCheckbox(
|
||||
label: 'deleteAfterExtract'.tr,
|
||||
value: _deleteAfterExtractController
|
||||
.value ?? false,
|
||||
label:
|
||||
'deleteAfterExtract'
|
||||
.tr,
|
||||
value:
|
||||
_deleteAfterExtractController
|
||||
.value ??
|
||||
false,
|
||||
onChanged: (bool? value) {
|
||||
_deleteAfterExtractController
|
||||
.value = value ?? false;
|
||||
.value =
|
||||
value ?? false;
|
||||
},
|
||||
textStyle: const TextStyle(
|
||||
textStyle:
|
||||
const TextStyle(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
@@ -1031,7 +1048,8 @@ class CreateView extends GetView<CreateController> {
|
||||
padding: const EdgeInsets.only(right: 8),
|
||||
child: OutlinedButton(
|
||||
onPressed: () {
|
||||
_pathController.text = renderPathPlaceholders(category.path);
|
||||
_pathController.text =
|
||||
renderPathPlaceholders(category.path);
|
||||
},
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
|
||||
@@ -179,7 +179,8 @@ class SettingView extends GetView<SettingController> {
|
||||
return Container(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: Switch(
|
||||
value: appController.downloaderConfig.value.archive.deleteAfterExtract,
|
||||
value:
|
||||
appController.downloaderConfig.value.archive.deleteAfterExtract,
|
||||
onChanged: (bool value) async {
|
||||
appController.downloaderConfig.update((val) {
|
||||
val!.archive.deleteAfterExtract = value;
|
||||
|
||||
@@ -195,6 +195,8 @@ class BuildTaskListView extends GetView {
|
||||
return 'extractDone'.tr;
|
||||
case ExtractStatus.error:
|
||||
return 'extractError'.tr;
|
||||
case ExtractStatus.waitingParts:
|
||||
return 'waitingParts'.tr;
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
@@ -333,13 +335,19 @@ class BuildTaskListView extends GetView {
|
||||
// Extraction status row
|
||||
if (task.progress.extractStatus != ExtractStatus.none)
|
||||
Builder(builder: (context) {
|
||||
final isExtracting =
|
||||
task.progress.extractStatus == ExtractStatus.extracting;
|
||||
final isExtracting = task.progress.extractStatus ==
|
||||
ExtractStatus.extracting;
|
||||
final isExtractDone =
|
||||
task.progress.extractStatus == ExtractStatus.done;
|
||||
final isWaitingParts = task.progress.extractStatus ==
|
||||
ExtractStatus.waitingParts;
|
||||
final statusColor = isExtracting
|
||||
? Get.theme.colorScheme.primary
|
||||
: (isExtractDone ? Colors.green : Colors.red);
|
||||
: (isExtractDone
|
||||
? Colors.green
|
||||
: isWaitingParts
|
||||
? Colors.orange
|
||||
: Colors.red);
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
@@ -367,7 +375,7 @@ class BuildTaskListView extends GetView {
|
||||
).padding(left: 18),
|
||||
),
|
||||
],
|
||||
).padding(top: 4, bottom: isExtracting ? 0 : 8),
|
||||
).padding(top: 4, bottom: 8),
|
||||
// Extraction progress bar
|
||||
if (isExtracting)
|
||||
LinearProgressIndicator(
|
||||
|
||||
@@ -52,9 +52,9 @@ class _CompactCheckboxState extends State<CompactCheckbox> {
|
||||
widget.scale == null
|
||||
? checkbox
|
||||
: Transform.scale(
|
||||
scale: widget.scale,
|
||||
child: checkbox,
|
||||
),
|
||||
scale: widget.scale,
|
||||
child: checkbox,
|
||||
),
|
||||
Text(
|
||||
widget.label,
|
||||
style: widget.textStyle,
|
||||
|
||||
@@ -246,7 +246,8 @@ class _DirectorySelectorState extends State<DirectorySelector> {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.arrow_forward, size: 14, color: Colors.blue[700]),
|
||||
Icon(Icons.arrow_forward,
|
||||
size: 14, color: Colors.blue[700]),
|
||||
const SizedBox(width: 4),
|
||||
Flexible(
|
||||
child: Text(
|
||||
@@ -263,9 +264,10 @@ class _DirectorySelectorState extends State<DirectorySelector> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
return TextFormField(
|
||||
readOnly: widget.allowEdit ? false : (Util.isWeb() ? false : true),
|
||||
readOnly:
|
||||
widget.allowEdit ? false : (Util.isWeb() ? false : true),
|
||||
controller: widget.controller,
|
||||
decoration: widget.showLabel
|
||||
? InputDecoration(
|
||||
|
||||
@@ -107,7 +107,8 @@ const deDE = {
|
||||
'browserExtension': 'Browsererweiterung',
|
||||
'launchAtStartup': 'Beim Start starten',
|
||||
'runAsMenubarApp': 'Als Menüleisten-App ausführen',
|
||||
'runAsMenubarAppDesc': 'Dock-Symbol ausblenden und nur in der Menüleiste ausführen',
|
||||
'runAsMenubarAppDesc':
|
||||
'Dock-Symbol ausblenden und nur in der Menüleiste ausführen',
|
||||
'seedConfig': 'Seed-Konfiguration',
|
||||
'seedKeep': 'Weiter-seeden bis manuell gestoppt',
|
||||
'seedRatio': 'Seed Verhältniss',
|
||||
@@ -126,6 +127,7 @@ const deDE = {
|
||||
'extracting': 'Wird entpackt',
|
||||
'extractDone': 'Entpacken abgeschlossen',
|
||||
'extractError': 'Entpacken fehlgeschlagen',
|
||||
'waitingParts': 'Warte auf Teile',
|
||||
'name': 'Name',
|
||||
'size': 'Größe',
|
||||
'unknown': 'Unbekannt',
|
||||
|
||||
@@ -134,6 +134,7 @@ const enUS = {
|
||||
'extracting': 'Extracting',
|
||||
'extractDone': 'Extraction completed',
|
||||
'extractError': 'Extraction failed',
|
||||
'waitingParts': 'Waiting for parts',
|
||||
'name': 'Name',
|
||||
'size': 'Size',
|
||||
'unknown': 'Unknown',
|
||||
|
||||
@@ -100,7 +100,8 @@ const esES = {
|
||||
'browserExtension': 'Extensión del Navegador',
|
||||
'launchAtStartup': 'Iniciar al Arranque',
|
||||
'runAsMenubarApp': 'Ejecutar como aplicación de barra de menú',
|
||||
'runAsMenubarAppDesc': 'Ocultar icono del Dock y ejecutar solo en la barra de menú',
|
||||
'runAsMenubarAppDesc':
|
||||
'Ocultar icono del Dock y ejecutar solo en la barra de menú',
|
||||
'seedConfig': 'Configuración de Semilla',
|
||||
'seedKeep': 'Mantener sembrado',
|
||||
'seedRatio': 'Ratio de semilla',
|
||||
@@ -118,6 +119,7 @@ const esES = {
|
||||
'extracting': 'Extrayendo',
|
||||
'extractDone': 'Extracción completada',
|
||||
'extractError': 'Error en la extracción',
|
||||
'waitingParts': 'Esperando partes',
|
||||
'insertPlaceholder': 'Insertar marcador de posición',
|
||||
'placeholderYear': 'Año actual',
|
||||
'placeholderMonth': 'Mes actual (01-12)',
|
||||
|
||||
@@ -71,6 +71,7 @@ const faIR = {
|
||||
'extracting': 'در حال استخراج',
|
||||
'extractDone': 'استخراج کامل شد',
|
||||
'extractError': 'استخراج ناموفق بود',
|
||||
'waitingParts': 'در انتظار قسمتها',
|
||||
'insertPlaceholder': 'درج نگهدارنده',
|
||||
'placeholderYear': 'سال جاری',
|
||||
'placeholderMonth': 'ماه جاری (01-12)',
|
||||
|
||||
@@ -100,7 +100,8 @@ const frFR = {
|
||||
'browserExtension': 'Extension du navigateur',
|
||||
'launchAtStartup': 'Lancer au démarrage',
|
||||
'runAsMenubarApp': 'Exécuter en tant qu\'application de barre de menus',
|
||||
'runAsMenubarAppDesc': 'Masquer l\'icône du Dock et exécuter uniquement dans la barre de menus',
|
||||
'runAsMenubarAppDesc':
|
||||
'Masquer l\'icône du Dock et exécuter uniquement dans la barre de menus',
|
||||
'skipVerifyCert': 'Ignorer la vérification du certificat',
|
||||
'archives': 'Archives',
|
||||
'autoExtract': 'Extraire automatiquement les archives',
|
||||
@@ -110,6 +111,7 @@ const frFR = {
|
||||
'extracting': 'Extraction en cours',
|
||||
'extractDone': 'Extraction terminée',
|
||||
'extractError': 'Échec de l\'extraction',
|
||||
'waitingParts': 'En attente des parties',
|
||||
'insertPlaceholder': 'Insérer un espace réservé',
|
||||
'placeholderYear': 'Année en cours',
|
||||
'placeholderMonth': 'Mois en cours (01-12)',
|
||||
|
||||
@@ -107,7 +107,8 @@ const huHU = {
|
||||
'browserExtension': 'Böngésző bővítmény',
|
||||
'launchAtStartup': 'Indítás indításkor',
|
||||
'runAsMenubarApp': 'Futtatás menüsáv alkalmazásként',
|
||||
'runAsMenubarAppDesc': 'Dock ikon elrejtése és csak a menüsávban való futtatás',
|
||||
'runAsMenubarAppDesc':
|
||||
'Dock ikon elrejtése és csak a menüsávban való futtatás',
|
||||
'seedConfig': 'Seed beállítás',
|
||||
'seedKeep': 'Tartsa a seedet amíg manuálisan le nem állítják',
|
||||
'seedRatio': 'Seed arány',
|
||||
@@ -126,6 +127,7 @@ const huHU = {
|
||||
'extracting': 'Kicsomagolás',
|
||||
'extractDone': 'Kicsomagolás befejezve',
|
||||
'extractError': 'Kicsomagolás sikertelen',
|
||||
'waitingParts': 'Várakozás a részekre',
|
||||
'name': 'Név',
|
||||
'size': 'Méret',
|
||||
'unknown': 'Ismeretlen',
|
||||
|
||||
@@ -102,7 +102,8 @@ const idID = {
|
||||
'browserExtension': 'Ekstensi Perambanan',
|
||||
'launchAtStartup': 'Luncurkan saat Startup',
|
||||
'runAsMenubarApp': 'Jalankan sebagai aplikasi bilah menu',
|
||||
'runAsMenubarAppDesc': 'Sembunyikan ikon Dock dan jalankan hanya di bilah menu',
|
||||
'runAsMenubarAppDesc':
|
||||
'Sembunyikan ikon Dock dan jalankan hanya di bilah menu',
|
||||
'seedConfig': 'Konfigurasi Seed',
|
||||
'seedKeep': 'Terus seeding hingga dihentikan secara manual',
|
||||
'seedRatio': 'Rasio Seed',
|
||||
@@ -120,6 +121,7 @@ const idID = {
|
||||
'extracting': 'Mengekstrak',
|
||||
'extractDone': 'Ekstraksi selesai',
|
||||
'extractError': 'Ekstraksi gagal',
|
||||
'waitingParts': 'Menunggu bagian',
|
||||
'insertPlaceholder': 'Sisipkan placeholder',
|
||||
'placeholderYear': 'Tahun saat ini',
|
||||
'placeholderMonth': 'Bulan saat ini (01-12)',
|
||||
|
||||
@@ -98,7 +98,8 @@ const itIT = {
|
||||
'browserExtension': 'Estensione del browser',
|
||||
'launchAtStartup': "Lancia all'avvio",
|
||||
'runAsMenubarApp': 'Esegui come app della barra dei menu',
|
||||
'runAsMenubarAppDesc': 'Nascondi icona Dock ed esegui solo nella barra dei menu',
|
||||
'runAsMenubarAppDesc':
|
||||
'Nascondi icona Dock ed esegui solo nella barra dei menu',
|
||||
'skipVerifyCert': 'Salta la verifica del certificato',
|
||||
'archives': 'Archivi',
|
||||
'autoExtract': 'Estrai automaticamente gli archivi',
|
||||
@@ -108,6 +109,7 @@ const itIT = {
|
||||
'extracting': 'Estrazione in corso',
|
||||
'extractDone': 'Estrazione completata',
|
||||
'extractError': 'Estrazione fallita',
|
||||
'waitingParts': 'In attesa delle parti',
|
||||
'insertPlaceholder': 'Inserisci segnaposto',
|
||||
'placeholderYear': 'Anno corrente',
|
||||
'placeholderMonth': 'Mese corrente (01-12)',
|
||||
|
||||
@@ -72,6 +72,7 @@ const jaJP = {
|
||||
'extracting': '展開中',
|
||||
'extractDone': '展開完了',
|
||||
'extractError': '展開失敗',
|
||||
'waitingParts': '分割ファイルを待っています',
|
||||
'insertPlaceholder': 'プレースホルダーを挿入',
|
||||
'placeholderYear': '現在の年',
|
||||
'placeholderMonth': '現在の月 (01-12)',
|
||||
|
||||
@@ -106,6 +106,7 @@ const plPL = {
|
||||
'extracting': 'Rozpakowywanie',
|
||||
'extractDone': 'Rozpakowywanie zakończone',
|
||||
'extractError': 'Rozpakowywanie nie powiodło się',
|
||||
'waitingParts': 'Oczekiwanie na części',
|
||||
'insertPlaceholder': 'Wstaw placeholder',
|
||||
'placeholderYear': 'Bieżący rok',
|
||||
'placeholderMonth': 'Bieżący miesiąc (01-12)',
|
||||
|
||||
@@ -104,7 +104,8 @@ const ptBR = {
|
||||
'browserExtension': 'Extensões para Navegadores',
|
||||
'launchAtStartup': 'Launch at Startup',
|
||||
'runAsMenubarApp': 'Executar como aplicativo da barra de menus',
|
||||
'runAsMenubarAppDesc': 'Ocultar ícone do Dock e executar apenas na barra de menus',
|
||||
'runAsMenubarAppDesc':
|
||||
'Ocultar ícone do Dock e executar apenas na barra de menus',
|
||||
'seedConfig': 'Seed Config',
|
||||
'seedKeep': 'Keep seeding until manually stopped',
|
||||
'seedRatio': 'Seed ratio',
|
||||
@@ -123,6 +124,7 @@ const ptBR = {
|
||||
'extracting': 'Extraindo',
|
||||
'extractDone': 'Extração concluída',
|
||||
'extractError': 'Falha na extração',
|
||||
'waitingParts': 'Aguardando partes',
|
||||
'name': 'Nome',
|
||||
'size': 'Tamanho',
|
||||
'unknown': 'Desconhecido',
|
||||
|
||||
@@ -104,7 +104,8 @@ const ruRU = {
|
||||
'browserExtension': 'Расширение браузера',
|
||||
'launchAtStartup': 'Запускать при загрузке системы',
|
||||
'runAsMenubarApp': 'Запускать как приложение в строке меню',
|
||||
'runAsMenubarAppDesc': 'Скрыть значок в Dock и запускать только в строке меню',
|
||||
'runAsMenubarAppDesc':
|
||||
'Скрыть значок в Dock и запускать только в строке меню',
|
||||
'seedConfig': 'Настройка раздачи',
|
||||
'seedKeep': 'Раздавать пока не остановлю вручную',
|
||||
'seedRatio': 'Коэффициент раздачи',
|
||||
@@ -123,6 +124,7 @@ const ruRU = {
|
||||
'extracting': 'Распаковка',
|
||||
'extractDone': 'Распаковка завершена',
|
||||
'extractError': 'Ошибка распаковки',
|
||||
'waitingParts': 'Ожидание частей',
|
||||
'name': 'Название',
|
||||
'size': 'Размер',
|
||||
'unknown': 'Неизвестно',
|
||||
|
||||
@@ -108,6 +108,7 @@ const taTA = {
|
||||
'extracting': 'பிரித்தெடுக்கிறது',
|
||||
'extractDone': 'பிரித்தெடுத்தல் முடிந்தது',
|
||||
'extractError': 'பிரித்தெடுத்தல் தோல்வியடைந்தது',
|
||||
'waitingParts': 'பாகங்களுக்காக காத்திருக்கிறது',
|
||||
'insertPlaceholder': 'இடங்காட்டி செருக',
|
||||
'placeholderYear': 'தற்போதைய ஆண்டு',
|
||||
'placeholderMonth': 'தற்போதைய மாதம் (01-12)',
|
||||
|
||||
@@ -105,7 +105,8 @@ const trTR = {
|
||||
'browserExtension': 'Tarayıcı Eklentisi',
|
||||
'launchAtStartup': 'Başlangıçta Çalıştır',
|
||||
'runAsMenubarApp': 'Menü çubuğu uygulaması olarak çalıştır',
|
||||
'runAsMenubarAppDesc': 'Dock simgesini gizle ve yalnızca menü çubuğunda çalıştır',
|
||||
'runAsMenubarAppDesc':
|
||||
'Dock simgesini gizle ve yalnızca menü çubuğunda çalıştır',
|
||||
'seedConfig': 'Seed Ayarları',
|
||||
'seedKeep': 'Manuel olarak durdurulana kadar seed etmeye devam et',
|
||||
'seedRatio': 'Seed oranı',
|
||||
@@ -124,6 +125,7 @@ const trTR = {
|
||||
'extracting': 'Çıkarılıyor',
|
||||
'extractDone': 'Çıkarma tamamlandı',
|
||||
'extractError': 'Çıkarma başarısız',
|
||||
'waitingParts': 'Parçalar bekleniyor',
|
||||
'name': 'Ad',
|
||||
'size': 'Boyut',
|
||||
'unknown': 'Bilinmeyen',
|
||||
|
||||
@@ -104,7 +104,8 @@ const ukUA = {
|
||||
'browserExtension': 'Розширення для браузера',
|
||||
'launchAtStartup': 'Запускати під час завантаження системи',
|
||||
'runAsMenubarApp': 'Запускати як додаток у рядку меню',
|
||||
'runAsMenubarAppDesc': 'Приховати значок у Dock і запускати лише в рядку меню',
|
||||
'runAsMenubarAppDesc':
|
||||
'Приховати значок у Dock і запускати лише в рядку меню',
|
||||
'seedConfig': 'Налаштування роздачі',
|
||||
'seedKeep': 'Роздавати доки не зупиню вручну',
|
||||
'seedRatio': 'Коефіцієнт роздачі',
|
||||
@@ -123,6 +124,7 @@ const ukUA = {
|
||||
'extracting': 'Розпакування',
|
||||
'extractDone': 'Розпакування завершено',
|
||||
'extractError': 'Помилка розпакування',
|
||||
'waitingParts': 'Очікування частин',
|
||||
'name': 'Назва',
|
||||
'size': 'Розмір',
|
||||
'unknown': 'Невідомо',
|
||||
|
||||
@@ -99,6 +99,7 @@ const viVN = {
|
||||
'extracting': 'Đang giải nén',
|
||||
'extractDone': 'Giải nén hoàn tất',
|
||||
'extractError': 'Giải nén thất bại',
|
||||
'waitingParts': 'Đang chờ các phần',
|
||||
'insertPlaceholder': 'Chèn placeholder',
|
||||
'placeholderYear': 'Năm hiện tại',
|
||||
'placeholderMonth': 'Tháng hiện tại (01-12)',
|
||||
|
||||
@@ -132,6 +132,7 @@ const zhCN = {
|
||||
'extracting': '正在解压',
|
||||
'extractDone': '解压完成',
|
||||
'extractError': '解压失败',
|
||||
'waitingParts': '等待分卷',
|
||||
'name': '名称',
|
||||
'size': '大小',
|
||||
'unknown': '未知',
|
||||
|
||||
@@ -120,6 +120,7 @@ const zhTW = {
|
||||
'extracting': '正在解壓',
|
||||
'extractDone': '解壓完成',
|
||||
'extractError': '解壓失敗',
|
||||
'waitingParts': '等待分卷',
|
||||
'name': '名稱',
|
||||
'size': '大小',
|
||||
'unknown': '未知',
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
/// fonts:
|
||||
/// - asset: fonts/Gopeed.ttf
|
||||
///
|
||||
///
|
||||
///
|
||||
/// * Entypo, Copyright (C) 2012 by Daniel Bruce
|
||||
/// Author: Daniel Bruce
|
||||
/// License: SIL (http://scripts.sil.org/OFL)
|
||||
@@ -33,24 +33,44 @@ class Gopeed {
|
||||
static const _kFontFam = 'Gopeed';
|
||||
static const String? _kFontPkg = null;
|
||||
|
||||
static const IconData install = IconData(0xe800, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData android = IconData(0xe801, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData cd = IconData(0xe802, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData folder_bt = IconData(0xe803, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file_bt = IconData(0xe804, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData folder = IconData(0xf07b, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData sort = IconData(0xf0dc, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData html5 = IconData(0xf13b, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file = IconData(0xf15b, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file_alt = IconData(0xf15c, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file_pdf = IconData(0xf1c1, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file_word = IconData(0xf1c2, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file_excel = IconData(0xf1c3, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file_powerpoint = IconData(0xf1c4, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file_image = IconData(0xf1c5, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file_archive = IconData(0xf1c6, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file_audio = IconData(0xf1c7, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file_video = IconData(0xf1c8, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file_code = IconData(0xf1c9, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData app_store_ios = IconData(0xf370, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData install =
|
||||
IconData(0xe800, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData android =
|
||||
IconData(0xe801, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData cd =
|
||||
IconData(0xe802, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData folder_bt =
|
||||
IconData(0xe803, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file_bt =
|
||||
IconData(0xe804, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData folder =
|
||||
IconData(0xf07b, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData sort =
|
||||
IconData(0xf0dc, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData html5 =
|
||||
IconData(0xf13b, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file =
|
||||
IconData(0xf15b, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file_alt =
|
||||
IconData(0xf15c, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file_pdf =
|
||||
IconData(0xf1c1, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file_word =
|
||||
IconData(0xf1c2, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file_excel =
|
||||
IconData(0xf1c3, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file_powerpoint =
|
||||
IconData(0xf1c4, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file_image =
|
||||
IconData(0xf1c5, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file_archive =
|
||||
IconData(0xf1c6, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file_audio =
|
||||
IconData(0xf1c7, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file_video =
|
||||
IconData(0xf1c8, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData file_code =
|
||||
IconData(0xf1c9, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
static const IconData app_store_ios =
|
||||
IconData(0xf370, fontFamily: _kFontFam, fontPackage: _kFontPkg);
|
||||
}
|
||||
@@ -71,7 +71,8 @@ Future<void> init(Args args) async {
|
||||
final windowState = Database.instance.getWindowState();
|
||||
|
||||
// Check if menubar mode is enabled (only for macOS)
|
||||
final runAsMenubarApp = Util.isMacos() && Database.instance.getRunAsMenubarApp();
|
||||
final runAsMenubarApp =
|
||||
Util.isMacos() && Database.instance.getRunAsMenubarApp();
|
||||
|
||||
final windowOptions = WindowOptions(
|
||||
size: Size(windowState?.width ?? 800, windowState?.height ?? 600),
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'arch_stub.dart'
|
||||
if (dart.library.io) 'entry/arch_native.dart'
|
||||
if (dart.library.html) 'entry/arch_web.dart';
|
||||
if (dart.library.io) 'entry/arch_native.dart'
|
||||
if (dart.library.html) 'entry/arch_web.dart';
|
||||
|
||||
// Copy from pkg/sky_engine/lib/ffi/abi.dart
|
||||
enum Architecture {
|
||||
|
||||
@@ -8,4 +8,3 @@ unregisterUrlScheme(String scheme) => doUnregisterUrlScheme(scheme);
|
||||
registerDefaultTorrentClient() => doRegisterDefaultTorrentClient();
|
||||
|
||||
unregisterDefaultTorrentClient() => doUnregisterDefaultTorrentClient();
|
||||
|
||||
@@ -7,7 +7,6 @@
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
|
||||
void main() {
|
||||
testWidgets('Counter increments smoke test', (WidgetTester tester) async {});
|
||||
}
|
||||
Reference in new issue
Block a user